4
0
0

Java 多线程:volatile、内存屏障与 happens-before

2026-11-03
文章摘要
|

一、volatile 的作用与底层原理

1. volatile 解决什么问题?

volatile 是 Java 提供的变量修饰符,主要用于解决多线程之间的可见性和有序性问题。

例如:

public class VolatileDemo {

    private static boolean running = true;

    public static void main(String[] args)
            throws InterruptedException {

        Thread worker = new Thread(() -> {

            while (running) {
                // 持续检查状态
            }

            System.out.println("线程退出");
        });

        worker.start();

        Thread.sleep(1000);

        running = false;

        worker.join();
    }
}

预期:主线程将 running 修改为 false 后,worker 线程退出。

但实际上,worker 可能一直循环,无法观察到 running 的修改。

原因是:

多个线程访问普通共享变量时,如果没有建立正确的同步关系,JMM 不保证其他线程能够按预期观察到变量的修改。

JIT 编译器和 CPU 可能进行优化,导致线程重复使用之前读取到的值。

解决方式:

private static volatile boolean running = true;

volatile 可以保证线程之间的可见性,并对相关操作提供内存顺序保证。

注意:volatile 不是让线程强制立即执行,也不等于每次都直接访问物理内存。

2. volatile 的三个特性

特性

是否支持

说明

可见性

支持

提供跨线程的内存可见性

有序性

支持

限制特定指令重排序

原子性

部分支持

单次 volatile 读写具有原子性,但不保证复合操作原子性

例如:

private static volatile int count = 0;

多个线程同时执行:

count++;

仍然可能出现数据不一致。

原因是 count++ 实际上包含三个步骤:

读取 count
    ↓
计算 count + 1
    ↓
写回 count

假设两个线程同时执行:

线程 A                  线程 B

读取 count = 0          读取 count = 0
     ↓                       ↓
计算得到 1              计算得到 1
     ↓                       ↓
写回 count = 1          写回 count = 1

最终结果可能是 1,而不是 2。

volatile 只能保证相关读写的可见性和内存顺序,不能把多个操作组合成一个原子操作。

如果需要线程安全计数,推荐使用:

private static final AtomicInteger COUNT =
        new AtomicInteger(0);

COUNT.incrementAndGet();

AtomicInteger 属于 Java 8 自带的 JUC 原子类,主要通过 CAS 实现原子递增。

二、JMM、内存屏障与 happens-before

1. JMM 是什么?

JMM(Java Memory Model)是 Java 内存模型。

它定义了多线程程序中共享变量访问的规则,包括:

  • 原子性

  • 可见性

  • 有序性

  • 线程之间的同步关系

JMM 使用主内存和工作内存作为抽象模型:

             主内存
                |
           共享变量 data
                |
       ┌────────┴────────┐
       |                 |
       v                 v
线程 A 的工作内存    线程 B 的工作内存
       |                 |
       v                 v
     线程 A             线程 B

注意:

  • 主内存、工作内存是 JMM 的抽象概念。

  • 工作内存不等于 Java 虚拟机栈。

  • JMM 不等于 JVM 的堆、栈、方法区等运行时内存结构。

  • JMM 也不能直接等同于 CPU 缓存结构。

volatile 的可见性主要来自 JMM 的同步规则,而不能简单理解成“每次读都绕过缓存”。

2. 什么是指令重排序?

为了提高程序执行效率,编译器和 CPU 可能调整部分指令的执行顺序。

例如:

int a = 10;
int b = 20;
int c = a + b;

a 和 b 的赋值没有数据依赖,因此编译器可能调整它们的执行顺序。

只要不改变单线程程序的可观察结果,这类优化就可能是合法的。

但在多线程环境中,如果没有正确同步,其他线程可能观察到不符合预期的状态。

3. 什么是内存屏障?

内存屏障(Memory Barrier)是一种限制内存操作重排序、配合保证内存可见性的机制。

常见的四种抽象内存屏障:

内存屏障

作用

LoadLoad

限制前后读操作的重排序

StoreStore

限制前后写操作的重排序

LoadStore

限制前面的读与后面的写重排序

StoreLoad

限制前面的写与后面的读重排序

其中:

  • Load:读取操作。

  • Store:写入操作。

这些属于内存顺序约束的抽象分类。

具体 JVM 可能通过编译器约束、CPU 内存屏障指令及硬件内存一致性机制共同实现,不一定分别生成四种机器指令。

