面试知识

volatile(可见性关键字)、内存屏障与原子性:从可见到可证明

10-Java并发与锁体系 面试知识整理。

volatile(可见性关键字)、内存屏障与原子性:从可见到可证明

这一章解决一个容易被背错的问题:volatile(可见性关键字)让一个线程“看见”另一个线程已经发布的状态,却不会把多步业务决策变成一个不可分割的动作。你要先划定共享状态的一致性边界,再选择 volatile(可见性关键字)、Atomic(原子类)、synchronized(同步锁)或 Lock(锁接口)。

1. 简历关联点与面试主线

Runner(执行器)调度需要安全地停止工作线程、把完整配置切换给后续任务;这很适合 volatile(可见性关键字)状态位或不可变配置快照。WMS(仓储管理系统)的库存、订单和冻结量则由多个字段共同约束,不能因为每个字段“都能看见”就认为库存不会超卖。支付资金一致性同理:进程内可见性只能减少本地竞态,最终仍由 MySQL(关系型数据库)条件更新、事务、流水唯一约束与对账裁决。

面试表达按三层展开:先说 JMM(Java 内存模型)给出的语言级可见性与有序性;再说编译器和 CPU(中央处理器)如何用屏障落实而不绑定某条指令;最后用 count++、DCL(双重检查锁)和库存复合状态证明“单变量正确”不等于“业务正确”。

2. 先建立边界:JMM(Java 内存模型)不等于缓存示意图

JMM(Java 内存模型)规定多线程程序允许观察到什么结果,是 Java(编程语言)层的契约;它没有要求某次 volatile(可见性关键字)写把“所有 CPU(中央处理器)缓存直接刷新到内存”,也没有规定所有处理器必须发出同一条机器指令。实现可以使用缓存一致性协议、编译器约束、硬件屏障和写缓冲区协调,只要最终不产生 JMM(Java 内存模型)禁止的观察结果即可。

所谓“工作内存”是理解可见性的抽象,不是可用来逐字对应寄存器、一级缓存、三级缓存或 Store Buffer(写缓冲区)的硬件结构图。现实里,编译器可能把读取保存在寄存器,CPU(中央处理器)可能乱序发射,写入可能暂存于 Store Buffer(写缓冲区),不同核心通过缓存一致性传播变化;这些机制共同造成“另一个线程暂时没看见”或“看见的顺序不符合源码直觉”。

3. volatile(可见性关键字)的语言语义

3.1 可见性、有序性与 happens-before(先行发生)

对同一个 volatile(可见性关键字)变量,写操作 happens-before(先行发生)后续读操作。若写线程先完成普通写 payload = value,再写 ready = true;读线程先读到 ready == true,再读 payload,那么读线程必须能观察到发布前的 payload 写入。这是发布—订阅关系,不是让所有变量自动变成原子变量。

flowchart LR
    A["写线程构造数据"] --> B["普通写 payload"]
    B --> C["volatile(可见性关键字)写 ready=true"]
    C --> D["happens-before(先行发生)"]
    D --> E["volatile(可见性关键字)读 ready=true"]
    E --> F["读线程读取 payload"]
    G["失败路径:先读 payload 再读 ready"] --> H["即使随后看到 ready=true 也不能倒推前一次读取"]
  • 正常路径:读线程先成功读取 ready,再读取数据,因此发布前的普通写对它可见。
  • 失败路径:读线程若把数据读取放在标志检查之前,读取已经发生;之后看到标志不能回溯修正旧值。
  • 面试结论:volatile(可见性关键字)保证的是围绕同一同步变量建立的有序发布,不是“读一次标志就自动修复所有已发生的读”。
能力volatile(可见性关键字)能否据此处理库存扣减
单次读/写可见支持;读到同步写后可见此前发布不能,库存扣减是读、判断、写的组合
特定重排序限制支持;保证发布与获取语义不能,不能替代互斥或条件更新
单次变量访问原子性支持不能让多个字段同时变更
互斥、排队、可重入不提供需要锁、数据库条件更新或状态机

数据演绎一:停止标志为何应先检查再执行。 假设 Runner(执行器)每轮取一个任务前检查 stopping。T1 管理线程完成“拒绝新任务、记录停止原因”后写 stopping=true;T2 工作线程读取到 true 后退出循环,不再取得任务。这是单一状态的发布。若 T2 先从队列取任务、再读到停止标志,任务已经被取走;这不是 volatile(可见性关键字)失效,而是业务顺序错误。应把“检查—领取”设计成队列或数据库中的原子状态迁移。

/** Runner(执行器)的协作停止信号,只表达是否继续领取新任务。 */
public final class StopSignal {
    private volatile boolean stopping;

    /**
     * 发布停止请求。
     * @param reason 停止原因,仅用于调用方记录,不参与并发判定
     * @return 无返回值
     */
    public void requestStop(String reason) {
        // 先持久化原因,再发布标志,读取到标志的线程才能看到已完成的准备工作。
        recordReason(reason);
        stopping = true;
    }

    /**
     * 判断工作线程是否应停止领取新任务。
     * @return 已请求停止时返回 true(真值)
     */
    public boolean isStopping() {
        return stopping;
    }

    /**
     * 记录停止原因。
     * @param reason 停止原因
     * @return 无返回值
     */
    private void recordReason(String reason) {
        // 示例省略持久化细节;真实实现须保证这里没有吞掉关键异常。
    }
}

热门面试题

  1. 问题(基础题):volatile(可见性关键字)到底保证什么?

    • 考点:可见性、有序性、单次访问边界。
    • 回答思路:先给出写读的 happens-before(先行发生)规则,再划出复合操作和多变量不变式的边界。
    • 详细答案:volatile(可见性关键字)变量的一次写,与随后对同一变量的一次读之间建立 happens-before(先行发生)。写线程在写标志之前完成的普通写,对读到该标志值的读线程可见;同时编译器和运行时不能把发布前的写移动到 volatile(可见性关键字)写之后,也不能把获取后的读移动到 volatile(可见性关键字)读之前。它保证单次读写不会被其他线程拆成半次访问,却不把“读取旧值、计算新值、写回结果”合并,也不让多个字段在同一瞬间切换。因此它适合停止开关、一次性初始化完成标志、不可变配置引用;库存、余额、订单状态等需要判断与更新共同成立时,必须采用可证明的原子迁移。
    • 进阶追问:为什么不能说 volatile(可见性关键字)会直接刷新所有缓存?
    • 进阶回答:这是把教学抽象误写成硬件事实。JMM(Java 内存模型)只规定可观察结果;具体 JVM(Java 虚拟机)可依目标 CPU(中央处理器)架构使用缓存一致性、屏障或编译器约束。某次写是否离开 Store Buffer(写缓冲区)、哪一级缓存何时失效,属于实现细节。面试应说“写读建立可见性与顺序”,而不是承诺不存在的全缓存刷新动作。
  2. 问题(原理题):为什么 while (!stopping) 在普通 boolean(布尔值)字段上可能永不结束?

    • 考点:数据竞争、寄存器复用、循环优化。
    • 回答思路:说明没有同步关系时读取可被复用,再说明 volatile(可见性关键字)读取为何成为观察点。
    • 详细答案:普通字段存在数据竞争时,JMM(Java 内存模型)不要求循环中的每次读取都观察到其他线程的写。编译器可能把值提升到寄存器并复用,也可能把循环优化为反复使用已读值;硬件层又可能让本核心暂时继续观察旧缓存行。于是管理线程虽然赋值为 true(真值),工作线程仍可能按旧值空转。把字段声明为 volatile(可见性关键字)后,每轮条件判断都是同步读,读到写入后建立 happens-before(先行发生),循环不能把该读取当作不变值随意消除。它仍不保证线程立刻获得 CPU(中央处理器)时间片,所以排障还要看阻塞、死循环和调度,而不是只盯字段修饰符。
    • 进阶追问:调用 sleep 能让普通标志变得安全吗?
    • 进阶回答:不能。睡眠只是调度提示,没有建立所需的同步关系;它不能要求编译器重新读取字段,也不能建立写读 happens-before(先行发生)。它最多改变复现概率,还会把问题变成偶发。应使用 volatile(可见性关键字)、中断机制或有同步语义的并发工具表达停止协议。
  3. 问题(项目题):Runner(执行器)优雅停止时,volatile(可见性关键字)状态位的职责边界是什么?

    • 考点:停止发布、任务领取、超时、可观测性。
    • 回答思路:将“通知停止”和“任务状态迁移”分开,说明正常、超时和重启路径。
    • 详细答案:我会用 volatile(可见性关键字)状态位发布“本实例不再领取新任务”,并在循环、阻塞等待返回后和执行前检查它;这解决线程间通知与停止原因的可见性。任务领取不能只靠这个标志:领取时要在任务表或队列中以条件更新把 PENDING 变为 RUNNING,并写入执行租约、实例标识和版本,保证多个实例只有一个成功。优雅停止先置拒绝新任务、等待在执行任务到截止时间、再上报未完成任务;超时任务由租约过期后的补偿器接管。监控应记录停止请求时间、仍在运行数、领取失败数、租约超时数与线程状态,避免把“标志已可见”误当成“业务已安全停机”。
    • 进阶追问:停止后仍执行了一条任务,是不是 volatile(可见性关键字)失效?
    • 进阶回答:不一定。要先看领取动作发生在停止发布之前还是之后;若之前已原子领取,执行完成通常是正确的优雅停机行为。若之后仍能领取,检查领取路径是否漏检、队列消费者是否只在启动时检查、读取的是否同一状态位,以及任务表条件更新是否允许已停止实例继续认领。用时间线、实例标识和任务状态日志取证,而不能凭现象断言内存屏障失效。

