之前有看过JDK中关于JUC底下的并发包源码以及拜读过大厂产出的文章解析(美团后台篇中的ReentrantLock),自认为八九不离十能知道源码里面的设计概念等等。直到今天!!没错==>脸又肿了。
Volatile的内存语义
介绍这个关键字,想必有的小伙伴一下子就想到了它的可见性以及原子性(复合操作不在其中)。然而,从计算机的角度去思考下,为什么会有这样的效果产生?这么做是为了什么?
volatile写的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中。
如图所示,当线程A对其共享变量进行操作时候,会将本地内存数据同步到主内存中,进行数据同步,保证线程A的本地内存值与主内存中一致。
volatile读的内存语义
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来会从主内存中读取共享变量
如图所示,当线程B进行共享变量读取时,则将本地内存中的值置为无效,此时必须从主内存中刷入该共享变量的值。
volatile读与写内存语义总结
将上面的读与写两个步骤综合来看,读线程B去读取共享变量之前,写线程A在写这个共享变量之后所有可见的共享变量值都将立即变得对读线程B可见。
线程A写了一个volatile变量,实质上是对接下来某个想读取该变量的线程发送一条消息。
线程B读了一个volatile变量,实质上是对接收之前某个线程发送的消息。
整体下来可以看做是,线程A通过主内存向线程B发送消息。
volatile内存语义的实现
volatile重排序规则表
是否能重排序第二个操作第一个操作普通读/写volatile读volatile写
普通读/写NO
volatile读NONONO
volatile写NONO
通过上面的表格,我们可以看出
当第二个操作为volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保了volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作为volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保了
volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成对应的字节码时,插入对应的内存屏障来禁止特定类型的处理器重排序。
在每个volatile写操作之前插入一个StoreStore屏障。
在每个volatile写操作之后插入一个StoreLoad屏障。
在每个volatile读操作之前插入一个LoadLoad屏障。
在每个volatile读操作之前插入一个LoadStore屏障。
通过上述内存屏障的插入策略,能保证在任何处理平台,任意的程序中都能得到正确的volatile内存语义。
volatile内存语义的加强
JSR-133之前旧的Java内存模型中,是不允许volatile变量之间重排序,但允许volatile变量与普通变量重排序。
如图,在旧的内存模型中,当步骤1与步骤2之间没有数据依赖,那么他们之间就有可能会被重新排序。最后导致的结果就是:线程B执行4时,不一定能看到线程A在执行1对共享变量的修改(此时就相当于脏读)。
所以JSR-133专家组决定增强volatile内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
锁的内存语义
谈及到锁的话,想必开始想到的就是happens-before关系,这里涉及的到前后仅仅是结果,而不一定是发生次序的happens-before。
锁的释放和获取的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
锁释放与锁获取的内存语义:
线程A释放一个锁,实质上是线程A向接下来将要获得这个锁的某个线程发送了消息。
线程B获取一个锁,实质上是线程B接受了之前某个线程发送释放锁的消息。
线程A释放锁,随后线程B获取了锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
我们知道除了synchronized关键字之外,java中锁的实现大部分依靠AQS去操作。而AQS中使用一个整型的volatile变量(命名为state)来维护同步状态(这个很重要)。
通过上图,我们可以看出CAS是如何同时具有volatile读和volatile写的内存语义的,接下来会阐述下处理器中是如何实现的。
确保对内存的读-改-写操作原子执行。多核CPU情况下,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。单核CPU则自身会维护单处理器的顺序一致性。
禁止该指令,与之前和之后的读和写指令重排序。
把写缓冲区中的所有数据刷新到内存中。
上面的2、3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义
锁内存语义的总结
通过上面,我们明显的可以看出CAS与volatile之间有什么相同点,起码在禁止指令排序上面是如何操作的。
公平锁和非公平锁释放时,最后都要写一个volatile变量state。
公平锁获取时,首先会去读volatile变量。
非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
于是,整个JUC底下的并发相关的操作类图层如上图所示。
final的内存语义
final域的读写比上面volatile的读写效果则弱了许多,更像是普通变量的访问。
编译器和处理器需要遵守的两个重排序规则:
在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作不能重排序。
为何final需要上述两个规则去保证内存操作呢?,接下来我们就进行讲解
eg:
public class FinalExample{
int i;
final int j;
static FinalExample obj;
public FinalExample(){
this.i = 1;
this.j = 2;
}
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域的写重排序到构造函数之外。
JMM禁止编译器把final域的写重排序到构造函数之外。
编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
如果没有写final域的重排序规则,则可能造成(如图所示)线程B错误的读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则"限定"在构造函数之内,则介意正确的读取final变量初始化之后的值。
读final域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
如果没有该限制,上图是可能的执行时序。那么此时在读取普通域的时,该普通域还未被线程A写入。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
避免final引用"逸出"
之前提及到,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在否早函数中被正确初始化过了。其实,这里还需要一个保证:在构造函数内,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能再构造函数中“逸出”。
public class FinalReferenceEscapeExample{
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample(){
this.i = 1;
obj = this; // this 引用逸出
}
public static void writer(){ // 线程A
new FinalReferenceEscapeExample();
}
public static void reader(){ // 线程B
if (obj!=null){
int temp = obj.i;
}
}
}
复制代码
上图是两个线程在执行过程中可能发生的时序,此时我们可以看到线程B拿到对象引用的时候,final域还没初始化完成。
final语义在处理器中的实现
通过上面的简单介绍,我们可以知道以下两点:
写final域的重排序规则会要区域编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。
读final域的重排序规则会要求编译器在读final域的操作前面插入一个LoadLoad屏障。
但是在X86处理器中,final域的读/写不会插入任何内存屏障的(首先不会对写-写操作做重排序,再然后不会对存在简介依赖关系的操作做重排序)
final语义增强
没错!JSR-133专家组为了方式上面的逸出情况考虑。
通过为final域增加写和读重排序规则,可以为我们提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有"逸出"),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都可以看到这个final域在构造函数中被初始化之后的值。
完结
针对于上述的内存语义的说法,可以的大致的看出,语言完全是在编译器以及处理器层面去进行控制数据的流动。往下走就对了!!!
小伙伴们可以看下之前的文章(含有重排序的介绍):
参考文献:JAVA并发编程艺术