百度面试真题 Java 面试通关笔记 04 |JMM 与 Happens-Before并发正确性的基石(面试可复述版)

第 04 期:JMM 与 Happens-Before------并发正确性的基石

1) 面试原题

  1. 解释 Java 内存模型(JMM)的三大性质:可见性有序性原子性
  2. volatilesynchronized 的语义差异是什么?
  3. 什么是 Happens-Before?它如何约束指令重排?
  4. 举例说明双重检查锁定(DCL)的错误与修复。

2) 第一性拆解(约束 → 成本 → 原语 → 可验证)

约束(Constraints)

  • 多核 CPU 有多级缓存;线程写入 到本地缓存,可能看不见 彼此更新 → 可见性问题
  • 编译器/CPU 为性能会做重排序 ,代码"看起来按顺序",但执行可能换位 → 有序性问题
  • 表达式如 i++ 不是不可分割 → 原子性问题
  • 并发正确性必须在这些现实约束下成立。

成本模型(Cost Model)

  • :提供互斥 + 可见性,但带来上下文切换/竞争/阻塞成本。
  • **volatile**:插入内存屏障 、刷新/失效缓存行;相比加锁开销低,但不提供互斥
  • CAS :无锁原子更新,低冲突高吞吐;高冲突下自旋浪费 CPU

最小原语(Primitives)

  • HB(Happens-Before)规则:建立"先行发生"关系以禁止破坏一致性的重排。
  • 内存屏障LoadLoad/LoadStore/StoreLoad/StoreStore 约束读写顺序。
  • 同步原语volatilesynchronized、CAS(Atomic*/VarHandle)、final 字段的发布语义。

可验证(Check)

  • 编写乱序复现小程序,观察"不可能值"出现。
  • volatile/锁/CAS 修复并验证消失
  • 通过循环放大/多次运行,观察统计特征而非"偶然正确"。

3) 概念透视:JMM 三大性质与一个核心关系

  • 可见性(Visibility):一个线程的写,何时被另一个线程看见(缓存一致性 + 屏障)。
  • 有序性(Ordering) :在不影响单线程语义的前提下,编译器/CPU会重排;HB 规则界定不能被重排的边界。
  • 原子性(Atomicity) :操作是否不可分割;long/double 在现代 JDK 已保证单次读写的原子性(64 位),但复合操作不原子
  • Happens-Before(HB)核心 关系。若 A HB B,则 A 的结果对 B 可见 ,且 A 与 B 不重排

记忆法 :HB = "可见 + 不乱序"的契约。volatile/锁/线程启动终止等,都是建立 HB 的方法。


4) HB 规则清单(面试可直接背)

  1. 程序次序规则:同一线程内,按代码顺序 HB。
  2. 监视器锁规则:对同一锁的解锁(unlock)HB 之后对该锁的加锁(lock)。
  3. **volatile**** 变量规则**:对 volatile 的写 HB 之后对同一变量的读。
  4. 线程启动规则Thread.start() HB 线程内的第一个动作
  5. 线程终止规则 :线程内的最后一个动作 HB 其它线程对 Thread.join() 的返回。
  6. 中断规则 :对线程 interrupt() 的调用 HB 被中断线程检测到中断(isInterrupted()/InterruptedException)。
  7. 对象终结规则 :对象构造完成 HB 其 finalize() 开始(几乎过时,仅了解)。
  8. 传递性:A HB B,B HB C ⇒ A HB C。

加分: **final**** 字段语义**

  • 构造函数结束前final 字段写入主内存,且引用安全发布 后,其他线程读取到的 final 字段值不可见被重写 (除非反射/Unsafe 破坏),用于构建不可变对象

5) 乱序"出现不可能值"与修复(可复现)

5.1 复现:乱序导致 (0,0)

java 复制代码
// 演示用途,可能需要多次运行才触发
public class ReorderingDemo {
    static int x, y, a, b;

    public static void main(String[] args) throws Exception {
        int count = 0;
        while (true) {
            count++;
            x = y = a = b = 0;

            Thread t1 = new Thread(() -> { a = 1; x = b; });
            Thread t2 = new Thread(() -> { b = 1; y = a; });

            t1.start(); t2.start();
            t1.join(); t2.join();

            if (x == 0 && y == 0) {
                System.out.println("Observed (0,0) at iteration: " + count);
                break;
            }
        }
    }
}

原因 :无 HB 约束时,编译器/CPU 允许将写/读乱序,导致两个线程都在对方写之前读到初始值 0。

5.2 修复 A:volatile 建立写→读 HB

java 复制代码
public class VolatileFix {
    static volatile int a, b;
    static int x, y;

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1_000_00; i++) {
            x = y = 0; a = b = 0;
            Thread t1 = new Thread(() -> { a = 1; x = b; });
            Thread t2 = new Thread(() -> { b = 1; y = a; });
            t1.start(); t2.start();
            t1.join(); t2.join();
            if (x == 0 && y == 0) throw new AssertionError("HB violated");
        }
        System.out.println("No (0,0) with volatile.");
    }
}