3.2 乱序从哪里来:优化、缓存一致性与架构差异

编译器优化要保留单线程语义:只看一个线程时,两个独立操作交换位置可能没有区别;但并发程序的另一个线程可以观察顺序,于是 JMM(Java 内存模型)需要限制跨线程可见的重排。CPU(中央处理器)同样会为了吞吐量乱序发射或推迟对外可见的写入。缓存一致性解决多个核心对同一 cache line(缓存行)最终如何协调,不自动赋予多步业务操作原子性,也不替代语言层同步。

flowchart TB
    A["普通写 data=42"] --> B["普通写 ready=true"]
    C["普通读 ready"] --> D["普通读 data"]
    E["volatile(可见性关键字)写 ready=true"] --> F["禁止将此前普通写移到其后"]
    G["volatile(可见性关键字)读 ready"] --> H["禁止将随后普通读移到其前"]
    A -. "普通代码可能重排" .-> B
    C -. "普通代码可能重排" .-> D
  • 正常路径:发布写与获取读把关键数据的发布顺序固定住,读到标志后再读数据是合法设计。
  • 失败路径:两边都是普通字段时,源码相邻不等于跨线程观察顺序;数据竞争让重排和旧值都成为可能。
  • 面试结论:不要问“CPU(中央处理器)会不会乱序”后就结束,关键是问该程序是否用同步关系把必须观察的顺序约束住。
层次做什么不能推导什么
Java(编程语言)与 JMM(Java 内存模型)定义读能看到哪些写、happens-before(先行发生)规则不规定固定汇编指令
编译器重排、寄存器分配、消除冗余读写不可越过语言同步语义
CPU(中央处理器)乱序执行、Store Buffer(写缓冲区)、缓存层次不因缓存一致性自动获得复合原子性
缓存一致性协调同一 cache line(缓存行)的副本不给多个 cache line(缓存行)提供事务
HotSpot(热点虚拟机)根据平台选择屏障插入和机器码序列不等于所有 JVM(Java 虚拟机)实现

在 x86(处理器架构)上,较强的存储顺序会使某些抽象屏障不需要额外的重型指令;在 ARM(处理器架构)上,通常需要更明确的获取、释放或屏障序列。稳定结论只有两条:HotSpot(热点虚拟机)必须维护 Java(编程语言)语义;抽象的 LoadLoad(读读屏障)不能机械地等同一条固定指令。JIT(即时编译)版本、处理器代际和上下文都会影响最终代码。

热门面试题

  1. 问题(原理题):编译器优化为何会让并发代码出现“看似不可能”的结果?

    • 考点:单线程语义、数据竞争、重排序。
    • 回答思路:先定义优化合法性的单线程前提,再解释缺少同步时其他线程为何能观察到不同顺序。
    • 详细答案:编译器在不改变单线程可观察结果的前提下,可以交换互不依赖的普通读写、复用已经读取的值或删除无用写入。CPU(中央处理器)也会用乱序执行和 Store Buffer(写缓冲区)提高吞吐。若两个线程没有 happens-before(先行发生)关系,JMM(Java 内存模型)允许读线程观察到这些优化后仍合法的结果;源码的先后顺序只保证线程内程序次序,不保证另一线程按该顺序看见。volatile(可见性关键字)、锁、线程启动与完成等同步动作会约束关键边界,使优化不能穿越发布或获取点。正确修复不是关闭优化,而是为共享状态建立明确同步协议。
    • 进阶追问:缓存一致性协议存在,为什么还会看到旧值?
    • 进阶回答:缓存一致性处理的是同一 cache line(缓存行)的副本协调,且实现仍可在寄存器保存旧读、让写先驻留 Store Buffer(写缓冲区),并不要求每段普通代码立刻重新读取。更重要的是,一致性协议并不定义 Java(编程语言)程序的同步语义和多变量顺序。是否可见应从 happens-before(先行发生)判断,不能从“机器有缓存一致性”推导。
  2. 问题(版本题):x86(处理器架构)与 ARM(处理器架构)下的 volatile(可见性关键字)实现有什么稳定差异?

    • 考点:抽象屏障、平台差异、实现边界。
    • 回答思路:强调语言契约一致,再说明内存序强弱影响具体实现成本。
    • 详细答案:两种架构上 Java(编程语言)程序的 JMM(Java 内存模型)结果必须一致:volatile(可见性关键字)写读建立 happens-before(先行发生),发布前后的关键重排序受限。差异在实现成本和指令选择。x86(处理器架构)提供相对强的存储顺序,HotSpot(热点虚拟机)可能利用其已有保证并只在必要位置加强;ARM(处理器架构)更宽松,通常需要更显式的获取、释放或屏障序列。不能在面试中把 StoreLoad(写读屏障)说成某一条永恒固定的汇编指令,因为 JIT(即时编译)、目标架构和运行时上下文都能改变落地方式。
    • 进阶追问:能否因为线上全部是 x86(处理器架构)就不用同步?
    • 进阶回答:不能。首先语言正确性必须以 JMM(Java 内存模型)为依据,未来部署可能迁移到 ARM(处理器架构);其次较强的存储顺序也不把 count++ 变成单步原子操作,不能维护检查后执行和多字段不变式。依赖偶然硬件行为会让代码不可证明、不可移植,也难以在压测未复现时定位。
  3. 问题(排障题):线程空转且 CPU(中央处理器)飙高,如何判断是不是普通停止标志导致?

    • 考点:证据链、线程栈、最小复现、止血。
    • 回答思路:先定位热点线程,再核对循环字段和发布链路,最后以可验证的修改止血。
    • 详细答案:先用系统监控定位进程和热点线程,再通过 jstack(线程栈工具)连续抓取数次,确认同一线程反复停在某个无阻塞循环,而不是锁竞争、网络重试或队列轮询。接着审查循环条件字段是否为普通 boolean(布尔值)、写入线程是否确实执行、两者是否是同一对象实例;添加低频计数和停止请求时间可以建立时间证据。临时止血可限流、缩短轮询并重启实例,但不能把 sleep 当修复。长期将单一停止状态改为 volatile(可见性关键字)或中断协议,并补充“停止后循环在截止时间内退出”的并发测试和线程空转告警。
    • 进阶追问:如何避免日志把空转问题放大?
    • 进阶回答:不要在每一轮循环打印日志;这会同时掩盖时序、制造磁盘与 CPU(中央处理器)压力。应使用按时间窗口聚合的计数器,记录每秒循环次数、最后一次有效工作时间与停止标志观察次数;达到阈值后采样一次线程栈和配置版本。证据要能区分“没有任务的正常等待”和“没有阻塞的异常自旋”。

4. 内存屏障与单变量原子性边界

4.1 四类抽象屏障、编译器屏障与 HotSpot(热点虚拟机)插入点

内存屏障首先是排序约束,不应理解成“把内存清空”。LoadLoad(读读屏障)要求其前读取先于其后读取完成;LoadStore(读写屏障)要求其前读取先于其后写入;StoreStore(写写屏障)要求其前写入先于其后写入对外可见;StoreLoad(写读屏障)要求其前写入先于其后读取,通常是代价较高的跨向约束。它们是 JMM(Java 内存模型)和 JVM(Java 虚拟机)实现讨论中的抽象工具。

flowchart TB
    A["普通写"] --> B["StoreStore(写写屏障)"] --> C["volatile(可见性关键字)写"]
    D["volatile(可见性关键字)写"] --> E["StoreLoad(写读屏障)"] --> F["后续读"]
    G["volatile(可见性关键字)读"] --> H["LoadLoad(读读屏障)"] --> I["后续普通读"]
    J["volatile(可见性关键字)读"] --> K["LoadStore(读写屏障)"] --> L["后续普通写"]
  • 正常路径:写侧发布前的数据写不会越过发布点,读侧获取后读取的数据不会越过获取点。
  • 失败路径:把四类名称当成固定指令,或只插屏障却没有让读写经过同一同步变量,都无法推出正确发布。
  • 面试结论:HotSpot(热点虚拟机)在 volatile(可见性关键字)读写周围按平台实现相应排序;源码层的屏障模型和最终汇编不是一一固定映射。
抽象屏障禁止的跨越关系常见语义位置重点误区
LoadLoad(读读屏障)后续读越过前读获取后的后续读不是要求所有读都串行
LoadStore(读写屏障)后续写越过前读获取后的后续写不提供互斥
StoreStore(写写屏障)后续写越过前写发布前的普通写与发布写之间不等于立刻写回所有缓存
StoreLoad(写读屏障)后续读越过前写发布后再读的边界不应硬编码为某条指令

编译器屏障约束编译器不能跨越移动内存访问;硬件屏障或获取、释放指令约束处理器对外可见的执行顺序。HotSpot(热点虚拟机)把二者组合,并根据 x86(处理器架构)或 ARM(处理器架构)的内存序选择实现。开发者应依赖语言保证,而不是用反汇编结果为业务方案背书;反汇编只用于解释特定版本、特定机器上的性能和排障证据。

