想象一家名叫“并发厨房”的深夜小馆:主厨、二厨、甜品师各占一个灶台,同时开火(这就是多线程)。后厨正中央有一张主操作台,摆着全店共享的菜谱卡和食材(主内存);而每位厨师手边都有一个小料台,放着常用调料的私人备份(工作内存 / CPU 缓存)。麻烦来了——主厨刚把改良的秘制辣酱配方放上主操作台,甜品师的小料台上却还摆着旧配方,于是他一勺下去,整块提拉米苏都串了味。更糟的是,主厨觉得“先切葱再热油”更快、悄悄调整了做菜步骤(重排序),单独做菜没问题,可一旦要和别人配合,整个厨房的秩序就崩了。 内存模型,就是这家厨房里那本看不见的《后厨行为准则》——它规定每个线程能对另一个线程的行动做多大程度的假设。而 Happens-Before(先行发生)是这本准则的核心条款:只要一个动作“先行发生”于另一个动作,前者的成果就保证被后者看见。没有它,厨房就是纯粹的混沌——程序能跑,但偶尔会端出一盘“盐当糖”的黑暗料理。这篇文章,就带你读懂这份隐形秩序,从主内存与工作内存的模型,到三大并发特性、指令重排序,再到整套 Happens-Before 规则,以及锁、volatile 和实战排查。

岗位关联

一句话价值:大模型推理服务、特征缓存更新、分布式训练同步,本质都是“多线程共享内存”问题。吃透内存模型与 Happens-Before,你写的服务才能在流量洪峰下不“串味”,这也是高并发系统设计面试的必考基石。

目录
  1. 可视化演示
  2. 一、内存模型图解——主操作台与小料台
  3. 二、三大并发特性——完整、可见、有序
  4. 三、指令重排序——偷懒的厨师与底线
  5. 四、Happens-Before 规则总览——厨房宪章
  6. 五、线程协同规则——开火与收工的交接
  7. 六、同步实践——广播、钥匙与自助核对
  8. 七、实战排查——事故现场还原
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示内存模型下 happen-before(先行发生)原则如何界定多线程事件之间的可见顺序,步进切换查看程序顺序、锁规则、volatile 规则与传递性,理解并发编程的可见性保障。

本节配有交互动效(进入视口后自动加载,约 3KB)。

happen-before 先行发生 程序顺序保证单线程内有序 锁规则保证解锁先于加锁 volatile 保证写先于读 可见 = 有序 传递性串联多线程关系

一、内存模型图解——主操作台与小料台

在“并发厨房”里,最贵重的是正中央那张主操作台,上面摆着全店共享的菜谱卡与食材——这就是主内存。但每位厨师手边都有一方小料台,放着常用调料的私人备份——这就是工作内存,也叫 CPU 缓存。做菜时,厨师总是先从小料台取用,而不是每次都百米冲刺跑去主操作台。这个设计让单人做菜飞快,却埋下了隐患。

隐患在于:小料台上的“备份”并不总是最新鲜的。主厨刚把新配方放上主操作台(写主内存),甜品师的小料台却还摆着五年前的旧配方(旧缓存),于是甜品师做出的味道,永远慢了主厨半拍。这正是内存模型要解决的核心问题:跨线程共享数据时,读到的到底是不是最新值。

以 Java 内存模型(JMM)为代表,所有共享变量都存于主内存,每个线程拥有自己的工作内存。线程只能直接操作自己的工作内存,不能直接碰主内存。两个线程想通信,必须绕行“工作内存 → 主内存 → 对方工作内存”这条迂回路。这条路上的任何一环滞后,都会出现“脏读”。

每个厨师都以为“世界以我为中心”,但真正的菜谱卡永远挂在那张主操作台上。要让所有厨师用同一版本干活,就必须立规矩——这正是后面的 Happens-Before 要充当的“厨房宪章”。

