Java 基础体系 · 第 15/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 内存模型与同步:happens-before、锁、volatile、Atomic 和 VarHandle

1. 为什么“共享变量”不仅是一个读写问题

并发程序中的一个共享变量,至少涉及三个彼此不同的问题:

  1. 原子性:一次操作是否不可分割。
  2. 可见性:一个线程写入的结果,另一个线程何时、以什么规则能够读到。
  3. 有序性:编译器、JIT 和处理器是否可以重排操作,以及哪些重排会被禁止。

例如:

counter++;

表面上是一条语句,实际通常包含:

读取 counter
计算 counter + 1
写回 counter

如果两个线程同时执行,可能出现以下交错:

初始 counter = 0

线程 A:读取 0
线程 B:读取 0
线程 A:写入 1
线程 B:写入 1

最终结果是 1,而不是两个自增操作应得到的 2。这首先是复合操作不具备原子性,不只是“缓存没有刷新”的问题。

另一方面,即使每次读写本身都是原子的,也不代表线程能看到另一个线程的写入。Java 内存模型(Java Memory Model,JMM)规定的是线程如何通过特定同步动作建立可见性和顺序关系,而不是简单规定“什么时候刷新 CPU 缓存”。


2. JMM 的基本对象:动作、顺序与数据竞争

2.1 线程动作

JMM 讨论的不是源代码行,而是线程执行过程中发生的动作,例如:

  • 普通变量的读和写;
  • volatile 变量的读和写;
  • 加锁和解锁;
  • 启动线程、等待线程结束;
  • 原子类和 VarHandle 执行的读改写操作。

同一线程内,程序执行顺序形成程序顺序(program order,简称 po)。例如:

data = 42;
ready = true;

在这个线程中,写入 data 的动作先于写入 ready 的动作。

但是,程序顺序不等于所有线程都必须直接观察到完全相同的执行顺序。编译器和处理器可以在不违反 JMM 的前提下重排操作。同步机制的作用,就是建立 JMM 明确承认的跨线程顺序。

2.2 happens-before 是什么

happens-before(先行发生,简称 hb)是一种偏序关系:

A hb B

表示:按照 JMM,动作 A 的结果对动作 B 可见,并且 A 在顺序上先于 B

它不是物理时间的精确描述,也不表示 A 执行完之后线程才会被操作系统调度去执行 B。它是一个用于判断可见性和允许重排范围的规范关系。

hb 至少具有以下性质:

  • 自反性:一个动作先行发生于自身;
  • 传递性:如果 A hb BB hb C,则 A hb C
  • 与程序顺序结合:同一线程内,前面的动作先行发生于后面的动作;
  • 由同步动作建立跨线程关系

2.3 synchronizes-with 与 happens-before

synchronizes-with(同步于,简称 sw)是少数能够直接建立跨线程关系的规范边。例如:

  • 对同一个监视器,解锁 unlock 同步于后续成功获取该监视器的加锁;
  • 对同一个 volatile 变量,写入同步于后续读取到该写入结果的读取;
  • 调用 Thread.start() 同步于新线程中的第一个动作;
  • 线程中的动作同步于另一个线程成功返回的 Thread.join()
  • ExecutorService 提交任务之前的动作,先行发生于任务中的动作;
  • 任务中的动作先行发生于成功从 Future.get() 返回之后的动作。

通常可以把跨线程的可见性推导写成:

程序顺序 + synchronizes-with + 传递性
        => happens-before

例如:

class Message {
    int data;
    volatile boolean ready;

    void publish() {
        data = 42;
        ready = true;
    }

    int consume() {
        while (!ready) {
            Thread.onSpinWait();
        }
        return data;
    }
}

假设消费者读取 ready 时读到了生产者写入的 true,推导过程如下:

生产者写 data = 42
    hb
生产者写 ready = true
    sw
消费者读 ready == true
    po
消费者读 data

根据传递性:

生产者写 data = 42 hb 消费者读 data

因此消费者必须看到 data == 42,而不仅仅是看到 ready == true

如果把 ready 改成普通变量:

boolean ready;

上述 sw 边就不存在。此时生产者和消费者对 ready 存在数据竞争,消费者可能长时间看不到 true,而且不能据此推导出对 data 的可见性。

2.4 数据竞争与顺序一致性

当两个线程访问同一个变量,至少一个访问是写操作,并且这些访问之间没有建立必要的 hb 关系时,就可能形成数据竞争(data race)。