4. volatile 与内存屏障的关系

volatile 的内存语义可以简单理解为:

volatile 写:Release(释放语义)

data = 100;

ready = true; // volatile 写

保证前面的普通读写操作不能被重排到该 volatile 写之后。

volatile 读:Acquire(获取语义)

if (ready) { // volatile 读

    System.out.println(data);
}

保证后面的普通读写操作不能被重排到该 volatile 读之前。

因此:

线程 A
------------------
data = 100
    ↓
volatile ready = true
    |
    | 建立同步关系
    v
线程 B
------------------
读取 volatile ready
    ↓
读取 data

当线程 B 观察到线程 A 写入的 ready = true 时,可以通过 JMM 的规则观察到之前对 data 的修改。

重点:

volatile 的作用并不是禁止所有指令重排序,而是限制会破坏其内存语义的重排序。

5. 什么是 happens-before?

happens-before 是 JMM 定义的内存可见性和顺序关系。

如果 A happens-before B,那么:

  1. A 的执行效果对 B 可见。

  2. A 在内存语义上先于 B。

注意:happens-before 不完全等同于真实时间上的执行先后关系。

常见规则:

规则

说明

程序次序规则

同一线程内,前面的操作 happens-before 后续操作

volatile 规则

volatile 写 happens-before 后续对同一变量的 volatile 读

监视器锁规则

unlock happens-before 后续对同一把锁的 lock

线程启动规则

start() happens-before 被启动线程中的操作

线程终止规则

线程内操作 happens-before 其他线程成功检测到其终止

传递性

A happens-before B,B happens-before C,则 A happens-before C

6. happens-before 传递性示例

public class HappensBeforeDemo {

    private static int data = 0;

    private static volatile boolean ready = false;

    public static void main(String[] args) {

        Thread writer = new Thread(() -> {

            data = 100;

            ready = true;
        });

        Thread reader = new Thread(() -> {

            while (!ready) {
                // 等待
            }

            System.out.println(data);
        });

        reader.start();
        writer.start();
    }
}

这里有一个重要问题:

data 没有使用 volatile,为什么 reader 仍然能够读取到 100?

因为 happens-before 具有传递性。

执行关系:

线程 A(writer)
-------------------
data = 100
    |
    | 程序次序规则
    v
ready = true(volatile 写)
    |
    | volatile 同步规则
    v
线程 B(reader)
-------------------
读取 ready == true
    |
    | 程序次序规则
    v
读取 data = 100

推导过程:

  1. 线程 A 写入 data = 100,先于写入 ready = true。

  2. volatile 写入 ready happens-before 线程 B 后续读取 ready。

  3. 线程 B 读取 ready 后,再读取 data。

  4. 根据传递性,线程 A 对 data 的写入对线程 B 可见。

因此,当线程 B 观察到线程 A 发布的 ready = true,且没有其他线程修改 data 时,可以保证读到 100。

重点:volatile 可以通过 happens-before 关系,让此前对普通变量的修改对其他线程可见。

但这不意味着普通变量 data 从此自动具有线程安全性。

三、DCL 双重检查锁与 volatile

1. 什么是 DCL?

DCL(Double-Checked Locking)中文叫双重检查锁。

常用于实现延迟初始化的单例模式。

先看普通单例:

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }

        return instance;
    }
}

这段代码不是线程安全的。

因为多个线程可能同时判断 instance == null,然后分别创建对象。

2. 使用 synchronized

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }

        return instance;
    }
}

这种方式能够保证线程安全。

但每次调用 getInstance() 都需要进入同步方法,即使单例对象已经创建,也要执行锁获取逻辑。

因此可以使用 DCL 进行优化。

3. DCL 的正确写法

public class Singleton {

    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {

        // 第一次检查,避免重复加锁
        if (instance == null) {

            synchronized (Singleton.class) {

                // 第二次检查,防止重复创建
                if (instance == null) {

                    instance = new Singleton();
                }
            }
        }

        return instance;
    }
}

两次检查的作用:

检查

作用

第一次 null 检查

已初始化时直接返回,减少锁开销

第二次 null 检查

防止多个线程依次获取锁后重复创建

4. 为什么 DCL 必须使用 volatile?

关键代码:

instance = new Singleton();

从概念上,可以分为三个步骤:

1. 分配对象内存

2. 初始化对象

3. 将对象引用赋值给 instance

