Java并发之volatile详解
带着问题阅读
1、为什么需要volatile,volatile能解决什么问题
2、volatile的实现原理是什么
3、什么是happen-before
4、volatile是否能保证线程安全
Java内存模型JMM
介绍volatile
之前,首先对Java内存模型进行说明。在C\C++
等语言中,内存管理直接使用物理硬件和操作系统的内存模型,也因此会导致程序无法在不同平台上完全兼容。《Java虚拟机规范》中试图定义Java内存模型(Java Memeroy Model)来屏蔽硬件和操作系统之间的内存访问差异,以达到Java程序的跨平台兼容性。
定义Java内存模型并非易事,模型必须定义严谨,不能让内存访问产生歧义;也必须足够宽松,便于虚拟机有足够的灵活度去利用硬件的特性来提升内存操作速度。经过长时间验证、修补,直到Jdk 5,Java内存模型才成熟起来。
Java内存模型规定所有变量都存储在主内存中(虚拟内存,非物理内存),每条线程都有自己的工作内存,工作内存中拷贝了线程所需变量的副本。线程对所有变量的操作都作用在工作内存的副本上,不能直接操作主内存。不同线程之间的工作内存互相隔离,无法直接访问其他工作内存中的变量。
volatile作用介绍
volatile
是Java提供的最轻量级的同步操作,用于保障可见性和有序性。
可见性
在JMM的介绍中可知,每个Java线程都拥有自己的工作内存,如果两个线程共享同一个变量, 那么每个线程都在自己的工作内存中拷贝了一份该变量。当A线程对变量做出修改后,B线程对变量的修改是不能立即可见的,只有当A线程将变量刷入主内存,并在B线程重新加载主内存变量时,B线程才能得到A线程修改的值。
变量添加volatile
后,即可让修改立即同步到主存中,并要求在使用前立即从主内存重新读取,以保证变量的可见性。
synchronized
释放锁后,同步块的修改都会同步到主存,因此synchronized
也可保证可见性。
有序性
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内似表现为串行的寓意",后半句是指"指令重排"和"工作内存与主内存同步延迟"现象。
public class Singleton {
private static volatile instance;
private Singleton() {}
public stativ Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
以标准的单例模式为例,创建单例对象在执行时实际分为三步:分配内存空间、初始化对象、将内存地址赋值给引用;在指令重排后可能会变为:分配空间、赋值引用、初始化对象。
考虑多线程环境,A线程执行了new Instance()
赋值引用且尚未初始化,B线程进入方法判断instance == null
为false
,直接获取到一个未初始化的对象,可能引起一些不可预料的错误。通过添加volatile
可确保insntace
的创建过程中不被重排序。
synchronized
由于同一时刻只允许一个线程进入代码块,因此synchronized
也可保证有序性。
原子性
volatile
只能保证读写的原子性,无法保证其他操作的原子性。
volatile int i = 0;
for (int n = 0; n < 1000; n++) {
new Thread(() -> i++).start();
}
如示例代码,常见的误区是i
在多线程自增到1000,实际上volatile
并不能保证i++
的同步和原子性。因为i++
会分解为三条指令:读取、加1、写入,volatile
只有在读取和写入阶段可保证原子性,因此想要解决同步问题,还是要依靠synchronized
和lock
。
volatile原理分析
可见性原理
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
通过hsdis
和jitwatch
查看编译后的汇编代码。
....
0x000000000295158c: lock cmpxchg %rdi,(%rdx) // volatile写增加lock前缀
....
lock指令和缓存一致性协议
lock前缀指令的执行会触发两件事:
- 将当前处理器缓存行的数据写回到主存
- 将其他处理缓存行内的该内存地址的缓存失效
假设A、B两个线程都将变量var
的指加载到了自己的工作内存,对var
添加volatile
修饰后,当A线程修改var
的值,该修改会立即刷新到主内存中,并且B线程的var
变量缓存会置为失效,当B线程读取该变量时,就需要重新到主内存中加载,如此便保持了变量的可见性。这种多缓存场景下的数据一致性通过缓存一致性协议(MESI)保证。
关于缓存一致性推荐参考第三篇文章,附MESI演示。
lock指令的早期实现会对总线进行锁定,锁定期间其他处理器对内存的读写都被阻塞,直到锁定释放。由于锁总线的开销太大,因此后期使用缓存锁代替总线锁。
有序性原理
lock指令由于锁的存在,对缓存的读一定在写之后,lock也因此暗含了一定有序性的保证。而在整体代码指令上的有序性,是由内存屏障保证的,lock并不能提供内存屏障。
JSR-133将读写分为:
- 普通读:非volatile字段的读取如getField,getStatic或数组加载
- 普通写:非volatile字段的写如setField,setStatic或数组存储
- volatile读:多线程可达的volatile字段读取
- volatile写:多线程可达的volatile字段
JMM对四种读写制定了重排规则:
根据该表有以下语义:
- 第一个操作为volatile读,后续任何读写都不得重排到该操作之前
- 第二个操作为volatile读,前序volatile读写不得重排到该操作之后
- 第一个操作为volatile写,后续volatile读写不得重排到该操作之前
- 第二个操作为volatile写,前序任何读写都不得重排到该操作之后
为了实现有序性语义,JMM提供了四种内存屏障
内存屏障 | 指令序 | 说明 |
---|---|---|
StoreStore屏障 | Store1;StoreStore:Store2 | 确保Store1数据对其他处理器可见先于Store2及所有后续存储指令 |
StoreLoad屏障 | Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器可见先于Load2及后续加载指令 |
LoadLoad屏障 | Load1;LoadLoad;Load2 | 确保Load1数据装载先于Load2及所有后续装载指令 |
LoadStore屏障 | Load1;LoadStore;Store2 | 确保Load1数据装载先于Store2及后续所有存储指令 |
volatile
基于内存屏障禁止指令重排序,实际上发现一个最小化插入内存屏障的总数几乎是不可能的,JMM采取了保守策略,主要遵循以下规则:
- 在每个volatile写之前插入StoreStore屏障,确保volatile写之前的写操作不会被重排到volatile写之后
- 在每个volatile写之后插入StoreLoad屏障,确保volatile写之后的读写操作不会重排到volatile写之前
- 在每个volatile读之后插入LoadLoad屏障和LoadStore屏障,禁止下面所有读写操作重排到volatile读之前
为什么在volatile写之前没有加入LoadStore保证读不能重排在写后面呢?
个人理解是因为volatile的lock指令限制了写优先于读,因此省略了该屏障。
实际执行时,编译器会根据具体情况省略不必要的屏障,如下面的示例:
int a;
volatile int v, u;
void f() {
int i, j;
i = a; // load a
i = v; // load v
// LoadLoad 因为store a不可能越过load u,可省略LoadStore
j = u; // load u
// LoadStore 因为下面没有读,可省略LoadLoad
a = i; // store a
// StoreStore
v = i; // store v
// 因为紧跟一个volatile写,省略StoreLoad交给store u添加
// StoreStore
u = j; // store u
// StoreLoad s
i = u; // load u
// LoadLoad 防止下面的load a重排
// LoadStore 防止下面的store a重排
j = a; // load a
a = i; // store a
}
先行发生原则 happen-before
如果Java中所有的有序性都靠添加volatile
和synchronized
来保证,会使得程序编写非常啰嗦。但我们在编写Java程序时并没有感受到这一点,是因为Java语言的先行发生原则happen-before
。
先行发生原则通俗解释即,如A操作产生的影响对B操作有效,那么A应当是先行发生于B。先行发生的概念不难理解,假如没有先行发生的约束,会出现什么问题呢?
boolean configured = false;
// Thread1
while (!configured) {}
doSometing();
// Thread2
loadConfig();
configured = true;
看一段示例代码,Thread1需等待Thread2加载配置完毕后,才能继续往下执行,于是逻辑上Thread2的操作应当先行发生于Thread1。在Thread2中,loadConfig
应当先行发生于configured=true
。如果没有先行发生原则,由于指令重排的存在,Thread2可能先执行了configured = true
然后才加载配置,而Thread1已读取到configured:true
,程序就会发生不可预知的错误。
Java内存模型定义了几条天然的先行发生关系,如两个操作不在以下范围内,则不保证操作间的顺序执行。
-
程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。
-
管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。
-
volatile变量规则。对一个volatile修饰的变量,对他的写操作先行发生于读操作。
-
线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。
-
线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。
-
线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。
-
对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。
-
传递性。A先行发生B,B先行发生C,那么,A先行发生C。
volatile boolean configured = false;
// Thread1
while (!configured) {} // 操作3
doSometing(); // 操作4
// Thread2
loadConfig(); // 操作1
configured = true; // 操作2
在上面给的例子中,为configured
添加volatile
修饰,则根据先行发生规则有:
- 操作1先行发生于操作2(程序次序规则)
- 操作3先行发生于操作4(程序次序规则)
- 操作2先行发生于操作3(volatile变量规则)
- 操作1先行发生于操作4(传递性规则)
如此,即可确保程序按照预定逻辑正常执行。