5.3 修复 B:synchronized(互斥 + 可见性)

java 复制代码
public class SyncFix {
    static int a, b, x, y;
    static final Object lock = new Object();
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100_000; i++) {
            x = y = 0; a = b = 0;
            Thread t1 = new Thread(() -> { synchronized (lock) { a = 1; x = b; }});
            Thread t2 = new Thread(() -> { synchronized (lock) { b = 1; y = a; }});
            t1.start(); t2.start();
            t1.join(); t2.join();
            if (x == 0 && y == 0) throw new AssertionError();
        }
        System.out.println("No (0,0) with synchronized.");
    }
}

6) 双重检查锁定(DCL)错误与修复

6.1 错误版本(缺乏有序性)

java 复制代码
public class SingletonBroken {
    private static SingletonBroken INSTANCE; // 非 volatile
    private final byte[] data = new byte[1024];

    private SingletonBroken() {}

    public static SingletonBroken getInstance() {
        if (INSTANCE == null) {                 // 1. 读
            synchronized (SingletonBroken.class) {
                if (INSTANCE == null) {         // 2. 再次读
                    INSTANCE = new SingletonBroken(); // 3. 分配→构造→赋值(可能被重排)
                }
            }
        }
        return INSTANCE;                         // 4. 可能读到"未完全构造"的对象
    }
}

问题 :赋值与构造可能被重排(先把引用写入,再执行构造),其他线程读到非空引用但对象未完全初始化。

6.2 正确版本(volatile 阻止重排 + 可见性)

java 复制代码
public class Singleton {
    private static volatile Singleton INSTANCE; // 关键:volatile
    private final byte[] data = new byte[1024];

    private Singleton() {}

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton(); // 构造完成的发布对其他线程可见
                }
            }
        }
        return INSTANCE;
    }
}

更进一步 :若能容忍类初始化时创建实例,静态初始化枚举实现更简单且天生安全:

java 复制代码
// 静态内部类方式
public class HolderSingleton {
    private HolderSingleton() {}
    private static class Holder { static final HolderSingleton I = new HolderSingleton(); }
    public static HolderSingleton getInstance() { return Holder.I; }
}

// 枚举
public enum EnumSingleton { INSTANCE; }

7) volatile 能与不能

能:

  • 保证可见性(写-读建立 HB)。
  • 限制重排(写前不会把后续操作移到写之前,读后不会把之前操作移到读之后)。
  • 适用于:状态标志 (停止位)、一次发布 (单写多读)、配置热更新(读多写少)。

不能:

  • 不保证复合操作原子性count++ 仍然竞争)。
  • 不提供互斥,无法保护不变量/组合条件。
  • 高吞吐计数需用 LongAdder 或带 CAS 的 AtomicLong;复杂不变量需不可变数据结构

8) 安全发布(Safe Publication)清单

确保"构造好的对象"对其他线程一次性完整地可见:

  1. 通过 **volatile**** 写**(把引用写入 volatile 字段)。
  2. 通过 (在解锁前构造并写入,另一线程加锁读取)。
  3. 通过 静态初始化(类初始化阶段天生有 HB 约束)。
  4. 将对象发布到 并发容器 (如 ConcurrentHashMapCopyOnWriteArrayList)。
  5. **final**** 字段**:只要对象构造期间没有把 this 逸出,final 字段对读者天然可见为构造后的值。

反例 :在构造函数中把 this 传给别的线程或注册到可被别的线程访问的全局结构,打破安全发布。


9) 工程实践:选择与权衡

  • 标志/配置volatile 字段 + 本地缓存读取频率控制。
  • 计数器 :低竞争用 AtomicLong;高并发下用 LongAdder(分段累加,读时合并)。
  • 队列生产-消费 :用 BlockingQueue(内部用 AQS/锁/条件队列保障 HB)。
  • 复杂不变量:锁保护(或使用不可变对象 + 复制写)。
  • 热点路径性能:先保证正确性,再用火焰图/JFR 定位锁竞争,评估改为 CAS 或分区化的性价比。

10) 常见误区 & 诊断

  • "用了 **volatile** 就线程安全" :错。volatile ≠ 互斥,复合操作仍然竞态。
  • "没复现就没问题" :错。并发 bug 随时间/硬件/编译优化而变;要用构造性测试 + 放大循环
  • "final 一定不可变" :仅对字段引用 不可变;若 final List,其内部元素仍可变。
  • "锁一定很慢" :现代 JVM 有偏向/轻量级/自适应自旋,短临界区代价可接受;要用数据说话。

诊断手段

  • 压测 + 火焰图(锁热点、CAS 失败重试)
  • jfr/jcmd 观察线程状态(BLOCKED、RUNNABLE 自旋)
  • 打开 -Xint/-Xmixed 对比乱序复现概率(仅辅助理解)

