面试考察点

  • 是否区分硬件内存结构与 Java 内存模型规范。
  • 能否用 happens-before 推导可见性和顺序。
  • 是否理解安全发布与数据竞争。

核心答案

Java 内存模型规定线程如何通过内存交互,以及哪些执行结果是合法的。happens-before 是可见性与顺序保证:如果 A happens-before B,则 A 的结果对 B 可见,且 A 在逻辑顺序上先于 B。

常见规则包括程序次序、监视器解锁先于后续加锁、volatile 写先于后续读、线程 start 前操作先于新线程、线程内操作先于其他线程成功 join 返回,以及传递性。

三个核心问题

原子性表示操作不可被观察到中间状态;可见性表示一个线程写入能被另一个线程看到;有序性限制编译器和处理器重排对程序结果的影响。volatile 解决可见性和特定顺序,不保证 count++ 原子。

常用 happens-before 规则表

规则 保证的关系 常见用途
程序次序 单线程前面操作先于后面操作 保持单线程语义
监视器锁 unlock 先于后续同锁 lock synchronized 临界区发布
volatile 写先于后续对同变量读 状态标记、不可变快照引用
线程启动 start 前操作先于新线程动作 初始化后启动工作线程
线程终止 线程动作先于 join 返回 等待任务结果后读取
传递性 A->B 且 B->C,则 A->C 组合并发推理

这套规则是语言规范的推理工具,不要求开发者手写内存屏障。JVM 会在不同 CPU 上生成恰当屏障或利用硬件已有顺序,保证程序满足规则。

可见性错误示例

class Worker {
    private boolean running = true;

    void loop() {
        while (running) {
            // JIT 可能把读取提升或缓存,另一个线程的写入不一定可见
        }
    }

    void stop() { running = false; }
}

把 running 声明为 volatile 后,stop 的写 happens-before loop 后续读取,循环才能按 JMM 规则看到停止信号。若循环还要同时读取多个字段,单独给每个字段加 volatile 可能看到组合状态不一致,应把它们封装为不可变配置对象并原子替换引用。

为什么 count++ 不安全

count++ 包含读取、加法、写回至少三步。volatile 只能让每次读写可见,两个线程仍可能读到同一个旧值并写回相同的新值。需要 AtomicInteger、LongAdder 或锁来保证复合更新的原子性。

final 字段与安全构造

正确构造的对象中,final 字段在构造器结束后有额外的可见性保证。但若构造器中把 this 发布到全局、启动线程或注册回调,其他线程仍可能在构造完成前观察到对象,破坏安全发布。

class BadListener {
    final int port;
    BadListener(EventBus bus) {
        bus.register(this); // this 过早逃逸
        this.port = 8080;
    }
}

应先完成构造,再由工厂或装配层发布对象。不可变对象加正确发布是最容易推理的并发模型之一。

双重检查单例的正确形态

private static volatile Service instance;

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

没有 volatile 时,对象内存分配、构造和引用赋值允许被观察到危险重排,其他线程可能拿到还未完成构造的对象。更简单的替代通常是静态内部类、枚举单例或依赖注入容器。

安全发布

对象应通过锁、volatile 引用、静态初始化、并发容器或正确构造的 final 字段规则发布。构造期间让 this 逃逸,其他线程可能看到未完整初始化的状态。

常见误区

happens-before 不是实际时钟上的先后,也不要求禁止所有重排;只要不破坏规范允许的观察结果,JVM 仍可优化。单线程测试稳定不能证明不存在数据竞争。

高频追问与参考回答

追问:双重检查单例为什么要 volatile?

它既保证实例引用可见,也阻止对象初始化与引用发布发生危险重排,避免其他线程拿到尚未正确构造的对象。

追问:synchronized 是否同时保证三性?

进入和退出同一监视器建立可见性与顺序,临界区使受保护代码的执行互斥,因此可用于复合操作的原子性。前提是所有访问都遵守同一把锁。

追问:线程安全的单例一定需要 volatile 吗?

不一定。类初始化、枚举和完全同步访问都能保证安全发布。volatile 是双重检查写法所需的关键条件,而不是所有单例的必选项。

追问:为什么在 x86 上不加 volatile 经常也“没问题”?

某些硬件顺序较强、测试时机偶然有利,但 JMM 允许的重排和编译器优化仍可能出错。并发正确性必须基于规范保证,不基于某一机器上的偶然现象。

总结

用 happens-before 判断跨线程可见性,不依赖“通常会刷新”之类经验描述;正确同步同时约束原子性、可见性和顺序。

机制全景图