例如:

class BrokenCounter {
    private int value;

    void increment() {
        value++;
    }

    int get() {
        return value;
    }
}

多个线程调用 increment()get() 时,value 的访问没有锁、volatile 或原子操作保护。

对于没有数据竞争的程序,JMM 提供了接近顺序一致性的推理方式:可以按照每个线程的程序顺序,再结合同步顺序,理解可观察结果。对于存在数据竞争的程序,不能简单地用“每个线程轮流执行一行代码”来模拟。

这也是为什么“在我的机器上跑了很多次都没出问题”不能证明无锁代码正确。


3. 普通字段、原子性与安全发布

3.1 普通读写不等于完整同步

对引用和大多数基本类型的单次读写,Java 语言和虚拟机保证不会读到任意位拼接出来的“半个值”。但这不意味着:

  • 多次操作自动具有原子性;
  • 一个线程一定能及时看到另一个线程的写入;
  • 读到一个引用后,对象内部所有字段都必然已正确发布。

例如:

class Config {
    int timeout;
    boolean enabled;
}

Config config;

如果一个线程构造并赋值:

Config c = new Config();
c.timeout = 1000;
c.enabled = true;
config = c;

另一个线程直接读取 config,但双方没有同步关系,则不能把这段代码当作安全发布。

3.2 安全发布

对象引用可以通过以下方式安全发布:

  • 写入 volatile 引用;
  • 在锁保护下写入和读取;
  • 通过静态初始化;
  • 通过线程启动前构造并发布;
  • 通过线程安全容器;
  • 通过任务提交和 Future.get() 等已有同步关系。

例如:

final class Config {
    final int timeout;
    final boolean enabled;

    Config(int timeout, boolean enabled) {
        this.timeout = timeout;
        this.enabled = enabled;
    }
}

class Service {
    private volatile Config config;

    void reload() {
        config = new Config(1000, true);
    }

    Config current() {
        return config;
    }
}

这里的 volatile 保证引用发布时的可见性和顺序。Configfinal 字段还具有 JMM 特殊的 final-field 语义:如果对象没有在构造过程中逃逸,其他线程通过正常方式取得该对象后,通常能够看到 final 字段在构造函数中写入的值。

但这不等于对象的所有状态都不可变:

final class Config {
    final int timeout;
    final List<String> routes; // 引用是 final,List 本身不一定不可变
}

final 只约束引用本身不能重新指向其他对象;它不会自动使 List 线程安全,也不会保护列表后续的修改。


4. synchronized 与显式锁

4.1 synchronized 的两个作用

synchronized 同时提供:

  1. 互斥:同一时刻最多一个线程执行同一个监视器保护的临界区;
  2. 内存同步:对同一个监视器的解锁与后续成功加锁之间建立 synchronizes-with 关系。
class SafeCounter {
    private int value;

    public synchronized void increment() {
        value++;
    }

    public synchronized int get() {
        return value;
    }
}

执行过程可以抽象为:

线程 A:
    获取 monitor
    读取 value
    写入 value
    释放 monitor

线程 B:
    获取同一个 monitor
    读取 value

如果线程 B 成功获取的是线程 A 释放的同一个监视器,那么:

线程 A 在 monitor 内的写入
    hb
线程 B 获取 monitor 后的读取

锁保护的不只是 value++ 的算术操作,也保护了进入临界区前后与该临界区相关的普通字段访问。

4.2 锁必须保护同一个状态和同一个锁对象

以下代码并没有保护 value

class WrongLock {
    private int value = 0;

    void increment() {
        synchronized (new Object()) {
            value++;
        }
    }
}

每次调用都创建了不同的锁对象,不同线程之间没有互斥关系。

以下代码也可能错误:

class InconsistentLock {
    private final Object lock = new Object();
    private int value;

    void increment() {
        synchronized (lock) {
            value++;
        }
    }

    int get() {
        return value; // 没有使用同一个锁
    }
}

increment() 的解锁与 get() 没有对应的加锁,因此不能从 synchronized 推导出 get()value 的可见性。

4.3 ReentrantLock

ReentrantLock 提供与内置监视器类似的内存同步效果,同时提供可中断加锁、限时加锁和多个条件队列等能力。

import java.util.concurrent.locks.ReentrantLock;

final class LockedCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int value;

    void increment() {
        lock.lock();
        try {
            value++;
        } finally {
            lock.unlock();
        }
    }

    int get() {
        lock.lock();
        try {
            return value;
        } finally {
            lock.unlock();
        }
    }
}

