共计 10083 个字符,预计需要花费 26 分钟才能阅读完成。
前言
volatile 是 Java 程序员必备的根底,也是面试官十分喜爱问的一个话题,本文跟大家一起开启 volatile 学习之旅,如果有不正确的中央,也麻烦大家指出哈,一起互相学习~
- 1.volatile 的用法
- 2.volatile 变量的作用
- 3. 古代计算机的内存模型(计算机模型,总线,MESI 协定,嗅探技术)
- 4.Java 内存模型(JMM)
- 5. 并发编程的 3 个个性(原子性、可见性、有序性、happen-before、as-if-serial、指令重排)
- 6.volatile 的底层原理(如何保障可见性,如何保障指令重排,内存屏障)
- 7.volatile 的典型场景(状态标记,DCL 单例模式)
- 8.volatile 常见面试题 && 答案解析
- 公众号:捡田螺的小男孩
github 地址
https://github.com/whx123/Jav…
1.volatile 的用法
volatile 关键字是 Java 虚拟机提供的的 最轻量级的同步机制 ,它作为一个修饰符呈现,用来 润饰变量,然而这里不包含局部变量哦。咱们来看个 demo 吧,代码如下:
/**
* @Author 捡田螺的小男孩
* @Date 2020/08/02
* @Desc volatile 的可见性摸索
*/
public class VolatileTest {public static void main(String[] args) throws InterruptedException {Task task = new Task();
Thread t1 = new Thread(task, "线程 t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {Thread.sleep(1000);
System.out.println("开始告诉线程进行");
task.stop = true; // 批改 stop 变量值。} catch (InterruptedException e) {e.printStackTrace();
}
}
}, "线程 t2");
t1.start(); // 开启线程 t1
t2.start(); // 开启线程 t2
Thread.sleep(1000);
}
}
class Task implements Runnable {
boolean stop = false;
int i = 0;
@Override
public void run() {long s = System.currentTimeMillis();
while (!stop) {i++;}
System.out.println("线程退出" + (System.currentTimeMillis() - s));
}
}
运行后果:
能够发现线程 t2,尽管把 stop 设置为 true 了,然而线程 t1 对 t2 的stop 变量视而不可见,因而,它始终在死循环 running 中。如果给变量 stop 加上 volatile 润饰,线程 t1 是能够停下来的, 运行后果如下:
volatile boolean stop = false;
2. vlatile 润饰变量的作用
从以上例子,咱们能够发现变量 stop,加了 vlatile 润饰之后,线程 t1 对 stop 就可见了。其实,vlatile 的作用就是:保障变量对所有线程可见性 。当然,vlatile 还有个作用就是, 禁止指令重排 ,然而它 不保障原子性。
所以当面试官问你volatile 的作用或者个性,都能够这么答复:
- 保障变量对所有线程可见性;
- 禁止指令重排序
- 不保障原子性
3. 古代计算机的内存模型(计算机模型,MESI 协定,嗅探技术,总线)
为了更好了解 volatile,先回顾一下计算机的内存模型与 JMM(Java 内存模型)吧~
计算机模型
计算机执行程序时,指令是由 CPU 处理器执行的,而打交道的数据是在主内存当中的。
因为计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次 CPU 执行完指令,而后等主内存慢吞吞存取数据吧,
所以古代计算机系统退出一层读写速度靠近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。
在多路处理器零碎中,每个处理器都有本人的高速缓存,而它们共享同一主内存。计算机形象内存模型 如下:
- 程序执行时,把须要用到的数据,从主内存拷贝一份到高速缓存。
- CPU 处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
- 当程序运算完结,把高速缓存的数据刷新会主内存。
随着科学技术的倒退,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3);
当多个处理器的运算工作都波及同一块主内存区域,可能导致 缓存数据不统一 问题。如何解决这个问题呢?有两种计划
- 1、通过在总线加 LOCK# 锁的形式。
- 2、通过缓存一致性协定(Cache Coherence Protocol)
总线
总线(Bus)是计算机各种性能部件之间传送信息的公共通信支线,它是由导线组成的传输线束,依照计算机所传输的信息品种,计算机的总线能够划分为数据总线、地址总线和管制总线,别离用来传输数据、数据地址和管制信号。
CPU 和其余性能部件是通过总线通信的,如果在总线加 LOCK# 锁,那么在锁住总线期间,其余 CPU 是无法访问内存,这样一来,效率就比拟低了。
MESI 协定
为了解决一致性问题,还能够通过缓存一致性协定。即各个处理器拜访缓存时都遵循一些协定,在读写时要依据协定来进行操作,这类协定有 MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly 及 DragonProtocol 等。比拟驰名的就是 Intel 的 MESI(Modified Exclusive Shared Or Invalid)协定,它的核心思想是:
当 CPU 写数据时,如果发现操作的变量是共享变量,即在其余 CPU 中也存在该变量的正本,会发出信号告诉其余 CPU 将该变量的缓存行置为有效状态,因而当其余 CPU 须要读取这个变量时,发现自己缓存中缓存该变量的缓存行是有效的,那么它就会从内存从新读取。
CPU 中每个缓存行标记的 4 种状态(M、E、S、I), 也理解一下吧:
缓存状态 | 形容 |
---|---|
M,被批改(Modified) | 该缓存行只被该 CPU 缓存,与主存的值不同,会在它被其余 CPU 读取之前写入内存,并设置为 Shared |
E,独享的(Exclusive) | 该缓存行只被该 CPU 缓存,与主存的值雷同,被其余 CPU 读取时置为 Shared,被其余 CPU 写时置为 Modified |
S,共享的(Shared) | 该缓存行可能被多个 CPU 缓存,各个缓存中的数据与主存数据雷同 |
I,有效的(Invalid) | 该缓存行数据是有效,须要时需从新从主存载入 |
MESI 协定是如何实现的?如何保障以后处理器的外部缓存、主内存和其余处理器的缓存数据在总线上保持一致的?多处理器总线嗅探
嗅探技术
在多处理器下,为了保障各个处理器的缓存是统一的,就会实现缓存缓存一致性协定,每个处理器通过嗅探在总线上流传的数据来查看本人的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被批改,就会将以后处理器的缓存行设置有效状态,当处理器对这个数据进行批改操作的时候,会从新从零碎内存中把数据库读到处理器缓存中。
4. Java 内存模型(JMM)
- Java 虚拟机标准试图定义一种 Java 内存模型, 来 屏蔽掉各种硬件和操作系统的内存拜访差别,以实现让 Java 程序在各种平台上都能达到统一的内存拜访成果。
- Java 内存模型 类比 于计算机内存模型。
- 为了更好的执行性能,java 内存模型并没有限度执行引擎应用处理器的特定寄存器或缓存来和主内存打交道,也没有限度编译器进行调整代码程序优化。所以 Java 内存模型 会存在缓存一致性问题和指令重排序问题的。
- Java 内存模型规定所有的变量都是存在主内存当中(相似于计算机模型中的物理内存),每个线程都有本人的工作内存(相似于计算机模型的高速缓存)。这里的 变量 包含实例变量和动态变量,然而 不包含局部变量,因为局部变量是线程公有的。
- 线程的工作内存保留了被该线程应用的变量的主内存正本,线程对变量的所有操作都必须在工作内存中进行,而不能间接操作操作主内存。并且每个线程不能拜访其余线程的工作内存。
举个例子吧,假如 i 的初始值是 0,执行以下语句:
i = i+1;
首先,执行线程 t1 从主内存中读取到 i =0,到工作内存。而后在工作内存中,赋值 i +1, 工作内存就失去 i =1,最初把后果写回主内存。因而,如果是单线程的话,该语句执行是没问题的。然而呢,线程 t2 的本地工作内存还没过期,那么它读到的数据就是脏数据了。如图:
Java 内存模型是围绕着如何在并发过程中如何解决 原子性、可见性和有序性 这 3 个特色来建设的,咱们再来一起回顾一下~
5. 并发编程的 3 个个性(原子性、可见性、有序性)
原子性
原子性,指操作是不可中断的,要么执行实现,要么不执行,根本数据类型的拜访和读写都是具备原子性,当然(long 和 double 的非原子性协定除外)。咱们来看几个小例子:
i =666;// 语句 1
i = j; // 语句 2
i = i+1; // 语句 3
i++; // 语句 4
- 语句 1 操作显然是原子性的,将数值 666 赋值给 i,即线程执行这个语句时,间接将数值 666 写入到工作内存中。
- 语句 2 操作看起来也是原子性的,然而它实际上波及两个操作,先去读 j 的值,再把 j 的值写入工作内存,两个操作离开都是原子操作,然而合起来就不满足原子性了。
- 语句 3 读取 i 的值,加 1,再写回主存,这个就不是原子性操作了。
- 语句 4 等同于语句 3,也是非原子性操作。
可见性
- 可见性就是指当一个线程批改了共享变量的值时,其余线程可能立刻得悉这个批改。
- Java 内存模型是通过在变量批改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的形式来实现可见性的,无论是一般变量还是 volatile 变量都是如此。
- volatile 变量,保障新值能立刻同步回主内存,以及每次应用前立刻从主内存刷新,所以咱们说 volatile 保障了多线程操作变量的可见性。
- synchronized 和 Lock 也可能保障可见性,线程在开释锁之前,会把共享变量值都刷回主存。final 也能够实现可见性。
有序性
Java 虚拟机这样形容 Java 程序的有序性的:如果在本线程内察看,所有的操作都是有序的;如果在一个线程中,察看另一个线程,所有的操作都是无序的。
后半句意思就是,在 Java 内存模型中,容许编译器和处理器对指令进行重排序 ,会影响到多线程并发执行的正确性;前半句意思就是as-if-serial 的语义,即不管怎么重排序(编译器和处理器为了进步并行度),(单线程)程序的执行后果不会被扭转。
比方以下程序代码:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
步骤 C 依赖于步骤 A 和 B,因为指令重排的存在,程序执行顺讯可能是 A ->B->C, 也可能是 B ->A->C, 然而 C 不能在 A 或者 B 后面执行,这将违反 as-if-serial 语义。
看段代码吧,假如程序先执行 read 办法,再执行 add 办法,后果肯定是输入 sum= 2 嘛?
bool flag = false;
int b = 0;
public void read() {
b = 1; //1
flag = true; //2
}
public void add() {if (flag) { //3
int sum =b+b; //4
System.out.println("bb sum is"+sum);
}
}
如果是单线程,后果应该没问题,如果是多线程,线程 t1 对步骤 1 和 2 进行了 指令重排序 呢?后果 sum 就不是 2 了,而是 0,如下图所示:
这是为啥呢?指令重排序 理解一下,指令重排是指在程序执行过程中,为了进步性能 , 编译器和 CPU 可能会对指令进行从新排序。CPU 重排序包含指令并行重排序和内存零碎重排序,重排序类型和重排序执行过程如下:
实际上,能够给 flag 加上 volatile 关键字,来保障有序性。当然,也能够通过 synchronized 和 Lock 来保障有序性。synchronized 和 Lock 保障某一时刻是只有一个线程执行同步代码,相当于是让线程程序执行程序代码了,天然就保障了有序性。
实际上 Java 内存模型的有序性并不是仅靠 volatile、synchronized 和 Lock 来保障有序性的。这是因为 Java 语言中,有一个后行产生准则(happens-before):
- 程序秩序规定:在一个线程内,依照控制流程序,书写在后面的操作后行产生于书写在前面的操作。
- 管程锁定规定:一个 unLock 操作后行产生于前面对同一个锁额 lock 操作
- volatile 变量规定:对一个变量的写操作后行产生于前面对这个变量的读操作
- 线程启动规定 :Thread 对象的 start() 办法后行产生于此线程的每个一个动作
- 线程终止规定 :线程中所有的操作都后行产生于线程的终止检测,咱们能够通过 Thread.join() 办法完结、Thread.isAlive()的返回值伎俩检测到线程曾经终止执行
- 线程中断规定 :对线程 interrupt() 办法的调用后行产生于被中断线程的代码检测到中断事件的产生
- 对象终结规定 :一个对象的初始化实现后行产生于他的 finalize() 办法的开始
- 传递性:如果操作 A 后行产生于操作 B,而操作 B 又后行产生于操作 C,则能够得出操作 A 后行产生于操作 C
依据 happens-before 的八大规定,咱们回到刚的例子,一起剖析一下。给 flag 加上 volatile 关键字,look look 它是如何保障有序性的,
volatile bool flag = false;
int b = 0;
public void read() {
b = 1; //1
flag = true; //2
}
public void add() {if (flag) { //3
int sum =b+b; //4
System.out.println("bb sum is"+sum);
}
}
- 首先呢,flag 加上 volatile 关键字,那就禁止了指令重排,也就是 1 happens-before 2 了
- 依据volatile 变量规定,2 happens-before 3
- 由 程序秩序规定,得出 3 happens-before 4
- 最初由 传递性,得出 1 happens-before 4,因而妥妥的输入 sum= 2 啦~
6.volatile 底层原理
以上探讨学习,咱们晓得 volatile 的语义就是保障变量对所有线程可见性以及禁止指令重排优化。那么,它的底层是如何保障可见性和禁止指令重排的呢?
图解 volatile 是如何保障可见性的?
在这里,先看几个图吧,哈哈~
假如 flag 变量的初始值 false,当初有两条线程 t1 和 t2 要拜访它,就能够简化为以下图:
如果线程 t1 执行以下代码语句,并且 flag 没有 volatile 润饰的话;t1 刚批改完 flag 的值,还没来得及刷新到主内存,t2 又跑过来读取了,很容易就数据 flag 不统一了,如下:
flag=true;
如果 flag 变量是由 volatile 润饰的话,就不一样了,如果线程 t1 批改了 flag 值,volatile 能保障润饰的 flag 变量后,能够 立刻同步回主内存。如图:
仔细的敌人会发现,线程 t2 不还是 flag 旧的值吗,这不还有问题嘛?其实 volatile 还有一个保障,就是 每次应用前立刻先从主内存刷新最新的值,线程 t1 批改完后,线程 t2 的变量正本会过期了,如图:
显然,这里还不是底层,实际上 volatile 保障可见性和禁止指令重排都跟 内存屏障 无关,咱们编译 volatile 相干代码看看~
DCL 单例模式(volatile)& 编译比照
DCL 单例模式(Double Check Lock,双重查看锁)比拟罕用,它是须要 volatile 润饰的,所以就拿这段代码编译吧
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton();
}
}
}
return instance;
}
}
编译这段代码后,察看有 volatile 关键字和没有 volatile 关键字时的 instance 所生成的汇编代码发现,有 volatile 关键字润饰时,会多出一个 lock addl $0x0,(%esp),即多出一个 lock 前缀指令
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24
lock 指令相当于一个 内存屏障,它保障以下这几点:
- 1. 重排序时不能把前面的指令重排序到内存屏障之前的地位
- 2. 将本处理器的缓存写入内存
- 3. 如果是写入动作,会导致其余处理器中对应的缓存有效。
显然,第 2、3 点不就是 volatile 保障可见性的体现嘛,第 1 点就是禁止指令重排列的体现。
内存屏障
内存屏障四大分类:(Load 代表读取指令,Store 代表写入指令)
内存屏障类型 | 形象场景 | 形容 |
---|---|---|
LoadLoad 屏障 | Load1; LoadLoad; Load2 | 在 Load2 要读取的数据被拜访前,保障 Load1 要读取的数据被读取结束。 |
StoreStore 屏障 | Store1; StoreStore; Store2 | 在 Store2 写入执行前,保障 Store1 的写入操作对其它处理器可见 |
LoadStore 屏障 | Load1; LoadStore; Store2 | 在 Store2 被写入前,保障 Load1 要读取的数据被读取结束。 |
StoreLoad 屏障 | Store1; StoreLoad; Load2 | 在 Load2 读取操作执行前,保障 Store1 的写入对所有处理器可见。 |
为了实现 volatile 的内存语义,Java 内存模型采取以下的激进策略
- 在每个 volatile 写操作的后面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的前面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的前面插入一个 LoadStore 屏障。
有些小伙伴,可能对这个还是有点纳闷,内存屏障这玩意太形象了。咱们照着代码看下吧:
内存屏障保障后面的指令先执行,所以这就保障了禁止了指令重排啦,同时内存屏障保障缓存写入内存和其余处理器缓存生效,这也就保障了可见性,哈哈~
7.volatile 的典型场景
通常来说,应用 volatile 必须具备以下 2 个条件:
- 1)对变量的写操作不依赖于以后值
- 2)该变量没有蕴含在具备其余变量的不变式中
实际上,volatile 场景个别就是 状态标记,以及DCL 单例模式。
7.1 状态标记
深刻了解 Java 虚拟机,书中的例子:
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = false;
// 假如以下代码在线程 A 中运行
// 模仿读取配置信息, 当读取实现后将 initialized 设置为 true 以告知其余线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假如以下代码在线程 B 中运行
// 期待 initialized 为 true, 代表线程 A 曾经把配置信息初始化实现
while(!initialized) {sleep();
}
// 应用线程 A 中初始化好的配置信息
doSomethingWithConfig();
7.2 DCL 单例模式
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {if(instance==null) {synchronized (Singleton.class) {if(instance==null)
instance = new Singleton();}
}
return instance;
}
}
8. volatile 相干经典面试题
- 谈谈 volatile 的个性
- volatile 的内存语义
- 说说并发编程的 3 大个性
- 什么是内存可见性,什么是指令重排序?
- volatile 是如何解决 java 并发中可见性的问题
- volatile 如何避免指令重排
- volatile 能够解决原子性嘛?为什么?
- volatile 底层的实现机制
- volatile 和 synchronized 的区别?
8.1 谈谈 volatile 的个性
8.2 volatile 的内存语义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为有效。线程接下来将从主内存中读取共享变量。
8.3 说说并发编程的 3 大个性
- 原子性
- 可见性
- 有序性
8.4 什么是内存可见性,什么是指令重排序?
- 可见性就是指当一个线程批改了共享变量的值时,其余线程可能立刻得悉这个批改。
- 指令重排是指 JVM 在编译 Java 代码的时候,或者 CPU 在执行 JVM 字节码的时候,对现有的指令程序进行从新排序。
8.5 volatile 是如何解决 java 并发中可见性的问题
底层是通过内存屏障实现的哦,volatile 能保障润饰的变量后,能够立刻同步回主内存,每次应用前立刻先从主内存刷新最新的值。
8.6 volatile 如何避免指令重排
也是内存屏障哦,跟面试官讲下 Java 内存的激进策略:
- 在每个 volatile 写操作的后面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的前面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的前面插入一个 LoadStore 屏障。
再讲下 volatile 的语义哦,重排序时不能把内存屏障前面的指令重排序到内存屏障之前的地位
8.7 volatile 能够解决原子性嘛?为什么?
不能够,能够间接举 i ++ 那个例子,原子性须要 synchronzied 或者 lock 保障
public class Test {
public volatile int race = 0;
public void increase() {race++;}
public static void main(String[] args) {final Test test = new Test();
for(int i=0;i<10;i++){new Thread(){public void run() {for(int j=0;j<100;j++)
test.increase();};
}.start();}
// 期待所有累加线程完结
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.race);
}
}
#### 8.8 volatile 底层的实现机制
能够看本文的第六大节,volatile 底层原理哈,次要你要跟面试官讲述,volatile 如何保障可见性和禁止指令重排,须要讲到内存屏障~
#### 8.9 volatile 和 synchronized 的区别?
- volatile 润饰的是变量,synchronized 个别润饰代码块或者办法
- volatile 保障可见性、禁止指令重排,然而不保障原子性;synchronized 能够保障原子性
- volatile 不会造成线程阻塞,synchronized 可能会造成线程的阻塞,所以前面才有锁优化那么多故事~
- 哈哈,你还有补充嘛~
举荐之前写的一篇文章:
Synchronized 解析——如果你违心一层一层剥开我的心
公众号
参考与感激
- << 深刻了解 Java 虚拟机 >>
- Java 并发编程:volatile 关键字解析
- 面试官最爱的 volatile 关键字
- 面试官没想到一个 Volatile,我都能跟他扯半小时
- 再有人问你 Java 内存模型是什么,就把这篇文章发给他。
- 【并发编程】MESI–CPU 缓存一致性协定
- 漫画:volatile 对指令重排的影响
- volatile 三大个性详解