如果缺少正确的内存同步,其他线程可能观察到非 null 的引用,却不能保证该对象已经完成安全初始化。

常见的示意过程:

线程 A                        线程 B

分配对象内存
    |
发布 instance 引用
    |                           |
    |                    发现 instance != null
    |                           |
初始化尚未安全可见             |
                                |
                           使用对象

这是一种用于解释安全发布问题的概念模型,并不意味着 JVM 必然按这几个物理指令执行。

如果 instance 没有 volatile,第一次 null 检查又在 synchronized 外面,线程之间便缺少必要的同步保证。

正确写法:

private static volatile Singleton instance;

volatile 通过内存顺序约束和 happens-before 关系,保证对象初始化的结果在其他线程观察到已发布引用时可见。

因此:

DCL 中 synchronized 和 volatile 的作用不同:

  • synchronized + 第二次 null 检查:防止重复创建。

  • volatile:保证对象安全发布和可见性。

5. 更推荐的单例实现

如果只是普通 Java 单例,通常推荐静态内部类方式:

public class Singleton {

    private Singleton() {
    }

    private static class Holder {

        private static final Singleton INSTANCE =
                new Singleton();
    }

    public static Singleton getInstance() {

        return Holder.INSTANCE;
    }
}

优势:

  1. 利用 JVM 类初始化机制保证线程安全。

  2. 支持延迟初始化。

  3. 不需要显式 synchronized。

  4. 不需要自己处理 volatile 和双重检查。

注意:在 Spring Boot 中,一般不需要为普通 Service 自己实现单例模式。

Spring 默认的 singleton 作用域已经可以管理 Bean 的实例复用。

但Spring 单例 Bean 不代表其内部所有共享可变字段都自动线程安全。

四、volatile 的生产实践与常见问题

1. 适合使用 volatile 的场景

典型场景是线程停止标记:

public class WorkerService {

    private volatile boolean running = true;

    public void run() {

        while (running) {
            // 执行非长期阻塞任务
        }
    }

    public void stop() {

        running = false;
    }
}

其他线程调用 stop() 后,工作线程可以通过 volatile 变量观察到状态变化。

但需要注意:

如果线程正在执行 sleep()、wait() 或可中断阻塞操作,单纯修改 volatile 标记无法立即解除阻塞。

这种场景可以结合 interrupt() 实现协作式停止。

2. volatile 修饰引用,内部对象就线程安全了吗?

不会。

例如:

private volatile List<String> list =
        new ArrayList<>();

虽然 list 引用使用 volatile 修饰,但并不意味着:

list.add("A");

就是线程安全的。

原因是:

volatile 保证的是引用本身的读写可见性及相关顺序语义,不会自动保证被引用对象内部操作的线程安全。

ArrayList.add() 仍然可能发生数据竞争。

如果需要并发访问,应根据实际需求选择:

  • ConcurrentHashMap 等并发容器。

  • CopyOnWriteArrayList。

  • synchronized 或其他同步机制。

3. 生产场景:动态配置更新

假设系统有两个运行参数:

  • 是否启用功能。

  • 请求超时时间。

如果分别维护:

private volatile boolean enabled = true;

private volatile int timeout = 3000;

两个变量虽然分别具有可见性保证,但一次业务操作分别读取时,仍可能读到不同版本的配置。

更推荐使用不可变对象加 volatile 引用整体替换。

public final class RuntimeConfig {

    private final boolean enabled;

    private final int timeoutMillis;

    public RuntimeConfig(boolean enabled, int timeoutMillis) {
        this.enabled = enabled;
        this.timeoutMillis = timeoutMillis;
    }

    public boolean isEnabled() {
        return enabled;
    }

    public int getTimeoutMillis() {
        return timeoutMillis;
    }
}

Spring Boot Service:

@Service
public class RuntimeConfigService {

    private volatile RuntimeConfig config =
            new RuntimeConfig(true, 3000);

    // 整体替换配置
    public void update(boolean enabled, int timeoutMillis) {

        config = new RuntimeConfig(enabled, timeoutMillis);
    }

    public RuntimeConfig getConfig() {

        return config;
    }
}

业务读取:

RuntimeConfig snapshot = runtimeConfigService.getConfig();

boolean enabled = snapshot.isEnabled();

int timeoutMillis = snapshot.getTimeoutMillis();