finally 不是格式要求,而是正确性要求。如果临界区抛出异常而没有执行 unlock(),其他线程可能永久阻塞。

可中断和限时获取改变的是等待和故障路径,不改变锁所提供的基本内存同步:

if (lock.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
    try {
        // 临界区
    } finally {
        lock.unlock();
    }
} else {
    // 超时后的降级或重试
}

如果使用 lockInterruptibly(),等待锁的线程可以因中断退出。中断本身不是强制停止线程;它是线程间传递取消意图的协作机制。

4.4 锁的取舍

锁适合保护需要同时维护的不变量。例如账户转账要求两个余额一起变化:

synchronized (accountPairLock) {
    from.balance -= amount;
    to.balance += amount;
}

仅用两个独立的原子整数,虽然每个余额单独更新是原子的,却不能自动保证“总余额不变”这一跨变量不变量。此时需要共同的锁、事务式算法,或其他能够统一协调多个状态的设计。


5. volatile:可见性与有序性,不是复合原子性

5.1 volatile 的内存语义

声明为 volatile 的字段:

private volatile boolean stopped;

对它的写和读具有特殊语义:

  • volatile 写具有 release 风格的发布效果;
  • volatile 读具有 acquire 风格的获取效果;
  • 对同一个 volatile 字段的访问参与同步顺序;
  • 读到某次 volatile 写入的结果时,该写入同步于该读取。

因此,下面的停止标志是典型用法:

class Worker implements Runnable {
    private volatile boolean stopped;

    void stopWorker() {
        stopped = true;
    }

    @Override
    public void run() {
        while (!stopped) {
            doOneUnitOfWork();
        }
    }

    private void doOneUnitOfWork() {
        // 一小段可取消工作
    }
}

如果 stopped 不是 volatile,JIT 可能在合法范围内把循环中的读取优化得不符合直觉,或者线程持续读取旧值。volatile 确保循环中的读取具有规定的可见性。

5.2 volatile 发布普通字段

volatile 常用于“数据先写好,再发布一个状态”的模式:

class Snapshot {
    private int version;
    private String text;
    private volatile boolean published;

    void publish(int version, String text) {
        this.version = version;
        this.text = text;
        this.published = true;
    }

    String read() {
        if (!published) {
            return null;
        }
        return version + ":" + text;
    }
}

读取到 published == true 后,读者可以根据 hb 推导看到此前的 versiontext 写入。

5.3 volatile 不能修复 counter++

下面仍然错误:

class WrongVolatileCounter {
    private volatile int value;

    void increment() {
        value++;
    }
}

volatile 保证每次读和写具有可见性与顺序,但 value++ 仍然是:

volatile 读
计算
volatile 写

两个线程仍可能读取相同旧值,然后分别写回相同的新值。

volatile 适合:

  • 状态标志;
  • 配置快照引用;
  • 一次写、多次读的发布协议;
  • 不需要复合更新的计数或版本号。

它不适合直接实现:

  • x++
  • “读取后根据旧值决定是否写入”的条件更新;
  • 多个字段必须保持整体不变量的状态。

6. Atomic:原子读改写与 CAS

6.1 Atomic 类提供什么保证

java.util.concurrent.atomic 中的类通常基于 CAS(Compare-And-Set,比较并设置)和 VarHandle 实现。

例如:

import java.util.concurrent.atomic.AtomicInteger;

final class AtomicCounter {
    private final AtomicInteger value = new AtomicInteger();

    void increment() {
        value.incrementAndGet();
    }

    int get() {
        return value.get();
    }
}

incrementAndGet() 把“读取、加一、写回”作为一个不可被其他原子更新插入的操作。它既解决了计数器更新的原子性,也提供相应的内存可见性。

常用操作包括:

  • get():原子读取;
  • set(int):原子写入;
  • incrementAndGet():加一后返回;
  • getAndIncrement():返回旧值后加一;
  • compareAndSet(expected, update):期望值匹配时更新;
  • updateAndGet(function):循环执行更新函数直到成功。

6.2 CAS 循环的逐步过程

一个通用的非阻塞更新可以写成:

import java.util.concurrent.atomic.AtomicInteger;

final class BoundedCounter {
    private final AtomicInteger value = new AtomicInteger();

