volatile关键字
# volatile关键字
前面在讲JMM内存模型的时候,提及到volatile关键字可以保证有序性和可见性,其实是内存屏障以及happen-before原则在背后支撑的。
这再聊聊深入聊聊内存屏障以及volatile关键字的特性。
# 内存屏障
内存屏障(也称内存栅栏、内存栅障、屏障指令...)指的是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,目的就是避免代码重排序。
- 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
- 而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
# 写后读
内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile
实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(可见性)。
简单来说,就是对一个volatile域的写, happens-before于任意后续对这个volatile域的读,也叫写后读
# JDK源码
上面提到,CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。由于内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了,直到JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
在理论层面,可以把基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。例如,Load1;LoadLoad;Load2,保证load1的读操作在load2以及后续操作之前执行。
- StoreStore:禁止写和写的重排序。例如,Store1;StoreStore;Store2,保证store1的写操作已经刷新到主内存后,store2及其后到写操作再执行。
- LoadStore:禁止读和写的重排序。例如,Load1;LoadStore;Store2,保证ocad1的读操作已经结束,store2及其后到写操作再执行。
- StoreLoad:禁止写和读的重排序。例如,Store1;StoreLoad;Load2,保证store1的写操作在load2以及后续读操作之前执行。
Unsafe中的方法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
# volatile实现原理
# 重排规则
volatile的重排规则参考如下:
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排。保证volatile读之后的操作不会被重排到volatile读之前。
- 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。保证volatile写之前的操作不会被重排到volatile写之后。
- 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。
# 插⼊策略
由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。
这里只探讨为了实现volatile关键字的语义的一种参考做法:
- 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
- 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
- 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。
具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。
代码示例:
// 模拟一个单线程,什么顺序读?什么顺序写?
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}
# volatile特性
被volatile修改的变量有三大特性:可见性、不保证原子性、禁止指令重排,下面重点演示可见性以及不保证原子性
# 可见性
保证不同线程对变量进行操作时的可见性,即变量一旦改变所有线程立即可以看到
/**
* 验证volatile的可见性:
* 1.不添加volatile关键字修饰,没有可见性,程序不会停止
* 2.添加了volatile,可以解决可见性问题
* */
class Resource{
// volatile int number = 0;
int number = 0;
public void addNumber(){
this.number = 60;
}
}
public class VolatileDemo1{
public static void main(String[] args) {
Resource resource = new Resource();
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t coming ");
try {
TimeUnit.SECONDS.sleep(4);
}catch (InterruptedException e){
e.printStackTrace();
}
resource.addNumber();
System.out.println(Thread.currentThread().getName() + "\t update " + resource.number);
},"线程A").start();
// 如果主线程访问resource.number==0,那么就一直进行循环
while(resource.number==0){
}
// 如果执行到了这里,证明main现在通过resource.number的值为60
System.out.println(Thread.currentThread().getName() + "\t" + resource.number);
}
}
# 不保证原子性
原子性指不可分割,要么同时成功,要么同时失败。例如,某个线程正在做某个具体业务时,中间不可以被加塞或者分割,需要保持完整性。
创建20个线程出来,每个线程执行2000次num++操作,预期结果是20000,以这个例子说明volatile不保证原子性:
class AutoResource{
public volatile int num = 0;
public AtomicInteger atomicInteger = new AtomicInteger();
public void numPlusPlus(){
num++; // 字节码,i++有三个操作 取值、自增、赋值
}
public void atomicIncrement(){
atomicInteger.getAndIncrement();
}
}
public class VolatileDemo2 {
public static void main(String[] args) {
AutoResource autoResource = new AutoResource();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 2000; j++) {
autoResource.numPlusPlus();
autoResource.atomicIncrement();
}
}, "Thread" + i).start();
}
// 规定线程数>2, 一般有GC线程以及Main主线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t finally num value is " + autoResource.num);
System.out.println(Thread.currentThread().getName() + "\t finally atomicInteger value is " + autoResource.atomicInteger.get());
}
}
在执行多次后,num的结果并不是预期的20000
main finally num value is 11955
main finally atomicInteger value is 20000
main finally num value is 13558
main finally atomicInteger value is 20000
main finally num value is 12821
main finally atomicInteger value is 20000
为什么会出现这种问题,主要是因为出现多个线程写的情况。试想一下,假如有线程A执行到num++
操作时,会拷贝num的值到本地内存,假设主内存num为0,在进行自增之后值变为1,不巧的时,线程A挂起了;此时,线程B又从主内存中读取num=0到自己的本地内存中,同样自增1之后线程B挂起;然后,线程A又恢复运行,将num=1写入了主内存,而后线程B也恢复运行将num值写入了主内存,此时num的值就少加了一次。所以,在多线程的情况下,最后导致总数少于20000。
如果想要保证原子性,则需要使用JUC提供AtomicInteger
类(底层用到了CAS),如代码所示。
# 内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
# 使用场景
简单的单一赋值,包含状态标记。注意,含复合运算赋值操作的不可以,例如i++。
public volatile int num = 0; public volatile long lonNum = 0; private volatile boolean flag = true;
特别是对于long、double的多线程写入,因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。所以,在多线程写入long、double一定要加volatile关键字。
当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
public class UseVolatileDemo{ /** * 利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性 */ public class Counter{ private volatile int value; public int getValue(){ return value; // 利用volatile保证读取操作的可见性 } public synchronized int increment(){ return value++; // 利用synchronized保证复合操作的原子性 } } }
单例模式-懒汉模式,DCL(Double Checking Locking)。
单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:
public class Singleton { private static Singleton instance; // private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) { if (instance == null) { // 此处代码有问题 instance = new Singleton(); } } } return instance; } }
上述的
instance = new Singleton();
代码有问题:其底层会分为三个操作:- step1:分配一块内存。
- step2:在内存上初始化成员变量。
- step3:把instance引用指向内存。
在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。
解决办法也很简单,就是为instance变量加上volatile修饰。
# JSR-133对volatile语义的增强
在JSR -133之前的旧内存模型中,一个64位long/double型变量的读/ 写操作可以被拆分为两个32位的读/写操作来执行。从JSR -133内存模型开始 (即从JDK5开始),仅仅只允许把一个64位long/ double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
这也正体现了Java对happen-before规则的严格遵守。