JCSprout 并发基石:Java 多线程三大核心(原子性、可见性、顺序性)深度解析
【免费下载链接】JCSprout👨🎓 Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout
导读
本篇文章以 JCSprout 项目中docs/thread/Threadcore.md为骨架,系统讲解 Java 并发编程绕不开的三大核心特性——原子性(Atomicity)、可见性(Visibility)、顺序性(Ordering),并结合仓库src/main/java/com/crossoverjie/concurrent目录下的真实源码(Singleton、Volatile、VolatileInc、StopThread等)逐行印证volatile、synchronized、AtomicInteger的底层原理与实战用法。读完你将掌握:为什么i++不是原子操作、volatile到底能保证什么不能保证什么、双重检查锁单例为何必须加volatile,以及如何正确使用标记变量优雅地停止线程。
一、原子性:一个操作要么全部成功,要么全部失败
1.1 什么是原子性
Java中的原子性(Atomicity)与数据库事务的原子性类似:一个操作要么全部执行成功,要么全部执行失败,不存在中间状态。JMM(Java 内存模型)只保证了基本的原子性,即对基本数据类型变量的读取、赋值这类简单操作是原子的。
1.2i++为什么不是原子操作
看似原子的i++,实际上在底层被拆分为三步:
- 获取
i的值; - 执行自增(+1);
- 将结果再赋值给
i。
这三步操作之间随时可能被线程调度打断,因此多个线程并发执行i++时,最终结果往往小于预期。仓库中的 VolatileInc.java 正是用来演示这一问题的:
private static volatile int count = 0 ; //使用 volatile 修饰基本数据内存不能保证原子性 @Override public void run() { for (int i=0;i<10000 ;i++){ count ++ ; } }main方法中启动t1、t2两个线程,加上main线程自身,三个线程同时对count累加 10000 次,最终打印的count几乎必然小于 30000。这正是因为count ++的“取值-自增-赋值”三步无法原子完成,即使count被volatile修饰(保证可见性)也无济于事。
想要让i++具备原子性,通常有三种方案:
- 使用
synchronized或Lock加锁处理,同一时刻只允许一个线程进入临界区; - 让多个线程串行执行(退化为单线程,无法发挥多线程优势);
- 使用
AtomicInteger这类原子类,其本质是利用 CPU 级别的CAS(Compare And Swap)指令完成。
1.3 AtomicInteger 与 CAS:原子自增的正确姿势
对于基础类型的自增操作,AtomicInteger是最常用的原子类。其中使用频率最高的方法就是incrementAndGet()——以原子的方式自增并返回新值。其源码逻辑如下:
public final long incrementAndGet() { for (;;) { long current = get(); long next = current + 1; if (compareAndSet(current, next)) return next; } }执行流程是:先获取当前值current,计算自增后的next = current + 1,然后调用最核心的compareAndSet(current, next)进行原子更新。CAS 的源码如下:
public final boolean compareAndSet(long expect, long update) { return unsafe.compareAndSwapLong(this, valueOffset, expect, update); }其逻辑为:判断当前内存中的值是否仍等于期望值current——如果相等,说明期间没有被其他线程更新过,于是将值原子地更新为next并返回true;如果不相等,则返回false,外层for(;;)进入下一轮循环重新读取最新值并重试,直到更新成功为止。这就是经典的自旋 CAS模式。
值得强调的是,AtomicInteger中的get()方法同样关键,它返回的当前值用volatile修饰,保证了内存可见性:
private volatile int value;也就是说,原子类内部本身就是"volatile 保证可见性 + CAS 保证原子性"的组合,两者缺一不可。
在 JCSprout 仓库中,AtomicInteger被广泛用于并发组件:
- CustomThreadPool.java 用
AtomicInteger totalTask记录提交到线程池的任务总数,并在任务提交时调用totalTask.incrementAndGet()自增计数; - MultipleThreadCountDownKit.java 用
AtomicInteger counter实现多线程协作的倒计数工具; - LRUAbstractMap.java 用
volatile AtomicInteger size记录 LRU Map 的大小,并在插入元素时调用size.incrementAndGet()。
这些源码印证了原子类在真实并发组件中的落地方式:用 CAS 保证计数操作的原子性,用 volatile 保证计数值对各线程可见。
二、可见性:一个线程的修改,另一个线程何时能看见
2.1 CPU 高速缓存带来的可见性问题
现代计算机中,CPU直接从主内存读取数据的效率不高,因此每个CPU核心都配有高速缓存(Cache)。线程修改数据时,首先更新到缓存,之后才刷回主内存。如果数据尚未刷回主内存,其他线程此时读取到的就是修改之前的数据。
用JMM的语言描述:所有变量都存放在主内存中,每个线程拥有自己的工作内存(可简单理解为线程栈对应的缓存区域)。线程工作时需要把主内存中的变量拷贝到工作内存,对变量的所有操作都基于工作内存完成,之后再刷新回主内存。
在这种模型下,并发运行时线程 B 读到的很可能就是线程 A 更新之前的数据,从而引发数据不一致。
2.2 volatile 如何保证可见性
volatile关键字就是用于解决可见性问题的:
当一个变量被
volatile修饰时,任何线程对它的写操作都会立即刷新到主内存,并且强制使其他线程缓存中该变量的副本失效(清空),其他线程只能重新从主内存读取最新值。
也就是说,每次读取 volatile 变量都能拿到最新数据,无论哪个线程修改它,都会立即同步到主内存。需要澄清的是,volatile修饰后并不是让线程直接从主内存获取数据,线程依然会将该变量拷贝到工作内存,只是每次读写都强制与主内存同步,并保证其他线程缓存副本立即失效。
2.3 synchronized 与锁也能保证可见性
synchronized和加锁同样可以保证可见性,其实现原理是:在释放锁之前,其他线程访问不到这个共享变量——进入临界区必须重新从主内存读取,退出临界区则强制将修改刷回主内存。但与volatile相比,加锁的线程阻塞与唤醒开销要大得多。volatile不涉及锁竞争,属于轻量级同步机制,因此适合"单写多读"的标记类场景。
三、顺序性:指令重排与 happen-before
3.1 什么是指令重排
看下面这段代码:
int a = 100 ; //1 int b = 200 ; //2 int c = a + b ; //3正常情况下执行顺序应该是1 → 2 → 3。但 JVM 为了提高整体执行效率,可能会进行指令重排(Instruction Reordering),使实际执行顺序变为2 → 1 → 3。当然,JVM 的重排不是任意的,它遵循一个前提:在保证单线程最终结果与代码顺序执行结果一致的情况下才允许重排(as-if-serial 语义)。
3.2 重排带来的并发隐患
指令重排在单线程中不会出现问题,但在多线程场景下可能引发数据不一致。例如下面这段伪代码:
private static Map<String,String> value ; private static boolean flag = false ; // 线程 A 中执行:初始化 Map public void initMap(){ value = getMapValue() ; //1 flag = true ; //2 } // 线程 B 中执行:等待 Map 初始化完成后使用 public void doSomeThing(){ while(!flag){ sleep() ; } doSomeThing(value); // 可能拿到尚未初始化的 value }如果flag没有用volatile修饰,JVM 可能对语句 1 和 2 进行重排,导致value还没初始化完成时线程 B 就已经跳出循环去使用它,从而引发错误。加上volatile之后,可以禁止这种重排,保证业务的正确性。
3.3 volatile、锁与 happen-before 原则
Java 中可以用volatile保证顺序性;synchronized和Lock也能保证有序性,其原理与保证原子性一致——通过同一段时间只允许一个线程访问临界区,从互斥的角度杜绝了重排导致的跨线程错误。
除了volatile的显式保证之外,JVM 还通过happen-before(先行发生)原则隐式地保证顺序性。其中与volatile直接相关的一条是:
对
volatile变量的写操作一定先行发生于对该变量的读操作。
这意味着任何线程读取 volatile 变量时,拿到的必然是最新写入的值,写读之间建立了跨线程的 happens-before 关系。
四、volatile 的两大经典应用(附仓库源码)
4.1 应用一:双重检查锁的单例模式
volatile最常见的应用场景之一就是双重检查锁(Double-Checked Locking)的单例模式。仓库中的 Singleton.java 即为完整实现:
public class Singleton { private static volatile Singleton singleton; private Singleton() { } public static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { //防止指令重排 singleton = new Singleton(); } } } return singleton; } }这里的volatile关键字主要是为了防止指令重排。如果不加volatile,singleton = new Singleton();这行代码在底层其实会被拆成三步:
- 分配内存空间;
- 初始化对象(执行构造函数);
- 将
singleton引用指向分配的内存地址。
问题在于,JVM 可能把第 2 步和第 3 步重排:先让singleton指向一块尚未完成初始化的内存。此时另一个线程通过第一重if (singleton == null)检查发现引用非空,直接返回了一个尚未初始化完成的单例对象,使用时就可能抛异常。加上volatile后,三步操作被强制按顺序执行,彻底杜绝了"拿到未初始化对象"的风险。
volatile在这里同时承担了两个职责:保证单例引用对各线程可见(防止拿到旧引用),禁止对象发布时的指令重排(防止拿到半初始化对象)。
4.2 应用二:控制停止线程的标记
volatile另一个经典应用是作为线程停止的标记位。仓库中的 Volatile.java 演示了完整用法:
public class Volatile implements Runnable{ private static volatile boolean flag = true ; @Override public void run() { while (flag){ } System.out.println(Thread.currentThread().getName() +"执行完毕"); } public static void main(String[] args) throws InterruptedException { Volatile aVolatile = new Volatile(); new Thread(aVolatile,"thread A").start(); // ... 主线程等待输入 "1" 后调用 stopThread() } private void stopThread(){ flag = false ; } }核心思想:thread A在while (flag)循环中持续工作;当某个线程调用stopThread()将flag置为false后,thread A观察到变化,退出循环并结束。
这里如果没有用volatile修饰flag,stopThread()修改了flag的值却不会立即刷新到主内存,其他线程(thread A)的工作内存中仍是旧值,循环就不会立即停止,甚至可能永远停不下来。该场景利用的正是 volatile 的内存可见性语义。
对比阅读:仓库中还有一个基于中断机制的 StopThread.java,它通过
Thread.currentThread().isInterrupted()配合thread.interrupt()实现线程停止。两种方式都遵循"协作式停止"理念,区别在于 volatile 标记更简单直接,而中断机制可以同时唤醒sleep/wait中的阻塞线程,实际项目中可根据场景选用。
五、volatile 的局限:不能保证原子性
总结volatile的能力边界:
volatile关键字只能保证可见性和顺序性,不能保证原子性。
回到 VolatileInc.java 的验证结果:即使count被volatile修饰,三个线程并发执行count ++的最终结果依然小于 30000。原因正如前文所述——count ++的三步操作中,任何一步都可能被打断,volatile 只解决了"读到的值是否最新",解决不了"三步操作是否被打断"。
正确的修正是把count换成AtomicInteger(源码中已注释的//private static AtomicInteger count = new AtomicInteger();),并调用count.incrementAndGet(),利用 CAS 从底层保证自增的原子性。
三者的选用建议:
| 需求 | 推荐手段 | 说明 |
|---|---|---|
| 仅需标记/开关类变量的可见性 | volatile | 单写多读,开销最小 |
| 需要复合操作的原子性 | synchronized/Lock/Atomic* | 互斥或 CAS |
| 需要禁止指令重排 | volatile | 如双重检查锁单例 |
| 需要线程间"写读"顺序 | volatile | 借助 happen-before 规则 |
六、总结
本篇文章完整梳理了 Java 多线程编程的三大核心:
- 原子性:
i++并非原子操作,需要synchronized、Lock或基于 CAS 的AtomicInteger来保证;AtomicInteger.incrementAndGet()通过"volatile 值 + CAS 自旋"实现线程安全的自增。 - 可见性:CPU 缓存导致线程间的修改不可见,
volatile通过"写立即刷主存、读强制失效缓存"解决;synchronized/锁也能保证可见性,但开销更大。 - 顺序性:JVM 可能指令重排,
volatile可禁止相关重排,synchronized/锁通过互斥保证有序,同时 JVM 通过 happen-before 原则(含 volatile 写读规则)隐式保障顺序。
volatile在 Java 并发体系中应用极广:Atomic包内部的值、AbstractQueuedLongSynchronizer(AQS)中的state等关键字段都被定义为volatile以保证内存可见性。把这三块理解透彻,是编写健壮并发程序的基础。
如果你想亲手运行验证,可查看 VolatileInc.java(观察并发自增结果小于预期)、Volatile.java(观察 volatile 标记停止线程)与 Singleton.java(双重检查锁单例);更系统的 volatile 专题可见仓库中的 MD/concurrent/volatile.md 与 docs/jvm/volatile.md,相关多线程常见问题(上下文切换、死锁、资源限制)可继续阅读 Thread-common-problem.md。
【免费下载链接】JCSprout👨🎓 Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考