深入解析Java中volatile的底层原理
作者:贤子磊
一、前言
之前我们学习过synchronized
,知道它是一个重量级的锁,虽然jdk1.6对其做了很大的优化,但是成本还是较高。因此Java另一个关键字闪亮登场——volatile
。volatile又被称为轻量级的synchronized,它在多处理器中保证了共享变量的可见性。volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低。下面我们将深入剖析volatile的实现原理。
二、什么是volatile
Java语言规范第3版中对volatile的定义如下
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
三、volatile的语义
当一个共享变量被volatile
修饰之后,这个变量就具备了两层语义:
- 保证共享变量的可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 防止局部指令重排序:happens-before规则中的volatile变量规则规定了一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
四、volatile的使用
1. volatile保证共享变量的可见性
public class MyTest { public static boolean stop = false; public static void main(String[] args) throws InterruptedException { //线程1 Thread thread1 = new Thread(() -> { while (!stop) { //do something... } }); //线程2 Thread thread2 = new Thread(() -> doStop()); thread1.start(); //这里的睡眠是保证线程1先执行 Thread.sleep(1000); thread2.start(); } public static void doStop() { stop = true; } }
如上所示的代码,主要用于线程2去利用共享变量stop去中断线程1的执行。正常情况下如果线程2先执行,线程1再执行不会有问题,线程2能够正常结束线程1的执行,但是上述的代码会一直死循环无法结束。
这是因为线程1先执行,此时stop的值初始为false,线程1执行死循环。根据Java内存模型,每个线程不能直接访问主内存的,需另拷贝一份到自己的工作内存中。因此线程1执行的时候会拷贝stop的副本到自己的工作空间内,而线程2执行的时候虽然修改了stop的值,但是线程1感知不到,因此会一直死循环。
因此我们可以使用volatile关键字修改stop变量
public static volatile boolean stop =false;
这里的volatile
有两个作用
- 被volatile关键字修饰的变量修改时会立即写入主内存
- 被volatile关键字修饰的变量修改时,其余线程存放的该变量的副本会被置为无效
因此当线程2修改stop的值的时候,会立即写回主内存,同时使线程1的工作内存中的stop变量置为无效。线程1在每次循环读取stop的时候发现自己的stop变量无效了,会重新去主内存读取最新的stop的值,即读取到线程2修改过的最新值,程序正常结束。
2. volatile保证一定程度的有序性
//线程1: context = loadContext(); //语句1 inited = true; //语句2 //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
借用网上的一个经典案例,线程1负责初始化上下文,线程2则是线程1初始化结束后进行的后续操作。我们知道处理器为了提高程序运行效率,可能会对输入代码进行重排优化,其中语句1和语句2不存在依赖关系,因此处理器可能会将语句1和语句2进行调换顺序,即先执行语句2再执行语句1,那么如果线程1先执行完语句2,还没有来得及执行语句1的时候,线程开始执行,发现inited
已经为true
,它就会认为上下文已经被初始化完成了,从而会调用doSomethingwithconfig
方法,此时就会发生错误。因此我们可以使用volatile关键字修改inited变量,这样会禁止inited语句前后的重排序,从而保证了线程安全性。
五、volatile的实现机制和原理
通过前面对volatile的语义的使用的介绍,相信大家已经有了一个初步的了解,但是volatile为什么会实现这些特性呢?接来下我们就正式进入volatile底层原理的讲解。
1. 内存屏障
在介绍原理前,我们先了解一下内存屏障。
1.1 什么是内存屏障
- 内存屏障(memory barrier)是一个CPU指令。这条指令可以确保一些特定指令的执行顺序,影响一些数据的可见性(可能是某些指令执行后的结果)。
- 插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。
1.2 内存屏障的分类
- LoadLoad屏障
Load1; LoadLoad屏障; Load2;
Load1
和 Load2
代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障
Store1; StoreStore屏障; Store2;
Store1
和 Store2
代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障
Load1; LoadStore屏障; Store2;
在Store2被写入前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障
Store1; StoreLoad屏障; Load2;
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad
屏障的开销是四种屏障中最大的。
2. 实现机制
从代码的层面我们看不到volatile的实现机制,因此我们需要从汇编指令的层次进行研究,我们查看一下第四章第一节代码中doStop方法的汇编指令
# 可以看到此时有一个lock前缀指令 0x0000000003226f6e: lock add dword ptr [rsp],0h ;*putstatic stop ; - com.jicl.MyTest::doStop@1 (line 35)
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它会提供3个功能:
确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据。
如果是写操作,它会导致其他CPU中对应的缓存行无效。
volatile
的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
- 在每一个volatile写操作前面插入一个StoreStore屏障:保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中
- 在每一个volatile写操作后面插入一个StoreLoad屏障:避免volatile写与后面可能有的volatile读/写操作重排序
- 在每一个volatile读操作后面插入一个LoadLoad屏障:禁止处理器把上面的volatile读与下面的普通读重排序
- 在每一个volatile读操作后面插入一个LoadStore屏障:禁止处理器把上面的volatile读与下面的普通写重排序
3. 实现原理
在介绍完volatile的底层实现机制,我们来分析volatile是如何实现可见性和有序性的
3.1 可见性
如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。这一步确保了如果有其他线程对声明了volatile
变量进行修改,则立即更新主内存中数据。但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile
变量都是从主内存中获取最新的。
3.2 有序性
Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
到此这篇关于深入解析Java中volatile的底层原理的文章就介绍到这了,更多相关volatile的底层原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!