热门面试题

  1. 问题(原理题):四类抽象屏障如何帮助理解 volatile(可见性关键字)?

    • 考点:发布、获取、排序方向。
    • 回答思路:分别说明写侧与读侧,再回到同一同步变量形成的 happens-before(先行发生)。
    • 详细答案:写线程发布对象时,需要保证此前初始化写不能跑到 volatile(可见性关键字)写之后,StoreStore(写写屏障)描述这一方向;若发布后还有读取,StoreLoad(写读屏障)描述不能让读取越过发布写。读线程获取标志后,需要保证后续普通读和写不能越过获取读,分别由 LoadLoad(读读屏障)与 LoadStore(读写屏障)描述。它们共同帮助实现“写前数据—发布标志—获取标志—读后数据”的观察顺序。真正的语言结论仍是同一 volatile(可见性关键字)变量的写读 happens-before(先行发生),而不是开发者手工拼四种屏障。
    • 进阶追问:为什么 StoreLoad(写读屏障)常被认为更重?
    • 进阶回答:它约束前一写与后一读,涉及写入何时能安全地与之后读取建立顺序,许多架构需要更强协调;但“更重”不是固定性能数字。实际开销取决于架构、缓存争用、JIT(即时编译)和周边代码。选型先看正确性边界,再用基准和线上指标测量。
  2. 问题(辨析题):volatile(可见性关键字)读写等于加锁和解锁吗?

    • 考点:同步效果局部相似、互斥差异。
    • 回答思路:承认可见性和顺序上的同步关系,再说明没有持有者与临界区。
    • 详细答案:volatile(可见性关键字)写读可以建立 happens-before(先行发生),这与解锁后加锁能发布此前写入的效果在可见性上有相似之处;但它没有锁对象、持有者、竞争等待或临界区。多个线程可以同时读取并同时写入同一 volatile(可见性关键字)变量,最后写入者覆盖前者,因此不能用它保护读取、校验、更新组成的业务动作。锁提供的是互斥加可见性,volatile(可见性关键字)提供的是单变量同步读写的可见性和顺序,两者的证明对象不同。
    • 进阶追问:能否用多个 volatile(可见性关键字)字段拼出原子快照?
    • 进阶回答:不能可靠地拼出。读线程可能在两个字段写入之间读取,得到版本不匹配的组合;即使每次单字段都可见,也没有一个共同线性化点。应将相关字段封装进不可变对象,用一个 volatile(可见性关键字)引用一次替换,或以锁、Atomic(原子类)条件更新保护整个不变式。
  3. 问题(实践题):何时值得看 HotSpot(热点虚拟机)反汇编?

    • 考点:性能验证、版本边界、不过度推论。
    • 回答思路:限定在性能或异常证据场景,说明必须记录环境并回归语言语义。
    • 详细答案:只有当性能优化假设依赖某个热点路径时,才值得在明确 JDK(Java 开发工具包)版本、CPU(中央处理器)型号、编译层级和负载下检查 HotSpot(热点虚拟机)生成代码,例如确认某段代码是否已被 JIT(即时编译)编译、是否出现意外的屏障或未内联调用。反汇编不能替代正确性证明:它只代表该次运行,不代表其他架构、其他版本或解释执行阶段。结论仍应写成 JMM(Java 内存模型)的发布与获取规则,性能结论则附带压测参数、争用程度和误差范围。
    • 进阶追问:为什么不能通过删掉 volatile(可见性关键字)来“省屏障”?
    • 进阶回答:删掉后可能让程序进入数据竞争,性能数值即使更高也没有业务正确性含义;异常往往只在负载、迁移架构或编译优化触发后出现。若同步成本是瓶颈,应先减少共享、缩短状态传播、改用分片计数或批处理,而不是移除保证。

4.2 count++、long(长整型)与 double(双精度浮点数)的原子性边界

count++ 至少包含读取、局部计算、写回三步;volatile(可见性关键字)只让每一步的单次访问具备可见性与顺序,两个线程仍可基于同一个旧值计算并覆盖。单次 long(长整型)和 double(双精度浮点数)访问也必须说清规范边界:Java(编程语言)SE 21(Java 平台标准版 21)的 JMM(Java 内存模型)仍允许**非 volatile(可见性关键字)**的 long(长整型)或 double(双精度浮点数)单次写在规范层被当作两个三十二位写,尽管现代常见 JVM(Java 虚拟机)通常会原子实现;volatile(可见性关键字)的 long(长整型)和 double(双精度浮点数)读写始终原子。无论是否撕裂,复合更新都仍非原子。

flowchart LR
    A["初始 count=0"] --> B["线程甲读到 0"]
    A --> C["线程乙读到 0"]
    B --> D["线程甲计算 1"] --> E["线程甲写回 1"]
    C --> F["线程乙计算 1"] --> G["线程乙写回 1"]
    E --> H["最终 count=1"]
    G --> H
  • 正常路径:若两个更新被锁或 CAS(比较并交换)串行化,第二个线程会基于 1 得到 2
  • 失败路径:两个线程都以 0 为输入,最后一次写没有保留另一次的增量,结果为 1
  • 面试结论:可见性不能合并读改写;要么让临界区互斥,要么让比较与替换成为一个原子步骤。
操作是否单次原子是否维护读改写合理工具
volatile int flag 读或写状态发布
非 volatile(可见性关键字)long(长整型)/double(双精度浮点数)读写规范允许非原子不用于无同步共享状态
volatile(可见性关键字)long(长整型)/double(双精度浮点数)读写单值时间戳、版本发布
AtomicInteger(原子整数)递增单变量计数、条件替换
锁内多个字段修改临界区整体原子多变量不变式

数据演绎二:count++ 丢失更新。 初始 count=0。T1 读到 0;T2 也读到 0;T1 计算 1 并写回;T2 计算 1 并写回。最终值 1,已执行两次请求却只记一次。给 count 加 volatile(可见性关键字)不改变三步结构,只让 T1、T2 的读写更及时可见;正确方案是 AtomicInteger(原子整数)的 CAS(比较并交换)重试,或把相关状态放进 synchronized(同步锁)/Lock(锁接口)临界区。

数据演绎三:多变量不变式被拆散。 WMS(仓储管理系统)中 available=10frozen=0,约束是 available + frozen = 10。冻结 3 件需要同时把它们改成 7 和 3。若写线程先写 available=7,读线程在写 frozen=3 前读到组合 (7,0),它会认为总库存只有 7;反过来可能读到 (10,3),认为库存凭空多了 3。两个字段都声明 volatile(可见性关键字)不能提供同一版本快照。

热门面试题

  1. 问题(基础题):为什么 volatile count++ 仍会丢失更新?

    • 考点:读改写、覆盖、线性化点。
    • 回答思路:拆开三步并给出两个线程的交错,再比较 CAS(比较并交换)和锁的线性化点。
    • 详细答案count++ 会先读取共享变量,再在本地寄存器或栈中计算加一,最后写回。volatile(可见性关键字)能保证读取和写回这两个单独动作遵守可见性规则,却无法阻止另一线程在中间读到同一旧值。两个线程各自把 0 计算成 1,后一次写覆盖前一次写,所以结果仍为 1。AtomicInteger(原子整数)通过 CAS(比较并交换)把“值仍等于期望值时才替换”为一个原子条件;失败者重新读取再试。synchronized(同步锁)或 Lock(锁接口)则让读取、计算、写回处于同一临界区。选择取决于是否只有一个值、冲突强度以及是否还要维护其他字段。
    • 进阶追问:CAS(比较并交换)一定比锁快吗?
    • 进阶回答:不一定。低冲突且临界工作很短时,CAS(比较并交换)通常避免阻塞;高冲突下大量失败重试会消耗 CPU(中央处理器),而锁可让等待线程挂起并保护多步逻辑。性能只是最后一层;若不变式跨多个变量,先选能表达原子边界的锁或数据库条件更新,再根据测量优化。
  2. 问题(规范题):共享 long(长整型)时间戳为何仍建议同步或 volatile(可见性关键字)?

    • 考点:规范允许撕裂、现代实现、可见性。
    • 回答思路:区分常见实现事实与语言保证,再指出原子读写不等于业务同步。
    • 详细答案:现代主流 JVM(Java 虚拟机)和六十四位 CPU(中央处理器)通常会原子处理 long(长整型)与 double(双精度浮点数)访问,但 Java(编程语言)SE 21(Java 平台标准版 21)规范仍允许非 volatile(可见性关键字)的这两类单次写被拆为两个三十二位写,读取者可能看到混合值。将共享值声明为 volatile(可见性关键字)能获得单次读写原子性、可见性和排序保证;用锁也能建立正确同步。即使确认目标实现没有撕裂,普通字段仍可能读取旧值,且 timestamp++ 之类复合操作仍不原子。因此应按语言契约设计,而不是把当前机器的偶然行为当正确性前提。
    • 进阶追问:引用类型的单次读写原子,是否说明对象字段都已初始化可见?
    • 进阶回答:不说明。引用本身不撕裂,只代表读到一个对象地址;若对象在构造未完成时被普通字段发布,读线程仍可能看到半初始化状态。应在构造完成后用 volatile(可见性关键字)引用、锁、线程安全容器或其他安全发布机制发布对象。
  3. 问题(项目题):WMS(仓储管理系统)库存扣减为什么不能只用 volatile(可见性关键字)?

    • 考点:检查后执行、复合状态、跨实例一致性。
    • 回答思路:用库存判断和扣减交错说明本地问题,再给出数据库最终兜底。
    • 详细答案:库存扣减包含读取可售量、判断是否足够、扣减可售量、增加冻结量、写库存流水和更新订单状态;任一并发请求在判断后都可能被另一个请求改变前提。volatile(可见性关键字)最多让它们更容易看见新数值,不能把这些动作绑成一次迁移,也不能覆盖多个服务实例。进程内可用锁缩小竞争面,但最终应由 MySQL(关系型数据库)条件更新,例如“仅当可售量不少于扣减量时更新”,并以受影响行数判断成功;库存流水使用业务唯一号,订单状态机与补偿任务处理超时和重复消息。这样即使缓存失效、实例重启或消息重投,最终账实仍可核对。
    • 进阶追问:库存缓存的 volatile(可见性关键字)引用还有价值吗?
    • 进阶回答:有,但只在读优化层。它可以一次替换不可变的库存展示快照,让本实例读线程看到完整版本,降低读锁成本;不能作为扣减成功的最终裁决。缓存与数据库不一致时,以数据库条件更新和流水为准,并通过消息或定时校正缓存。

