上一章讲解的 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 不会如设预想的停下来
}
分析一下:
- 初始状态, t线程刚开始从主内存读取了 run 的值到工作内存
- 因为线程 t 要频繁获取 主内存中 run 的值,JIT编译器 会将 run的值缓存到自己工作内存中的高速缓存中,减少对主内存中run的访问,提高效率。
- 1 秒之后主线程 修改了 run 的值,并且同步到主存,而 t 是从自己工作内存中的高速缓存中读取了这个变量的值,结果永远是旧值。
2. 解决方法
- volatile【推荐】 轻量
- 只能够加载静态成员变量跟 成员变量上, 不能加载局部变量。
- 为了让 线程 不去高速缓冲中获取值,使得每次都去主内存中获取值,避免缓存影响结果。
- 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 并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见。
- 线程解锁 m 之前对变量的写,对于接下来 对 m 加锁的其他线程对该变量的读可见。
- 线程对 volatile 变量的写, 对接下来其他线程对该变量的读可见
- 线程 start 前对变量的写,对该线程后对该变量的读可见
- 线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用 t1.isAlive() 或 t1.join() 等待它结束)
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对该变量的读可见 (通过 t2.interrupted 或 t2.isInterrupted)
- 对变量默认值(0, false, null)的写,对其他线程对该变量的读可见。
- 具有传递性,如果x hb -> y 并且 y hb -> z 那么有 x hb -> z, 配合 volatile 的防指令重排,有下面的例子
七、线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用 getInstance) 时的线程安全,并思考注释的问题
饿汉式: 类加载的时候就会导致该单例对象被创建
懒汉式:类加载不会导致该单例对象被创建,而是首次使用该对象的时候才会创建
实现1:饿汉式
实现2:饿汉式
实现3:懒汉式
实现4:
实现5:静态内部类实现懒汉式