x∈主内存,xT1′∈工作内存(T1),xT2′∈工作内存(T2)\displaystyle x \in \text{主内存}, \quad x'_{T_1} \in \text{工作内存}(T_1), \quad x'_{T_2} \in \text{工作内存}(T_2)
共享变量 x 的“权威版本”属于主内存,每个线程 T₁、T₂ 各自维护一份副本。副本同步的时机,决定了可见性。
  1. 共享变量归属主内存只要被多个线程访问的变量 x,其权威版本存放在所有线程共用的主内存中,如同菜谱卡挂在主操作台上。
    x∈主内存x \in \text{主内存}
  2. 线程持有私有副本每个线程 Tᵢ 在自家工作内存中维护副本 x′,读写都先发生在副本上,就像厨师先在小料台调料。
    xTi′∈工作内存(Ti)x'_{T_i} \in \text{工作内存}(T_i)
  3. 副本与主存双向同步副本与主内存通过 read/write 动作来回同步;同步时机一旦错位,读到的就是过期值。
    xTi′→read/writexx'_{T_i} \xrightarrow{\text{read}/\text{write}} x

二、三大并发特性——完整、可见、有序

做菜有三大基本功:一盘菜必须完整端出,不能端半个盘子;端出的菜必须让客人看见,不能藏起来;上菜顺序不能乱,凉菜不能比汤还晚。并发编程里,这三件事分别叫原子性、可见性、有序性。

原子性指一个操作不可分割:x = x + 1 必须像“盛一勺酱”一样一气呵成,不能盛到一半被别的厨师抢了勺子。可见性指一个线程写共享变量后,其他线程能立刻看到新值——小料台的备份必须跟随主操作台更新。有序性指程序看起来按代码顺序执行,不能让编译器或 CPU 悄悄调戏你的逻辑。

三者经常集体作妖。比如 i++ 看似一行代码,实则是“读-改-写”三步。没有原子性保护时,两个厨师同时执行 i++,各读出 1、各写回 2,本应加两次的计数只加了一次——这是教科书级别的数据竞争。

厨房从不要求厨师时刻完美,而是用工具保障这三大基本功:锁保原子,volatile 保可见,Happens-Before 保有序——三种法器各司其职,缺一不可。

原子性: x=x+1 不可分割;可见性: 写后读必见新值;有序性: 无同步时可重排\displaystyle \text{原子性: } x = x + 1 \text{ 不可分割}; \quad \text{可见性: 写后读必见新值}; \quad \text{有序性: 无同步时可重排}
原子性管“分不分得开”,可见性管“看不看得见”,有序性管“乱不乱序”。三者独立又联动。
  1. 原子性x = x + 1 包含读、加、写三步,必须一气呵成完成;否则两个线程各读各的、各写各的,会丢掉一次更新。
    原子性: x=x+1 不可分割\text{原子性: } x = x + 1 \text{ 不可分割}
  2. 可见性写线程更新共享变量后,读线程必须能读到新值,不能一直盯着小料台上的过期备份。
    可见性: 写后读必见新值\text{可见性: 写后读必见新值}
  3. 有序性单线程内允许重排序以提速,但跨线程共享时,有序性需要 Happens-Before 来兜底。
    有序性: 无同步时指令可重排\text{有序性: 无同步时指令可重排}

三、指令重排序——偷懒的厨师与底线

厨师们有个偷懒提速的爱好:菜谱明明写“先热油再下葱”,他觉得“先切葱再热油”更顺手的,只要最终菜的味道一模一样,中间怎么折腾没人管。这就是重排序。操作系统里的编译器、处理器、内存系统,个个都是这样的“偷懒厨师”。

重排序有三个“作案者”:编译器会调整语句顺序以优化寄存器使用,处理器会乱序执行以填满流水线,内存系统会合并且批量写入缓存。它们各自为了性能而折腾,单线程内毫无风险,风险全藏在跨线程协作里。

但重排序有条底线:as-if-serial——单线程内随便折腾,最终结果必须与顺序执行完全一致。然而一旦跨线程共享数据且没有同步,底线立即失效:比如 A 线程本应先写数据再写标志位,被重排成先写标志位,B 线程看到标志位就冲去读数据,结果读到个半成品。这就是“标志位陷阱”。

