Java并发编程 -- volatile

一. Java内存模型(JMM)

1.1 可见性

核心定义:可见性是指当一个线程修改了共享变量的值后,其他线程能够立即"看"到这个修改。

问题根源:现代计算机体系结构为了提高性能,在 CPU 和主内存之间引入了多级高速缓存(Cache)。每个线程在运行时,可能会将共享变量从主内存加载到自己的 CPU 缓存(即 JMM 中的"工作内存")中进行计算。如果线程 A 修改了变量但尚未刷回主内存,或者线程 B 仍在读取自己缓存中的旧副本,就会产生数据不一致的"可见性问题"。

JMM 的解决方案

  • volatile 关键字:当一个变量被 volatile 修饰时,JMM 会通过插入内存屏障(Memory Barrier),强制线程在写入后立即将新值刷新到主内存,并在读取前强制使本地缓存失效,重新从主内存获取最新值。
  • synchronizedLock:JMM 规定,线程在释放锁(unlock)之前,必须将工作内存中的共享变量刷新到主内存;而在获取锁(lock)时,必须清空工作内存并从主内存重新加载最新值。

1.2 原子性

核心定义:原子性是指一个或多个操作在执行过程中是不可分割的,要么全部执行成功且不被任何线程打断,要么全部不执行。

问题根源 :在多线程抢占式调度下,CPU 会随机分配时间片。许多看似简单的操作(如 count++)在底层实际上包含了"读取、计算、写入"三个步骤。如果线程 A 在执行完"读取"后被挂起,线程 B 抢占了 CPU 并完成了整个 count++ 操作,那么当线程 A 恢复执行时,会基于过期的旧值进行计算并覆盖掉线程 B 的结果,导致数据丢失。

JMM 的解决方案

  • 基础原子操作:JMM 直接保证了基本数据类型(除 long 和 double 外)的简单读写操作是原子的。
  • synchronizedLock:通过互斥锁机制,保证同一时刻只有一个线程能进入同步代码块,从而将复合操作转化为原子操作。
  • 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 变量的读写,制定了严格的屏障插入规则:

  1. volatile 写操作屏障(Store Barrier)
    当线程对 volatile 变量进行写操作时,JVM 会在其前后插入屏障:
    写操作前:插入 StoreStore 屏障。保证该 volatile 写之前的所有普通写操作都已完成,防止它们被重排到 volatile 写之后。
    写操作后:插入 StoreLoad 屏障(全能屏障,开销最大)。强制将当前工作内存中该变量的最新值刷入主内存,并使其他 CPU 缓存中对应的副本失效。同时,禁止屏障之后的读写操作被重排到这里。
java 复制代码
public void actor2(I_Result r) {
 	num = 2;
 	ready = true; // ready 是 volatile 赋值带写屏障
 	// 写屏障
}
  1. 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 的底层支撑主要依赖以下两点:

  1. Lock 前缀指令:当 JVM 遇到 volatile 写操作时,会生成一个带 lock 前缀的汇编指令(如 lock add)。该指令的核心作用有两个:一是强制 CPU 将修改后的变量刷回主存;二是使其他处理器缓存该内存地址的数据无效。
  2. 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 关键字可以保证可见性和有序性。

相关推荐
plainGeekDev2 小时前
工厂模式 → Hilt
android·java·kotlin
plainGeekDev3 小时前
Application 单例 → Hilt Singleton
android·java·kotlin
888CC++3 小时前
C语言与C++的区别:从面向过程到面向对象
java·c语言·c++
学者猫头鹰4 小时前
分布式事务实战教程
java·分布式
heimeiyingwang5 小时前
【架构实战】Kubernetes调度器深度剖析:从Pod调度到自定义调度器
java·架构·kubernetes
纳兰青华6 小时前
回文侦探:三种境界破解最长回文子串
java·算法·动态规划
Zane19947 小时前
CAS 与原子类:Java 如何实现无锁编程
java·后端
TunerT_TQ7 小时前
【智能体安全治理|专栏第9期】从“一堆规则”到“数字宪法”:智能体治理的下一个阶段
java·开发语言·安全·开源治理·大模型安全·ai基础设施·智能体安全
legendary_bruce7 小时前
RAG知识库进阶-1
java·aigc