本文绝大部分抄写于https://www.infoq.cn/minibook/java_memory_model。特别感谢作者:程晓明。在大多数抄的情况下为什么还要复制粘贴一次。只是为了让自己在看得过程,有自己一部分的思考。光看不练,等于白看!
另外好的文章
https://www.cnblogs.com/xrq730/p/7048693.html
简介:在并发编程中需要处理两个关键问题:线程之间如果通信以及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令时编程中,线程之间的通信机制有两种:共享内存和消息传递
java的并发采用的是共享内存模型,java线程之间的通信总是隐式进行,整个通信过程对于程序员安全透明。
java内存模型的抽象
共享变量:实例域,静态域,数组元素。这些存放在堆内存的数据都是线程之间共享的
不共享变量:局部变量,方法型参,异常处理器参数。
java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。(简单来说就是对于计算机内部CPU调度过程的一个抽象)如图
如果线程A需要跟线程B通信需要哪些步骤?
1、线程A跟改自己的共享变量副本
2、线程A把自己更改过后的变量副本同步到主内存当中
3、线程B到主内存中读取线程A修改过的数据
从整体来看,这3个步骤实际上是线程A在向线程B发送信息,而且这个通信必须经过主存。JMM通过控制主存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
这也就是volatile对应的写语义,为什么在CopyOnWriteArrayList源码里面里面add方法,就算没有更改,也要重新set一个没有变化的值。保证了内存可见性
重排序
重排序分三种类型:
1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的顺序。
2、指令级并发的重排序。现代处理器采用了指令集并发技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机械指令的执行顺序。
3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
处理器重排序与内存屏障指令
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:
屏障类型 | 指令实例 | 说明 |
---|---|---|
LoadLoadBarriers | Load1;LoadLoad;Load2 | 确保Load1数据的装载前于Load2及所有后续的装载指令的装载 |
StoreStoreBarriers | Store1;StoreStore;Store2 | 确保Store1数据对于其他处理器可见(刷信到内存),之前于Store2及所有后续的存储指令的装载 |
LoadStoreBarriers | Load1;LoadStore;Store2 | 确保Load1数据装载前于Store2及所有后续的存储指令的装载 |
StoreLoadBarriers | Store1;StoreLoad;Load | 确保Store1数据对于其他处理器变化可见(指刷新到内存)。前于Load2及所有后续装载指令的装载。StoreLoadBarriers会使该屏障之前的所有内存访问指令(存取和装载指令)完成之后,才执行该屏障之后的内存访问指令 |
StoreLoadBarriers显然是它们四个中王者,”全能型屏障“。volatile也是基于该指令实现的。
happens-before
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-befor关系。这里提到的两个操作既可以是在一个线程内,也可以是在不同线程之间。
与程序员密切相关的happens-before规则
1、程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
2、监视器锁规则:对一个监视器的解锁,happens-before 于随后对这个监视器的加锁(可以有效让锁进行传递,打个比方。如果A线程获取到锁,B线程想要尝试。线程A释放的操作将会下,B线程下次尝试之前开发。对于自旋锁尝试获取锁有很大的帮助)
3、volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
4、传递性:如果A happens-before B 且 B happens-before C,那么A happens-before C。
注意:A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行结果)对后一个操作可见,并且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的知心结果,于操作A和操作B按happens-before顺序执行一致。在这种情况下,JMM会认为这种重排序并不非法,JMM允许这种重排序
release 和 acquire
**Java内存型确保处理是按照“release终止后对应的acquire才开始”**如图部分release 和 acquire 操作顺序图
release | acquire |
---|---|
volatile write | volatile read |
unlock | lock |
线程的启动(start) | 线程启动后的第一个操作 |
线程终止前的最后一个操作 | 检测线程的终止(join、isAlice) |
中断(interrupt) | 检测中断(isIntertupted、Thred.interrupted、InterruptedException) |
向字段写入默认值 | 线程的第一个操作 |
为什么重排序能提高执行效率?
class SynchronizedExample{
int a = 0;
boolean flag = false;
public synchronized void write(){
a = 1;
flag = true;
}
public synchronized void reader(){
if(flag){
int i = a;
}
}
}
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序 (但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。 JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然想成A在临界区内做了重排序,但由于监视器的互斥执行的特性,这里线程B根本无法“观察”到线程A在临界区内执行的重排序。这种重排序及提升了执行效率【1】,又没有改变程序执行结果。
【1】: 链接: 点击
Volatile
想要理解好volatile特性:最好的办法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写做了同步。
特性:
1、可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
2、原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复杂操作不具备原子性
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义
Volatile 写-读的内存语义
volatile写的内存语义如下:
1、当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值同时也刷新到主存。确保自身本地内存的值和主存中的值一致
volatile读的内存语义如下:
1、当读一个volatile变量时,JMM会将该线程对应的本地内存置为无效。线程接下来将从主存中读取共享变量。
对volatile写和volatile读的内存语义做个总结:
1、线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某些线程发出了(其对共享变量所在修改的)消息。
2、线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在读这个volatile变量之前对共享变量修改的)消息。当然不管接不接到这种信息,还是会直接去内存拿值,忽略本地缓存。这只是简单理解为一个通信过程
3、线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息
上述volatile 写和volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明︰
class BolatileBarrierExample{
int a;
valatile int v1 = 1;
valatile int v2 = 2;
void readAndWrire(){
int i = v1;//第一个volatile读
int j = v2;//第二个volatile读
a = i + j;//普通度
v1 = i + 1;//第一个volatile写
v2 = j * 2;//第二个volatile写
}
}
编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile 写与volatile 写前面的任意内存操作重排序。(ReentrainLock中native方法CAS对valatile的state进行写/读操作,为此意味着CAS前面和后面的任意内存操作不会重排序)
final
1、在构造函数内对一个final域的写入,于随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(这也就是为什么并发的时候需要保证对象逸出问题)。
2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
写final域的重排序规则
想要理解上面,优先要明白final在修饰域时候有什么作用?fianl修饰成员变量和静态变量有什么区别?
final在修饰域的时候,该域被定义为不可修改。我们直到java语言在传递参数的时候是按照值的方式传递的,不管是基本数据类型还是引用数据类型(我相信大部分该学java程序的人因为基本数据类型是值传递,引用类型是引用传递)基本数据类型我就不详说了,在引用数据类型传递时候,他是把自己的地址值复制一份,传递给方法的形参当中。
public static void main(String[] args) {
String a = "123";
changeStr(a);
sout(a);//123;
}
public static void changeStr(String str){
str = "321";
}
参数a复制自己地址值传递changeStr。内部str是一个指向内存中“123”的地址值。str = “321”;只是创建了一个新的地址值 存放“321”. 被让 str 指向它。根本不关外部a的事。
扯远了,话又说回来final修饰的域,就是不能改变它的值,不管是地址值还是值本身。那么 static final 域 和 final 域 又有什么区别呢?
static final修饰的域,有两种初始化的方式,一种是静态代码块,一种是声明时初始化。它们之间是存在区别的。
声明时初始化:会在类还未加载到jvm的时候就已经初始化了。在idea能看到。是已经存在值得
静态代码块初始化:会在类加载的时候才初始化。在idea又能到。它是还不存在值得。
final修饰的域,也就是上面提到的。它有三种初始化方式,声明时初始化,构造函数初始化,非静态代码块初始化。它们都有一个特点就是在创建对象得时候,才会被调用到。那么在并发执行创建了,和读取普通对象得时候。普通变量得赋值可能就会在构造函数指令之外。然后final修饰的域对象就不会。回到正题
下面用代码演示以下
public class FinalExample{
int i; //普通变量
final int j; //final变量
static final Example obj;
public void final example(){ //构造函数
i = 1; //写普通域
j = 2; //写final域
}
public static void writer(){ //写线程A执行
obj = new FinalExample();
}
public static void reader(){ //读线程B执行
FinalExample object = obj;//读对象引用
int a = object.i;//读普通域
int b = object.j;//读final域
}
}
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2各方面
1、JMM禁止编译器把final域的写重排序到构造函数之外
2、编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序戴结构函数之外。
优先先分析write()方法。fianl Example = new FinalExample();这行代码包含两个步骤;
1、构造一个FinalExample类型的对象;
2、把这个对象的引用赋值给引用变量obj;
现在假设B读对象引用与读对象的成员域之间没有重排序
读final域的重排序规则
构造函数对象“逸出”
在并发编程实战中明确规定了在构建不可变对象的时候,要注意不要再对象构造的时候把this对象“逸出”。不然会导致域还未初始化完成,解析来我们看看原因
public class FinalReferenceEscapeExample{
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample(){
i = 1; //写fianl语义 确保了他在构造函数指令内部完成
obj = this;//this在此“逸出”,普通写,在哪里完成初始化 未知!
}
public static void writer(){
new FinalReferenceEscapeExample();
}
public static void reader(){
if(obj != null) {
int temp = obj.i;
}
}
}
结果可能如图