有一些知识点,属于“面试必考、工作必用、但很多人其实没完全吃透”的类型。Java 并发编程里的原子性、可见性、有序性,就是最典型的一组。三个词听起来像概念题,实际上每一行并发代码都在跟它们打交道。你不理解它们,写出来的多线程程序就是“偶尔出错、难以复现、线上炸了还不知道怎么查”。这篇文章我用自己的理解和实战经验,把这些东西拆开揉碎讲清楚,重点放在“为什么需要它”“它是怎么做到的”“哪里最容易踩坑”。
1. 三个性质到底在解决什么问题
1.1 所有并发问题的根源:CPU、内存、编译器都在“偷偷优化”
先看一个最基本的场景。两个线程同时操作一个共享变量,你到底期不期待它每次的行为都“符合人类直觉”?比如某个线程读到一个变量,然后修改它,然后另一个线程又读它。你脑子里想象的执行顺序,和实际硬件上执行的顺序,往往不是一回事。
CPU 为了提高速度,不会每次都直接读写内存,而是先把数据加载到自己的高速缓存里,操作完再写回内存。编译器为了优化性能,可能会把代码顺序重排。JIT(即时编译器)可能会在运行时再次重排。JMM(Java 内存模型)就是用来约定这些优化“能到什么程度”的规则,它定义了 Java 程序在并发场景下哪些行为是合法的,哪些行为是禁止的。
原子性、可见性、有序性,就是 JMM 围绕“共享内存访问”制定的核心规范。打个比方,多个线程就像多个厨师共用一个冰箱,每个人都有自己的“便签纸”记录食材量。原子性解决的是“你看我放酱油的时候别人不能偷看”,可见性解决的是“我改了记录你那边得马上知道”,有序性解决的是“你别把先放盐再放糖的顺序搞反了”。
1.2 从一段“看起来没问题”的代码说起
public class Counter { private boolean flag = false; private int count = 0; public void writer() { count = 42; // 1 flag = true; // 2 } public void reader() { if (flag) { // 3 System.out.println(count); // 4 } } }线程 A 调用writer(),线程 B 调用reader()。直觉上,如果 B 看到flag == true,它打印的count一定 是 42,因为代码顺序是先赋值 count 再设置 flag。但现实里 B 可能打印 0,甚至可能无限循环读不到flag的变化。原因分别对应:
count = 42; flag = true;被重排成flag = true; count = 42;(有序性问题)。- B 线程自己的缓存里 flag 一直是 false,没有及时看到 A 的修改(可见性问题)。
count = 42这个操作本身不是原子性的,虽然对 int 赋值理论上原子,但在 64 位虚拟机里对 long/double 的非 volatile 写入可能被拆成两步,读到中间值(原子性问题)。
这三类问题互相纠缠,但解决办法又是配套的:** synchronized、volatile、final、Lock 这些关键字,本质上就是在帮你同时或部分地保证这三个性质**。
2. 原子性:要么全做,要么全不做
2.1 什么是真正的原子操作
原子性指的是一个操作或者多个操作在 CPU 执行过程中不被中断的特性。要么全部执行成功,要么全部不执行,不会出现“执行到一半”的状态。注意,这里说的“操作”可以是一条机器指令,也可以是由锁包围的一组代码块。Java 中你可以粗略理解成:
- 对基本类型(除了 long/double)的赋值,在 32 位虚拟机上,
i = 1是原子的。但在 64 位虚拟机上,long和double的赋值不保证原子,因为可能先写高 32 位再写低 32 位。 i++不是原子操作。它读取 i、加 1、写回 i,三步可能被线程切换打断。if (a == null) { a = new Object(); }不是原子的,if 判断和赋值是两个独立步骤。
很多人一开始都会误解“我用 int 自增,它不就是一行代码吗?”。一行代码不等于一条指令。字节码里i++会被拆成GETSTATIC、ICONST_1、IADD、PUTSTATIC等多条指令,随便哪一步都可能被其他线程插进来。
2.2 有哪些保证原子性的手段
volatile只能保证单个读/写操作的原子性,不能保证i++这种复合操作的原子性。很多人以为 volatile 能解决原子性问题,这是个经典误区。所以对于自增、自减、CAS 这类复合操作,你需要使用:
- synchronized 块或方法:锁住整个临界区,临界区内的代码不会被并发打扰。
- Lock 接口(如 ReentrantLock):提供更灵活的锁控制。
- AtomicInteger / AtomicLong / AtomicReference 等原子类:内部通过 CAS(比较并交换)实现无锁原子更新,适合高频并发计数场景。
public class SafeCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 内部是 CAS 循环,保证原子性 } }2.3 为什么不推荐只用 int + synchronized 做计数
synchronized 当然能保证原子性,但它在锁竞争激烈的时候,性能损失比较明显。Atomic 类利用 CPU 的 CAS 指令,在低竞争场景下开销更小。不过 CAS 也有自己的问题:ABA 问题、自旋消耗 CPU、只能保证单个变量的原子性。如果业务要同时更新多个变量,就必须用锁。
这里说一个我踩过的坑:曾经做一个秒杀库存扣减,第一版用 synchronized 方法,压测时发现吞吐量很低。后来换成 AtomicInteger + 乐观重试,吞吐量上来了,但库存扣成负数的情况出现了。原因是我直接把“如果库存大于0才扣减”的检查放在了 CAS 外面。后来改成 AtomicInteger 的updateAndGet,把检查逻辑放进 lambda 里重试,才真的安全。
2.4 atomic 包底层原理:CAS
CAS 是 CPU 指令级别的原子操作,核心逻辑是“比较并交换”:如果当前内存值等于预期值,就把它更新成新值,否则就重新尝试。Java 里的实现类通过调用 Unsafe 类的 native 方法完成这个动作。所以它不需要加锁,不会有线程阻塞切换,但会自旋,适合临界区很短、竞争不激烈的场景。
使用 CAS 时必须注意数据一致性边界。比如AtomicReference只能保证对象引用本身的原子更新,不能保证对象内部状态的一致。如果你把某个对象塞进原子类,然后多个线程同时修改对象里的字段,那依然会产生并发问题。
3. 可见性:改了不等于别人看得见
3.1 不可见的问题是怎么发生的
先看一个最经典的例子:
public class VisibilityDemo { private static boolean running = true; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (running) { // 空循环 } System.out.println("线程退出"); }).start(); Thread.sleep(1000); running = false; // 主线程修改,但子线程可能永远看不到 } }单线程下,running = false之后,循环必然退出。多线程下,子线程可能一直在自己的 CPU 缓存里读running = true,哪怕主线程已经把主内存里的值改成了 false,子线程也不会感知到。这就是不可见。
本质原因:现代 CPU 都有多级缓存。线程 A 改写了一个共享变量,修改先落在 A 所在 CPU 的缓存里,还没有刷回主内存。线程 B 读这个变量时,如果它的缓存里还有旧值,就直接用旧值,不会去主内存取新鲜数据。
3.2 volatile 的可见性原理
volatile关键字做出两个承诺:
- 所有线程读取该变量时,都会直接从主内存读取,而不是从线程的工作内存读取。
- 所有线程对该变量的写入,都会立即刷新到主内存中。
更准确的说法是,volatile 变量的写操作会强制让其他 CPU 的缓存行失效,读操作时如果发现缓存行被标记为失效,就会重新从主内存加载。一个常见比喻是“主内存是公告栏,每个线程有自己的小抄。volatile 规定你必须每次去看公告栏,不能对着小抄自嗨”。
在实现层面,volatile 写操作会插入 StoreStore 屏障,保证之前的普通写操作不会被重排序到 volatile 写之后;volatile 读操作会插入 LoadLoad 屏障,保证之后的普通读操作不会被重排序到 volatile 读之前。这些内存屏障在 JMM 层面看不见,但在 JIT 编译后的汇编里能看到(用-XX:+PrintAssembly可以验证)。
3.3 synchronized 与可见性
synchronized 块对可见性的保证是通过“锁的获取与释放”实现的。线程进入 synchronized 块时,会清空自己的工作内存,重新从主内存加载所有变量;退出 synchronized 块时,会把工作内存中的修改刷新到主内存。所以 synchronized 既能保证原子性,也能保证可见性。这也是为什么很多并发问题可以用 synchronized 一把梭,因为它在三个性质上都有正贡献。
不过要注意,synchronized 保证的是“同一个锁”下的可见性。如果你线程 A 用锁 X 修改数据,线程 B 用锁 Y 读数据,那依然会出现不可见问题。锁对象必须一致,才能建立 happens-before 关系。
3.4 什么时候需要关心可见性
- 一个线程写、多个线程读共享状态,且读写没有锁保护:必须用 volatile。
- 多个线程写同一个变量,且写不依赖当前值(比如只是简单的赋值):可以用 volatile,但不能用 volatile 做自增。
- 状态标志位(如 shutdown 标志):非常适合 volatile。
public class ShutdownDemo { private volatile boolean shutdown = false; public void stop() { shutdown = true; } public void work() { while (!shutdown) { // 做任务 } } }这个模式叫“状态标志模式”,是 volatile 最典型的应用。注意工作线程里的任务不能太重,否则即使 shutdown 已经可见,也可能要等当前迭代结束才退出。如果要求立刻退出,应该配合中断或LockSupport.parkNanos等机制定期检查。
4. 有序性:代码顺序不是你看到的顺序
4.1 指令重排的三种来源
编译器重排:Java 编译器在不改变单线程语义的前提下,调整语句执行顺序。比如a = 1; b = 2;如果 a 和 b 相互独立,编译器可能把先改 b 再改 a。
处理器重排:CPU 采用乱序执行技术,指令不严格按照程序顺序执行,只要最终结果与顺序执行等价。现代 CPU 在保证单线程结果一致的前提下,会对没有数据依赖的指令做乱序。
内存系统重排:CPU 缓存和写缓冲区可能导致“读到的值不是最新值”,本质属于可见性问题,但也会造成乱序观感。
单线程下这些重排不会影响结果,因为 JMM 会遵守 as-if-serial 语义:不管怎么重排,单线程程序的执行结果不能变。但多线程下,重排可能导致另一个线程观察到“不符合代码顺序”的结果。
4.2 最典型的双重检查锁问题
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 初始化 } } } return instance; } }这个版本是有问题的。new Singleton()在字节码层面分为三步:
- 分配内存空间。
- 调用构造方法初始化对象。
- 将 instance 引用指向这块内存。
如果 JIT 把 2 和 3 重排,变成先分配内存并设置引用,再真正执行构造方法。此时线程 A 执行到“引用指向内存”但还没执行构造方法时,线程 B 进来检查 instance,发现它不为 null,直接返回了。线程 B 拿到的是一个“半初始化”的对象,使用它的字段时可能读到默认值或空指针。
解决办法:给 instance 加上 volatile。因为 volatile 会禁止重排,保证引用赋值一定发生在构造方法完成之后。
private static volatile Singleton instance;还有一种办法是静态内部类单例,利用类加载机制天然保证线程安全,但如果你要用双重检查锁,volatile 是必须的。
4.3 happens-before 规则
JMM 定义了一套 happens-before 规则,用来判断“某个操作的结果对另一个操作是否可见”。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。
主要的 happens-before 规则:
- 程序次序规则:单线程内,写在前面的代码 happens-before 后面的代码。
- 监视器锁规则:一个锁的解锁 happens-before 对同一个锁的加锁。
- volatile 变量规则:对一个 volatile 变量的写 happens-before 之后对它的读。
- 传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。
有了这套规则,你就不需要对每条代码都心算内存屏障,只要判断两个操作之间是否有一条 happens-before 链路。如果存在链路,前面的操作结果一定对后面可见。
4.4 volatile 与 synchronized 的有序性效果
volatile 通过内存屏障禁止指令重排,它既禁止编译器重排,也通过插入屏障阻止 CPU 乱序执行。synchronized 的有序性体现在:同一个锁上,代码块内的操作按一定顺序执行,多个线程争抢同一个锁时,锁保证临界区之间的顺序是“串行化”的,但临界区内部仍然可能重排,只是由于锁的互斥性,重排不会被其他线程观察到。
在 JMM 中,synchronized 本身并不禁止临界区内部的重排,但它通过 happens-before 规则让“同一锁”的读线程能看到写线程在临界区里的所有修改,这就足够保证安全性了。
5. 三者之间如何协同工作
5.1 一个线程的写能被另一个线程看到,需要同时满足哪些条件
这里很容易混淆。很多人以为加 volatile 只是解决可见性,其实 volatile 还提供了有序性(禁止重排),但原子性只在单个 volatile 变量的读/写上成立。
我整理了一个速查表:
| 关键字/工具 | 原子性 | 可见性 | 有序性 | 适用场景 |
|---|---|---|---|---|
| volatile | 仅单个读/写 | 是 | 是(禁止重排) | 状态标志、单次发布不可变引用 |
| synchronized | 锁内代码块 | 是 | 是(锁内互斥) | 复合操作、多变量一致性 |
| Lock | 锁内代码块 | 是 | 是 | 需要超时、可中断、多条件队列 |
| final | 部分(安全发布) | 是 | 是(构造器内) | 不可变对象发布 |
| AtomicInteger | 是(CAS 操作本身) | 是 | 是 | 高并发计数器、原子状态更新 |
注意 final 的可见性:如果一个对象在构造器里将 final 字段赋值,并且在构造器完成前没有把 this 引用逸出,那么其他线程看见这个对象时,final 字段必定已经初始化完成。这是 JMM 对 final 的特殊保证,适用于不可变对象模式。
5.2 实际写代码时怎么组合使用
一个实用的经验法则:
- 如果共享变量只有一个线程在写、其他线程只读,用 volatile。
- 如果有多个线程写同一个变量,且写操作是复合操作(先读后写等),用原子类或锁。
- 如果需要多个变量保持一致(例如库存数量和已售数量必须同时更新),用 synchronized 或 Lock。
- 如果能设计成不可变对象,优先用 final + 不可变模式,连 volatile 都不用考虑。
举个组合使用的例子:
public class OrderService { private final Map<String, Order> orderCache = new ConcurrentHashMap<>(); private final AtomicInteger totalOrders = new AtomicInteger(0); private volatile boolean closed = false; public void createOrder(Order order) { if (closed) { throw new IllegalStateException("已关闭"); } orderCache.put(order.getId(), order); totalOrders.incrementAndGet(); } public void close() { closed = true; } }这里orderCache用并发容器保证内部可见性和原子性,totalOrders用原子类保证计数不丢,closed用 volatile 状态标志快速失效。不同类型的共享数据用不同的工具,并发性能会好很多。如果全部用一把大锁,代码更简单,但并发吞吐量会受影响。
5.3 千万不要混淆“线程安全”和这三个性质
线程安全是一个更宽泛的概念。一个类线程安全,意味着它被多个线程同时调用时,不需要外部同步就能保持内部一致性。原子性、可见性、有序性是底层支撑,但是线程安全还涉及不变式、状态封装、生命周期管理等。比如 HashMap 不是线程安全的,不是因为它的每个方法不原子,而是它的复合操作(如“如果不存在则添加”)可能被中间打断导致结构损坏。
所以在排查并发问题时,先定位是哪种性质被破坏了。我总结了一套排查思路:
- 如果结果出现“数据错误但没抛异常”,优先怀疑原子性问题。
- 如果某个线程一直看到旧值,迟迟不退出循环,优先怀疑可见性问题。
- 如果出现“难以解释”的中间状态(比如对象半初始化),优先怀疑有序性问题。
一个复杂的并发 Bug 往往是几个性质同时被破坏,需要层层排查。
6. 三个实战排查案例
6.1 一个 1 小时才出现一次的“不可能”Bug
某开发者写了一个日志异步上传组件,核心是生产者往队列里放日志,消费者从队列取日志上传。队列用了一个自定义的有界数组,配合两个 volatile int 的 head/tail 索引。理论上 volatile 能保证 head/tail 对消费者可见,但实际运行时偶尔会出现写入丢失或越界读取。
排查发现,head/tail 索引虽然可见,但“检查队列是否满”“移动 tail”是一个复合操作,volatile 只保证了两个变量各自的原子读/写,没有保证复合判断的原子性。解决方案换成ArrayBlockingQueue或ReentrantLock保护索引更新,Bug 消失。
6.2 一个可见性导致的“假死”服务
某服务有一个线程专用于定时清理过期缓存,另一个线程负责外部请求。清理线程每隔 30 秒检查一个cleanerRunning标志位,外部请求线程在某些情况下会把它改掉。某次线上发现清理线程完全不工作,CPU 占用却很高。检查代码发现cleanerRunning没有加 volatile,清理线程里读到的永远是初始值。
换成 volatile 之后恢复。这类问题在单核测试环境很难复现,多 CPU 服务器上才容易爆发,因为多核缓存不一致的概率更大。
6.3 一个有序性引发的单例错乱
某系统的配置中心采用双重检查锁加载全局配置,上线后发现偶尔出现某些线程拿到配置里的某个字段为 null,但其他字段正常。最终加了 volatile 解决。
这类问题最难复现,因为重排是随机的,需要特定的 CPU 调度时机。排查工具上,可以借助 JMM 测试工具或压测并发访问来放大概率,但最根本的办法还是消灭不安全的发布模式,比如用枚举单例、静态内部类、volatile 或 final。
7. 内存屏障的前世今生
7.1 内存屏障到底做了什么
我在讲 volatile 和 synchronized 时反复提到内存屏障。它实际上是 CPU 提供的一组特殊指令或者 JIT 插入的指令序列,用来限制重排策略。常见的内存屏障有四种组合:
- LoadLoad:禁止前一个读操作与后一个读操作重排。
- StoreStore:禁止前一个写操作与后一个写操作重排。
- LoadStore:禁止前一个读操作与后一个写操作重排。
- StoreLoad:禁止前一个写操作与后一个读操作重排,开销最大。
volatile 读会在其后插入 LoadLoad/LoadStore 屏障,保证后续普通读不能跑到 volatile 读前面。volatile 写会在其前插入 StoreStore 屏障,保证前面的普通写不能跑到 volatile 写后面。synchronized 的重屏障逻辑更复杂一点,但同样围绕“解锁写回、加锁重读”来实现。
7.2 为什么说 StoreLoad 是最贵的
StoreLoad 屏障不仅要禁止重排,还要要求所有处理器把写缓冲区的内容冲刷到内存,然后才能执行后续读。这意味着 CPU 必须等待内存事务完成,所以性能开销最高。volatile 写加 volatile 读如果成对出现,JIT 可能会在中间插入 StoreLoad 屏障。这也是为什么过度使用 volatile 会影响性能的原因之一。
不过在日常业务代码里,这些屏障的耗时远小于锁竞争和上下文切换的开销,所以不必过分担心。真正需要关注的是“无意义的 volatile”带来的缓存行伪共享问题,比如多个 volatile 变量恰好落在同一个缓存行,被不同 CPU 频繁写,就会产生大量一致性消息,性能急剧下降。可以通过 @Contended 注解(Java 8+)填充字节隔离。
8. 进阶:DCL 与安全发布的完整解决方案
8.1 双重检查锁为什么必须加 volatile
前面讲了重排导致半初始化对象外泄的问题。这里再从 happens-before 角度解释一遍:synchronized(Singleton.class)只能保证线程 A 在锁内赋值的操作对后续获取同一把锁的线程 B 可见,但第一个 ifinstance == null检查在锁外。线程 B 看到 instance 不为 null 时,并没有获取锁,所以与 A 之间不建立锁规则下的 happens-before。只有 volatile 变量规则能够建立“A 的写 instance happens-before B 的读 instance”,因此必须加 volatile。
8.2 枚举单例和静态内部类的原理
不用 volatile 也可以实现线程安全单例:
public enum SingletonEnum { INSTANCE; public void doSomething() { } }枚举类在 JVM 中加载是天然线程安全的,并且枚举实例的创建发生在类加载阶段。静态内部类同样利用类加载时机:
public class SingletonHolder { private static class Holder { private static final SingletonHolder INSTANCE = new SingletonHolder(); } public static SingletonHolder getInstance() { return Holder.INSTANCE; } }这种方式既懒加载又线程安全,因为Holder只在getInstance()第一次被调用时才加载,而类加载过程由 JVM 保证互斥性和可见性。如果项目里没有特殊情况,优先推荐这种方式,代码简单还不容易出错。
8.3 安全发布的其他模式
除了单例,还有几种常见的安全发布对象的方式:
- 通过 volatile 字段发布(比如
volatile Holder holder)。 - 通过 static final 字段发布。
- 通过 ConcurrentHashMap、BlockingQueue 等并发安全容器发布。
- 通过“写入普通字段 + 写入 volatile 标志位”这种先数据后旗标的模式发布,前提是严格保证写顺序。
我自己见过不少把普通字段改成 volatile 就“碰巧修好”的代码,逻辑上并没有建立完整的 happens-before 链。所以正确的做法是理解规则之后,再选择与场景匹配的发布方式。
9. 常见问题速查与避坑清单
9.1 常见问题速查表
| 问题现象 | 可能原因 | 推荐排查手段 |
|---|---|---|
| 多线程自增计数,结果偏小 | 复合操作缺少原子性 | 检查是否用了 volatile 做 i++、改用 AtomicInteger 或 synchronized |
| 子线程看不到主线程修改的标志位 | 缺少可见性 | 加 volatile,或使用 synchronized/Lock 读写共享变量 |
| 双重检查锁里拿到半初始化对象 | 指令重排 | instance 加 volatile,或改用静态内部类单例 |
| 线程频繁自旋但结果混乱 | 多个变量组合更新未加锁 | 缩小锁范围或引入并发容器 |
| 线上偶发错误,本地无法复现 | 多核缓存不一致 | 尽量压测放大,按 JMM 规则审查代码 |
| 加了 volatile 但仍然“有时不对” | volatile 不能保证复合操作原子性 | 使用原子类或锁 |
9.2 避坑清单
第一,不要用 volatile 修饰复合状态。例如:
private volatile int count; public void increment() { count++; // 错误,不是原子的 }第二,不要依赖“侥幸顺序”。有些代码在低并发下跑得好好的,高并发下就崩,因为重排和缓存不一致会被更多线程放大。线上修 Bug 时一定要先审视共享变量是否满足 happens-before,而不是盲目加锁。
第三,注意long/double的原子性问题。虽然现代 64 位 JVM 上读写 long/double 基本是原子的,但 JMM 并不强制要求非 volatile 的 long/double 读写必须原子。为了保证跨平台一致性,最好把共享的 long/double 声明为 volatile,或使用 AtomicLong/AtomicDouble。
第四,不要忽略final的释放规则。如果构造函数中把 this 引用发布出去(比如启动一个线程并传入 this),final 字段的保证会被破坏。这叫作“逸出”。安全的做法是在构造函数完成之后再发布对象。
第五,synchronized 虽然强,但粒度大。过度同步会导致并发性能低下。能用 volatile 状态标志或原子类解决的简单场景,就不必上锁。能用局部变量就绝不共享变量,这是最简单的并发优化。
第六,使用@Contended注解时要注意 JVM 参数。该注解默认在 JDK 9+ 才默认启用,且需要-XX:-RestrictContended才能生效。它不是业务代码的常用手段,遇到伪共享性能热点时才值得折腾。
10. 我的一些实际体会
关于这三个性质,我最想说的是:不要把它们当成三个孤立的知识点,而要当成一套“并发时空模型”。原子性管的是“时间切面——操作能否被分割”,可见性管的是“空间传播——变量修改能否跨越 CPU 缓存”,有序性管的是“时序逻辑——执行顺序是否会被改写”。你在写一行共享变量代码的时候,脑子里应当自动跑一遍这三个检查。
多年的经验告诉我,并发 Bug 之所以难查,是因为它不像普通逻辑错误那样稳定复现。它像一个会隐身的刺客,只在特定的缓存刷新、线程调度和重排组合下现身。你不可能靠运气避开,只能靠规则和工具去约束。写代码时多问自己一句:“这个变量真的满足 happens-before 吗?这个复合操作真的不会被并发打乱吗?”能挡住大部分问题。
还有一个实用建议:团队里做并发代码评审时,不要只说“这里加个锁”,要把具体破坏的性质讲清楚。比如“这里 i++ 不是原子的,所以要改 AtomicInteger”比“这里可能有问题,加个锁吧”更有价值。带着这个思路去读别人的代码,你很快就能一眼识别出并发隐患,比依赖测试压测更靠谱。
最后,如果条件允许,强烈建议看一看 JMM 源码和 HotSpot 在 x86/ARM 不同平台下的屏障实现细节。这些知识平时用不上,但能在你遇到极其诡异的线上问题时,提供足够的排查底气。
如果你正在面试,把这三个性质结合 happens-before 规则、volatile/synchronized 的内存语义、DCL 为什么要加 volatile 三个方面讲清楚,面试官基本会认为你是真正的并发高手。工作里能把这些规则落到代码细节上,你就已经比大多数只知道概念的人强很多了。