    boolean tryIncrementTo(int limit) {
        for (;;) {
            int current = value.get();

            if (current >= limit) {
                return false;
            }

            int next = current + 1;
            if (value.compareAndSet(current, next)) {
                return true;
            }
        }
    }

    int get() {
        return value.get();
    }
}

假设初始值为 0,限制为 1,两个线程同时调用:

线程 A:读取 current = 0,准备写 1
线程 B:读取 current = 0,准备写 1

线程 A:CAS(0, 1) 成功
线程 B:CAS(0, 1) 失败,因为当前值已经是 1

线程 B:重新读取 current = 1
线程 B:发现达到 limit,返回 false

CAS 失败不是异常,而是并发竞争的正常分支。循环重新读取值,再根据新状态决定下一步。

6.3 Atomic 不等于任意对象都线程安全

原子引用可以原子地替换引用:

import java.util.concurrent.atomic.AtomicReference;

AtomicReference<String> state =
        new AtomicReference<>("CLOSED");

state.compareAndSet("CLOSED", "OPEN");

但它不会自动保护被引用对象内部的修改:

AtomicReference<java.util.ArrayList<String>> ref =
        new AtomicReference<>(new java.util.ArrayList<>());

// ref.get().add("x") 仍然是非线程安全的

如果状态是不可变对象,可以采用“构造新对象,再 CAS 替换引用”的方式:

record Config(int timeout, boolean enabled) {}

AtomicReference<Config> config =
        new AtomicReference<>(new Config(1000, true));

config.updateAndGet(old ->
        new Config(old.timeout() + 100, old.enabled()));

这里的更新函数可能因为 CAS 失败而被重新执行,因此函数应尽量无副作用。不要在 updateAndGet 的函数中发送消息、写文件或执行只能做一次的外部操作。

6.4 ABA 问题

CAS 通常只比较值。一个线程读取到 A 后暂停,另一个线程将值改为 B,再改回 A,第一个线程的 CAS 仍可能成功:

线程 A:读取 A
线程 B:A -> B -> A
线程 A:CAS(A, C) 成功

如果中间变化本身具有意义,就需要版本号,例如:

import java.util.concurrent.atomic.AtomicStampedReference;

AtomicStampedReference<String> ref =
        new AtomicStampedReference<>("A", 0);

它同时比较引用和值版本。是否需要它取决于算法是否把“中间变化”视为重要事件。


7. VarHandle:显式选择访问内存模式

7.1 VarHandle 是什么

VarHandle 是对字段、数组元素或其他可变变量访问的统一抽象。它允许程序选择不同的访问模式,而不必为每一种模式设计一个独立的类。

典型模式从弱到强大致包括:

模式 主要含义
Plain 普通访问,不额外提供跨线程同步
Opaque 保证访问不会被完全消除或合并到违反 opaque 语义的程度,但同步保证弱于 acquire/release
Acquire / Release 建立单向获取与发布关系
Volatile 提供 volatile 级别的可见性与顺序保证

对于一个 VarHandle,常见方法包括:

getPlain()
setPlain(value)

getOpaque()
setOpaque(value)

getAcquire()
setRelease(value)

getVolatile()
setVolatile(value)

精确的方法签名取决于变量的类型和坐标。例如静态字段、实例字段和数组元素的调用参数不同。

7.2 用 VarHandle 实现发布协议

下面的类使用 VarHandle 的 release/acquire 模式发布一条消息:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

final class MessageBox {
    private int data;
    private boolean ready;

    private static final VarHandle READY;