5. 安全发布:DCL(双重检查锁)与配置快照

5.1 DCL(双重检查锁)发布与独立配置快照

DCL(双重检查锁)用于按需创建一个对象,正确写法要求实例引用是 volatile(可见性关键字)。原因不是“禁止所有重排”,而是防止对象引用在构造完成前被发布:对象分配、构造字段、把引用赋给共享变量,在没有同步时可能让另一个线程先拿到引用,再读到默认字段值。volatile(可见性关键字)写引用发布构造前的状态,读引用获取该状态。

flowchart TD
    A["线程甲首次读 instance 为空"] --> B["进入 synchronized(同步锁)"]
    B --> C["再次读 instance 为空"]
    C --> D["构造完整对象"]
    D --> E["volatile(可见性关键字)写 instance"]
    F["线程乙读 volatile(可见性关键字)instance"] --> G["看到非空后读取完整字段"]
    E --> F
    H["错误路径:普通引用先发布"] --> I["线程乙可能读取半初始化对象"]
  • 正常路径:锁只承担首次创建互斥;volatile(可见性关键字)引用承担构造完成后的安全发布和后续快速读取。
  • 失败路径:实例引用不是 volatile(可见性关键字)时,双重检查的外层非空判断可能读到半初始化对象。
  • 面试结论:DCL(双重检查锁)解决的是单例对象的构造发布,不是解决任意业务更新;能使用静态初始化或枚举时通常更简单。

数据演绎四:配置快照的版本切换。 初始当前引用指向 V7{超时=30, 路由=A}。配置线程先在本地构造并校验 V8{超时=45, 路由=B},确认字段和嵌套规则不可变后,一次 volatile(可见性关键字)写把当前引用从 V7 替换为 V8。已经启动的 Runner(执行器)任务持有 V7 并完整执行;新领取的任务读取 V8。错误做法是分别写 timeout=45route=B 两个 volatile(可见性关键字)字段,任务可能读到 (45,A),得到不存在的配置版本。

/** Runner(执行器)使用的不可变配置快照。 */
public final class RunnerConfig {
    private final int timeoutSeconds;
    private final String route;

    /**
     * 创建经过校验的配置快照。
     * @param timeoutSeconds 超时秒数,必须大于零
     * @param route 已校验的路由名称
     * @throws IllegalArgumentException 参数非法时抛出
     */
    public RunnerConfig(int timeoutSeconds, String route) {
        if (timeoutSeconds <= 0 || route == null || route.isBlank()) {
            throw new IllegalArgumentException("配置非法");
        }
        this.timeoutSeconds = timeoutSeconds;
        this.route = route;
    }

    /**
     * 返回超时秒数。
     * @return 已冻结的超时秒数
     */
    public int timeoutSeconds() { return timeoutSeconds; }

    /**
     * 返回路由名称。
     * @return 已冻结的路由名称
     */
    public String route() { return route; }
}
方案一致性单元适合场景主要风险
多个 volatile(可见性关键字)字段单字段相互独立的开关读到混合版本
volatile(可见性关键字)不可变对象引用整个快照配置热更新、路由规则深层对象若可变会破坏快照
DCL(双重检查锁)单例构造与发布延迟创建且构造昂贵把它误用于多字段业务更新
synchronized(同步锁)/Lock(锁接口)临界区读改写、多状态迁移锁范围过大影响吞吐

热门面试题

  1. 问题(原理题):DCL(双重检查锁)为何必须配合 volatile(可见性关键字)?

    • 考点:半初始化对象、安全发布、外层快速路径。
    • 回答思路:拆分分配、初始化、发布三个概念,再说明写读 happens-before(先行发生)。
    • 详细答案:DCL(双重检查锁)的内层锁确保只有一个线程创建对象,外层检查避免对象创建后每次都加锁。但若共享实例引用不是 volatile(可见性关键字),对象引用的发布与构造字段初始化之间缺少对其他线程可见的顺序约束;另一个线程可能看到非空引用而字段仍是默认值。将引用声明为 volatile(可见性关键字)后,构造线程对引用的写与读取线程对同一引用的读建立 happens-before(先行发生),读取到非空引用的线程也能看到构造前完成的初始化。它解决的是安全发布,不会替对象内部后续可变字段自动加锁。
    • 进阶追问:为什么枚举单例或静态初始化常更推荐?
    • 进阶回答:它们利用类初始化的线程安全语义,代码更短、证明面更小,也避免手写双重检查的遗漏。只有确实需要延迟创建、且不希望类加载即初始化时,才考虑 DCL(双重检查锁);无论哪种方式,对象构造期间都不应让 this 泄漏。
  2. 问题(设计题):如何实现 Runner(执行器)配置热更新而不让执行中的任务混用版本?

    • 考点:不可变性、一次替换、任务边界。
    • 回答思路:先确定快照包含哪些字段,再说明构造校验、发布和任务绑定时点。
    • 详细答案:把超时、重试次数、并发配额、路由规则和版本号封装进不可变配置对象;更新线程先拉取、校验和完整构造新对象,确保集合和嵌套规则也不可变或已复制,再以 volatile(可见性关键字)引用一次替换当前快照。任务在领取成功时读取一次并绑定到任务上下文,之后即使配置更新也继续使用该版本;新任务读取新版本。这样每个任务的重试、超时和路由可审计。若配置需要立即终止某类任务,应额外定义明确的取消协议,不能依赖把对象内部字段悄悄改掉。
    • 进阶追问:配置快照是否能保证跨实例同时切换?
    • 进阶回答:不能。它只保证单个 JVM(Java 虚拟机)内的安全发布。跨实例要有配置版本、灰度策略、实例确认、兼容窗口和回滚记录;任务记录持久化版本号,重试时按原版本或显式迁移规则执行。对于影响资金或库存的规则,还要将版本纳入数据库事务与审计。
  3. 问题(反例题):把订单对象引用声明为 volatile(可见性关键字)就能解决订单并发修改吗?

    • 考点:引用发布、对象可变性、业务状态机。
    • 回答思路:先承认引用安全发布,再说明对象内部字段和状态迁移仍需要保护。
    • 详细答案:volatile(可见性关键字)引用可以安全发布构造完成的订单快照,读线程读取该引用后能看到发布前初始化的字段;但如果多个线程随后修改同一个订单对象中的金额、状态、明细或版本,引用没有变化,volatile(可见性关键字)也不会把这些多步更新变为互斥。订单支付、取消和发货通常要求状态机迁移、版本校验、幂等键和持久化事务共同成立。正确做法是把读取模型设计为不可变快照并一次替换,或把写模型交给数据库条件更新和事务,而不是给可变实体的外层引用加一个修饰符。
    • 进阶追问:final(最终关键字)字段能完全替代 volatile(可见性关键字)发布吗?
    • 进阶回答:final(最终关键字)字段有专门的构造可见性语义,但前提是构造完成前不泄漏对象引用,并且字段引用到的对象本身不能在外部被随意修改。它不能替代后续可变状态的同步,也不能处理多变量更新。需要动态替换当前配置时,volatile(可见性关键字)引用仍是清晰选择。

5.2 工具选择:先证明一致性,再谈性能

选择工具的第一问不是“哪个最快”,而是“哪一个操作必须作为同一个事实发生”。只读一次状态并据此退出,用 volatile(可见性关键字);一个计数或条件替换,用 Atomic(原子类)与 CAS(比较并交换);多个字段、外部调用前后的状态机,需要 synchronized(同步锁)或 Lock(锁接口)协调;库存和资金跨 JVM(Java 虚拟机)实例时,最终由数据库原子条件、事务和幂等约束负责。

flowchart TD
    A["先定义必须同时成立的不变式"] --> B{"是否只有一个独立变量"}
    B -- "是" --> C{"是单次发布或读取吗"}
    C -- "是" --> D["volatile(可见性关键字)"]
    C -- "否" --> E["Atomic(原子类)与 CAS(比较并交换)"]
    B -- "否" --> F{"是否跨进程或需持久化裁决"}
    F -- "否" --> G["synchronized(同步锁)或 Lock(锁接口)"]
    F -- "是" --> H["数据库条件更新、事务、幂等与对账"]
  • 正常路径:先写出不变式和线性化点,再从图中选能覆盖边界的最小工具。
  • 失败路径:因“无锁更快”把多字段状态拆成多个 volatile(可见性关键字),或因“加锁安全”把远程调用放进大锁,都会让系统难以证明或难以扩展。
  • 面试结论:性能优化只能在正确边界内做;可证明性、故障恢复和可观测性同样是并发方案的成本。
