目录
2.2 volatile 写/读的happens-before关系
前言:
在上一部分我们学习了java内存模型的基础还有指令重排序问题,接下来我们开始探索顺序一致性和volatile语义
1.数据一致性
1.1 数据竞争与顺序一致性
当程序未正确同步时,就可能会存在数据竞争,java内存模型规范对数据竞争的定义如下。
在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性,这对程序员来说是一个极强的保证。这里说的同步是广义上的同步,包括日常的同步原语(synchronized,final和volatile)的正确使用。
1.2 顺序一致性内存模型
顺序一致性内存模型为程序员提供了极强的内存可见性保证,顺序一致性内存模型有两大特性
- 一个线程中的所有操作都必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型示意图如下
顺序一致性模型有一个全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序顺序来执行内存读/写操作。从图上可以看出,在任意时间点最多只有一个线程可以连接到内存。当多个线程并发执行时,图中的开关能把所有的读/写操作串行化。
未同步的程序在顺序一致性模型中的整体执行顺序是无序的,但所有线程都只能看到一个一致的整体顺序,之所有能看到一致的整体顺序是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是JMM中就没有这个保证,线程如果没有同步,在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作顺序也可能不一致。因为JMM中每个线程都有自己的本地内存,当线程把写过的线程缓存到本地内存中还没刷新到主内存前,这个写操作仅对当前线程可见;对于其他线程来说,会认为这个写操作根本没有被当前线程执行,直到当前线程把本地内存中写过的数据刷新到主内存之后,写操作才会对所有线程可见。
1.3 同步程序的顺序一致性结果
我们对上一部分中的示例程序用锁来同步,看看正确同步的程序如何具有顺序一致性
public class RecordExample {
int a = 0;
boolean flag = false;
public synchronized void writer() { //获取锁
a = 1;
flag = true;
} //释放锁
public synchronized void reader() { //获取锁
if (flag) {
int i = a * a;
}
} //释放锁
}
这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果会和该程序在顺序一致性内存模型中的执行结果相同。下面来看下该程序在两个内存模型中的执行对比图
1.4 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值,为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象。所以在已清零的内存空间上分配对象时,域的默认初始化已经完成了。
JMM不保证未同步的程序和该程序在顺序一致性模型的执行结果一致,因为如果想保证执行结果一致,JMM要禁止大量的处理器和编译器的优化,这对性能影响很大,而且保证未同步的程序在这两个模型中执行结果一致没什么意义。
未同步在两个模型中的执行特性有几个差异如下
- 顺序一致性模型保证单线程内的操作会按程序顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。
- 顺序一致性模型保证所有线程只能看到一致的执行顺序,而JMM不保证所有线程能看到一致性的执行顺序。
- JMM不保证对64位的long和double类型变量的写操作具有原子性,而顺序一致性模型保证对所有线程的读/写操作都具有原子性。
2. volatile的内存语义
2.1volatile的特性
可以把对单个volatile变量的读/写,看作是对这些单个读/写做了同步。下面给出一段代码,帮助理解
public class VolitaleTest01 {
volatile long b = 0L;
public void set(long l) {
b = l;
}
public long get() {
return b;
}
public void getAndIncrement() {
b++;
}
}
上面这段代码等价于下面这段代码
public class VolitaleTest01 {
long b=0L;
public synchronized void set(long l){
b=l;
}
public synchronized long get(){
return b;
}
public void getAndIncrement(){
b++;
}
}
可以看到,一个共享变量声明了volitale之后,对它的读/写操作就相当于在它的读/写操作上单独加锁执行。
锁的happens-before规则保证了释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总会看到(任意线程)对这个变量的最后写入。
锁的语义决定了临界区代码的执行具有原子性,那么对于64位的long或者double类型变量,只要它是volatile变量,那么对该变量的读/写就具有原子性。
总结起来volatile的特性如下:
- 可见性。对一个volatile变量的读,总是能看到其他任意线程对这个变量最后的写入
- 原子性。对任意volatile变量的读/写都具有原子性,但类似于volatile++这种复合操作不具有原子性。
2.2 volatile 写/读的happens-before关系
对我们程序员来说,volatile对内存可见性的影响比volatile自身的特性更为重要。
volatile写和锁释放有相同的内存语义,volatile读和锁获取有相同的内存语义。下面来看一个例子
public class VolitaleTest01 {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1;//1
flag = true;//2
}
public void reader() {
if (flag) {//3
int i = a;//4
...
}
}
}
假设现在有两个线程执行这一段代码,线程A执行writer方法后,线程B执行reader方法,根据happens-before规则,此时会产生3种happens-before关系
- 程序顺序规则 1 happens before 2;3 happens before 4
- volitale规则 2 happens before 3
- happens-before传递性规则,1 happens before 4
再强调一点,在写一个volatile变量的时候,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中去。
总结一下volatile写和读中的内存语义
- 线程A写一个volatile变量,实际上是线程A向接下来将要读这个volatile的某个线程发出了(其对共享变量所做修改)的消息
- 线程B读一个volatile变量,实际上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改)的消息
- 线程A写一个volatile变量,然后线程B读一个volatile变量,实际上是线程A通过主内存向线程B发送消息
2.3 volatile内存语义的实现
前面提到过重排序分为编译器重排序和处理器重排序,为了实现volatile的内存,JMM会分别限制这两种类型的重排序类型。整理如下
- 第二个操作是volatile写时,不管第一个操作是什么,都不能重排序
- 第一个操作是volatile读时,不管第二个操作是什么,都不能重排序
- 第一个操作是volatile写,第二个操作是volatile读的时候不能重排序
这些限制确保了1、volatile写之前的操作不会被重排序到写操作之后,2、volatile读之后的操作不会被重排序到读之前
为了实现volatile内存语义,JMM会在指令序列之间插入内存屏障,具体策略如下
- 每个volatile写操作之前插入一个StoreStore屏障
- 每个volatile写操作后面插入一个StoreLoad屏障
- 每个volatile读操作后面插入一个LoadLoad屏障
- 每个volatile读操作后面插入一个LoadStore屏障
volatile写的执行序列示意图如下
volatile读的指令序列示意图如下
总结
这一部分,我们一起学习了顺序一致性内存模型,顺序一致性和JMM控制下的重排序的实际区别,还学习了volatile的内存语义及特性,下一部分,我们将继续学习锁的内存语义和final域的内存语义。