偷懒的厨师在单人灶台上怎么折腾都行,可一旦要给别人的菜当配料,就必须按宪章来——Happens-Before 正是那本宪章。

单线程: 重排⇒结果不变;多线程无同步: 重排⇒结果不可预期\displaystyle \text{单线程: } \text{重排} \Rightarrow \text{结果不变}; \qquad \text{多线程无同步: } \text{重排} \Rightarrow \text{结果不可预期}
as-if-serial 只保护“一个人的表演”;一旦多人共享一个舞台,没有 Happens-Before 约束,重排就会让结果随机。
  1. 单线程底线只要最终结果与顺序执行一致,编译器与 CPU 就可以任意调整指令顺序,这保证了程序员的直觉在单线程内不会失灵。
    重排⇒结果不变(as-if-serial)\text{重排} \Rightarrow \text{结果不变} \quad (\text{as-if-serial})
  2. 跨线程失控多线程共享变量且无同步时,重排可以让“先写数据再写标志”变成“先写标志”,读者读到未就绪的数据。
    多线程无同步: 重排⇒结果不可预期\text{多线程无同步: } \text{重排} \Rightarrow \text{结果不可预期}

四、Happens-Before 规则总览——厨房宪章

为了对抗混沌,厨房立了宪章——Happens-Before(先行发生)。核心条文只有一句话:若操作 A happens-before 操作 B,则 A 的一切结果对 B 可见,且 A 必须排在 B 前面执行。它就像传菜窗口的交接规矩:出菜人把菜放上窗口,接菜人从窗口取菜,出菜前的一切努力都随菜完整传给了接菜人。

JSR-133 为 Java 定义了整套 Happens-Before 规则,其中最重要的有:程序次序规则(线程内代码顺序)、锁规则(解锁先行于同一把锁的后续加锁)、volatile 变量规则(对易变变量的写先行于后续读)、传递性规则等。这些规则凑在一起,构成了并发秩序的总纲。

传递性是整套规则的黏合剂。单看 A 和 C 没有直接联系,但通过 B 这个“中间传菜人”,A 的成果也能保证被 C 看见。设计无锁或弱同步算法时,传递性是推理的关键工具——比如通过一个 volatile 标志位,把一堆普通变量的写“捎带”给另一个线程。

宪章立在墙上,但厨房里还有几个特殊的“交接仪式”——开新灶、等传菜员回来、给菜封口上桌。这些仪式都有自己的 Happens-Before 条款。

HB(A,B)⇒A 的结果对 B 可见且 A 先行于 B;HB(A,B)∧HB(B,C)⇒HB(A,C)\displaystyle \mathrm{HB}(A,B) \Rightarrow \text{A 的结果对 B 可见且 A 先行于 B}; \qquad \mathrm{HB}(A,B) \land \mathrm{HB}(B,C) \Rightarrow \mathrm{HB}(A,C)
第一行是 Happens-Before 的定义,第二行是传递性:有中间人 B 接力,A 与 C 之间也能建立可见性契约。
  1. HB 的定义若 A 先行发生于 B,则 A 的写结果对 B 的读可见,且执行顺序上 A 严格在 B 之前。
    HB(A,B)⇒A 的结果对 B 可见\mathrm{HB}(A,B) \Rightarrow \text{A 的结果对 B 可见}
  2. 传递性若 A 到 B、B 到 C 分别有 HB 关系,则 A 到 C 也必然有;中间人 B 起到了接力棒的作用。
    HB(A,B)∧HB(B,C)⇒HB(A,C)\mathrm{HB}(A,B) \land \mathrm{HB}(B,C) \Rightarrow \mathrm{HB}(A,C)

五、线程协同规则——开火与收工的交接

厨房里,有些交接不靠传菜窗口,而是靠仪式。新开一个灶台(启动线程),老灶台要先把火候经验传过去;等传菜员回来(等待线程结束),他一路上的见闻都该补全给你;给菜品封口上桌(对象发布),也要保证客人拿到的是一道完整菜而不是半成品。