工具核心能力不适合典型使用
volatile(可见性关键字)单变量发布、读取、顺序读改写、多个字段一致性停止标志、不可变快照引用
Atomic(原子类)/CAS(比较并交换)单变量条件替换长临界区、高争用复合逻辑计数、状态从 A 到 B
synchronized(同步锁)互斥、可见性、结构简单需要可中断/定时/多条件队列的复杂协调多字段内存不变式
Lock(锁接口)显式获取、可中断、定时、条件队列忘记释放的复杂路径需要细粒度协调的临界区
MySQL(关系型数据库)条件更新跨实例持久化原子裁决只为本地开关付出远程成本库存扣减、订单状态迁移

false(假值) sharing(伪共享)是性能扩展,不是正确性问题:两个无关热点字段落在同一 cache line(缓存行)时,不同核心频繁写会互相触发缓存一致性流量。@Contended(缓存行填充注解)可通过填充降低某些场景的干扰,但会增大内存占用,且受 JVM(Java 虚拟机)参数和布局影响。先用 JMH(Java 微基准测试工具)和线上剖析证实争用,再考虑分片、批量或填充;不要仅凭字段相邻猜测。

热门面试题

  1. 问题(选型题):Atomic(原子类)和 synchronized(同步锁)怎样选?

    • 考点:单变量、复合不变式、竞争退化。
    • 回答思路:以不变式范围为第一判断,再补充冲突和失败处理差异。
    • 详细答案:若业务只需让一个数值或枚举状态基于期望值更新,例如任务状态从 PENDINGRUNNING,Atomic(原子类)结合 CAS(比较并交换)能清楚表达条件替换,失败可由调用方重读或返回失败。若一次操作必须同步维护余额、冻结额、流水缓存和内存索引,应该用 synchronized(同步锁)或 Lock(锁接口)把所有共享步骤放入一个临界区,并避免在锁内执行慢 I/O(输入输出)或远程调用。高竞争下 CAS(比较并交换)反复失败会自旋消耗 CPU(中央处理器),锁则有阻塞与唤醒代价;但这些都排在一致性边界之后。跨实例的最终正确性不应由本地任一工具承担。
    • 进阶追问:Lock(锁接口)相比 synchronized(同步锁)是否天然更高级?
    • 进阶回答:不是。Lock(锁接口)提供可中断获取、定时尝试、多个条件队列和更灵活的策略,适合确有这些需求的协调;synchronized(同步锁)语法简洁,异常退出自动释放,许多短临界区更不易写错。选型要看需要的语义和团队可维护性,而非标签。
  2. 问题(性能题):false(假值) sharing(伪共享)什么时候才值得处理?

    • 考点:缓存行、写竞争、测量方法。
    • 回答思路:先说明触发条件,再给出从基准到线上证据的步骤和替代方案。
    • 详细答案:只有多个线程在不同核心上高频写不同字段、这些字段恰好位于同一 cache line(缓存行)、并且剖析显示一致性流量或吞吐明显受限时,false(假值) sharing(伪共享)才可能成为主要矛盾。先构造与真实并发度、写比例和对象生命周期相近的 JMH(Java 微基准测试工具)基准,避免死代码消除、常量折叠和单线程测试;再用线上吞吐、CPU(中央处理器)、延迟和火焰图验证。优先考虑分片计数、每线程局部累积后汇总或降低共享写频率。@Contended(缓存行填充注解)是最后的结构性优化,必须评估内存放大和运行参数。
    • 进阶追问:为什么 JMH(Java 微基准测试工具)结果不能直接等同线上收益?
    • 进阶回答:微基准往往缺少真实的对象分配、GC(垃圾回收)、网络、数据库、调度抖动和多租户竞争;错误的状态作用域、预热不足或使用结果方式还会让编译器优化掉被测代码。JMH(Java 微基准测试工具)用于比较受控假设,线上指标用于验证端到端收益,两者都不能省。
  3. 问题(项目题):库存扣减工具选择如何体现“性能不是唯一标准”?

    • 考点:本地优化、数据库裁决、审计补偿。
    • 回答思路:分层说明缓存、本地并发控制和持久化事实各自职责。
    • 详细答案:WMS(仓储管理系统)可以用本地 Atomic(原子类)或锁减少同一实例内的热点争用,也可以用 volatile(可见性关键字)发布只读库存快照给查询接口,但扣减成功必须由 MySQL(关系型数据库)条件更新判断,库存流水以订单明细或幂等键去重,订单状态在同一事务中迁移。消息重投、支付超时和人工取消再由补偿与对账覆盖。这样设计看似多层,却把性能优化限制在可回滚的加速层,把最终事实放在可审计的持久层;单纯追求无锁吞吐而没有失败恢复,发生超卖时成本远高于一次锁竞争。
    • 进阶追问:本地锁能否替代数据库条件更新?
    • 进阶回答:不能。多实例部署、进程重启、定时任务和手工操作都可能绕过某个实例的锁。本地锁只能缩小局部竞争,数据库条件更新才在持久化数据上形成跨实例裁决;必要时还要配合分布式协调和幂等,但不能去掉数据库底线。

6. 项目排障、SOP(标准操作流程)与项目话术

6.1 从停止标志失效到半初始化对象的取证

并发故障先收集证据,再改代码。停止标志失效、线程空转、CPU(中央处理器)飙高、发布半初始化对象和 JMH(Java 微基准测试工具)误测,表面都可能是“volatile(可见性关键字)问题”,实际根因分别可能是普通字段、阻塞协议遗漏、重试风暴、对象逃逸或测试方法错误。

flowchart TD
    A["收到线程空转或状态不一致告警"] --> B["冻结版本、实例、时间窗与配置版本"]
    B --> C["采集线程栈、CPU(中央处理器)和业务状态"]
    C --> D{"循环是否无阻塞且读取普通字段"}
    D -- "是" --> E["验证停止标志发布链路"]
    D -- "否" --> F{"是否读到混合字段或半初始化对象"}
    F -- "是" --> G["检查安全发布与快照边界"]
    F -- "否" --> H["检查重试、队列、锁竞争和外部依赖"]
    E --> I["最小复现、修复并回归"]
    G --> I
    H --> I
  • 正常路径:先固定版本和时间窗,线程栈、请求日志、状态迁移记录能互相对应,再决定是内存模型、协议还是依赖问题。
  • 失败路径:只看到 CPU(中央处理器)高就给字段加 volatile(可见性关键字),可能掩盖无界重试、队列轮询或锁内远程调用。
  • 面试结论:并发排障的产物应是可复核时间线和修复验证,不是“我加了一个关键字后好了”。
现象首要证据高概率根因修复验证
停止请求后线程不退出停止时间、连续 jstack(线程栈工具)、字段声明普通标志被复用、未检查中断并发测试证明截止时间内退出
CPU(中央处理器)持续高热点线程栈、每秒循环数、重试数空转、快速失败重试、轮询无退避修复后循环率和 CPU(中央处理器)回落
偶发默认字段对象创建与发布日志、字段快照构造期间逃逸、普通引用发布压力测试不再读到半初始化组合
基准异常快JMH(Java 微基准测试工具)预热、结果消费、线程数死代码消除、常量折叠、无争用与线上负载特征交叉验证

SOP(标准操作流程):第一步记录 JDK(Java 开发工具包)版本、镜像、CPU(中央处理器)架构、实例数、停止请求和配置版本;第二步连续采集线程栈并将线程标识映射到 CPU(中央处理器)热点;第三步查询任务领取、执行租约、库存条件更新和重试事件,构造分钟级时间线;第四步在隔离环境用相同开关、并发数与数据量复现;第五步以最小语义修复并加上压力测试、指标与回滚开关。发布半初始化对象时,还要搜索构造器注册回调、启动线程、放入静态集合或向其他组件传递 this 的路径。

项目话术:Runner(执行器)优雅停止与热更新。“我们最初把停止理解为关掉线程,后来把它拆成三件事:本实例不再领取、已领取任务有截止时间、未完成任务可接管。单一停止信号用 volatile(可见性关键字)发布,保证工作循环能看见;任务领取通过持久化条件更新和租约保证多实例只有一个执行者;配置热更新不逐字段改,而是构造校验后的不可变快照并一次替换引用。线上遇到停机后 CPU(中央处理器)仍高时,我们先用连续线程栈证明是普通标志空转还是重试,再按时间线修复,最终把停止耗时、未领取数、租约超时数做成告警。”

项目话术:WMS(仓储管理系统)库存复合状态。“库存不能只讲 volatile(可见性关键字),因为可售、冻结、已分配和订单状态是一个不变式。我们把 volatile(可见性关键字)快照用于查询加速,但扣减走 MySQL(关系型数据库)条件更新,成功才插入唯一库存流水并推进订单状态;失败返回库存不足。这样即便多个实例同时请求、缓存滞后或消息重复,最终都由受影响行数和流水唯一约束裁决。排障时我会按订单号关联条件更新、流水、消息投递和补偿记录,而不是只看内存变量。”

