目录
前言
在写内存模型的文章的时候,恰好赶上小周同学的JVM基础文章,对内存模型和虚拟机栈讨论了一番。
在JVM和JMM中都提到了内存,不管是哪块内存从操作系统层面来说都是物理内存,只是从不同的层面给他了不同的表述,就像勾勾公众号里是小编,在工作中是个搬砖者,描述不一样但是同指一个对象。
JMM层面内存:工作内存、主内存。
JVM:堆,虚拟机栈,方法区,本地方法栈,程序计数器。
工作内存等价于虚拟机栈,虚拟机栈与线程同生共死。
主内存等价于堆区+方法区。
你是否有不同的观点呢?
本篇文章勾勾想从不那么深的角度理解一下volatile,虽然平时开发中不常使用,但是阅读源码比如AQS这个是必会的知识点。
想要深入的理解volatile的底层原理,Java代码层面展示出来的只是结果,它的底层涉及到Jdk源码、汇编、CPU、操作系统等知识,奈何勾勾才疏学浅,只能从表象理解一下volatile关键字。
volatile语义
volatile可以用来修饰实例变量或者类变量。被volatile修饰的变量具有如下语义:
-
当一个线程修改volatile修饰的变量时,其他线程能立即看到修改后的最新值,即volatile保证了可见性。
-
volatile关键字禁止JMM和处理器对volatile修饰的变量进行重排序,即volatile保证了有序性。
-
volatile不保证原子性,因此volatile被称为轻量级锁。
volatile与可见性
public class TestVolatile {
private static int state ;
public void setState(int newState) {
state = newState;
}
public static void main(String[] args) {
new Thread(()->{
while (true) {
if (state == 1){
System.out.println("判断标志位为1,退出循环");
break;
}
}
},"listen").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
new TestVolatile().setState(1);
System.out.println("将标志修改为1");
},"update").start();
}
}
在上述代码中定义了一个共享的state状态变量,启动listen线程监听state状态的值,当state状态为1时则退出循环,优先启动监听线程。另外启动update线程修改state状态的值为1。
运行结果如下图:
然后我们用volatile修饰state状态变量:
private static volatile int state ;
线程监听到了修改的值,退出了循环,那么volatile是怎么实现可见性呢?
我们先查看没有volatile修饰和有volatile修饰的字节码信息,勾勾给大家推荐一个可视化查看字节码的插件jclasslib,只需要安装插件即可使用。
没有volatile修饰的字节码内容:
有volatile关键字修饰时的字节码内容:
通过字节码对比可以发现,字节码文件是一样的,那么volatile是如何实现可见性呢?勾勾是一个面向百度开发的程序媛,这个时候必须问下度娘,看了几篇博客结合《深入理解Java虚拟机》这本书,勾勾对可见性有个大致的概念性理解:
读volatile修饰的变量时,会从主内存中取数据,然后在线程的工作内存中创建变量副本。
写volatile修饰的变量时,会对总线lock加锁,此时其他CPU都不能访问到这个变量,当线程将修改后的数据写入主内存并通知其他CPU的数据失效后,对总线解锁。其他线程在后续的过程中因为变量失效不得不从主内存再次获取数据,从而保证了可见性。
所以测试用例的执行过程即是:
listen线程从主内存中获取state的值为0,然后缓存到工作内存中。
update线程修改state的值为1,并且写回到主内存中。
listen线程在工作内存中缓存失效,映射到硬件上就是CPU的L1 Cache或者L2 Cache中的Cache Line失效,因此需要从主内存中再次加载数据,就可以取到最新修改的值了。
volatile与原子性
public class TestVolatile {
public static volatile int num = 0;
private static final int threadCount = 20;
static CountDownLatch countDownLatch = new CountDownLatch(threadCount);
public static void incre() {
num ++;
}
public static void main(String[] args) {
for (int i = 0; i < threadCount; i++) {
new Thread(()->{
for (int m = 0; m < 1000; m++){
incre();
}
countDownLatch.countDown();
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("计算结果为:"+num);
}
}
在上面的代码中,勾勾启动了20个线程,每个线程都对共享变量num执行了1000次的加1操作,我们期望的结果是20000,但是不论执行多少次,得到的结果都比20000小。
volatile为什么不能保证原子性呢?
volatile变量在各个线程的的工作内存中可以存在不一致的情况,但是由于每次使用之前都需先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,但是Java里运算并非原子操作,导致volatile变量的运算在并发下也是不安全的。
我们对上面的测试类进行反编译,查看其中的incre方法:
public static void incre();
Code:
0: getstatic #2 // Field num:I
3: iconst_1
4: iadd
5: putstatic #2 // Field num:I
8: return
通过字节码文件分析,getstatic指定得到num的值后,volatile保证了num的值此时是最新的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经修改了num的值,而在操作栈顶的值就变成了过期的数据,putstatic执行执行后就可能把小的num值更新到了主内存。
JMM要求lock、unlock、read、load、assign、use、store、write这8个原子操作都具有原子性,但多个原子操作合在一起是不一定原子的,且对64位的数据类型(long和double)允许虚拟机将没有volatile修饰的读写操作划分为2次的32位操作,但是现在基本商用虚拟机都选择把64位数据的读写操作作为原子性来对待,所以开发中不必过多忧虑啦。
因为volatile变量不能保证原子性我们需要通过加锁(synchronized或者JUC中的原子类)来保证原子性。
volatile与有序性
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance(){
if (instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
上述测试用例是比较经典的DCL+volatile实现单例模式。
17: new #3 // class com/example/demo/articles/jmm/Singleton
//1)在堆区分配内存,生成一个空壳,并压入栈顶
20: dup
//2)赋值栈顶元素,将复制的数据压入栈顶
21: invokespecial #4 // Method "<init>":()V
//3)执行默认构造函数
24: putstatic #2 // Field nstance:Lcom/example/demo/articles/jmm/Singleton;
//4)将完整的对象赋值给共享变量instance
从字节码分析,第三步和第四步之间是没有依赖关系的,有可能发生指令排序而导致有的线程拿到了不完整的对象,这个时候的对象没有执行构造参数。
volatile保证有序性是通过禁止指令重排序来实现的。那么它是怎么做到禁止指令重排序呢?
勾勾先理解一个概念“内存屏障”,内存屏障分为两类Load和Store。
内存屏障的作用有两个:
-
阻塞屏障前后的指令重排序;
-
强制把工作内存中的数据写入主内存,并把其他缓存中的数据失效。
Load屏障的主要作用是在指令前插入Load屏障,可以让缓存的数据失效,强制从主存中获取数据。
Store屏障的主要作用是在指令后插入Store屏障,能让缓存中的最新数据写入主存,并且通知其他缓存中的数据失效。
勾勾对查看了上边测试代码的汇编指令,发现用volatile修饰instance的时候多了lock。
0x000000000297ed75: lock add dword ptr [rsp],0h
0x000000000297ed7a: cmp qword ptr [r10+46h],0h
0x000000000297ed82: jne 297ed9bh
0x000000000297ed84: mov rax,0h
0x000000000297ed8e: lock cmpxchg qword ptr [r10+16h],r15
0x000000000297ed94: jne 297ed9bh
0x000000000297ed96: or eax,1h
0x000000000297ed99: jmp 297edabh
0x000000000297ed9b: test eax,0h
0x000000000297eda0: jmp 297edabh
0x000000000297eda2: mov r10,qword ptr [rax]
0x000000000297eda5: lock cmpxchg qword ptr [rbp+0h],r10
volatile禁止指令重排序是通过“lock”前缀来实现的,其作用相当于内存屏障。
-
确保指令重排序时不会将其后面的代码排到内存屏障之前,也不会将前面的代码排到内存屏障之后。
-
确保在执行到内存屏障修饰的指令时前面的代码全部执行完毕。
-
强制将线程工作内存中的值刷新到主内存中。
-
如果是写操作,则会导致其他线程工作内存中的缓存数据失效。
等价于:
在每个volatile修饰的变量的写操作之前插入StoreStore屏障,在写操作之后插入了StoreLoad屏障。
在每个volatile修饰的变量读操作前插入LoadLoad屏障。在读操作后插入LoadStore屏障。
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
happens-before原则
学到这里我们发现JMM中的有序性需要通过volatile和锁来维护,但是平时开发中却不是太需要考虑,主要是因为Java语言中的天然的先行发生原则(happens-before),只需要满足这些原则,就无需任何同步器。
- 程序次序规则:在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作 。
- 线程终止规则:线程中所有的操作都先行发生于线程的终止检测。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行于它的finalize()方法的开始。
- 传递规则:如果操作A先行发生于操作B,而操作B先行发生于操作C,则可以得出操作A先行发生于操作C。
volatile与synchronized
使用:
-
volatile用于修饰变量,不能用于修饰方法,代码块,局部变量和常量。
synchronized可以用于修饰方法和代码块,不能修饰变量。
-
基础概念的保证:
volatile保证了可见性和有序性,无法保证原子性。
synchronized保证了原子性,有序性和可见性。虽然都保证了可见性和有序性,但是其实现原理也不相同。
-
阻塞:
volatile不会使线程进入阻塞。
synchronized会使线程进入阻塞。
小小的思考
将第一个测试用例中的state变量不用volatile修饰,但是监听线程进行sleep短暂的时间,运行可以看到listen线程很快退出循环。勾勾怀疑sleep进入阻塞状态,休眠到时后进入就绪状态时没有保留工作内存的变量副本。
再次修改代码测试,通过while循环等待一段时间,依然会退出循环。勾勾的另外一个怀疑是变量副本在工作内存中存在失效时间,且其他线程修改后的值什么时候刷新到主存是不确定的,如果我们的程序中没有一直监听共享变量的值,那么可能就会失效导致再次从主内存获取数据。
多并发的程序总是能给人惊喜,不知道勾勾的理解是否正确,你是否有其他的想法呢?
public class TestVolatile {
public static int state ;
public void setState(int newState) {
state = newState;
}
public static void main(String[] args) {
new Thread(()->{
while (true) {
//try {
// TimeUnit.MILLISECONDS.sleep(100);
//} catch (InterruptedException e) {
// e.printStackTrace();
// }
Long time = System.currentTimeMillis();
while ((System.currentTimeMillis()-time) < 1000){
}
if (state == 1){
System.out.println("判断标志位为1,退出循环");
break;
}
}
},"listen").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
new TestVolatile().setState(1);
System.out.println("将标志修改为1");
},"update").start();
}
}
我是勾勾,一直在努力的程序媛,感谢您的点赞和转发!
参考文档:
《深入理解Java虚拟机》
《Java高并发编程详解:多线程与架构设计》