start 规则:线程 A 调用 B.start() 之前的所有写,对 B 线程内的所有动作可见——新灶台开火前,老灶台的火候经验必须传过去。join 规则:B 线程内的所有写,对 B.join() 返回之后的 A 线程可见——传菜员回来后,他路上发生的一切你都知道,不需要额外加锁。

此外还有 final 规则:只要对象在构造完成后被正确发布,其他线程读取其 final 字段时必见初始化值,无需额外同步。这像“封口菜”——上桌前已经定妆,不允许任何人再动筷子。

这些仪式让“启动、等待、终止”本身变成内存同步的通道。但日常更多时候,我们还需要主动掏出三件法器——广播、钥匙与自助核对。

start 前的写≺新线程一切动作;线程内最后写≺join() 返回\displaystyle \text{start 前的写} \prec \text{新线程一切动作}; \qquad \text{线程内最后写} \prec \text{join() 返回}
启动和等待这两个看似简单的动作,本身就是内存同步通道:start 向前传,join 向后收,天然建立 Happens-Before。
  1. start 规则调用线程在 start 之前的所有共享变量写,对新线程内的所有读操作可见,仿佛开火前先交接火候。
    start 前的写≺新线程动作\text{start 前的写} \prec \text{新线程动作}
  2. join 规则被 join 线程内最后一条语句的执行,先行发生于 join 方法成功返回之后,调用线程能看见子线程全部写操作。
    线程内最后写≺join() 返回\text{线程内最后写} \prec \text{join() 返回}
  3. final 规则final 字段在构造结束后持锁发布,其他线程无需同步即可读到完整初始化值,实现安全发布。
    final 字段: 构造后可见⇒安全发布\text{final 字段: 构造后可见} \Rightarrow \text{安全发布}

六、同步实践——广播、钥匙与自助核对

有了宪章,厨师们按规矩掏出三件法器来维护秩序:广播(volatile)、冰箱钥匙(锁)、自助核对(CAS)。

volatile 是轻量级广播:写 volatile 变量时,把写刷入主内存并让其他线程的副本失效;读时强制从主内存取新值。它天生适合“单写多读”的状态标志,比如特征版本号、服务开关。但广播不能互斥——volatile int i++ 依然是“读-加-写”三步,该乱还是乱,复合操作必须交给锁或原子类。

synchronized / ReentrantLock 的 unlock–lock 建立完整 HB 通道:解锁前所有写,对随后拿到同一把锁的线程可见。冰箱钥匙的道理就在这——不只保证“一个时间一个人进冰箱”,还保证后进冰箱的人能看到前人留下的全部痕迹。原子类(CAS) 则用乐观自旋实现无锁更新:读出旧值、比对、写回,失败就重试,用于计数器等高竞争场景。

三件法器齐了,还得学会“安全发布”——只有让对象以正确姿势端上桌,客人才不会吃到盖了半截章的半成品。这正是排查事故时最先要检查的环节。

unlock(L)≺lock(L);volatile 写≺volatile 读;CAS 自旋: ai→ai+1\displaystyle \text{unlock}(L) \prec \text{lock}(L); \qquad \text{volatile 写} \prec \text{volatile 读}; \qquad \text{CAS 自旋: } a_i \rightarrow a_{i+1}
锁是“双向通道”,volatile 是“单向广播”,CAS 是“自助核对”。三者都在建立或利用 Happens-Before。
  1. 锁规则同一把锁的解锁先行发生于后续加锁,解锁前的全部写操作对加锁后的线程可见。
    unlock(L)≺lock(L)\text{unlock}(L) \prec \text{lock}(L)
  2. volatile 规则volatile 写先行发生于后续同变量的读;写线程刷主存,读线程强制读主存。
    volatile 写≺volatile 读\text{volatile 写} \prec \text{volatile 读}
  3. CAS 自旋原子类用 compare-and-swap 循环,把旧值 aᵢ 更新为新值 aᵢ₊₁,失败即重试,无锁实现原子更新。
    CAS: ai→ai+1 循环重试\text{CAS: } a_i \rightarrow a_{i+1} \text{ 循环重试}

