一. Java内存模型(JMM)
1.1 可见性
核心定义:可见性是指当一个线程修改了共享变量的值后,其他线程能够立即"看"到这个修改。
问题根源:现代计算机体系结构为了提高性能,在 CPU 和主内存之间引入了多级高速缓存(Cache)。每个线程在运行时,可能会将共享变量从主内存加载到自己的 CPU 缓存(即 JMM 中的"工作内存")中进行计算。如果线程 A 修改了变量但尚未刷回主内存,或者线程 B 仍在读取自己缓存中的旧副本,就会产生数据不一致的"可见性问题"。
JMM 的解决方案:
volatile关键字:当一个变量被volatile修饰时,JMM 会通过插入内存屏障(Memory Barrier),强制线程在写入后立即将新值刷新到主内存,并在读取前强制使本地缓存失效,重新从主内存获取最新值。synchronized与Lock:JMM 规定,线程在释放锁(unlock)之前,必须将工作内存中的共享变量刷新到主内存;而在获取锁(lock)时,必须清空工作内存并从主内存重新加载最新值。
1.2 原子性
核心定义:原子性是指一个或多个操作在执行过程中是不可分割的,要么全部执行成功且不被任何线程打断,要么全部不执行。
问题根源 :在多线程抢占式调度下,CPU 会随机分配时间片。许多看似简单的操作(如 count++)在底层实际上包含了"读取、计算、写入"三个步骤。如果线程 A 在执行完"读取"后被挂起,线程 B 抢占了 CPU 并完成了整个 count++ 操作,那么当线程 A 恢复执行时,会基于过期的旧值进行计算并覆盖掉线程 B 的结果,导致数据丢失。
JMM 的解决方案:
- 基础原子操作:JMM 直接保证了基本数据类型(除 long 和 double 外)的简单读写操作是原子的。
synchronized与Lock:通过互斥锁机制,保证同一时刻只有一个线程能进入同步代码块,从而将复合操作转化为原子操作。- CAS 与
Atomic类:利用硬件级别的 CAS(Compare-And-Swap)指令,在不加锁的情况下实现无锁的线程安全自增等操作(如AtomicInteger)。
1.3 有序性
核心定义:有序性是指程序执行的顺序必须与代码的编写顺序一致,保证多线程环境下的逻辑正确性。
问题根源:为了压榨硬件的极致性能,编译器和处理器(CPU)常常会对指令进行"重排序(Reordering)"。在单线程下,这种重排序是透明的(即 as-if-serial 语义,只要最终结果不变即可);但在多线程环境下,重排序可能会打破线程间的执行顺序约定,导致其他线程读取到未完全初始化的数据或错乱的逻辑状态。
JMM 的解决方案:
volatile关键字:通过内存屏障禁止编译器和处理器对volatile变量前后的指令进行特定类型的重排序。happens-before原则:这是 JMM 最核心的理论基础。它定义了一套规则(如程序顺序规则、锁规则、volatile 变量规则等),只要操作 A happens-before 操作 B,那么 A 的执行结果就对 B 可见,且 A 在逻辑上先于 B 执行。它在不禁止所有重排序的前提下,为程序员提供了可预测的并发语义。
二. Volatile原理
volatile 是 Java 提供的轻量级同步机制,它不保证原子性,但能完美解决多线程环境下的可见性和有序性问题。其底层实现主要依赖于 JVM 层面的内存屏障(Memory Barrier)和硬件层面的缓存一致性协议。
2.1 内存屏障(Memory Barrier)
内存屏障是 JVM 在指令序列中插入的特殊指令。你可以把它想象成一道"关卡",它有两个核心作用:
阻止重排序:禁止编译器和 CPU 对屏障前后的指令进行特定类型的重排序。
强制刷新缓存:强制将工作内存中的修改刷回主存,或强制使本地缓存失效。
JMM 针对 volatile 变量的读写,制定了严格的屏障插入规则:
- volatile 写操作屏障(Store Barrier)
当线程对 volatile 变量进行写操作时,JVM 会在其前后插入屏障:
写操作前:插入 StoreStore 屏障。保证该 volatile 写之前的所有普通写操作都已完成,防止它们被重排到 volatile 写之后。
写操作后:插入 StoreLoad 屏障(全能屏障,开销最大)。强制将当前工作内存中该变量的最新值刷入主内存,并使其他 CPU 缓存中对应的副本失效。同时,禁止屏障之后的读写操作被重排到这里。
java
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
- volatile 读操作屏障(Load Barrier)
当线程对 volatile 变量进行读操作时,JVM 同样会在其前后插入屏障:
读操作前:插入 LoadLoad 屏障。强制清空本地缓存,从主内存加载该 volatile 变量的最新值。
读操作后:插入 LoadStore 屏障 和 LoadLoad 屏障。保证先读取 volatile 变量,再对其后的普通变量进行读/写操作,禁止后续的读写操作被重排到读取之前。
java
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
2.2 硬件层面的支撑:缓存一致性协议
JVM 的内存屏障最终需要依赖底层 CPU 硬件来落地执行。在 x86 架构下,volatile 的底层支撑主要依赖以下两点:
- Lock 前缀指令:当 JVM 遇到 volatile 写操作时,会生成一个带 lock 前缀的汇编指令(如 lock add)。该指令的核心作用有两个:一是强制 CPU 将修改后的变量刷回主存;二是使其他处理器缓存该内存地址的数据无效。
- MESI 协议与总线嗅探(Bus Sniffing):现代 CPU 是多核的,每个核心有自己的缓存。当一个 CPU 核心通过 lock 指令修改了共享变量时,会通过总线广播通知其他核心。其他核心通过"总线嗅探"机制监听到该通知后,会将自己缓存中该变量的副本标记为"失效(Invalid)"。当它们后续需要读取该变量时,就必须从主存重新加载最新值,从而保证了可见性。
三. 双重检查锁(DCL)单例模式
java
/**
* 双重检查方式
*/
public class Singleton {
//私有构造方法
private Singleton() {}
private static volatile Singleton instance;
//对外提供静态方法获取该对象
public static Singleton getInstance() {
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
if(instance == null) {
synchronized (Singleton.class) {
//抢到锁之后再次判断是否为null
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。
要解决双重检查锁模式带来空指针异常的问题,只需要使用 volatile 关键字, volatile 关键字可以保证可见性和有序性。