热门面试题

  1. 问题(排障题):如何排查“服务停止了但线程还在跑”?

    • 考点:状态协议、线程栈、任务租约、时间线。
    • 回答思路:区分不再领取、正在执行和已失控空转三种状态,再逐一取证。
    • 详细答案:先定义“还在跑”的含义:线程可能在正常完成已领取任务,也可能在等待外部 I/O(输入输出),还可能因空转持续占用 CPU(中央处理器)。采集停止请求时间、实例状态、任务领取时间和连续 jstack(线程栈工具),看线程是否反复停在循环条件、队列轮询、重试调用或阻塞点。若是普通 boolean(布尔值)停止标志,检查是否缺 volatile(可见性关键字)及是否同一对象;若是已领取任务,检查租约和截止时间;若是重试,检查退避和最大次数。修复后用并发测试验证停止后不再领取、在执行任务按期限收敛、超时可被其他实例接管,并为每类路径建立指标。
    • 进阶追问:为什么不能直接强杀工作线程?
    • 进阶回答:强制终止可能让锁、文件、连接、库存流水或外部调用停在未知位置,破坏业务不变式。优雅停止应先停止新领取,再等待或取消可中断任务,超时任务由幂等和租约恢复;进程级强杀只作为故障隔离手段,之后必须依赖持久化状态进行恢复与对账。
  2. 问题(排障题):怎样证明半初始化对象来自错误发布?

    • 考点:构造逃逸、字段组合、对照实验。
    • 回答思路:先收集不可能的字段组合,再沿引用发布路径查构造期间泄漏。
    • 详细答案:先记录对象标识、创建线程、发布时间和每个字段的值,确认读到了构造逻辑不可能产生的默认值组合,而不是配置源本身缺字段。随后搜索构造器及其调用链:是否把 this 注册为监听器、提交到线程池、写入普通静态字段或在字段赋值前调用可覆写方法。若共享引用不是 volatile(可见性关键字)且没有锁,也属于重点路径。隔离环境可通过高并发重复创建与读取,并在构造关键点加入受控屏障扩大窗口;修复为构造完成后安全发布、避免 this 逃逸或使用不可变对象后,重复验证不再出现该字段组合。
    • 进阶追问:只把所有字段改为 volatile(可见性关键字)是否可修复?
    • 进阶回答:通常不是好修复。字段逐个 volatile(可见性关键字)仍可能让读线程看到混合版本,而且无法阻止构造期间引用泄漏。正确边界是对象完整构造后一次安全发布;若对象需要后续变更,则设计清晰的锁或版本化快照协议。
  3. 问题(测试题):JMH(Java 微基准测试工具)怎样避免把 volatile(可见性关键字)测试成假结论?

    • 考点:预热、状态范围、结果消费、争用建模。
    • 回答思路:说明 JIT(即时编译)与基准夹具的影响,再给出可复核配置。
    • 详细答案:先明确测的是单线程读取延迟、双线程发布吞吐,还是高竞争更新;三种问题需要不同线程组和状态作用域。JMH(Java 微基准测试工具)必须有充分预热,让 JIT(即时编译)进入稳定状态;把读取结果交给黑洞或用于后续计算,防止死代码消除;共享状态必须真正在多个工作线程间共享,不能误放到每线程私有对象;还要报告分叉次数、线程数、CPU(中央处理器)绑定和对象分配。最后将基准结果与生产中的争用、缓存失效、GC(垃圾回收)和外部 I/O(输入输出)分开解释,避免把纳秒级局部差异夸大为系统收益。
    • 进阶追问:JMH(Java 微基准测试工具)能证明并发代码正确吗?
    • 进阶回答:不能。它主要测量受控性能,不能穷尽所有交错,也不能证明跨实例的库存或支付一致性。正确性应由不变式、模型化测试、压力测试、数据库约束和故障演练共同验证;性能基准只是选择同样正确方案时的证据之一。

6.2 排障要点小结

本小节只收束前述排障证据链,不引入新的知识主题:先证明现象、再定位同步边界、最后以可复现测试验证修复。

7. 复习清单

  • 能用“同一 volatile(可见性关键字)写读 establishes happens-before(先行发生)”说明发布,不说“刷新所有缓存”。
  • 能区分 JMM(Java 内存模型)语言规则、编译器优化、CPU(中央处理器)乱序与缓存一致性。
  • 能画出四类抽象屏障,且说明它们不是固定汇编指令。
  • 能逐步演绎 count++、多变量库存不变式和 DCL(双重检查锁)半初始化风险。
  • 能解释非 volatile(可见性关键字)long(长整型)/double(双精度浮点数)的规范边界与现代实现事实不同。
  • 能把停止标志、配置热更新和库存扣减分别匹配到 volatile(可见性关键字)快照、锁或数据库条件更新。
  • 能按证据链排查空转、CPU(中央处理器)飙高、发布逃逸和 JMH(Java 微基准测试工具)误测。

8. 延伸阅读

9. 本章可复述结论

volatile(可见性关键字)是发布单一状态或不可变快照的工具;它提供可见性、有序性和单次访问边界,却不提供互斥与复合原子性。并发设计要先给不变式找线性化点:单变量条件替换用 Atomic(原子类)与 CAS(比较并交换),进程内复合状态用 synchronized(同步锁)或 Lock(锁接口),跨实例库存和资金以 MySQL(关系型数据库)条件更新、事务、幂等与对账为最终裁决。性能问题必须测量,不能把 x86(处理器架构)的偶然实现或微基准结果当作通用结论。

