JAVA中的内存语义
之前有看过
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读 |
NO |
NO |
NO |
||
volatile写 |
|
|
NO |
NO |
通过上面的表格,我们可以看出
- 当第二个操作为
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
域在构造函数中被初始化之后的值。
完结
针对于上述的内存语义的说法,可以的大致的看出,语言完全是在编译器以及处理器层面去进行控制数据的流动。往下走就对了!!!