文章目录
1 问题背景
复习完面试必备——Java内存模型Java Memory Model(JMM),仍处于一种懂原理但是不懂如何组织语言回答给面试官听。今天再次整理一下回答的思路。
本文仅供自己总结参考使用,如有不正确之处请指正。如对底层原理有兴趣,详情见面试必备——Java内存模型Java Memory Model(JMM)
2 说说你对volatile关键字的理解
要谈volatile关键字,首先得说说Java内存模型。而Java内存模型是在硬件效率的问题下引入的。
2.1 硬件效率
2.1.1 引入高速缓存
CPU计算的速度要远高于内存的速度,因此引入一个高速缓存来弥补这个差异。具体实现是,CPU从主内存中读取运算所需的数据到高速缓存中,运算结束后,再将结果同步到主内存。
2.1.2 处理器内部优化——乱序执行
处理器执行代码的顺序不一定与输入代码的顺序一致
2.2 带来缓存一致性的问题
引入了高速缓存,却带来了缓存一致性的问题。多个CPU的运算都涉及到同一个共享变量,将运算结果从各自的工作内存同步到主内存时,到底取哪一个值呢?
2.3 Java内存模型JMM
为了解决缓存一致性的问题,设计了一个协议——Java内存模型。线程做读写操作时,需要按照协议定下的规则执行。
Java内存模型如下所示:
按照上面的图分别从主内存、工作内存、线程之间的影响来介绍Java内存模型:
- 主内存是虚拟机内存中的一部分。所有的变量都处于主内存中。
- 每个线程都有自己的工作内存,保存了线程需要用到的变量的主内存副本拷贝。线程不能直接读写主内存的变量,只能操作各自工作内存的变量副本。
- 线程之间无法访问对方的工作内存中的变量。主内存充当中介角色完成线程之间的值传递。
2.4 8个原子性操作
介绍完Java内存模型,现在详细讲述该模型定义的8个原子操作,这些操作也就是从主内存拷贝到工作内存、从工作内存同步回主内存的实现细节。
结合下面的代码以及图讲解,代码参考自诸葛老师的Java高并发编程精髓Java内存模型JMM详解全集:
有一个变量
initFlag = false
。有两个线程,线程1一直循坏执行空操作,直到initFlag
被改为true
。线程2执行操作,将initFlag
修改为true
。运行结果是线程1一直在循环里面空转,无法结束。代码如下:
public class VarVisibility {
private static boolean initFlag = false;
public static void change() {
initFlag = true;
}
public static void main(String[] args) throws InterruptedException {
// 线程1
new Thread(new Runable() {
@Override
public void run() {
System.out.println("initializing data...");
while (!initFlag) {
}
System.out.println("Success...");
}
}).start();
Thread.sleep(2000);
// 线程2
new Thread(new Runable() {
@Override
public void run() {
change();
}
}).start();
}
}
用下图来描述上面代码发生的过程:
如上图,主内存中有initFlag
变量,初始值是false
。线程1执行read操作,将false
值从主内存中读取到线程1的工作内存,执行load操作将false
值放入initFlag
变量副本中,执行use 操作从工作内存读取initFlag = false
到执行引擎进行计算。线程2执行read操作,将false
值从主内存中读取到线程2的工作内存,执行load操作将false
值放入initFlag
变量副本,执行use操作从工作内存读取initFlag = false
到执行引擎进行计算,在执行引擎中修改initFlag
的值为true
,执行assign
操作将执行引擎中的true
重新赋值给工作内存的initFlag
变量副本,使得工作内存中initFlag = true
,执行store操作将initFlag = true
传递到主内存,执行write操作将true
存入到主内存中的initFlag
变量。
由于线程2更新完initFlag
变量的值后,线程1没拿到最新的值,那么线程1一直循坏执行空操作。
从上面的程序运行结果可以看到,这样会存在工作内存一致性问题。由于多CPU并发执行操作时可能会存在各自的工作内存不一致,早期的解决方案是对总线加锁。如下:
如上图所示,线程读取、写入数据到主内存时,需要通过CPU总线作为媒介。当有线程要修改主内存的变量时,对总线加锁,以此来达到线程独占主内存的目的。这样就是其他线程能拿到最新的 值。但是总线被加锁的期间,其他线程无法从主内存读取数据活向主内存写入数据。多CPU并发执行任务变成顺序串型执行任务,效率大大降低。
对CPU总线加锁的锁定范围太大,可以适当减小锁定范围的粒度。因此又提出了一种解决方案,常见的有MESI协议,如下所示:
如上图所示,线程2执行store操作将initFlag
变量副本被修改后的true
值传回到主内存并执行write操作将true
值写回给主内存的initFlag
变量。线程2要将新值写回主内存,会向总线发消息,线程1会通过总线嗅探机制监听到自己工作内存中的initFlag
变量被修改了,线程1会立即将自己工作内存的initFlag = false
失效,当线程1的执行引擎需要用到initFlag
变量时,线程1要重新执行read 和 load操作,那么线程1就能拿到最新值了。(不必纠结于底层到底是怎么做到失效的,有兴趣可了解MESI协议的详情)
2.5 volatile型变量的特性
被
volatile
关键字修饰的变量有2个特性:对所有线程的可见性;禁止指令重排序
2.5.1 可见性
一个线程修改了被
volatile
修饰的变量,新值对其他线程来说是可以立即得知的。
还是采用前面的代码例子,用
volatile
修饰共享变量initFlag
。代码如下:
public class VarVisibility {
private volatile static boolean initFlag = false;
public static void change() {
initFlag = true;
}
public static void main(String[] args) throws InterruptedException {
// 线程1
new Thread(new Runable() {
@Override
public void run() {
System.out.println("initializing data...");
while (!initFlag) {
}
System.out.println("Success...");
}
}).start();
Thread.sleep(2000);
// 线程2
new Thread(new Runable() {
@Override
public void run() {
change();
}
}).start();
}
}
以上代码被转成汇编指令时,会在change()
方法中的initFlag = true
指令前加一个lock
前缀指令。该lock指令
会 锁定initFlag
变量副本所在的缓存行的数据,并 立即 写回到系统内存。这个写回操作会引起在其他CPU里缓存了该内存地址的数据 无效。(MESI协议详情可了解CPU中的缓存、缓存一致性、伪共享和缓存行填充)
此处有3个重要点,一是 立即写 回内存;二是写的时候(即store、write操作)会 在store前先 lock,write完之后再 unlock;三是使其他CPU的缓存的数据 失效 。 如下图所示:
为什么要加锁?如果不加锁会有什么后果?
有可能读取到旧数据。前面lock前缀
汇编指令提到,有一个点是写回内存时会引起在其他CPU里缓存了该内存地址的数据 无效。而当其他CPU的执行引擎此时要用到那个数据,发现数据无效,就会执行read 和 load操作重新读取数据。没加锁的情况下,立即就能读取数据,并且该数据还是旧的。如果加锁,要等待锁释放才能读取数据,并且是读到新数据。
前面提到的总线加锁与此处的加锁有什么区别?
此处加锁的粒度比总线加锁更小,前者的加锁只有在回写主内存时候才无法运算,效率比总线加锁更高。
2.5.2 禁止指令重排序
在虚拟机看来,在 同一个线程中,如果调整某些指令的顺序对最终结果没有影响,那么虚拟机有可能会对这些指令进行 重排序,来达到更好的性能表现(这其实就是as-if-serial原则,不管怎么重排序,单线程程序的执行结果不能被改变)。但是在多线程并发的场景下,重排序可能会存在问题,最经典的例子就是双重检查锁定单例(Double Check Lock Singleton)。代码如下:
使用
jclasslib
的idea插件查看下面代码中synchronied
同步代码块的字节码,注意下面代码中的注释:
public class DoubleCheckLockSingleton {
private static DoubleCheckLockSingleton instance = null;
public DoubleCheckLockSingleton() {
}
public static DoubleCheckLockSingleton getInstance() {
if (instance == null) { // 2. 此时来了一个线程2,因为instance字段目前已经是非null,所以直接return了出去一个未完全初始化的instance对象。
/**
* 字节码
* 10 monitorenter
* 11 getstatic #2 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.instance : Lcom/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton;>
* 14 ifnonnull 27 (+13)
* 17 new #3 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton>
* 20 dup
* 21 invokespecial #4 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.<init> : ()V>
* 24 putstatic #2 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.instance : Lcom/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton;>
* 27 aload_0
* 28 monitorexit
*/
synchronized (DoubleCheckLockSingleton.class) {
if (instance == null) {
/**
* 1. 线程1执行new DoubleCheckLockSingleton,而该代码是有若干条指令组成的。
* 1)第一条指令是上面字节码中的序号17的new指令,表示new一个对象,划分一个内存区域给一个对象,但是该对象的值还是0值,还没有完全初始化
* 2)第二条指令是上面字节码中的序号20的dup指令,表示复制栈顶的内容,此处不需要关注
* 3)第三条指令是上面字节码中的序号21的invokespecial指令,表示对一个对象执行init方法,即初始化对象的值
* 4)第四条指令是上面字节码中的序号24的putstatic指令,表示将new指令出来的对象赋值给DoubleCheckLockSingleton类的instance字段
* 在同一个线程内,调整putstatic和invokespecial指令对最终结果没有影响,因此虚拟机有可能会对这两个指令重排序。
* 当先执行putstatic指令再执行invokespecial指令,即将一个未完全初始化的对象复制给了instance字段,再执行初始化。
* 那么当执行到第一个if的时候,见第一个if的注释
*/
instance = new DoubleCheckLockSingleton();
}
}
}
return instance;
}
}
解释:注释中提到未完全初始化的对象是指,一个对象要完成初始化,必须要经过划分内存区域、初始化0值、设置对象头、执行init方法,才能算是一个完整的对象,没有经过这四个过程的对象都是未完全初始化的对象。上面出现的问题就是一个对象未经过init方法就被返回出去了。
如何解决上面指令重排序的问题?方法是对共享对象加
volatile
,禁止指令重排序。底层实现是内存屏障
。
什么是
内存屏障
?看下面伪代码:
int x = 0;
volatile int y = 0;
int z = 0;
handle() {
x = x + 1;
// ------读屏障--------
y = y + 1;
// ------写屏障--------
z = z + 1;
}
解释:根据as-if-serial 原则,不管怎么排序,单线程内程序执行的结果不能被改变。又因为x、y、z
三个变量之间的运算不存在依赖关系,因此该代码块有可能被重排序。但是y变量是被volatile
修饰,因此会在对y操作的代码前后加上屏障。所谓的屏障,即x = x + 1
的代码不能调到读屏障之后,z = z + 1
的代码不能调到写屏障之前。这样就实现了禁止对y变量的操作进行重排序。
下面用代码例子给出jvm规范定义的内存屏障:(Load表示读取,Store表示写。)
StoreStore屏障:
volatile int a = 0;
a = 1; // 写操作,将1写给a
StoreStore屏障
a = 2; // 写操作,将2写给a
如上,因为a是volatile变量,因此只需看对a变量是做写操作还是读操作即可。在两个写操作之间,要禁止这两个指令重排序,则会在他们之间加上StoreStore
屏障。
LoadLoad屏障:
volatile int a = 0;
int b = 1;
int c = 1;
b = a; // 读操作,读取a的值
LoadLoad屏障
c = a; // 读操作,读取a的值
如上,因为a是volatile变量,只需关注对a做写还是做读。因为是两个读操作,所以要禁止他们重排序,则会在两个读操作之间加上LoadLoad
屏障。
以此类推,还有LoadStore
屏障、StoreLoad
屏障。
jvm规范规定对volatile变量要实现内存屏障,如下:
volatile int a = 0;
a = 2; // volatile写
StoreStore屏障
a = 1; //volatile写
StoreLoad屏障
b = a; // volatile读
LoadLoad屏障
LoadStore屏障
为什么加了屏障就能实现禁止重排序?底层是什么?如下:
volatile int a = 0;
a = 1;
StoreLoad屏障
b = a;
解释:hotspot底层的c++代码运行StoreLoad屏障,StoreLoad屏障其实是一个c++写的方法。里面会最终在a = 1;
后加一个lock指令,而cpu 识别到lock指令,则会禁止lock指令前后的操作进行重排序。
2.6 volatile不保证原子性
现有10个线程,每个线程执行1000次对num加1操作,最终的num能否保证等于
10 * 1000
。代码如下:
public class NotAtomic {
public static volatile int num = 0;
public static void increarse(){
num++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
increarse();
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("num = " + num);
}
}
答案:num最终结果只会小于等于10 * 1000
。
为什么会出现小于的情况?因为
num++
操作是由以下4条字节码指令组成的:
0 getstatic #2 <com/ganzalang/gmall/concurrentart/jvm/NotAtomic.num : I>
3 iconst_1
4 iadd
5 putstatic #2 <com/ganzalang/gmall/concurrentart/jvm/NotAtomic.num : I>
解释:getstatic是从主内存获取num值,iconst_l是将num值放入线程的栈(栈是线程私有的,由栈帧组成,每个栈帧其实就是每一个方法,栈帧里面有局部变量表、操作数栈等。此处从JMM角度可以将线程的栈理解为线程的工作内存),iadd是对num做加法操作,putstatic是将num值写回到主内存。(如果对这些概念不理解,可以看看黑马出品的JVM教程,推荐从第一集开始看,如果有基础可直接看P112)
结合字节码指令解释:
当getstatic指令把num的值取到操作栈顶时,volatile保证num的值在此时是正确的,但是在执行iconst_1、iadd这些指令时,其他线程可能已经把num的值加大了,而在操作栈顶的值变成了过期的数据,所以putstatic指令执行后就把较小的num值同步回主内存。
结合字节码指令并结合图来讲解:
如上图所示,先看蓝色线的数据流向,再看紫色线的数据流向。线程1线程2都将num = 0
加载到各自的工作内存。线程2的执行引擎 use num的值到执行引擎中,此时线程1已经将num加1并同步回主内存。线程2工作内存中的num = 0
失效,执行引擎中的num值其实也是过期的数据了。线程2将num加1后把1值assign给工作内存中的num变量,然后线程2把num = 1
同步回主内存,此时主内存也是num = 1
,因此实际上num没有增加过。所以最终的num值会小于等于10 * 1000
。