七、实战排查——事故现场还原

即使立了宪章,厨房偶尔也会出事:两个人同时改同一份菜谱,且彼此之间没有搭上任何交接通道——这就是数据竞争(data race)。它是内存模型领域最经典的“幽灵故障”。

数据竞争的定义很严格:同一变量被至少两个线程访问,其中至少一个是写,且读写之间没有 happens-before 排序。注意:“两个线程同时读”不算数据竞争;无同步的“读写缠绕”才算。它和“竞态条件”不同——竞态条件说的是逻辑顺序问题,数据竞争说的是内存可见性问题。

数据竞争的症状极具欺骗性:偶发错值、莫名死循环、性能抖动,跑一万次测试可能一次都不发作,一上线就崩。排查手法有四步:第一步由代码评审对照 Happens-Before 清单,找出所有共享变量及其访问点;第二步给可疑字段加 volatile 观察症状是否消失;第三步用竞争检测工具(如 Java Flight Recorder、ThreadSanitizer)抓现场;第四步修复后回归验证并用压测复现。

事故还原完毕,教训深刻:改共享状态前先自问三连——谁来写、谁来读、HB 链在哪?把共享边界最小化,能表达为 final/volatile 的绝不上锁,能上锁的绝不依赖巧合时序。

Data Race  ⟺  ∃ x:(至少一个线程写 x)∧(访问 x 之间无 HB 排序)\displaystyle \text{Data Race} \iff \exists\, x: (\text{至少一个线程写} \, x) \land (\text{访问 } x \text{ 之间无 HB 排序})
数据竞争 = 有写者 + 无 HB 契约。它把程序拖入未定义行为,结果随机且极具迷惑性。
  1. 有写者存在至少一个线程对变量 x 执行写操作,这是数据竞争的必要前提。
    ∃ x:至少一个线程写 x\exists\, x: \text{至少一个线程写} \, x
  2. 无 HB 排序访问 x 的各操作之间不存在任何 Happens-Before 链,编译器与 CPU 因而可以任意重排。
    访问间无 HB 排序⇒未定义行为\text{访问间无 HB 排序} \Rightarrow \text{未定义行为}

AI 应用场景

AI 推理服务的特征快照热更新

阶段:模型部署与在线推理

解决的问题:推理引擎为降低特征 IO 延迟,会把特征库缓存在进程内共享 Map 中。传统做法是直接修改这个 Map,多个 worker 线程可能在写入中途读到“半新半旧”的特征组合,导致模型预测漂移;停服重启又牺牲了可用性。利用 volatile 引用发布不可变特征快照,写线程先构建完整新快照再发布,读线程依据 volatile 写读的 Happens-Before 关系,必然拿到完整新版本。

具体案例:某推荐排序服务以 40 线程并发推理,每天零点切换特征版本。改造前偶发“一半新特征、一半旧特征”的串味预测;改为“不可变快照 + volatile 发布”后,版本切换零停服、零串味,A/B 实验的指标也不再被脏数据污染。

分布式训练中的指标采集与心跳

阶段:模型训练与可观测性

解决的问题:训练主线程不断更新 loss、吞吐量等指标,监控线程负责读取并上报看板。若两者之间没有 Happens-Before 通道,监控线程可能读到过期指标,把正常的 loss 波动误报成训练崩溃。用 volatile 字段发布最新 loss、用锁保护批量统计,即可让监控线程稳定读到同一批次的一致性数据。

具体案例:某大模型分布式训练任务中,监控线程曾周期性上报“loss 突跳”,经排查是主线程写入与监控读取之间缺少 HB 链导致的脏读。将指标改为 volatile 发布后,90 天训练周期内误报清零。

高并发 API 网关的限流计数器

阶段:在线服务治理