下面把「Java 内存模型和 happens-before 规则是什么?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["源代码操作"]
    A --> B["编译器与 CPU 重排序"]
    B --> C["线程本地执行与缓存"]
    C --> D["同步动作建立 happens-before"]
    D --> E["其他线程获得合法可见结果"]

完整链路:从输入到结果

沿着「源代码操作 → 编译器与 CPU 重排序 → 线程本地执行与缓存 → 同步动作建立 happens-before → 其他线程获得合法可见结果」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 源代码操作

JMM 定义程序动作与线程间可观察结果,屏蔽不同 CPU 内存模型差异。

2. 编译器与 CPU 重排序

只要不改变单线程语义,编译器和处理器可以重排序;数据竞争程序不能用直觉推断执行顺序。

3. 线程本地执行与缓存

工作内存是规范抽象,实际可能对应寄存器、缓存和编译器优化,不能机械等同某一级硬件缓存。

4. 同步动作建立 happens-before

锁释放/获取、volatile 写/读、线程 start/join 等规则建立 happens-before,并具有传递性。

5. 其他线程获得合法可见结果

正确同步程序具有顺序一致性推理效果;没有 happens-before 的读可能看到旧值,但读取仍受类型安全等规则约束。

源码与实现定位

入口 阅读重点
JLS 17.4 内存动作、同步顺序与 happens-before
HotSpot OrderAccess/VarHandle 屏障在 JVM 的实现入口

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

private static volatile Holder instance;
static Holder get() {
  Holder h = instance;
  if (h == null) synchronized (Holder.class) { if ((h = instance) == null) instance = h = new Holder(); }
  return h;
}

用 jcstress 验证安全发布、this 逸出和消息传递;不要用 sleep 证明正确,必须枚举允许/禁止结果。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「forbidden outcome」为主基线,记录值应满足「必须 0」;同时保存 数据竞争检测结果、旧值或默认值样本,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「JLS 17.4」确认请求确实进入「内存动作、同步顺序与 happens-before」对应的实现,再沿「HotSpot OrderAccess/VarHandle」观察「屏障在 JVM 的实现入口」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「用 sleep 代替同步等待可见性」,并把单一变量逐级放大,直到「forbidden outcome」越过「出现即数据竞争」。随后再分别验证「对象构造期间 this 逸出」和「认为原子读写自动建立跨变量顺序」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「不可变对象通过 volatile/final 发布」,确认它能控制影响范围;第二轮应用「消除构造期间 this 逸出」,验证核心链路恢复;最后落实「用同步器替代时间等待」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「forbidden outcome」回到「必须 0」、「锁竞争」回到「与写频率匹配」、「默认值观察」回到「必须 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
forbidden outcome 必须 0 出现即数据竞争 补同步
锁竞争 与写频率匹配 读路径也竞争 改安全发布
默认值观察 必须 0 出现 0/null 构造逸出

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:双重检查单例偶发读取未初始化字段

instance 引用未声明 volatile 时,对象分配、构造和引用赋值可能被重排序。另一个线程看到非 null 引用,却读取默认字段值。volatile 发布或使用静态内部类初始化可以建立构造完成到读取之间的顺序。

失败模式 首要证据 第一处置动作
用 sleep 代替同步等待可见性 数据竞争检测结果 不可变对象通过 volatile/final 发布
对象构造期间 this 逸出 旧值或默认值样本 消除构造期间 this 逸出
认为原子读写自动建立跨变量顺序 锁与 volatile 热点 用同步器替代时间等待

发布与回滚检查点

  • 发布前:确认「JLS 17.4」对应实现和上述配置在目标版本仍然有效,并保存「forbidden outcome」基线。
  • 灰度中:同时观察 数据竞争检测结果、旧值或默认值样本、锁与 volatile 热点;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「不可变对象通过 volatile/final 发布」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「用 sleep 代替同步等待可见性」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
多变量不变量与临界区 互斥和可见性一起保证 存在竞争与阻塞
volatile 发布 独立状态或不可变对象发布 开销低、语义精确 不提供复合原子性
final 安全初始化 构造后不变的对象图 正确构造后可安全读取 final 字段 this 逸出会破坏保证

选型至少带上 并发线程数、临界区长度、阻塞比例和任务到达速率,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

happens-before 是“必须可见的顺序关系”,不是现实时间顺序,也不意味着前一个动作在物理上先执行;它是判断并发程序是否合法的核心工具。

工程落地遵循:先建立 happens-before 与所有权边界,再谈吞吐和无锁优化。回答时直接引用「JLS 17.4」、配置实验和事故数据,比复述固定模板更有说服力。