这种设计的优势:

  1. 配置对象初始化完成后再发布。

  2. 一次获取到的是同一个配置快照。

  3. 避免一次业务处理过程中读取到不同版本的字段组合。

  4. 读取不需要显式加锁。

注意:如果多个线程需要基于旧配置计算并更新新配置,仍然可能发生覆盖更新,需要额外使用锁或 CAS。

五、重点总结

知识点

核心结论

volatile

提供可见性和特定的有序性保证

原子性

volatile 不保证 count++ 等复合操作的原子性

JMM

定义多线程共享变量的内存访问规则

指令重排序

编译器和 CPU 可以在规则允许范围内优化执行顺序

内存屏障

约束内存操作顺序,配合实现可见性

volatile 写

具有 Release 语义

volatile 读

具有 Acquire 语义

happens-before

定义内存可见性和顺序关系

happens-before 传递性

可以建立普通变量的跨线程可见性

DCL 第一次检查

减少不必要的同步

DCL 第二次检查

防止重复创建对象

DCL 的 volatile

保证对象安全发布

静态内部类单例

利用 JVM 类初始化机制实现安全的延迟初始化

需要重点记住的六个结论:

  1. volatile 不是锁,不提供互斥。

  2. volatile 保证可见性和特定的有序性,但不能保证复合操作原子性。

  3. 内存屏障限制部分指令重排序,但不能简单等同于 CPU 缓存刷新。

  4. happens-before 是理解多线程可见性的核心。

  5. DCL 中 synchronized 防止重复创建,volatile 保证安全发布。

  6. volatile 修饰对象引用,不代表对象内部操作自动线程安全。

六、常见问题与答案

1. volatile 为什么能够保证可见性?

因为 JMM 规定 volatile 读写具有同步语义,volatile 写 happens-before 后续对同一变量的 volatile 读,从而建立跨线程可见性保证。

2. volatile 为什么不能保证 count++ 的原子性?

因为 count++ 包含读取、计算和写回多个步骤,volatile 不会将这些步骤合并成一个不可分割的原子操作。

3. volatile 和 synchronized 有什么区别?

volatile 不提供互斥,适用于状态标记、安全发布等场景。

synchronized 通过锁实现互斥,并提供相应的可见性和有序性保证,可以保护包含多个操作的临界区。

4. 什么是内存屏障?

内存屏障是约束内存操作顺序的机制,能够限制特定重排序,并配合其他机制实现内存可见性。

5. 什么是 happens-before?

happens-before 是 JMM 定义的内存顺序关系。如果 A happens-before B,则 A 的执行结果对 B 可见,并具有相应的顺序保证。

6. 为什么普通变量 data 可以借助 volatile ready 实现可见性?

因为 data 的写入先于 volatile ready 的写入,而 volatile 写和后续读建立 happens-before 关系,再通过传递性保证之前对 data 的修改可见。

7. DCL 为什么需要两次 null 判断?

第一次判断用于减少不必要的锁获取;第二次判断用于防止多个线程通过第一次检查后,依次进入同步代码块时重复创建对象。

8. DCL 为什么必须使用 volatile?

因为第一次读取 instance 发生在同步代码块之外,必须使用 volatile 建立安全发布所需的可见性与有序性保证。

9. volatile 修饰 ArrayList,ArrayList 就线程安全了吗?

不会。volatile 只保证引用访问及相关内存顺序语义,不能保证 ArrayList 内部 add、remove 等操作的线程安全。

10. Spring Boot 中使用 volatile 有什么典型场景?

例如后台线程停止标记、运行状态开关,以及使用不可变对象整体替换的动态配置快照。

七、复习测试

volatile 的两个核心作用是什么?

  1. 为什么 volatile int count 执行 count++ 仍然可能出现数据不一致?

  2. JMM 的主内存和工作内存能否直接等同于物理内存和 CPU 缓存?

  3. 什么是内存屏障?它与指令重排序有什么关系?

  4. volatile 写和 volatile 读分别具有什么内存语义?

  5. happens-before 的传递性如何保证普通变量的跨线程可见性?

  6. DCL 为什么需要两次判断 instance == null?

  7. DCL 中 synchronized 和 volatile 分别解决什么问题?

  8. volatile 修饰对象引用后,对象内部的方法是否自动线程安全?

  9. 为什么使用不可变配置对象 + volatile 引用整体替换,比单独使用多个 volatile 字段更适合保持配置一致性?

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论