解决的问题:大模型 API 网关每秒处理海量 token 请求,限流计数器是典型的“多线程高竞争写”。用 synchronized 保护会引入锁竞争开销,且每次都要抢锁;用原子类 CAS 实现无锁自增,同时配合 volatile 定期发布计数快照给监控线程,既保证原子性又保证可见性。

具体案例:某 LLM 网关按 QPS 限流,改造前单机锁竞争导致 P99 延迟抖动 15%;改用 LongAdder 分段计数 + volatile 快照发布后,吞吐提升 20%,监控线程拿到的始终是完整的计数快照。

Python 演示

python58 行
# -*- coding: utf-8 -*-"""happens-before 演示: threading.Event 建立线程间可见性秩序"""import threadingimport time  class SauceShelf:    """共享酱料架: 一个变量 + 一个传菜窗口(Event)"""     def __init__(self):        self.sauce_id = None  # 共享变量: 酱料编号        self._window = threading.Event()  # Event 自带内存屏障     def chef_a_write(self, recipe):        self.sauce_id = recipe  # 先写共享变量        self._window.set()  # 再打开传菜窗口: 建立 happens-before     def chef_b_read(self, timeout=5.0):        got = self._window.wait(timeout)  # 等窗口通知, 保证看见写        return self.sauce_id if got else None  def broken_order():    shelf = SauceShelf()     def a():        time.sleep(0.4)  # A 需要 0.4 秒准备        shelf.sauce_id = 9527  # 写完就走, 没有通知 B     def b():        time.sleep(0.2)  # B 0.2 秒就来取        print("无同步 T2 读到:", shelf.sauce_id)  # 极可能读到 None     threading.Thread(target=a).start()    threading.Thread(target=b).start()    time.sleep(1.0)  def fixed_order():    shelf = SauceShelf()     def a():        time.sleep(0.4)        shelf.chef_a_write(9527)  # 先写, 再 set 通知     def b():        print("有HB T2 等到:", shelf.chef_b_read())  # 一定读到 9527     threading.Thread(target=a).start()    threading.Thread(target=b).start()    time.sleep(1.0)  if __name__ == "__main__":    print("=== 场景一: 没有 happens-before ===")    broken_order()    print("=== 场景二: Event 建立 happens-before ===")    fixed_order()
目的:用两个线程复现“厨房串味”——先演示没有同步时读者读到旧值(None),再用 threading.Event 建立 happens-before 通道,让读者稳定读到写者的新值。
  1. self._window = threading.Event()Event 内部使用锁与条件变量实现,自带内存屏障,天然是 Python 世界的“传菜窗口”。
  2. self.sauce_id = recipe先写普通共享变量,再打开窗口;set 之前的所有写,wait 之后都可见。
  3. self._window.set()打开传菜窗口,建立 happens-before:此前的写操作对等待者可见。
  4. got = self._window.wait(timeout)接菜人先等通知,保证读到完整新值,而不是靠 sleep 碰运气。
  5. time.sleep(0.4)模拟写线程的准备耗时,制造“读者过早到来”的时间差,让无同步场景的脏读稳定复现。
  6. print("无同步 T2 读到:", shelf.sauce_id)只靠 sleep 凑时序,B 在 A 写完前来取,读到的就是旧值 None。
  7. shelf.chef_a_write(9527)修复版把“写变量 + 开窗口”封装成一个动作,确保顺序正确。

常见误区

误区很多初学者觉得 volatile 变量自带“原子光环”,直接拿 `volatile int` 做多线程计数器自增,认为又可见又安全。

正解volatile 只保证可见性与有序性,不保证复合操作的原子性。i++ 在底层是“读-加-写”三步,volatile 只让每一步的结果可见,却阻止不了两个线程同时读出 1、又同时写回 2,导致丢更新。就像广播能让大家听见“新酱汁好了”,却不能让“盛酱→端走”这个动作不被另一位厨师打断。需要原子性,请用锁或原子类。

为何常见:volatile 语法极轻,一行关键字就有“同步感”,初学者容易把它脑补成万能同步器,忽略了“复合操作”这个前提。

