文章目录
1、概述
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
2、可见性
2.1、退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
public class Test {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
TimeUnit.SECONDS.sleep(1);
// 线程t不会如预想的停下来
run = false;
System.out.println("主线程停止 t");
}
2.2、原因分析
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 即时编译器在运行一段时间后,会将程序优化,将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
-
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
2.3、解决办法
我们可以使用 volatile 关键字来修饰 run,它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
private static volatile boolean run = true;
我们还可以使用重量级的 synchronized 来保证 run 的可见性,它还需要创建对应的 Monitor 对象
private static boolean run = true;
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
synchronized (LOCK) {
if (!run) {
// ....
break;
}
}
}
});
t.start();
TimeUnit.SECONDS.sleep(1);
synchronized (LOCK) {
run = false;
}
System.out.println("主线程停止 t");
}
2.4、可见性 VS 原子性
如上例子中体现的那样,可见性保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,但是,volatile 修饰的变量并不能保证原子性,因为它只能保证当前线程看到的值为最新值,并不能解决指令的交错问题,比如两个线程分别做 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
为了保证原子性,还是需要乖乖的使用 synchronized 或者 ReentrantLock
- synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低
3、有序性
3.1、概述
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为『指令重排』,但是多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?明明顺序执行就可以了呀,接下来从 CPU 执行指令的原理来理解一下吧
3.2、原理
3.2.1、鱼罐头的故事
加工一条鱼需要 50 分钟,其中包含以下五个步骤
- 去鳞清洗 10 分钟
- 蒸煮沥水 10 分钟
- 加注汤料 10 分钟
- 杀菌出锅 10 分钟
- 真空封罐 10 分钟
其中,我们有去鳞机、蒸煮锅、汤料锅、杀菌锅、封罐器分别对以上五个步骤处理,现在我们有一批鱼需要处理,只有一个工人的情况,且最理想的情况下,我们可以在 10 分钟的时间里同时做这五件事,因为第二条鱼的去鳞清洗,不会影响第一条与的蒸煮沥水
3.2.2、指令重排优化
和鱼罐头类似,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令,因为一条指令还可以再划分成一个个更小的阶段例如,每条指令都可以分为:取指令 -> 指令译码 -> 执行指令 -> 内存访问 -> 数据写回 这 5 个阶段,就和鱼罐头的加工过程一样,在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,从而实现提升效率
指令重排的前提就是,不能影响结果,例如
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
3.2.3、支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
ps:奔腾四(Pentium 4)支持高达 35 级流水线,但由于功耗太高被废弃
3.3、诡异的结果
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;
}
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
- 情况4:由于 JIT 指令重排,将 num = 2; 和 ready = true; 两个赋值语句交换了顺序,线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
对于情况4,这个现象需要通过大量测试才能复现,可以借助 java 并发压测工具 jcstress,这里不展开讲了,这里只关心如何解决
其实解决也不难,只需要给 ready 加上一个 volatile 即可,volatile 修饰的变量,可以禁用指令重排
3.4、volatile原理
3.4.1、保证可见性
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障(保证该屏障之前的,对共享变量的改动都同步到主存中)
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
- 对 volatile 变量的读指令前会加入读屏障(保证该屏障之后的,对共享变量的读取,加载的都是主存中最新的数据)
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
3.4.2、保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
volatile 还是不能解决指令交错的问题(原子性)
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
3.4.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;
}
}
加载静态方法上的 synchronized 实际上是给类 class 加的锁
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
synchronized{
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
上述实现有一个问题:对于第一次进入该方法,加锁后实例化,没有问题,但是后续已经创建完成了,每次 getInstance 都需要加锁,性能太低了,所以我们便有了如下改进
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) {
synchronized{
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
但是上述代码,还是存在一定的问题,我们一起来观察它对应的字节码
0: getstatic #2 // Field INSTANCE:Lcom/phz/test/Singleton;
3: ifnonnull 37
6: ldc #3 // 获得类对象 class com/phz/test/Singleton,用于加锁
8: dup // 复制引用地址
9: astore_0 // 存储一份,用于解锁
10: monitorenter // 创建 Monitor 开始进入同步代码块
11: getstatic #2 // Field INSTANCE:Lcom/phz/test/Singleton;
14: ifnonnull 27
17: new #3 // class com/phz/test/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcom/phz/test/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:Lcom/phz/test/Singleton;
40: areturn
其中
- 17 表示创建对象,将对象引用入栈
- 20 表示复制一份对象引用,因为调用构造方法还会消耗一份
- 21 表示利用一个对象引用,调用构造方法,消耗一份
- 24 表示利用一个对象引用,赋值给 static INSTANCE,将最后一份引用消耗
但是 JIT 可能会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
按照代码来看就是,第一个线程进入,由于 INSTANCE 还为空,所以获取到了锁,代码执行到了 INSTANCE = new Singleton(); 此时先将引用传递给了 INSTANCE,但是还没有执行 new 操作(或者操作太多,时间有点长),此时第二个线程也来了,此时判断 INSTANCE 已经不为空了,就直接 return,然后去使用这个空对象,最后第一个线程才执行 new 操作,然后释放锁
这个问题出现的原因,就是因为指令的重排序,所以我们为了保证程序的正确性,需要禁止指令重排序,我们可以给 INSTANCE 变量加上 volatile 关键字