Java内存模型(JMM)

1、概述

JMMJava 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");
    }

image-20220209211446618

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 分钟的时间里同时做这五件事,因为第二条鱼的去鳞清洗,不会影响第一条与的蒸煮沥水

image-20220210142318975

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;
    }
}
t1 线程 num = 0 volatile ready = false t2 线程 num = 2 ready = true 写屏障 读屏障 读取 ready = true 读取 num = 2 t1 线程 num = 0 volatile ready = false t2 线程

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;
    }
}
t1 线程 num = 0 volatile ready = false t2 线程 num = 2 ready = true 写屏障 读屏障 读取 ready = true 读取 num = 2 t1 线程 num = 0 volatile ready = false t2 线程

volatile 还是不能解决指令交错的问题(原子性)

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
t1 线程 volatile i = 0 t2 线程 读取 i = 0 读取 i = 0 i + 1 写入 i = 1 i - 1 写入 i = -1 t1 线程 volatile i = 0 t2 线程

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 按如下时间序列执行:

t1 INSTANCE t2 17 : new 20 : dup 24 : putstatic(给 INSTANCE 赋值) 0 : getstatic(获取 INSTANCE 引用) 3 : ifnonnu1l 37(判断不为空,跳转 37 行) 37 : getstatic(获取 INSTANCE 引用) 40 : areturn(返回) 使用对象 21 : invokespecial(调用构造方法) t1 INSTANCE t2

按照代码来看就是,第一个线程进入,由于 INSTANCE 还为空,所以获取到了锁,代码执行到了 INSTANCE = new Singleton(); 此时先将引用传递给了 INSTANCE,但是还没有执行 new 操作(或者操作太多,时间有点长),此时第二个线程也来了,此时判断 INSTANCE 已经不为空了,就直接 return,然后去使用这个空对象,最后第一个线程才执行 new 操作,然后释放锁

这个问题出现的原因,就是因为指令的重排序,所以我们为了保证程序的正确性,需要禁止指令重排序,我们可以给 INSTANCE 变量加上 volatile 关键字

t1 INSTANCE t2 17 : new 20 : dup 21 : invokespecial(调用构造方法) 24 : putstatic(INSTANCE 赋值,带写屏障) 0 : getstatic(获取 INSTANCE 引用,带读屏障) 3 : ifnonnull 37(判断不为空,跳转 37) 37 : getstatic(获取 INSTANCE 引用) 40 : areturn(返回) 使用对象 t1 INSTANCE t2
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值