误区有人认为 synchronized 或 Lock 的作用仅仅是“不让两个线程同时进临界区”,可见性问题还得靠 volatile。

正解锁同时在建立 happens-before 通道:解锁先行于后续同一把锁的加锁。线程 A 在锁内写的一切共享变量,在线程 B 拿到同一把锁后全部可见。就像冰箱钥匙——不只保证“一个时间只有一人进冰箱”,还保证后进冰箱的人能看到前人留下的全部取货痕迹。

为何常见:教科书常把锁讲成“互斥器”,弱化了它在内存语义上的效力,让人误以为互斥与可见性是两套割裂的机制。

误区“我跑了十万次都没出错,说明没有数据竞争。”这个信念在并发领域非常危险。

正解数据竞争属于未定义行为级别的问题,是否出错取决于编译优化路径、CPU 调度时序和缓存失效时机,和运行次数没有必然关系。就像厨房里偶尔打翻一次的酱油瓶,你站十小时没出事,不代表明天不洒。必须靠 happens-before 推理和检测工具确认,而不是靠运气。

为何常见:并发 bug 有极强的随机性和环境依赖性,普通测试很难恰好覆盖到触发崩溃的调度时序。

误区把 `thread.join()` 当成简单的“等待线程结束”,觉得它和 `Thread.sleep` 差不多,只是耗时间。

正解join() 除了等待,还自动建立 happens-before:被 join 线程内的所有写,对 join 返回之后的调用线程可见,不需要额外加锁。这是天然的跨线程交接通道。而 sleep 只是让出 CPU,不建立任何内存语义保证。

为何常见:join 的名字太“白话”,让人只盯着“等待”这个行为,忽略了它隐藏的内存屏障效应。

误区“我的服务器是单核 CPU,没有多核缓存不一致,所以不需要关心 happens-before。”

正解重排序不只来自 CPU 缓存,还有编译器优化。即便单核,编译器也可能调整指令顺序,操作系统也可能在任意指令边界切换线程,让读者读到“半成品”状态;JIT 的激进优化还会放大问题。内存模型约束的是“编译器 + 处理器 + 内存系统”三者,与核数无关。

为何常见:把内存模型问题片面理解为“多核缓存同步”,忽视了编译器和线程调度这两大重排来源。

面试题

Q1. happens-before 规则的核心作用是?

单选题 · easy

  • A.提升多线程程序的运行速度
  • B.保证操作的可见性与有序性← 正确答案
  • C.确保线程不被系统打断
  • D.让共享变量自动加锁

答案:B。Happens-Before 解决的是“谁先谁后、能否看见”:前序操作的结果对后续操作保证可见,且顺序不乱。它不保证提速,也不保证原子性,更不是给变量加锁。就像传菜窗口的交接规矩保证出菜人的手艺完整传给接菜人,而不是让传菜变快。

Q2. 下面哪个关键字能保证可见性但不保证原子性?

单选题 · easy

  • A.synchronized 关键字
  • B.ReentrantLock 锁对象
  • C.volatile 关键字← 正确答案
  • D.ThreadLocal 变量

答案:C。volatile 是“广播器”:写后立即刷入主内存,读时强制读主内存,保证可见性;但它不做互斥,i++ 这种读-改-写复合操作依然会被打断。synchronized 和 ReentrantLock 既保证互斥也建立 HB 通道;ThreadLocal 则是线程私有变量,不涉及共享。

Q3. 锁规则描述的是?

单选题 · mid

  • A.加锁操作必须先于解锁操作
  • B.解锁先行于同一锁后续加锁← 正确答案
  • C.每个线程只能使用一把锁
  • D.锁的粒度必须尽量最小

答案:B。锁规则是“解锁 happens-before 后续加锁”:线程 A 解锁前写的所有共享变量,对随后获取同一把锁的线程 B 可见。这好比冰箱钥匙的交接——后者能看到前人在冰箱里留下的全部痕迹。粒度最小化是性能建议,不是锁规则本身。

