四、JMM

上一章讲解的 Monitor 主要关注的是 访问共享变量时, 保护临界区代码的原子性

这一章节进一步深入学习 共享变量在多线程之间的【可见性】问题和 多条指令执行时的【有序性】问题

一、Java 内存模型

JMM 即 Java Memory Model, 它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。

JMM 体现在一下几个方面

  • 原子性 - 保证指令不会受到上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

主存 —— 所有共享信息存储的位置。

工作内存 —— 所有线程私有信息 存储的位置。

二、可见性

1. 问题:

main线程 对 run变量的修改对于 t线程 不可见, 导致了 t 线程 无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while (run) {
            // 。。。
        }
    });
    t.start();

    Thread.sleep(10);
    run = false;   // 线程 t 不会如设预想的停下来
}

分析一下:

  1. 初始状态, t线程刚开始从主内存读取了 run 的值到工作内存

四、JMM_第1张图片

  1. 因为线程 t 要频繁获取 主内存中 run 的值,JIT编译器 会将 run的值缓存到自己工作内存中的高速缓存中,减少对主内存中run的访问,提高效率。

四、JMM_第2张图片

  1. 1 秒之后主线程 修改了 run 的值,并且同步到主存,而 t 是从自己工作内存中的高速缓存中读取了这个变量的值,结果永远是旧值。

四、JMM_第3张图片

2. 解决方法

  1. volatile【推荐】 轻量
  • 只能够加载静态成员变量跟 成员变量上, 不能加载局部变量。
  • 为了让 线程 不去高速缓冲中获取值,使得每次都去主内存中获取值,避免缓存影响结果。

  1. synchronized 需要创建Monitor 重量 性能低
  • 某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁
  • 从而保证了可见性。

3. 可见性 vs 原子性

volatile

  • 只能保证 可见性,不能保证原子性,因为他只能保证每次获取的是最新的值,但是不能保证指令交错
  • 使用场景,一个线程对 volatile 变量进行修改,多个线程进行读取的情况

synchronized

  • 能保证可见性,也能保证原子性
  • 缺点: synchronized属于重量级操作,性能相对更低

注意:

如果在上面例子 while中添加 System.out.println() 会发现即使不加 volatile 也能停下来

因为 源码实现 println 加了 synchronized锁

三、设计模式

1. 两阶段终止模式

四、JMM_第4张图片

四、JMM_第5张图片

2. Balking (犹豫)模式

  • 一个线程发现 另一个线程 或 本线程 已经做好了某一件相同的事,那么本线程就无需在做了,直接结束返回。

上面的例子中,如果连续启动两次start方法,那么就会创建两个监控线程,做相同的事,所以可以使用 Balking模式 改进一下。

四、JMM_第6张图片

四、JMM_第7张图片

2.1. 应用

四、JMM_第8张图片

四、有序性

四、JMM_第9张图片

这种特性 称为 【指令重排】,多线程下【指令重排】会影响正确性。

1. cpu 层面

1.1. 鱼罐头故事

四、JMM_第10张图片

1.2. 指令重排序优化

四、JMM_第11张图片

1.3. 支持流水线的处理器

四、JMM_第12张图片

1.4. 指令重排序优化

在不改变程序结果的前提下,这些指令的各个阶段可以通过 重排序 组合 来实现 指令级并行

分阶段,分工是提升效率的关键

指令重排的前提是,不能影响运行结果

四、JMM_第13张图片

2. Java 层面

2.1. 诡异的结果

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;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果, 问, 可能的结果有几种?

四、JMM_第14张图片

2.2. 解决方法

将ready 设置成 volatile ,就能避免volatile变量前面的代码被进行重排序

涉及到一个 写屏障

五、Volatile 原理

