JUC - 共享模式之内存
笔记
前面的共享模式讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。
这一章我们进一步深入学习共享变量在多线程间的「可见性」问题与多条指令执行时的「有序性」问题。
2022-5-12 @YoungKbt
# Java 内存模型
# 什么是JMM
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java 内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM 从 Java5 开始的 JSR-133 发布后,已经成熟和完善起来。
JMM 规定了内存主要划分为 主内存 和 工作内存 两种。此处的主内存和工作内存跟 JVM 内存划分(堆、 栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是 Java 堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。
JVM 在设计时候考虑到,如果 Java 线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因此 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。
# JMM 的内存模型
线程 A 感知不到线程 B 操作了值的变化。如何能够保证线程间可以同步感知这个问题呢?只需要使用 volatile 关键字即可。volatile 保证线程间变量的可见性,简单地说就是当线程 A 对变量 X 进行了修改后,在线程 A 后面执行的其他线程能看到变量 X 的变动,更详细地说是要符合以下两个规则:
- 线程对变量进行修改之后,要立刻回写到主内存
- 线程对变量读取的时候,要从主内存中读,而不是缓存
各线程的工作内存间彼此独立,互不可见,在线程启动的时候,虚拟机为每个内存分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的副本,即为了提高执行效率。
内存交互操作
内存交互操作有 8 种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许例外)
- lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
- load(载入):作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中
- use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
- assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
- store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用
- write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内 存的变量中
JMM 对这八种指令的使用,制定了如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现。即:使用了 read 必须 load,使用了 store 必须 write
- 不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有 assign 的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 assign 和 load 操作
- 一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁
- 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前, 必须重新 load 或 assign 操作初始化变量的值
- 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量
- 对一个变量进行 unlock 操作之前,必须把此变量同步回主内存
JMM 对这八种操作规则和对 volatile 的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用 Java 的 happen-before 规则来进行分析。
happens-before 字面翻译过来就是先行发生。
A happens-before B 就是 A 先行发生于 B?
不准确!在 Java 内存模型中,happens-before 应该翻译成:前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量 a 赋值为 1,那后面一个操作肯定能知道 a 已经变成了 1。
我们再来看看为什么需要这几条规则?
因为我们现在电脑都是多 CPU,并且都有缓存,导致多线程直接的可见性问题。
所以为了解决多线程的可见性问题,就搞出了 happens-before 原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的不知道东南西北了!
JMM 体现在以下几个方面:
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 CPU 缓存的影响
- 有序性 - 保证指令不会受 CPU 指令并行优化的影响
volatile 就是 JMM 的一个同步机制类。
volitile 是 Java 虚拟机提供的轻量级的同步机制,三大特性:
保证可见性
不保证原子性
禁止指令重排
# volatile 可见性
# 退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
public static void main(String[] args) {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程 t 不会如预想的停下来
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们发现,即使修改 run 为 false,但是 while(run)
内部依然一直运行,没有停止,这是为什么呢?
分析一下:
初始状态,t 线程刚开始从主内存读取了 run 的值到工作内存,主内存是所有线程共享的内存,而工作内存是线程私有的。
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中(热点存储),减少对主存中 run 的访问,提高效率。
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
# 解决方法
使用 volatile(易变关键字)。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主主存。
public static void main(String[] args) {
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在 一个线程写,多个线程读 的情况:上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
2
3
4
5
6
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i--,只能保证看到最新值,不能解决指令交错,如下:
// 假设 i 的初始值为 0
getstatic i // 线程 2 获取静态变量i的值 线程内 i = 0
getstatic i // 线程 1 获取静态变量i的值 线程内 i = 0
iconst_1 // 线程 1 准备常量 1
iadd // 线程 1 自增 线程内 i = 1
putstatic i // 线程 1 将修改后的值存入静态变量 i 静态变量 i = 1
iconst_1 // 线程 2 准备常量 1
isub // 线程 2 自减线程内 i = -1
putstatic i // 线程 2 将修改后的值存入静态变量 i 静态变量 i = -1
2
3
4
5
6
7
8
9
10
11
注意:synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。如下:
public static void main(String[] args) {
static boolean run = true;
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
synchronized(lock) {
while(run){
// ....
}
}
});
t.start();
sleep(1);
synchronized(lock) {
run = false;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
所以更加建议使用 volatile 关键字。
如果代码的死循环中加入 System.out.println()
,会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,这是因为 System.out.println()
内部使用了 synchronized。
# volatile 两阶段终止模式
在一个线程 T1 中如何「优雅」终止线程 T2?这里的「优雅」指的是给 T2 一个料理后事的机会。
在前面 JUC - java线程 我们利用 interrupt 方法来实现两阶段终止模式,现在利用停止标记 + volatile 来实现。
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
private Thread thread;
private volatile boolean stop = false;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
}
// 执行监控操作
log.debug("执行监控记录");
}
},"监控线程");
thread.start();
}
public void stop() {
stop = true;
// 这里 interrupt 打断的是 17 行睡眠,否则等睡眠 1s 才能重新进入 while 循环
thread.interrupt();
}
}
public class Test {
public static void main(String[] args) {
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
结果
11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事
2
3
4
5
# 同步模式之 Balking
# 定义
Balking(犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。
# 实现
例如:
public class MonitorService {
// 用来表示是否已经有线程已经在执行启动了
private volatile boolean starting;
public void start() {
log.info("该监控线程已启动?({})", starting);
// 不加锁,可能多个线程进入 if 里,同时得出 starting 为 false,同时修改 starting 为 true
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
// 真正启动监控线程...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
当前端页面多次点击按钮调用 start 时,输出:
[http-nio-8080-exec-1] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(false)
[http-nio-8080-exec-1] cn.itcast.monitor.service.MonitorService - 监控线程已启动...
[http-nio-8080-exec-2] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)
[http-nio-8080-exec-3] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)
[http-nio-8080-exec-4] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)
2
3
4
5
它还经常用来实现线程安全的单例
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。
# volatile 有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码:
public static void main(String[] args) {
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = 1;
j = 2;
}
2
3
4
5
6
7
可以看到,至于是先执行 i 还是先执行 j,对最终的结果不会产生影响。所以,上面代码真正执行时,JVM 执行后既可以是
i = 1;
j = 2;
2
也可以是
j = 2;
i = 1;
2
这种特性称之为『指令重排』,上面代码在单线程没有任何影响,但是在多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧。
# 鱼罐头的故事
加工一条鱼需要 50 分钟,只能一条鱼、一条鱼顺序加工。
怎么优化时间呢?可以将每个鱼罐头的加工流程细分为 5 个步骤:
- 去鳞清洗 10 分钟
- 蒸煮沥水 10 分钟
- 加注汤料 10 分钟
- 杀菌出锅 10 分钟
- 真空封罐 10 分钟
首先工人无法做同一条鱼的第一步,还做这条鱼的第二步,因为第二步蒸煮沥水必须先完成第一步去鳞清洗(针对同一条鱼)。
但是工人可以在做第一条鱼的第一步时,做第二条鱼的第二步,第三条鱼的第三步,第四条鱼的第四步,第五条鱼的第五步。
这样即使只有一个工人,最理想的情况是:他能够在 10 分钟内同时做好这 5 件事,因为对第一条鱼的真空装罐,不会影响对第二条鱼的杀菌出锅,依此类推。
# 指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分 5 个阶段:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回。
术语参考:
- instruction fetch (IF)
- instruction decode (ID)
- execute (EX)
- memory access (MEM)
- register write back (WB)
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80 年代中叶到 90 年代中叶占据了计算架构的重要地位。
分阶段,分工是提升效率的关键!
指令重排的前提是,重排指令不能影响结果,例如:
public void test() {
// 可以重排的例子
int a = 10; // 指令 1
int b = 20; // 指令 2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令 1
int b = a - 5; // 指令 2
}
2
3
4
5
6
7
8
9
10
# 支持流水线的处理器
Clock Cycle Time:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的 Cycle Time 是 1s
CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数
IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
现代 CPU 支持 多级指令流水线,例如支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回的处理器,就可以称之为 五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
奔腾四(Pentium 4)支持高达 35 级流水线,但由于功耗太高被废弃。
如下图,CPU 虽然无法同时执行一条指令的所有步骤,但是可以执行一条指令的某个步骤,同时其他指令的其他步骤(所以理论上能同时执行五个步骤(五个指令的一个步骤加起来等于五个步骤)。
# 诡异的结果
public class Test {
int num = 0;
boolean ready = false;
// 线程 1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程 2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
I_Result 是一个对象,有一个属性 r1 用来保存结果,问可能的结果有几种?
- 情况 1:线程 1 先执行,这时
ready = false
,所以进入 else 分支结果为 1 - 情况 2:线程 2 先执行 num = 2,但没来得及执行 ready = true,线程 1 执行,还是进入 else 分支,结果为 1
- 情况 3:线程 2 执行到
ready = true
,线程 1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
还有一个诡异的结果:
- 线程 2 执行
ready = true
,切换到线程 1,进入 if 分支,相加为 0,再切回线程 2 执行 num = 2
代码变成了:
public class Test {
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
ready = true;
num = 2;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
13 和 14 行代码发生交换,这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化。
如何验证呢?
这个现象需要通过大量测试才能复现,借助 Java 并发压测工具 jcstress,官方地址:https://wiki.openjdk.java.net/display/CodeTools/jcstress
首先在命令窗口执行如下代码,就能创建一个并发压测的 Maven 项目:
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -
DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=cn.itcast -
DartifactId=ordering -Dversion=1.0
2
3
创建 Maven 项目成功,提供如下测试类:
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
我们将上面测试类打包成 jar 包并运行
mvn clean install
java -jar target/jcstress.jar
2
会输出我们感兴趣的结果,摘录其中一次结果:
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
2 matching test results.
[OK] test.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 1,729 ACCEPTABLE_INTERESTING !!!!
1 42,617,915 ACCEPTABLE ok
4 5,146,627 ACCEPTABLE ok
[OK] test.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 1,652 ACCEPTABLE_INTERESTING !!!!
1 46,460,657 ACCEPTABLE ok
4 4,571,072 ACCEPTABLE ok
2
3
4
5
6
7
8
9
10
11
12
13
14
15
可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。
# 解决方法
volatile 修饰的变量,可以禁用指令重排。
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
结果为:
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
0 matching test results.
2
3
可以看到,没有 0 出现了。
为什么 volatile 不修饰 num,而是修饰 ready 呢?
volatile 修饰变量的前面所有代码禁用指令重排,即 19 行代码之前的代码都不会发送指令重排。
如果仅修饰 num,则 num 后面的代码还是会发送指令重排(ready),所以 volatile 修饰变量的前面代码禁用指令重排。
# 原理之 volatile
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)。
- 对 volatile 变量的 写指令后 会加入写屏障
- 对 volatile 变量的 读指令前 会加入读屏障
# 如何保证可见性
写屏障(sfence)保证在该屏障之前的代码,对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
2
3
4
5
ready 是 volatile 修饰,则第 3 行形成写屏障,所以写屏障 之前的所有代码,包括写屏障所在的代码:ready = true
和 num = 2
都同步到主存当中。
而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
2
3
4
5
6
7
8
9
ready 是 volatile 修饰,则第 4 行形成读屏障,所以读屏障 之后的所有代码,包括读屏障所在的代码,即第 4 行到第 7 行都同步到主存当中。
# 如何保证有序性
写屏障会确保指令重排序时,不会将写屏障 之前的代码 排在写屏障之后。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
2
3
4
5
ready 是 volatile 修饰,则第 3 行形成写屏障,所以写屏障 之前的所有代码,包括写屏障所在的代码:ready = true
和 num = 2
都不会发生指令重排序。
读屏障会确保指令重排序时,不会将读屏障 之后的代码 排在读屏障之前。
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
2
3
4
5
6
7
8
9
ready 是 volatile 修饰,则第 4 行形成读屏障,所以读屏障 之后的所有代码,包括读屏障所在的代码,即第 4 行到第 7 行都不会发生指令重排序。
缺点:还是那句话,volatile 不能解决指令交错:
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
# synchronized 能保证有序性吗?
synchronized 无法保证有序性(指令重排),但能保证正确的最终结果是对的,即 synchronized 代码块里虽然发生了指令重排,但是 得到的结果还是对的,所以另一个角度看是能保证有序性。
保证有序性(结果是对的)条件
共享变量必须完全在 synchronized 代码块里,才能保证发生了指令重排,但是结果还是对的。
一旦共享变量在 synchronized 代码块外面被读写,那么 synchronized 内部发生了指令重排,无法确保结果是预期的。
下面提出的问题会进一步说明。
# double-checked locking 问题
double-checked 单例模式如何实现来源视频:
https://www.bilibili.com/video/BV16J411h7Rd?p=149
https://www.bilibili.com/video/BV16J411h7Rd?p=150
https://www.bilibili.com/video/BV16J411h7Rd?p=151
以著名的 double-checked locking 单例模式为例:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) {
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
以上的实现特点是:
- 懒惰实例化
- 首次使用
getInstance()
才使用 synchronized 加锁,后续使用时无需加锁 - 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外
但在多线程环境下,上面的代码是有线程不安全问题的,即第 9 行的 INSTANCE = new Singleton();
代码可能发生指令重排,首先我们要知道该代码有两步执行:
new Singleton()
调用 Singleton 构造方法进行初始化- 初始化完成后,赋值给 INSTANCE
上面两步可能会发生指令重排,即先赋值给 INSTANCE,再进行构造方法初始化。
当 t1 线程先将 没有完全初始化 的值赋值给 INSTANCE,t2 线程进入第 5 行 if(INSTANCE == null)
,此时 INSTANCE 是一个没有完全初始化的值(不为 false),所以直接到 13 行 return 出去,最后 t1 线程才进行完成初始化。但为时已晚,t2 线程拿着没有完全初始化的 INSTANCE 使用了。
从 getInstance 方法对应的字节码分析:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
发生指令重排的字节码如下:
- 17 表示创建对象,将对象引用入栈,即
new Singleton()
- 20 表示复制一份对象引用地址
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,赋值给
static INSTANCE
也许 JVM 会优化为:先执行 24,再执行 21,即先赋值给 INSTANCE,再调用构造方法。
如果两个线程 t1,t2 按如下时间序列执行:
关键在于 0: getstatic
这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值。
如上图,t1 线程先执行 24: putstatic(给 INSTANCE 赋值)
,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例,并 return 返回。
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。
synchronized 能保证有序性吗?
synchronized 无法保证有序性(指令重排),但能保证正确的最终结果是对的,即 synchronized 代码块里虽然发生了指令重排,但是 得到的结果还是对的,所以另一个角度看是能保证有序性。
但是上面例子可以看到 synchronized 代码块里发生了指令重排,结果发生了改变,不是对的,这冲突了。
synchronized 保证结果是正确的,这需要一个条件:共享变量必须完全在 synchronized 代码块里,才能保证发生了指令重排,结果还是对的。
上面例子 INSTANCE 是共享变量,但是 INSTANCE 并不完全在 synchronized 代码块里,第 5 行 if (INSTANCE == null) {
在 synchronized 代码块外面,所以无法保证结果是正确的。
所以完全解决有序性(指令重排),就是加上 volatile 修饰。
# double-checked locking 解决
给 INSTANCE 加上 volatile 修饰。
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 读屏障下面都能防止指令重排
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 读屏障下面都能防止指令重排
if (INSTANCE == null) {
INSTANCE = new Singleton();
// 写屏障上面都能防止指令重排
}
}
}
return INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
字节码上看不出来 volatile 指令的效果。
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
# happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
# 规则
happens-before 是一套规则总结,有如下规则:
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
public static void main(String[] args) { static int x; static Object m = new Object(); new Thread(()->{ synchronized(m) { x = 10; } },"t1").start(); new Thread(()->{ synchronized(m) { System.out.println(x); } },"t2").start(); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
public static void main(String[] args) { volatile static int x; new Thread(()->{ x = 10; },"t1").start(); new Thread(()->{ System.out.println(x); },"t2").start(); }
1
2
3
4
5
6
7
8
9
10
11线程 start 前对变量的写,该线程开始后对该变量的读可见
public static void main(String[] args) { static int x; x = 10; new Thread(()->{ System.out.println(x); },"t2").start(); }
1
2
3
4
5
6
7
8线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用
t1.isAlive()
或t1.join()
等待它结束)public static void main(String[] args) { static int x; Thread t1 = new Thread(()->{ x = 10; },"t1"); t1.start(); t1.join(); System.out.println(x) }
1
2
3
4
5
6
7
8
9
10
11线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
t2.interrupted
或t2.isInterrupted
)public static void main(String[] args) { static int x; public static void main(String[] args) { Thread t2 = new Thread(()->{ while(true) { if(Thread.currentThread().isInterrupted()) { System.out.println(x); break; } } },"t2"); t2.start(); new Thread(()->{ sleep(1); x = 10; t2.interrupt(); },"t1").start(); while(!t2.isInterrupted()) { Thread.yield(); } System.out.println(x); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
public static void main(String[] args) { volatile static int x; static int y; new Thread(()->{ y = 10; x = 20; },"t1").start(); new Thread(()->{ // x=20 对 t2 可见, 同时 y=10 也对 t2 可见 System.out.println(x); },"t2").start(); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
上面说的变量都是指成员变量或静态成员变量。
# 总结
什么时候发生不可见性?
当一个线程反复使用某个变量时,该变量就会变成 热点变量(反复使用变量次数达到阈值,变成热点)。热点变量就会进入该线程的私有工作内存,所以外界修改了该变量值,也不会同步到该线程的私有工作内存里。
上面的 happens-before 里,虽然有些例子没有用到 volatile 修饰变量,但是变量并没有反复被线程使用,如下:
static int x;
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x); // x 没有被反复使用
break;
}
}
},"t2");
2
3
4
5
6
7
8
9
x 变量并没有被 t2 线程使用,它仅仅在 t2 线程被打断后才被使用,而一旦 t2 线程被打断,就会使用一次 x 变量,然后跳出循环,而使用一次 x 变量,并没有达到热点阈值,所以不会发生不可见性。
只有被反复使用的变量才会被变成热点变量(run 变量):
static boolean run = true;
Thread t2 = new Thread(()->{
while(run) {
// ...
}
},"t2");
2
3
4
5
6
# 习题
# balking 模式习题
希望 doInit()
方法仅被调用一次,下面的实现是否有问题,为什么?
public class TestVolatile {
volatile boolean initialized = false;
void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() {
}
}
2
3
4
5
6
7
8
9
10
11
12
有问题,t1、t2 线程可以同时进入第 4 行的 if (initialized)
,这时 initialized = false
,所以两个线程都会进入 doInit()
方法,如何避免?使用 synchronized 保护。
public class TestVolatile {
volatile boolean initialized = false;
void init() {
synchronized(this) {
if (initialized) {
return;
}
}
doInit();
initialized = true;
}
private void doInit() {
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题。
饿汉式:类加载就会导致该单实例对象被创建
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建
实现 1
// 问题 1:为什么加 final
// 问题 2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
public final class Singleton implements Serializable {
// 问题 3:为什么设置为私有? 是否能防止反射创建新的实例?
private Singleton() {}
// 问题 4:这样初始化是否能保证单例对象创建时的线程安全?
private static final Singleton INSTANCE = new Singleton();
// 问题 5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 问题 1:final 为了防止子类继承 Singleton,然后重写方法
- 问题 2:加入 13 - 15 代码,因为反序列化时,发现有自己写的
readResolve
方法,则会调用该方法 - 问题 3:设为私有防止被外界
new Singleton()
,违反单例思想。不能防止反射创建新的实例 - 问题 4:能保证单例对象创建时的线程安全
- 因为 INSTANCE 是静态变量,private 防止外界直接修改 INSTANCE,而且这样在 getInstance 方法里有更多关于 INSTANCE 的操作
实现 2
// 问题 1:枚举单例是如何限制实例个数的
// 问题 2:枚举单例在创建时是否有并发问题
// 问题 3:枚举单例能否被反射破坏单例
// 问题 4:枚举单例能否被反序列化破坏单例
// 问题 5:枚举单例属于懒汉式还是饿汉式
// 问题 6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton {
INSTANCE;
}
2
3
4
5
6
7
8
9
- 问题 1:创建对象时,写多少个对象,就有多少个实例个数
- 问题 2:没有,因为是静态变量,由 JVM 先创建出来
- 问题 3:不会被反射破坏单例
- 问题 4:不会被反序列化破坏单例
- 问题 5:属于饿汉式
- 问题 6:可以写构造方法,在构造方法内部实现
实现 3
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析这里的线程安全, 并说明有什么缺点
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
加了 synchronized,每次调用 getInstance 都加锁,损耗性能。
实际上第一次调用 getInstance,赋值给 INSTANCE 后,后面的调用都不应该加锁,因为 INSTANCE 肯定不为 null,而 每次加锁会白白损耗性能。
实现 4:DCL(double-checked locking)
public final class Singleton {
private Singleton() { }
// 问题 1:解释为什么要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 问题 2:对比实现 3, 说出这样做的意义
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题 3:为什么还要在这里加为空判断, 之前不是判断过了吗
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 问题 1:第 16 行代码可能发生指令重排,即 INSTANCE 先赋值,再初始化,这样 9 行 INSTANCE 还没被初始化,就返回赋值的 INSTANCE。volatile 能解决指令重排问题
- 问题 2:一旦 INSTANCE 被赋值不为 null 后,直接返回即可
- 问题 3:防止多线程安全问题,INSTANCE 还为 null 时,t1、t2 线程进入到 synchronized 处,当 t1 线程先拿到锁,进入代码块里进行初始化后,t2 次数进入代码块里,先进行 if 判断,此时 INSTANCE 被赋值了,所以直接返回
实现 5
public final class Singleton {
private Singleton() { }
// 问题 1:属于懒汉式还是饿汉式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题 2:在创建时是否有并发问题
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
- 问题 1:懒汉式,LazyHolder 是内部类,当内部类没有被调用时,是不会初始化的
- 问题 2:没有并发问题,static 静态变量是唯一的,这由 JVM 内部保证安全唯一的