Q4. 线程 A 调用 B.start() 之后,以下哪个成立?

单选题 · mid

  • A.B 的写操作对 A 全部可见
  • B.A 中 start 前的写对 B 可见← 正确答案
  • C.A 与 B 之间无法传递数据
  • D.B 所有操作对 A 立即可见

答案:B。start 规则是“开火前的火候交接”:A 在调用 start 之前的所有写,对 B 线程内的动作可见;反向不成立,B 内写要对 A 可见需要 join 或其他同步手段。数据当然能传递,但必须借助同步机制。

Q5. 以下哪些机制可以建立 happens-before 关系?

多选题 · mid

  • A.锁的解锁与后续加锁← 正确答案
  • B.volatile 写与后续读← 正确答案
  • C.普通变量的连续读取
  • D.线程 join 方法返回← 正确答案

答案:ABD。锁的 unlock/lock、volatile 的写/读、线程的 start/join 都是官方钦定的 HB 通道。普通变量的读写不携带任何内存屏障,编译器可以任意重排或延迟刷新,因此 C 不能建立 HB 关系。

Q6. as-if-serial 语义指的是?

单选题 · hard

  • A.所有线程看到完全一致的顺序
  • B.单线程结果必须与顺序执行一致← 正确答案
  • C.多线程必须严格串行访问共享
  • D.禁止编译器做任何指令优化

答案:B。as-if-serial 是重排序的底线:无论编译器与 CPU 怎么折腾,单线程内最终结果必须与顺序执行完全一致。它只管“一个人的表演”,不管多线程间的可见性。跨线程的秩序要靠 Happens-Before 补足。

Q7. 下面哪种场景最适合使用 volatile?

单选题 · mid

  • A.多线程计数器进行自增
  • B.状态标志位单写多读场景← 正确答案
  • C.替代锁保护临界区代码
  • D.任意共享集合的并发修改

答案:B。volatile 是“广播”,适合一个线程写、多个线程读的状态标志,比如版本号、开关。计数器自增是复合操作,需要原子类或锁;临界区互斥和集合并发修改也都超出 volatile 的能力范围,贸然替代锁只会从“串味”变成“糊锅”。

Q8. 安全的对象发布方式包括?

多选题 · hard

  • A.通过 volatile 引用发布← 正确答案
  • B.通过 final 字段发布← 正确答案
  • C.通过锁保护的字段发布← 正确答案
  • D.无同步直接赋值静态字段

答案:ABC。安全发布就是“完整地把菜端上桌”:volatile 引用保证引用可见前对象已初始化;final 字段在构造完成后具备安全发布语义;锁保护的赋值借助 unlock/lock 的 HB 通道。无同步直接赋值可能让读者拿到“盖了半截章的对象”,属于不安全发布。

Q9. 数据竞争成立的必要条件有哪些?

多选题 · easy

  • A.同一变量被多个线程访问← 正确答案
  • B.至少存在一个写操作← 正确答案
  • C.读写之间没有 HB 排序← 正确答案
  • D.变量必须经过 volatile 修饰

答案:ABC。数据竞争 = 同一变量 + 多线程访问 + 至少一个写 + 无 HB 排序。两个线程同时读同一变量不算竞争;加了 volatile 反而是在建 HB 通道,是修复手段而非必要条件。数据竞争是未定义行为,不能靠“跑出来没出错”来排除。

Q10. 若 A 与 B 有 HB 关系且 B 与 C 有 HB 关系,则?

单选题 · hard

  • A.A 与 C 之间一定有 HB 关系← 正确答案
  • B.C 与 A 之间一定有 HB 关系
  • C.A 与 C 之间不存在任何关系
  • D.仅同一线程内传递才成立

答案:A。传递性是 Happens-Before 的公理之一:A→B→C 构成有序链,必然推出 A→C。B 就是那个“中间传菜人”,把 A 的成果接力传给 C,无论三者是否在同一线程。这一性质是设计无锁算法时最常用的推理工具。