四、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 的值到工作内存

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

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

2. 解决方法

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

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

3. 可见性 vs 原子性

volatile

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

synchronized

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

注意:

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

因为 源码实现 println 加了 synchronized锁

三、设计模式

1. 两阶段终止模式

2. Balking (犹豫)模式

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

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

2.1. 应用

四、有序性

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

1. cpu 层面

1.1. 鱼罐头故事

1.2. 指令重排序优化

1.3. 支持流水线的处理器

1.4. 指令重排序优化

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

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

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

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 用来保存结果, 问, 可能的结果有几种?

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 单例模式为例

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

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

以上实现的特点是:

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

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

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

指令重排的流程图

添加volatile的流程图

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

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

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

六、happens-before规则

happens-before 规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。

抛开以下 happens-before 规则, JMM 并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见。

  1. 线程解锁 m 之前对变量的写,对于接下来 对 m 加锁的其他线程对该变量的读可见。

  1. 线程对 volatile 变量的写, 对接下来其他线程对该变量的读可见

  1. 线程 start 前对变量的写,对该线程后对该变量的读可见

  1. 线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用 t1.isAlive() 或 t1.join() 等待它结束)

  1. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对该变量的读可见 (通过 t2.interrupted 或 t2.isInterrupted)

  1. 对变量默认值(0, false, null)的写,对其他线程对该变量的读可见。
  2. 具有传递性,如果x hb -> y 并且 y hb -> z 那么有 x hb -> z, 配合 volatile 的防指令重排,有下面的例子

七、线程安全单例习题

单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用 getInstance) 时的线程安全,并思考注释的问题

饿汉式: 类加载的时候就会导致该单例对象被创建

懒汉式:类加载不会导致该单例对象被创建,而是首次使用该对象的时候才会创建

实现1:饿汉式

实现2:饿汉式

实现3:懒汉式

实现4:

实现5:静态内部类实现懒汉式

  • 16
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

栗子ing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值