11) 速答卡(30 秒背诵)

  • JMM 关心:可见性 / 有序性 / 原子性
  • HB 规则 :程序次序、volatile 写→读、锁解锁→加锁、start()/join()、传递性。
  • **volatile**:可见 + 部分禁止重排;提供互斥,复合操作仍需 CAS/锁。
  • DCL :未加 volatile 会因重排读到"半初始化对象";正确解法是 volatile 或静态初始化/枚举。
  • 安全发布volatile、锁、静态初始化、并发容器、final 字段构造后不逸出。

12) 代码清单(可直接改造实验)

12.1 可取消的生产者-消费者:volatile 标志 vs 中断

java 复制代码
import java.util.concurrent.*;

public class CancelDemo {
    static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        BlockingQueue<Integer> q = new ArrayBlockingQueue<>(1024);
        Thread producer = new Thread(() -> {
            int i = 0;
            while (running) {
                try { q.put(i++); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            }
        });

        Thread consumer = new Thread(() -> {
            while (running) {
                try { q.take(); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            }
        });

        producer.start(); consumer.start();

        TimeUnit.SECONDS.sleep(1);
        running = false;               // 通过 volatile 可见
        producer.interrupt();          // 响应阻塞状态
        consumer.interrupt();

        producer.join(); consumer.join();
        System.out.println("Stopped.");
    }
}

要点

  • 计算中 可用 volatile 标志停止;
  • 阻塞中 必须用中断 唤醒(take/put),两者结合才可靠。

12.2 计数对比:AtomicLong vs LongAdder

java 复制代码
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class CounterCompare {
    static final int THREADS = 32;
    static final int OPS = 1_000_00;

    public static void main(String[] args) throws Exception {
        benchAtomic();
        benchAdder();
    }

    static void benchAtomic() throws Exception {
        AtomicLong c = new AtomicLong();
        run(() -> { for (int i = 0; i < OPS; i++) c.incrementAndGet(); }, "AtomicLong");
        System.out.println(c.get());
    }

    static void benchAdder() throws Exception {
        LongAdder c = new LongAdder();
        run(() -> { for (int i = 0; i < OPS; i++) c.increment(); }, "LongAdder");
        System.out.println(c.sum());
    }

    static void run(Runnable task, String label) throws Exception {
        long t0 = System.nanoTime();
        ExecutorService es = Executors.newFixedThreadPool(THREADS);
        for (int i = 0; i < THREADS; i++) es.submit(task);
        es.shutdown();
        es.awaitTermination(1, TimeUnit.MINUTES);
        long t1 = System.nanoTime();
        System.out.printf("%s took %.2f ms%n", label, (t1 - t0) / 1e6);
    }
}

预期 :高并发下 LongAdder 更快(减少单点 CAS 冲突),但读取开销略高(需要合并)。


13) 小挑战(可验证、可评分)

  1. 乱序频度实验 :在 ReorderingDemo 中加入"无意义计算"或 Thread.onSpinWait(),观察 (0,0) 触发频率变化,写出结论。
  2. DCL 拆解 :用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation 观察 getInstance() 的编译情况,解释为何 volatile 能阻止错误。
  3. 安全发布 :写一个不可变配置类 Config{ final String a; final int b; },对比用 volatile 引用发布 vs 非 volatile 发布,设计测试证明读取一致性差异。
  4. 面试复述 :用"约束→成本→原语→结论 ",讲清 volatile 能与不能。

14) 加分项(深入但不必死记)

  • x86 与 ARM 的内存模型差异 :x86 较强(TSO),StoreLoad 最敏感;ARM 更弱,volatile 成本更高。
  • JIT 优化与屏障折叠:JIT 可能合并/消除冗余屏障;理解这一点帮助解释"看不见的优化"。
  • 不可变对象的威力:用不可变数据 + 写时复制(Copy-On-Write)绕开大量同步复杂度。

15) 小结(面试说法)

Java 并发的本质是在弱内存模型上用 HB 建立跨线程的"可见 + 不乱序"关系
volatile 负责轻量的可见/顺序synchronized/CAS 负责原子性/互斥

正确性优先、再谈性能,选择时遵循:标志/发布→volatile简单计数→原子类/LongAdder复杂不变量→锁或不可变设计

相关推荐
Ray663 小时前
guide-rpc-framework笔记
后端
37手游后端团队3 小时前
Claude Code Review:让AI审核更懂你的代码
人工智能·后端·ai编程
飞快的蜗牛3 小时前
利用linux系统自带的cron 定时备份数据库,不需要写代码了
java·docker
bot5556663 小时前
“企业微信iPad协议”静默 72 小时:一台被遗忘的测试机如何成为私域的逃生梯
javascript·面试
火星MARK3 小时前
k8s面试题
容器·面试·kubernetes
长安不见4 小时前
解锁网络性能优化利器HTTP/2C
后端
LSTM974 小时前
使用Python对PDF进行拆分与合并
后端
用户298698530144 小时前
C#:将 HTML 转换为图像(Spire.Doc for .NET 为例)
后端·.net
crystal_pin4 小时前
axios统一封装的思路
面试