10. 综合口述题库

  1. 问题(核心语义):请完整解释 volatile(可见性关键字)的作用和边界。

    • 口述答案:volatile(可见性关键字)是 Java(编程语言)并发中的单变量同步机制。我会从写读关系讲:一个线程对同一 volatile(可见性关键字)变量的写,happens-before(先行发生)后续线程对它的读,因此写线程在发布标志前完成的普通写,对读到该标志的线程可见;同时发布前的写不能越过发布点,获取后的读不能越过获取点。它适合停止标志、一次性初始化完成标志和不可变配置引用。它不提供互斥,所以 count++ 的读取、加一、写回仍可被两个线程交错,最后发生丢失更新;也不能让库存可售量、冻结量和订单状态同时切换。工程上我把它定位为“发布事实”,不是“完成业务事务”:Runner(执行器)用它通知不再领取任务,配置中心用它一次替换完整快照;WMS(仓储管理系统)库存扣减仍要由 MySQL(关系型数据库)条件更新、流水唯一约束和事务裁决。这样既能讲清内存模型,也能说清一致性边界。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • volatile(可见性关键字)会刷新所有缓存吗?答:不会这样描述;JMM(Java 内存模型)只规定可观察的可见性和顺序,具体由 JVM(Java 虚拟机)与硬件实现。
      • volatile(可见性关键字)能保护两个字段吗?答:不能形成同一版本快照,应替换一个不可变对象或加锁。
      • 读到标志后为何能读到数据?答:因为写标志前的数据写与读标志后的数据读被同一同步变量的 happens-before(先行发生)连接。
    • 详细章节本章语义与屏障
  2. 问题(原子性):为什么 count++ 加上 volatile(可见性关键字)还是不安全?

    • 口述答案count++ 不是一次机器或语言动作,而是读共享值、在本地计算、写回结果三个阶段。假设初始值为零,两个线程都先读到零,各自计算一,随后先后写回一;volatile(可见性关键字)会让这些单次读写有可见性,却无法阻止两个线程基于同一个旧值计算,因此最终只有一而不是二。这说明“看得见”与“不能被插队”是两件事。若这是单一计数器,我会用 AtomicInteger(原子整数)的 CAS(比较并交换)更新:只有当前值仍等于预期值才替换,失败线程重新读取;若更新同时涉及可售量、冻结量、日志缓存等多个状态,我会用 synchronized(同步锁)或 Lock(锁接口)界定临界区。对于 WMS(仓储管理系统)库存,还必须将最终扣减下沉到 MySQL(关系型数据库)条件更新,用受影响行数判断是否成功,并写唯一库存流水。这样本地并发、跨实例并发和消息重投都有对应的事实边界。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • CAS(比较并交换)一定更快吗?答:高冲突下重试会烧 CPU(中央处理器),锁可能更合适。
      • LongAdder(长整型累加器)能替代库存吗?答:不能,它偏向统计吞吐,不提供库存判断与扣减的一次裁决。
      • 条件更新失败怎么办?答:区分库存不足和版本冲突,返回业务结果或按幂等策略重试。
    • 详细章节本章原子性边界
  3. 问题(底层机制):JMM(Java 内存模型)、编译器优化和 CPU(中央处理器)乱序分别解决什么问题?

    • 口述答案:JMM(Java 内存模型)是 Java(编程语言)给开发者的语言级合同,它定义多线程读允许观察到哪些写,以及 volatile(可见性关键字)、锁等同步动作如何建立 happens-before(先行发生)。编译器优化和 CPU(中央处理器)乱序是实现为了吞吐做的事情:编译器可能复用一次普通读取、调整互不依赖的普通操作;CPU(中央处理器)可能乱序发射,写先进入 Store Buffer(写缓冲区),缓存一致性再协调各核心副本。它们在单线程里不改变结果,却能让缺少同步的并发代码出现旧值或意外顺序。JMM(Java 内存模型)不要求“刷新所有缓存”,也不规定一条固定汇编指令;HotSpot(热点虚拟机)会在 x86(处理器架构)、ARM(处理器架构)等平台选择不同屏障或获取、释放序列,只要语言结果一致。工程实践不能依赖当前机器恰好较强的内存序,而要通过同步变量、锁或并发容器明确表达必须被观察到的顺序。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • 缓存一致性为何不够?答:它不提供多字段事务,也不替代语言同步和寄存器重用约束。
      • x86(处理器架构)强内存序能省同步吗?答:不能,语言可移植性与复合原子性仍需要正确同步。
      • 何时看反汇编?答:仅在特定版本和机器上验证性能假设,不作为语义依据。
    • 详细章节本章乱序背景
  4. 问题(屏障):四类抽象内存屏障怎样解释,为什么不能等同固定指令?

    • 口述答案:我把四类屏障当作排序方向的语言:LoadLoad(读读屏障)约束前读先于后读,LoadStore(读写屏障)约束前读先于后写,StoreStore(写写屏障)约束前写先于后写,StoreLoad(写读屏障)约束前写先于后读。volatile(可见性关键字)发布通常需要保证普通数据写在发布写之前,获取通常需要保证后续数据访问不跑到获取读之前,因此这些抽象关系能帮助解释发布—获取。它们不等于“清缓存”,也不等于某一条固定机器指令:编译器屏障约束编译器重排,硬件屏障或获取、释放指令约束 CPU(中央处理器)顺序;HotSpot(热点虚拟机)会按 JDK(Java 开发工具包)版本、x86(处理器架构)或 ARM(处理器架构)以及周边代码生成不同序列。稳定结论是同一 volatile(可见性关键字)写读建立 happens-before(先行发生),开发者只依赖这个合同;需要优化时才在固定环境中测量具体实现成本。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • StoreLoad(写读屏障)为什么常较重?答:它跨越写到后读的顺序,具体成本依架构和上下文而变。
      • 手工插屏障能修业务吗?答:Java(编程语言)业务代码应使用语言并发原语,不应依赖手工机器屏障。
      • 屏障能让两个字段原子吗?答:不能,排序不等于一个共同线性化点。
    • 详细章节本章四类屏障
  5. 问题(DCL):DCL(双重检查锁)为什么要让实例引用为 volatile(可见性关键字)?

    • 口述答案:DCL(双重检查锁)有两层目的:内层 synchronized(同步锁)保证只有一个线程创建实例,外层非空判断避免实例创建后的每次访问都加锁。风险在于对象创建不是抽象上的“一步”:需要分配存储、初始化字段、再把引用发布给其他线程。若共享引用只是普通字段,另一个线程可能先看到非空引用,却没有获得构造字段初始化的可见性,于是读到半初始化对象。把实例引用声明为 volatile(可见性关键字)后,构造线程写引用与读线程读引用建立 happens-before(先行发生),读取到非空引用就能看到发布前完成的初始化。这个修复只覆盖构造后的安全发布,不覆盖对象后续的可变业务状态;构造器也不能注册监听器、启动线程或把 this 交给外部。若没有延迟初始化的硬需求,静态初始化或枚举单例更短、更难写错。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • 内层锁既然存在,为何还不够?答:外层快速路径不加锁,读线程可能绕过内层锁。
      • final(最终关键字)字段能帮忙吗?答:有构造可见性语义,但不能替代动态引用发布或后续状态同步。
      • DCL(双重检查锁)能初始化配置吗?答:只能建单例;配置更新更适合不可变快照的一次替换。
    • 详细章节本章安全发布
  6. 问题(配置热更新):为何用一个 volatile(可见性关键字)配置快照引用,而不是多个 volatile(可见性关键字)字段?

    • 口述答案:多个 volatile(可见性关键字)字段只保证每个字段单独可见,不能保证读线程在同一次业务判断里看到同一版本。比如更新线程先把超时从三十改为四十五,再把路由从 A 改为 B;执行中的 Runner(执行器)可能读到“超时四十五、路由 A”,这在任何正式配置版本里都不存在。正确做法是先在本地构造不可变快照,校验超时、重试、路由规则、版本号以及嵌套集合都完整有效,然后把当前 volatile(可见性关键字)引用一次替换为新对象。新领取任务读取一次并绑定版本,运行过程中继续用旧快照;新任务使用新快照,所以重试和审计都有确定规则。这个方案只保证单 JVM(Java 虚拟机)安全发布,跨实例还需要版本分发、灰度确认、兼容窗口与回滚;影响库存、资金的配置版本还要进入持久化记录,不能只留在内存。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • 快照对象内部有可变列表怎么办?答:构造时复制并确保元素不可变或按业务深拷贝。
      • 要立即让旧任务停止怎么办?答:另定义取消协议和安全检查点,不能修改旧快照。
      • 多实例如何审计?答:任务记录保存配置版本,实例上报已应用版本和切换时间。
    • 详细章节本章配置快照
  7. 问题(库存):WMS(仓储管理系统)库存为什么不能只靠 volatile(可见性关键字)?

    • 口述答案:库存是典型复合不变式。一次扣减往往要读取可售量、判断是否足够、减少可售、增加冻结、写库存流水并推进订单状态;任意两个请求都可能在判断后交错。volatile(可见性关键字)即使让每次读写都很快可见,也不会把这些步骤变成一次不可分割的迁移;多个字段还会让读线程看到可售已经减少、冻结尚未增加的混合快照。我的设计是把 volatile(可见性关键字)或本地缓存用于查询展示和本实例读优化,但最终扣减由 MySQL(关系型数据库)条件更新完成,例如可售量不少于申请量才更新,受影响行数为一才算成功;同一事务内写唯一库存流水和订单状态,消息重投由业务唯一号幂等,超时由补偿和对账处理。这样实例扩容、缓存滞后和服务重启都不会把“内存可见”误当成“库存正确”。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。 同时以库存流水、订单状态与条件更新行数三方对账,防止只从内存观测得出错误结论。
    • 追问树(直接结论)
      • 本地锁有价值吗?答:可降低单实例热点竞争,但不能替代数据库最终裁决。
      • 条件更新后消息发送失败怎么办?答:使用事务内事件记录或可靠投递,再异步补发。
      • 缓存库存与数据库不一致怎么办?答:数据库为准,缓存失效或异步校正,不能反向覆盖事实。
    • 详细章节本章库存反例
  8. 问题(工具选型):volatile(可见性关键字)、Atomic(原子类)、synchronized(同步锁)和 Lock(锁接口)如何选择?

    • 口述答案:我先画出业务不变式,不先比较速度。若只是发布一个停止位或替换一个不可变对象引用,volatile(可见性关键字)最直接;若一个独立计数或状态需要“仍等于旧值才更新”,用 Atomic(原子类)和 CAS(比较并交换);若读取、校验和更新必须连同多个字段一起成立,用 synchronized(同步锁)或 Lock(锁接口)建立临界区。synchronized(同步锁)适合结构简单、异常自动释放的场景;Lock(锁接口)在需要可中断、定时尝试或多个条件队列时更灵活,但必须严格释放。若问题跨 JVM(Java 虚拟机)实例或要求资金库存最终一致,本地工具都不是最后裁决,应由 MySQL(关系型数据库)条件更新、事务、唯一约束和对账兜底。性能上,低冲突 CAS(比较并交换)可能好,高冲突会自旋;锁也有排队和唤醒成本。因此最终选型依据是可证明性、恢复路径、可观测性和测量结果的组合。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • volatile(可见性关键字)能替代锁吗?答:只能替代单变量发布场景,不能替代互斥临界区。
      • CAS(比较并交换)失败如何处理?答:重试、返回冲突或退避,取决于业务是否允许。
      • 锁内可调用远程服务吗?答:尽量避免,扩大持锁时间并可能造成级联阻塞。
    • 详细章节本章工具选择
  9. 问题(long/double):现代 JVM(Java 虚拟机)下还需要关心 long(长整型)和 double(双精度浮点数)撕裂吗?

    • 口述答案:需要关心的是规范边界,而不是恐慌。现代主流 JVM(Java 虚拟机)在常见六十四位 CPU(中央处理器)上通常会原子实现 long(长整型)与 double(双精度浮点数)读写,但 Java(编程语言)SE 21(Java 平台标准版 21)的 JMM(Java 内存模型)仍允许非 volatile(可见性关键字)的单次访问在规范上被拆成两个三十二位部分,因此共享值不能把“我的机器上通常没问题”当作合同。若一个时间戳、版本号或金额表示以单变量共享,我会用 volatile(可见性关键字)或锁确保单次读写的原子性和可见性;若它还参与“读取余额后扣减”之类读改写,原子读写也不够,仍要使用 Atomic(原子类)、锁或数据库条件更新。引用类型单次读写虽然原子,也不能保证对象内部完整初始化,所以对象发布还需安全发布协议。正确性依据永远是语言同步关系和业务不变式。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
    • 追问树(直接结论)
      • volatile(可见性关键字)long(长整型)递增安全吗?答:不安全,递增仍是读改写。
      • double(双精度浮点数)适合金额吗?答:不适合,金额应使用精确十进制或最小货币单位整数。
      • 只读时间戳要锁吗?答:若用 volatile(可见性关键字)安全发布单值通常不需要额外锁。
    • 详细章节本章规范边界
  10. 问题(性能扩展):什么是 false(假值) sharing(伪共享),为什么不能上来就用 @Contended(缓存行填充注解)?

  • 口述答案:false(假值) sharing(伪共享)发生在两个逻辑无关但高频写的字段恰好位于同一 cache line(缓存行)时。不同 CPU(中央处理器)核心虽然写的是不同变量,却要反复让整条缓存行在核心之间失效和转移,吞吐会下降。它不影响程序正确性,只是可能成为极端写竞争下的性能瓶颈。不能看到字段相邻就盲目加 @Contended(缓存行填充注解),因为对象布局受 JVM(Java 虚拟机)和参数影响,填充会增加内存,并且真正瓶颈常是锁竞争、CAS(比较并交换)重试、GC(垃圾回收)或 I/O(输入输出)。我会先定义真实的线程数和写比例,用 JMH(Java 微基准测试工具)做足预热、正确共享状态并消费结果,再结合线上 CPU(中央处理器)、延迟和剖析确认;优先减少共享写、分片累积或批量汇总,只有证据充分时才评估填充。这样避免为了局部纳秒优化引入长期内存成本。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
  • 追问树(直接结论)
    • cache line(缓存行)大小固定吗?答:常见大小不应写死为业务前提,应按目标平台核对。
    • 读多写少会严重吗?答:通常高频写更容易触发,需要用实际负载判断。
    • JMH(Java 微基准测试工具)结果为何不等于线上?答:线上还包括 GC(垃圾回收)、调度、网络和真实争用。
  • 详细章节本章性能边界
  1. 问题(优雅停止):Runner(执行器)如何实现可恢复的优雅停止?
  • 口述答案:我把优雅停止拆成“停止新领取、收敛已执行、恢复未完成”三段。停止新领取可用 volatile(可见性关键字)标志发布给工作循环,但工作线程必须在领取前和阻塞返回后检查;领取动作本身不能只靠内存标志,而要在任务表或队列中用条件更新把待执行改为执行中,并写入实例标识、租约和配置版本。收到停止后,实例不再认领新任务,已领取任务在截止时间内完成;超过期限的任务依赖租约过期,由其他实例在幂等约束下接管。热更新通过不可变配置快照一次替换,已开始任务保持原版本,新任务采用新版本,避免同一重试混用路由。排障时我会用停止请求时间、连续线程栈、认领记录、租约超时和重试数建立时间线,区别正常收敛、普通标志空转和外部依赖卡住,并将停止耗时与未收敛任务做成告警。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。 并在故障演练中验证租约接管不会重复产生外部副作用,保证停机与恢复能够审计。
  • 追问树(直接结论)
    • 停止后为何仍有任务完成?答:已在停止前原子认领的任务应允许按协议收敛。
    • 任务幂等如何保证?答:用业务唯一键、状态机与结果记录防止接管后重复副作用。
    • 强杀进程怎么办?答:依靠租约、持久化状态和补偿恢复,不依赖内存清理。
  • 详细章节本章排障与话术
  1. 问题(排障):线上 CPU(中央处理器)飙高时,怎样定位是否由停止标志或空转造成?
  • 口述答案:先不要直接修改字段。我要固定事故时间窗、JDK(Java 开发工具包)版本、镜像、实例和配置版本,然后从监控定位热点进程与线程,连续多次抓 jstack(线程栈工具)确认同一线程是否反复停在无阻塞循环。若栈显示循环读取普通 boolean(布尔值)停止字段,且停止请求日志早于循环持续时间,就重点审查该字段是否 volatile(可见性关键字)、写读是否同一对象、循环是否在每次迭代真实读取;若栈落在重试或队列轮询,要关联每秒失败数、退避时间和下游延迟,避免把重试风暴误判为内存可见性。止血可暂停非核心任务、限流、加退避或摘除实例;长期修复是建立明确的停止协议、阻塞等待或中断处理,并用并发测试验证截止时间内退出。最后增加循环率、停止耗时、热点线程数和重试次数指标,让下一次不必靠猜测。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
  • 追问树(直接结论)
    • sleep 能修空转吗?答:只能降低表象,不能建立普通字段的可见性。
    • 如何区分锁竞争?答:线程栈会显示阻塞或等待锁,且锁持有者和等待链可继续分析。
    • 为何要连续抓栈?答:单次快照不足以判断线程是瞬时运行还是持续卡在同一点。
  • 详细章节本章排障 SOP(标准操作流程)
  1. 问题(安全发布):什么是半初始化对象,如何预防和排查?
  • 口述答案:半初始化对象指读线程取得了对象引用,却没有按构造逻辑看到全部字段初始化后的状态。常见根因是构造过程中把 this 注册到监听器、提交到线程池、放进静态集合,或把完整对象写入普通共享引用;另一个线程便可能在构造尚未结束时访问它。预防的原则是对象构造期间不逃逸,构造完后通过 volatile(可见性关键字)引用、锁、线程安全容器或类初始化安全发布;配置和读取模型尽量设计为深层不可变快照。排查时先记录对象标识、字段组合、创建线程与发布时间,确认读到的是构造不可能产生的默认组合,再沿构造器和发布链搜索 this 逃逸、普通字段和异步回调。不能简单把每个字段都改 volatile(可见性关键字),那仍会产生混合快照;修复应把对象整体作为一个发布单元,并以压力测试多轮验证不再观察到异常组合。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
  • 追问树(直接结论)
    • 引用读写原子为何还出问题?答:引用原子只保证地址不撕裂,不保证对象字段安全发布。
    • final(最终关键字)字段有何作用?答:构造不逃逸时提供额外可见性,但不处理后续可变状态。
    • 单例是否天然安全?答:取决于初始化和发布方式,错误 DCL(双重检查锁)仍可能不安全。
  • 详细章节本章 DCL(双重检查锁)
  1. 问题(测试):如何设计一个可信的 JMH(Java 微基准测试工具)并发基准?
  • 口述答案:我先把问题定义清楚:测单线程 volatile(可见性关键字)读取、单生产者单消费者发布,还是多线程竞争计数,不能把三者混为一谈。随后选择正确的 JMH(Java 微基准测试工具)状态作用域:需要争用的变量必须真正被多个线程共享,不需要争用的对象不能意外共享;设置足够预热和多个分叉,让 JIT(即时编译)稳定;使用黑洞消费读取结果或让结果影响可见输出,避免死代码消除和常量折叠。报告中保留线程数、CPU(中央处理器)型号、JDK(Java 开发工具包)版本、堆设置、预热、测量轮数和分配率,并分别看吞吐和尾延迟。之后我会用线上剖析验证真实服务中是否真有相同争用:网络、数据库、GC(垃圾回收)、锁等待和调度都会改变结论。基准只能比较受控假设,不能证明业务并发正确,更不能以纳秒差异替代库存与资金的一致性设计。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
  • 追问树(直接结论)
    • 为什么要预热?答:让 JIT(即时编译)优化完成,避免把解释执行时间混入结果。
    • 为什么需要多个分叉?答:降低单次进程状态和偶然调度对结论的影响。
    • 能否在基准内访问数据库?答:通常应拆开,外部 I/O(输入输出)会掩盖待测的内存机制。
  • 详细章节本章 JMH(Java 微基准测试工具)排障
  1. 问题(项目串讲):请用一个案例串讲 volatile(可见性关键字)到库存一致性的方法论。
  • 口述答案:以跨境 WMS(仓储管理系统)为例,我先将问题分层。Runner(执行器)接收配置热更新和停机信号时,使用 volatile(可见性关键字)发布单一停止状态,以及一次替换不可变配置快照;工作线程读取到状态后不再领取,读取到快照后看到完整的超时和路由版本。这里的关键是同一同步变量的 happens-before(先行发生),不是刷新所有缓存。进入库存扣减后,边界改变了:可售、冻结、订单和流水必须同时满足,所以不能靠多个 volatile(可见性关键字)字段,也不能靠本地锁声称跨实例安全。我用 MySQL(关系型数据库)条件更新作为库存线性化点,成功后写唯一流水和订单状态,消息采用幂等投递,超时依租约和补偿恢复;缓存只加速读。性能优化时先测量 CAS(比较并交换)争用、锁等待和 false(假值) sharing(伪共享),不凭直觉加 @Contended(缓存行填充注解)。发生 CPU(中央处理器)飙高或状态错乱时,以线程栈、任务状态、条件更新和版本日志构造时间线。这个案例体现的是先证明正确,再局部优化,并让每个失败路径可恢复、可观测、可对账。 面试表达还应交代线性化点、失败后的恢复动作和可观测证据:我会记录状态版本、发起线程、完成时间与失败原因,并用并发测试覆盖两个请求交错、发布后立即读取、停止与重试同时发生等路径。只有这些交错下业务不变式仍成立,方案才可上线;若只能说明字段可见,却不能说明超时、重复和重启后的恢复,就不能把它视作完整的并发设计。 复盘时还要把告警阈值、回滚开关和审计记录前置,确保能还原谁在何时依据哪个版本作出决定,并把修复结果与线上指标闭环核对。
  • 追问树(直接结论)
    • 为什么配置用快照、库存不用?答:配置是进程内读取模型,库存需要持久化跨实例裁决。
    • 缓存能否作为库存成功依据?答:不能,缓存只用于性能,数据库条件更新为最终事实。
    • 停机时如何避免重复执行?答:任务认领、租约、幂等键和状态机共同保证。
    • 如何向面试官证明方案可靠?答:说明线性化点、失败恢复、指标、日志关联和对账闭环。
  • 详细章节本章项目话术