    static {
        try {
            READY = MethodHandles.lookup()
                    .findVarHandle(MessageBox.class, "ready", boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    void publish(int value) {
        data = value;                 // 普通写
        READY.setRelease(this, true); // 发布
    }

    int consume() {
        while (!(boolean) READY.getAcquire(this)) {
            Thread.onSpinWait();
        }
        return data;                  // 获取后读取普通字段
    }
}

推导过程与 volatile 示例相同:

写 data
    po
setRelease(ready = true)
    sw/同步配对语义
getAcquire(ready == true)
    po
读 data

当 acquire 读取观察到对应的 release 发布结果时,release 之前的写入对 acquire 之后的读取可见。

这里没有把 ready 声明为 volatile,而是通过 VarHandle 指定了访问模式。这样可以在某些底层数据结构中使用比完整 volatile 访问更精细的内存语义,但也要求实现者自己证明每一条发布和获取路径都正确。

7.3 VarHandle CAS

VarHandle 也可以直接执行原子比较更新:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

final class VarHandleCounter {
    private int value;

    private static final VarHandle VALUE;

    static {
        try {
            VALUE = MethodHandles.lookup()
                    .findVarHandle(VarHandleCounter.class, "value", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    void increment() {
        for (;;) {
            int current = (int) VALUE.getVolatile(this);
            if (VALUE.compareAndSet(this, current, current + 1)) {
                return;
            }
        }
    }

    int get() {
        return (int) VALUE.getVolatile(this);
    }
}

compareAndSet 是原子条件更新:只有当前值仍等于 current 时才写入新值。失败说明其他线程先改变了该变量,循环需要重新读取。

AtomicInteger 相比,VarHandle 的优势是可以直接操作自定义对象中的字段、数组元素,并选择 plain、opaque、acquire/release 或 volatile 访问模式;代价是代码更接近底层协议,错误更容易隐藏。

7.4 weakCompareAndSet 与访问模式

VarHandle 提供不同内存模式的弱 CAS 变体,例如 plain、acquire、release 和 volatile 语义。弱 CAS 还可能出现伪失败:即使当前值匹配,也可能报告失败,因此通常必须放在循环中。

使用哪种模式不能根据“性能更高”直接决定,而应从算法的同步边建立推导:

  • 只需要普通独占更新,但不需要发布其他数据,可能使用较弱模式;
  • 需要发布此前写入的数据,写侧至少要有 release 语义;
  • 需要获取另一个线程发布的数据,读侧至少要有 acquire 语义;
  • 不确定或协议复杂时,使用 volatile 语义通常更容易验证。

8. Atomic 与 VarHandle 的内存语义和锁的区别

可以从两个维度区分这些工具:

工具 主要解决的问题
synchronized / Lock 互斥执行临界区,并建立内存同步
volatile 单变量的可见性和有序发布,不提供复合更新
Atomic* 单变量的原子读改写、CAS 和可见性
VarHandle 自定义变量访问、内存模式和原子操作

例如,下面两个计数器都能正确递增:

synchronized (lock) {
    value++;
}
for (;;) {
    int old = VALUE.getVolatile(this);
    if (VALUE.compareAndSet(this, old, old + 1)) {
        break;
    }
}

但它们的运行行为不同:

  • 锁竞争时,线程可能阻塞;
  • CAS 竞争时,线程可能反复重试;
  • 锁可以方便地保护多个字段;
  • CAS 更适合单变量或能够设计成不可变快照的状态;
  • 高竞争下,CAS 循环可能消耗大量 CPU;
  • 锁的公平性、可中断性和条件队列会影响调度与故障处理,但不改变基本的 hb 规则。

“无锁”不等于“没有同步”。CAS、volatile 和 VarHandle 都是同步机制,只是不一定通过互斥锁阻塞线程。


9. 等待、通知、线程启动和任务提交中的 happens-before

9.1 Thread.start()join()

class StartJoinExample {
    private int result;

    int run() throws InterruptedException {
        Thread worker = new Thread(() -> result = 42);

        worker.start(); // start 前的动作 hb worker 中的动作
        worker.join();  // worker 中的动作 hb join 成功返回后的动作

        return result;
    }
}

join() 返回后读取 result 是安全的,因为工作线程中的写入先行发生于 join() 返回之后的读取。

如果不用 join()、锁或其他同步手段,就不能仅凭“线程大概率已经执行完”推导可见性。

9.2 ExecutorService 与 Future

线程池不会取消 JMM 的规则。任务提交和等待结果同样有明确的内存关系:

import java.util.concurrent.*;

class FutureExample {
    public static void main(String[] args)
            throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        try {
            int[] input = {1};
            Future<Integer> future = executor.submit(() -> {
                input[0] = 42;
                return input[0];
            });

            int result = future.get();
            System.out.println(result); // 42
        } finally {
            executor.shutdown();
        }
    }
}

成功从 future.get() 返回后,任务中在返回结果前完成的动作对调用方可见。这里的 get() 不只是“等待一个值”,也是同步边。

取消则有不同语义:

future.cancel(true);

它表示尝试取消任务;true 允许尝试中断执行线程,但不保证正在运行的任务立即停止。任务必须主动响应中断,例如检查:

while (!Thread.currentThread().isInterrupted()) {
    doOneUnitOfWork();
}

或者正确处理抛出的 InterruptedException。取消操作本身不能替代任务内部的状态同步,也不能安全地强行终止任意 Java 代码。

9.3 虚拟线程与 JMM

Java 25 的虚拟线程改变的是线程调度和资源模型,不改变:

  • 普通字段访问的 JMM 规则;
  • synchronizedvolatile、Atomic 和 VarHandle 的同步语义;
  • Futurejoin 等建立的可见性关系。

因此,虚拟线程中的共享变量仍然需要同步。虚拟线程能降低创建大量阻塞任务的成本,但不会使 counter++ 自动变成原子操作。

虚拟线程还有运行时层面的 Pinning 问题:虚拟线程在某些同步块或本地代码路径中可能暂时无法卸载其承载平台线程。Pinning 影响调度和吞吐,不会改变锁、volatile 或 happens-before 的规范语义。


10. 常见错误的完整对照

10.1 只用 volatile 保护复合状态

错误:

class BrokenState {
    private volatile int balance;

    void transfer(int amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

两个线程可能同时读取同一个余额,都通过检查,然后分别扣款。volatile 不能把“检查并扣除”变成一个原子事务。

正确方向是使用锁:

class SafeState {
    private int balance;

    synchronized boolean transfer(int amount) {
        if (balance < amount) {
            return false;
        }
        balance -= amount;
        return true;
    }
}

或者使用能够表达完整状态转换的 CAS 设计,例如把余额和版本包装成不可变对象后原子替换引用。

10.2 Atomic 只保护一个字段

错误:

class Order {
    final java.util.concurrent.atomic.AtomicInteger status =
            new java.util.concurrent.atomic.AtomicInteger();
    int itemCount;

    void complete() {
        itemCount = 10;
        status.set(1);
    }

    boolean isComplete() {
        return status.get() == 1 && itemCount == 10;
    }
}

如果 status.set(1) 的发布语义与 status.get() 的获取语义成立,那么 itemCount 的先前写入可以被发布;但如果后续还会修改 itemCount,或者其他字段没有遵守同一发布协议,整体状态仍可能失效。原子变量只自动保护自己的访问,不会自动为所有相关字段建立业务不变量。

10.3 双重检查初始化

错误写法:

class BrokenSingleton {
    private static BrokenSingleton instance;

    static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

外层读取没有同步,构造完成和引用发布之间也没有可靠的发布语义。正确写法需要 volatile

class Singleton {
    private static volatile Singleton instance;

    static Singleton getInstance() {
        Singleton result = instance;
        if (result == null) {
            synchronized (Singleton.class) {
                result = instance;
                if (result == null) {
                    result = new Singleton();
                    instance = result;
                }
            }
        }
        return result;
    }
}

更简单的方式通常是利用类初始化的线程安全:

class HolderSingleton {
    private HolderSingleton() {}

    private static class Holder {
        static final HolderSingleton INSTANCE = new HolderSingleton();
    }

    static HolderSingleton getInstance() {
        return Holder.INSTANCE;
    }
}

类初始化本身具有规定的同步语义,静态初始化完成后,后续使用者可以安全看到初始化结果。


11. 如何从现象反推同步缺失

11.1 失败表现

不同同步缺陷可能表现为:

  • 计数结果偶尔少于预期:通常是复合更新丢失;
  • 线程偶尔读到旧配置:通常是发布或可见性缺失;
  • 程序偶尔读取到互相矛盾的字段组合:通常是跨字段不变量没有统一同步;
  • CPU 持续升高但任务不前进:可能是 CAS 高竞争或自旋条件没有可见性;
  • 任务取消后仍继续运行:取消信号未被消费,或阻塞调用未正确响应中断;
  • 加锁线程长期等待:可能是锁未释放、临界区过大或锁顺序导致死锁。

这些现象没有唯一原因,不能只凭一次日志确定是“缓存问题”。

11.2 诊断方法

应先根据代码建立同步图:

写入共享状态
  -> 哪个锁释放 / volatile 写 / release 写 / CAS
  -> 读线程通过什么动作获取
  -> 获取后读取哪些字段

如果某个跨线程读取找不到对应的 hb 路径,就不能把它当作可靠同步。

运行时可以结合:

  • 线程转储检查阻塞、等待和死锁;
  • JFR 观察锁竞争、线程停顿和调度行为;
  • JDK Mission Control 查看 JFR 记录;
  • 为并发协议构造高竞争测试;
  • 用断言验证不变量,而不是只验证最终计数;
  • 将普通字段、发布字段和更新操作分开审查。

JFR 或 Mission Control 可以帮助观察锁竞争和线程行为,但通常不能自动证明某段无锁算法满足 JMM。证明仍然需要回到代码中的 hb 路径和原子更新条件。


12. 选择同步工具时的推导顺序

不要从“哪个 API 更快”开始,而应先回答三个问题。

第一个问题:保护的是一个值还是一个不变量

如果只是停止标志或配置引用,可以考虑 volatile

如果是单个计数器、序列号或状态机中的单变量 CAS,可以考虑 Atomic 或 VarHandle。

如果必须同时修改多个字段并保持关系,例如:

库存 >= 0
订单状态与库存扣减必须同时生效
账户 A + 账户 B 总额不变

通常需要锁,或者将整个状态建模为不可变对象后用单次 CAS 替换。

第二个问题:更新是否需要“读取后判断再写入”

仅仅写入一个新值:

ready = true;

可以用 volatile

但以下操作需要原子条件更新:

if (state == OPEN) {
    state = CLOSED;
}

可以使用:

atomic.compareAndSet(OPEN, CLOSED);

或者:

HANDLE.compareAndSet(object, OPEN, CLOSED);

第三个问题:需要什么内存模式

如果需要完整、容易审查的跨线程发布,可以使用锁或 volatile 语义。

如果正在实现底层并发容器,并且能够严格证明:

release 写入
    -> acquire 读取

足以完成发布,可以使用 VarHandle 的 release/acquire 模式。

如果只是使用 Opaque 或更弱访问模式,却无法明确写出同步边,代码通常已经超出了“微优化”的范围,进入了并发算法设计和形式验证问题。


13. 一个可运行的综合示例

下面的程序同时展示:

  • 普通字段由 volatile 状态发布;
  • Atomic 负责并发计数;
  • ExecutorService 提交任务;
  • Future.get() 等待并获取结果;
  • 使用 shutdown() 管理线程池生命周期。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class MemoryModelDemo {
    static final class Result {
        private int payload;
        private volatile boolean ready;

        void publish(int value) {
            payload = value;
            ready = true;
        }

        int awaitAndRead() {
            while (!ready) {
                Thread.onSpinWait();
            }
            return payload;
        }
    }

    public static void main(String[] args) throws Exception {
        Result result = new Result();
        AtomicInteger counter = new AtomicInteger();

        ExecutorService executor = Executors.newFixedThreadPool(2);
        try {
            Future<?> producer = executor.submit(() -> {
                result.publish(42);
                counter.incrementAndGet();
            });

            Future<Integer> consumer = executor.submit(result::awaitAndRead);

            producer.get();
            int payload = consumer.get();

            System.out.println("payload = " + payload);
            System.out.println("count = " + counter.get());
        } finally {
            executor.shutdown();
        }
    }
}

使用 Java 25 编译运行:

javac --release 25 MemoryModelDemo.java
java MemoryModelDemo

预期输出为:

payload = 42
count = 1

关键因果关系是:

  1. publish() 先写入普通字段 payload
  2. 再通过 ready 的 volatile 写发布状态;
  3. 消费者通过读取 ready == true 获取该发布;
  4. 因此读取 payload 时可以看到 42
  5. 生产者任务完成后,producer.get() 返回;
  6. counter.incrementAndGet() 是原子更新;
  7. executor.shutdown() 只负责发起线程池关闭,不代替 Future.get() 的结果等待。

如果把 ready 改为普通字段,程序可能仍然在一次运行中输出 42,但这种输出不再由 JMM 的跨线程可见性规则保证。


结语:同步正确性来自可证明的关系

synchronizedLock 通过互斥与解锁—加锁关系保护临界区;volatile 通过读写关系提供发布和获取,但不提供复合更新;Atomic 将单变量的比较更新做成原子操作;VarHandle 则把访问模式和原子操作直接暴露给并发算法实现者。

真正需要验证的不是“有没有用了某个并发 API”,而是:

每个共享读取,是否存在一条可靠的 happens-before 路径?
每个复合状态更新,是否由同一个原子操作或临界区保护?
每个取消、线程结束和任务结果,是否都有明确的生命周期与可见性边界?

只要这三类问题能够从程序顺序、同步动作和传递性中逐步推导出来,并发代码才不仅是“通常能运行”,而是具有 JMM 意义上的正确性基础。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。