volatile 的底层实现原理是内存屏障, Memory Barrier (Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

1. 如何保证可见性

  • 写屏障(sfence) 保证在该屏障之前的,对共享变量的改动,都同步到主存中
public void actor2 (I_Result r) {
	num = 2;
	ready = true; // ready 是 volatile 赋值 带写屏障
	// 写屏障
}
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的都是主存中的最新数据
public void actor1(I_Result r) {
	// 读屏障
	//ready 是 volatile 读取值带读屏障
	if (ready) {
        r.r1 = num + num;
	} else {
    	r.r1 = 1;
	}
}

2. 如何保证有序性

  • 写屏障(sfence) 确保在指令重排序时, 该屏障 之前 的代码不会排在写屏障 之后
public void actor2 (I_Result r) {
	num = 2;
	ready = true; // ready 是 volatile 赋值 带写屏障
	// 写屏障
}
  • 读屏障(lfence)确保在指令重排序时, 该屏障 之后 的代码不会排在读屏障 之前
public void actor1(I_Result r) {
	// 读屏障
	//ready 是 volatile 读取值带读屏障
	if (ready) {
        r.r1 = num + num;
	} else {
    	r.r1 = 1;
	}
}

3. 总结

volatile 只能保证 有序性 和 可见性,但是不能保证原子性,不能解决指令交错。

  • 写屏障只能保证之后的读都是读取最新的结果,但是如果其他线程在当前线程写屏障写入主存的时候读取了旧值,这时候就不能保证了。
  • 有序性 只是保证了本线程内相关代码不被重排序,还是不能避免多个线程之间指令交错。

4. 补充 synchronized 有序性、可见性

原子性:

  • synchronized天生具有保证原子性

synchronized 底层字节码是通过monitorenter的指令来进行加锁的、通过monitorexit指令来释放锁的

可见性:

  • monitorenter 前也是有一个 读屏障 ,锁 同步块中的代码 每次都从主存中获取最新的数据
  • monitorexit 后有一个写屏障 , 解锁的时候 每次将 代码块中修改了的数据都更新到主存中。

有序性:

  • monitorenter 前有一个读屏障,保证锁前面的代码不会重排到 同步块内部,内部的代码不会重排到锁前面
  • monitorexit 后有一个写屏障,保证锁后面的代码不会重排到 同步块内部, 内部的代码不会重排到锁后面

如果 共享变量完全被synchronized 完全包裹起来,一般是不会出现问题

只有 共享变量 一部分暴露出来了,在多线程情况下,是不能控制其他线程访问不被包裹的部分,在加上同步块的指令重排序,可能出现问题。

写写屏障: 禁止写写屏障的前后写操作重排

读读屏障: 禁止读读屏障的前后读操作进行重排

读写屏障: 禁止读写屏障的前面的读操作进行重排 、 禁止读写屏障的后面的写操作重排

写读屏障:禁止写读屏障前面的写操作重排、禁止写读屏障后面的读写操作重排

5. double-checked locking 问题

著名的 double-checked locking 单例模式为例

用到时候再创建,只会创建一个。

四、JMM_第15张图片

四、JMM_第16张图片

但是锁范围太大,继续优化,两次判断,这也就是double-checked locking

四、JMM_第17张图片

以上实现的特点是:

  • 懒惰实例化 (用到才创建)
  • 首次使用 getInstance()才使用 synchronized 锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点: 第一个 if 使用了 INSTANCE 变量, 是在同步块之外

但是在多线程环境下,上面的代码是有问题的。getInstance方法对应的字节码为:

四、JMM_第18张图片

四、JMM_第19张图片

  • synchronized只是保证了有序性,但是同步块中是能够发生指令重排的,如果先进行赋值,然后在进行调用构造方法,
  • 然后第二线程在还没有调用构造之前进行了外部的不在synchronized内部的 if 判断,这时候拿到的是不为空,直接返回了这个没有调用构造方法的对象
  • 如果构造方法中有很多初始化操作,返回的这个就是没有初始化的单例对象,这就是问题所在

指令重排的流程图

四、JMM_第20张图片

添加volatile的流程图

四、JMM_第21张图片

将 INSTANCE 变量设置成 volatile ,那么就会在变量后一个写屏障,保证 volatile 前面的代码不被重排序道后面去

那么底层的 调用构造方法 跟 赋值操作 的字节码操作就不会被其他线程中途打断,从而造成问题。

这时候就算是其他线程在赋值操作之前进行了调用,但是这时候进来获取到的是null,所以就需要在 synchronized 等待,所以能够保证安全性。

你可能感兴趣的:(JUC并发编程,spring,java,后端,jvm,个人开发)