12 JMM and Troubleshooting¶
说明
本文档仅涉及部分内容,仅可用于复习重点知识
1 Java Memory Model¶
JMM 是 Java 虚拟机规范的一部分,它是一个抽象的概念,不是物理上的内存划分。它的核心目标是定义一套规则,来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,从而解决多线程环境下的可见性、原子性和有序性问题
在现代多核处理器中,为了提高性能,每个 CPU 核心都有自己的高速缓存(Cache)。线程在执行时,会把主内存(Main Memory)中的数据拷贝一份到自己的工作内存(Working Memory,可以理解为 CPU 缓存和寄存器)中
这带来了三个核心问题:
- 可见性 (Visibility):一个线程修改了共享变量的值,但这个新值只存在于它自己的工作内存中,还没有被写回主内存。此时,其他线程读取这个变量时,读到的仍然是主内存中的旧值,导致数据不一致
- 原子性 (Atomicity):一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。像
i++这样的操作在 Java 中看起来是一行,但实际上它包含了“读取i的值”、“将值加 1”、“将新值写回”三个步骤,它不是原子的,在多线程环境下可能被中断 - 有序性 (Ordering):为了优化性能,编译器和处理器可能会对指令进行重排序(Instruction Reordering)。在单线程环境下,重排序不会影响最终结果。但在多线程环境下,这种重排序可能会打乱程序原有的逻辑顺序,导致意想不到的错误
JMM 的存在,就是为了给开发者提供一套保证,告诉我们如何编写代码才能确保在多线程环境下的可见性、原子性和有序性
JMM 定义了线程和主内存之间的抽象关系:
- 主内存 (Main Memory):是所有线程共享的区域,存储了所有的实例字段、静态字段和构成数组对象的元素
- 工作内存 (Working Memory):是每个线程私有的数据区域。它存储了该线程使用的变量在主内存中的副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量
线程间的通信必须通过主内存来完成。线程 A 把其工作内存中更新过的共享变量的值刷新到主内存中,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
1.1 Happens-Before¶
为了让开发者更容易地理解和编写线程安全的代码,JMM 提出了一套先行发生(Happens-Before)原则。这是判断数据是否存在竞争、线程是否安全的主要依据
JMM 定义了以下 8 条天然的 Happens-Before 规则,我们无需任何同步措施就能依赖它们:
- 程序次序规则 (Program Order Rule):在一个线程内,按照代码的先后顺序,书写在前面的操作先行发生于书写在后面的操作
- 管程锁定规则 (Monitor Lock Rule):一个
unlock操作先行发生于后面对同一个锁的lock操作。这就是synchronized保证可见性的原因 volatile变量规则 (Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这就是volatile保证可见性的原因- 线程启动规则 (Thread Start Rule):
Thread对象的start()方法先行发生于此线程的每一个动作 - 线程终止规则 (Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,例如
Thread.join()方法的返回 - 线程中断规则 (Thread Interruption Rule):对线程
interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生 - 对象终结规则 (Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的
finalize()方法的开始 - 传递性 (Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么可以得出操作 A 先行发生于操作 C
2 Volatile¶
volatile 是 Java 提供的一个轻量级的同步机制。当一个变量被声明为 volatile 时,JMM 会为这个变量提供两种特殊的保证:可见性和有序性
volatile 不保证原子性
2.1 Visibility¶
- 写操作:当一个线程修改一个
volatile变量的值时,JMM 会立即将这个修改后的新值从该线程的工作内存(CPU 缓存)强制刷新到主内存中 - 读操作:当一个线程读取一个
volatile变量时,JMM 会强制该线程使其本地工作内存中该变量的缓存失效,然后直接从主内存中重新读取最新的值
通过这一写一读的强制规定,volatile 确保了任何线程在任何时间点读取一个 volatile 变量,看到的都是其他线程对这个变量的最新写入结果,从而解决了多线程间的可见性问题
停止标志
一个线程负责执行任务,另一个线程负责在某个时刻停止它
如果没有 volatile,t1 线程为了优化性能,可能会将 stop 变量缓存到自己的工作内存中。即使主线程修改了主内存中的 stop 为 true,t1 也可能一直读取自己缓存中的 false,导致循环无法停止
2.2 Ordering¶
为了提高性能,编译器和处理器可能会对指令进行重排序。volatile 关键字可以禁止特定类型的指令重排序,从而保证一定程度的有序性
volatile 通过插入内存屏障 (Memory Barrier) 来实现这一点:
- 在对
volatile变量的写操作之前,会插入一个“Store”屏障。这保证了所有在volatile写之前的普通写操作都已经完成,并且其结果对其他处理器可见 - 在对
volatile变量的读操作之后,会插入一个“Load”屏障。这保证了所有在volatile读之后的普通读操作都能读到最新的值
简单来说,就是:当程序执行到 volatile 变量的读操作或写操作时,其前面的所有普通操作都已经执行完毕,且结果已对其他线程可见。其后面的所有普通操作都还没有执行
双重检查锁定
Double-Checked Locking,DCL 单例模式
instance = new Singleton() 这行代码不是原子的,它大致分为三步:
- 分配内存空间
- 初始化对象(调用构造函数)
- 将
instance引用指向分配的内存地址
如果没有 volatile,步骤 2 和 3 可能会被重排序。一个线程可能先执行了第 3 步(instance 已经不为 null),但第 2 步(对象初始化)还没完成。此时另一个线程执行到第一次检查,发现 instance 不为 null,就会直接返回一个尚未完全构造好的对象,从而导致错误。volatile 禁止了这种重排序,保证了对象一定是在完全初始化之后,引用才会被赋值
2.3 Full Volatile Visibility Guarantee¶
当一个线程写入一个 volatile 变量时,JMM 会确保,该线程在写入 volatile 变量之前对所有其他普通变量的修改,都会随着这个 volatile 变量的写入而一起被刷新到主内存中
相应地,当一个线程读取这个 volatile 变量时,JMM 会确保,它也会从主内存中重新加载在 volatile 变量写入之前被修改过的那些普通变量的值
简单来说,volatile 变量的读/写操作就像一个内存同步的开关,它不仅同步自己,还顺便同步了在它之前发生的所有其他变量的修改
示例
volatile 修饰类对象
当 volatile 修饰一个对象引用时,它只保证引用本身的可见性和有序性,而不保证对象内部字段的可见性和有序性
3 Singleton¶
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个唯一的实例
实现单例模式的两条基本规则:
- 将构造函数私有化:为了防止外部代码通过
new关键字随意创建该类的多个实例。既然目标是单例,就必须把创建实例的权力控制在类的内部 - 提供一个静态方法来获取那个唯一的对象:既然构造函数是私有的,外部代码就需要一个公共的、统一的入口来获取这个唯一的实例。这个静态方法(通常命名为
getInstance())就扮演了这个角色
单例模式有两种主要实现方式,也对应着两种不同的实例化时机
3.1 Eager Initialization¶
类加载时实例化:在类被加载到 JVM 时,就立即创建单例的实例。这个实例作为一个静态成员变量存在,无论后续是否有人调用 getInstance(),它都已经存在了
优点:实现简单,并且是天然线程安全的,因为类的加载和初始化过程由 JVM 保证是线程安全的
缺点:如果这个单例对象很耗费资源,并且在程序运行期间一直没有被使用,那么就会造成内存浪费
3.2 Lazy Initialization¶
首次获取时实例化:不在类加载时创建实例,而是在 getInstance() 方法第一次被调用时才创建
优点:实现了延迟加载,避免了不必要的资源消耗。只有在真正需要时才创建实例
缺点:需要在多线程环境下手动处理线程安全问题,否则可能会创建出多个实例
第一次检查:synchronized 关键字虽然能保证线程安全,但它是一个重量级的操作。当一个线程进入 synchronized 代码块时,它需要获取锁,这可能会导致其他试图进入的线程被阻塞,从而引起上下文切换,带来性能开销
- 首次创建后:一旦单例对象
instance被成功创建一次,它就不再是null了 - 后续调用:之后成千上万次的
getInstance()调用,都会执行到这个第一次检查 - 性能提升:由于
instance已经不是null,这些后续的调用会直接通过这个if判断,然后立即返回已创建好的实例。它们完全不需要进入synchronized代码块,也就不需要去竞争锁,从而极大地提高了在高并发环境下的访问性能
第二次检查:第一次检查虽然优化了性能,但它本身不是线程安全的。在 instance 尚未被创建时,多个线程可能同时通过第一次检查
- 初始状态:
instance为null - 线程 A 和 线程 B 同时调用
getInstance() - 它们都执行第一次检查
if (instance == null),发现结果为true,于是它们都准备进入synchronized代码块 - 线程 A 首先获得了锁,进入了同步块
- 线程 B 没有获得锁,在同步块外面等待
- 线程 A 执行第二次检查
if (instance == null),发现结果为true,于是它创建了一个Singleton实例,然后退出同步块并释放锁 - 此时,线程 B 终于获得了锁,进入了同步块
- 如果没有第二次检查,线程 B 会再次执行
instance = new LazySingleton(),从而创建了第二个Singleton实例。这就违背了单例模式的原则 - 如果有第二次检查:线程 B 在进入同步块后,会先执行第二次检查
if (instance == null)。但此时instance已经被线程 A 创建好了,不再是null。因此,这个检查结果为false,线程 B 不会执行创建实例的代码,直接跳出if语句,然后退出同步块