面试知识

01:线程生命周期与 Java(编程语言)内存模型

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

01:线程生命周期与 Java(编程语言)内存模型

你学完本章后,能够把“线程卡住、停止不下来、读到旧配置、单例偶发空指针”统一还原为状态、同步边界与发布关系问题,并给出可验证的线上证据。

1. 定位:先划清并发正确性的边界

本章讨论两件彼此相连但不能混为一谈的事:Thread(线程)什么时候运行、等待或终止;共享数据在没有正确同步时,其他 Thread(线程)为什么可能看不到、看错或看到不完整。前者是可观测的生命周期,后者是 JMM(Java 内存模型)规定的可见性与排序契约。JMM(Java 内存模型)不是 JVM(Java 虚拟机)的堆、栈、元空间等运行时内存区域,也不是某一种 CPU(中央处理器)的缓存手册;它定义 Java(编程语言)程序在不同编译器和硬件上的并发可观察行为。

并发正确性从低到高有三层:单次读写是否原子;一组操作能否作为不可分割的临界区完成;对象或配置是否在“构造完成后”才被其他 Thread(线程)取得。最后一层常被忽略,却最容易让锁写得“看似正确”而仍然出错。

1.1 用状态、所有权和发布关系建模

把共享数据的每一次跨 Thread(线程)交接画成一条边:谁创建、谁拥有修改权、哪条同步边把完成的状态交给谁。没有这条边,就算某次压测没有复现,也只是碰巧读到了新值。不可变对象把修改权收敛到构造期;锁把一段复合修改收敛到临界区;volatile(可见性关键字)把单个状态写交给后续读;并发容器把内部结构和发布边封装起来。

抽象层次要回答的问题常见手段典型误判
生命周期Thread(线程)此刻为何不能前进状态、栈、锁拥有者把 WAITING(无限等待)都当成故障
互斥与原子性多步更新能否被插入synchronized(同步锁)、Lock(锁接口)用 volatile(可见性关键字)保护 count++
可见性与顺序读者能否看到完整结果happens-before(先行发生)规则把“先执行”当成“必然可见”
所有权与发布谁可改、何时可读不可变对象、最终字段、容器构造中暴露 this(当前对象引用)

面试时先说清问题属于哪一层,再选工具。比如 WMS(仓储管理系统)库存扣减需要原子性和数据库最终约束;配置热更新主要需要一次完整快照的安全发布;Runner(执行器)停止主要需要中断协作和任务边界,而不是强杀 Thread(线程)。

热门面试题

  1. 问题(基础题):为什么不能把 JMM(Java 内存模型)理解为 JVM(Java 虚拟机)内存结构?

    • 考点:规范边界、运行时区域、跨硬件契约。
    • 回答思路:先区分“数据放在哪里”和“并发读写能观察到什么”,再说明这一区分如何指导排查。
    • 详细答案:JVM(Java 虚拟机)运行时数据区描述堆、栈、程序计数器等存储和生命周期;JMM(Java 内存模型)描述多个 Thread(线程)读写共享变量时,什么结果是合法的。主内存和工作内存是 JMM(Java 内存模型)的抽象,用来表达共享变量在不同 Thread(线程)中的可见性,并不等于一块物理内存、某级 CPU(中央处理器)缓存或寄存器。编译器可在不破坏单 Thread(线程)语义时重排,处理器可暂存或延迟传播写入;JMM(Java 内存模型)要求同步操作建立可证明的可见性和顺序。线上看到“新配置偶发没生效”时,不能只查堆大小,应检查配置对象是否完整构造、引用是否通过 volatile(可见性关键字)或锁发布、读者是否走同一同步边。它是跨硬件的语言契约,避免业务正确性依赖某台机器偶然的缓存行为。
    • 进阶追问:主内存和工作内存是否对应 CPU(中央处理器)缓存?
    • 进阶回答:不能一一对应。它们是规范中的可见性模型;实现可能利用寄存器、缓存、一致性协议和编译器优化,但 Java(编程语言)只承诺 JMM(Java 内存模型)允许的结果。把抽象直接等同某一缓存层,会在不同 CPU(中央处理器)架构和 JVM(Java 虚拟机)实现上得出错误结论。
  2. 问题(原理题):并发正确性为什么要先定义同步边界?

    • 考点:所有权、临界区、安全发布、最小共享面。
    • 回答思路:从共享状态的生产者和消费者出发,说明边界把可见性、原子性和资源释放串在一起。
    • 详细答案:同步边界是“从此处开始,外部可以观察内部完成状态”的明确承诺。若订单对象仍在填充金额、明细和路由规则时就交给异步 Thread(线程),消费者会面对半完成对象;若多个 Thread(线程)都能改库存快照,任何读写锁都难说明谁对版本负责。先规定对象构造期由一个拥有者独占,构造完毕后转为不可变快照;再规定发布引用时使用锁、volatile(可见性关键字)或线程安全容器;最后规定任务退出时谁负责关闭资源和确认停止。边界越小、共享面越窄,正确性越容易证明,锁竞争也越低。它不是“多加锁”,而是让每项共享状态都有唯一的修改协议和可验证的交接点。
    • 进阶追问:只读对象为什么仍要关心发布?
    • 进阶回答:只读只说明发布之后不再修改,不说明读者首次拿到它时已经看见构造期所有写入。没有安全发布,读者可能看到默认值、空集合引用或字段组合不一致;最终字段有特殊初始化安全性,但包含可变对象时仍要隔离其后续修改。
  3. 问题(项目题):WMS(仓储管理系统)库存快照怎样划分并发边界?

    • 考点:快照所有权、版本、发布、库存一致性边界。
    • 回答思路:区分内存快照与扣减事实,说明创建、发布、读取和失效四个阶段。
    • 详细答案:库存快照只能承担低延迟读取和预判,不能替代数据库扣减或预占的最终事实。刷新 Thread(线程)先在局部变量中完成版本、可售量、冻结量和更新时间校验,生成字段不可变的快照,再通过 volatile(可见性关键字)引用或 ConcurrentHashMap(并发哈希映射)一次替换。读请求只读取一个完整版本,不在快照上原地增减;扣减请求进入数据库条件更新或库存预占流程,并以版本号、幂等键和审计记录兜底。刷新失败则保留旧的最后成功版本并标记陈旧度,不能把半拉取数据发布为新快照。这样“内存读快”和“库存不超卖”分别有清楚所有者和同步边界,线上也能按快照版本与数据库流水比对。
    • 进阶追问:为什么不直接把可售量放进 AtomicLong(原子长整型)?
    • 进阶回答:AtomicLong(原子长整型)只能原子修改一个数,无法保证可售量、冻结量、版本、更新时间和规则来源作为一个业务快照同时一致。若业务只需要单变量限额可用它;库存事实涉及多字段和持久化约束,应以不可变快照发布加数据库条件更新组成分层方案。

2. Thread(线程)生命周期:状态不是调度结果的同义词

2.1 六种 Java(编程语言)状态与操作系统边界

Thread(线程)的 getState() 返回六种 Java(编程语言)层状态。RUNNABLE(可运行)同时覆盖“正在占用 CPU(中央处理器)”和“已就绪、等待操作系统调度”;因此它不等于一定在运行。BLOCKED(阻塞)专指等待进入 synchronized(同步锁)监视器;等待 Lock(锁接口)、队列、条件变量或 I/O(输入输出)时,栈和实现可能显示为 WAITING(无限等待)或 RUNNABLE(可运行),必须结合栈帧判断。

stateDiagram-v2
    [*] --> NEW: "new Thread(线程)"
    NEW --> RUNNABLE: "start(启动)"
    RUNNABLE --> BLOCKED: "等待 synchronized(同步锁)监视器"
    BLOCKED --> RUNNABLE: "取得监视器"
    RUNNABLE --> WAITING: "wait(等待)/ join(等待结束)/ park(挂起)"
    WAITING --> RUNNABLE: "notify(通知)/ unpark(恢复)/ 目标结束"
    RUNNABLE --> TIMED_WAITING: "sleep(睡眠)/ wait(等待)超时/ join(等待结束)超时/ park(挂起)超时"
    TIMED_WAITING --> RUNNABLE: "超时或通知"
    RUNNABLE --> TERMINATED: "run(运行)返回或未捕获异常"
    TERMINATED --> [*]

图解:节点是 Java(编程语言)公开状态;正常路径是 NEW(新建)调用 start() 后进入 RUNNABLE(可运行),获得调度并最终从 run() 返回。失败路径包括争抢监视器进入 BLOCKED(阻塞)、遗漏通知或永久条件不成立进入 WAITING(无限等待)、业务 Thread(线程)抛出未捕获异常直达 TERMINATED(终止)。面试结论是:状态只说明“在等什么类型的条件”,不能单凭 RUNNABLE(可运行)断言 CPU(中央处理器)忙,也不能单凭 WAITING(无限等待)断言死锁。

Java(编程语言)状态常见触发是否释放 synchronized(同步锁)监视器诊断重点
NEW(新建)已创建但未 start()未持有是否重复启动、是否忘记提交任务
RUNNABLE(可运行)执行或等待调度、部分本地 I/O(输入输出)取决于代码栈是否热点循环、系统调用、CPU(中央处理器)占用
BLOCKED(阻塞)竞争 synchronized(同步锁)监视器失败尚未取得- lockedwaiting to lock 的同一对象
WAITING(无限等待)wait()、无时限 join()park()wait() 会释放;park() 不会自动释放其他锁是否有唤醒方、条件是否永久不满足
TIMED_WAITING(限时等待)sleep()、带超时等待、定时挂起sleep() 不释放;wait() 会释放超时值、是否把超时当成业务成功
TERMINATED(终止)run() 返回或异常逃逸已释放栈上资源;外部资源未必关闭未捕获异常、任务统计、资源清理

平台 Thread(线程)映射到操作系统 Thread(线程);JDK(Java 开发工具包)21 起的虚拟 Thread(线程)由 JVM(Java 虚拟机)调度到少量载体平台 Thread(线程)。二者都遵循同一 Java(编程语言)状态枚举和 JMM(Java 内存模型)规则,但虚拟 Thread(线程)被阻塞时未必占住一个载体;在 synchronized(同步锁)临界区或本地调用中发生固定绑定时,载体可能不能卸载,诊断要同时观察虚拟 Thread(线程)栈和载体利用率。

热门面试题

  1. 问题(基础题):RUNNABLE(可运行)为什么不能证明 Thread(线程)正在使用 CPU(中央处理器)?

    • 考点:Java(编程语言)状态与操作系统调度的差异。
    • 回答思路:说明 RUNNABLE(可运行)的语义范围,再给出用状态加栈和系统指标联合诊断的方法。
    • 详细答案:RUNNABLE(可运行)表示 Thread(线程)在 Java(编程语言)层面可执行,既可能真的占用 CPU(中央处理器),也可能已就绪但还没被操作系统选中,或正停在某些 JVM(Java 虚拟机)未细分为等待状态的本地调用。它不是操作系统“running”状态的一对一镜像。排查 CPU(中央处理器)飙高时,先用 top -H 找高占用线程标识,再把十进制标识转换为十六进制,在 jstack(线程栈工具)中定位同一 Thread(线程)的重复栈;若多个 RUNNABLE(可运行)栈都在锁竞争、网络读取或短暂轮询,结论完全不同。状态是入口,持续采样的一致栈、CPU(中央处理器)时间和业务吞吐才构成证据链。
    • 进阶追问:虚拟 Thread(线程)出现 RUNNABLE(可运行)时如何解释?
    • 进阶回答:先看该虚拟 Thread(线程)的业务栈,再看载体平台 Thread(线程)是否繁忙或发生固定绑定。虚拟 Thread(线程)的可运行不等于独占一个内核 Thread(线程);若大量任务都固定在少数载体上,才需要进一步检查同步临界区和本地调用边界。
  2. 问题(原理题)start() 与直接调用 run() 的差异是什么?

    • 考点:创建边界、调度、线程启动规则。
    • 回答思路:比较调用栈、状态转换和内存语义,并说明重复启动的边界。
    • 详细答案start() 请求 JVM(Java 虚拟机)创建并调度新的执行 Thread(线程),原对象从 NEW(新建)转向 RUNNABLE(可运行),而调用 run() 只是当前 Thread(线程)的一次普通方法调用,不会产生并发、不会改变目标对象的生命周期。start() 前的动作通过线程启动规则对新 Thread(线程)中的动作建立 happens-before(先行发生)规则,但这不替代启动后共享可变状态的同步。一个 Thread(线程)对象只能成功启动一次,终止后也不能再次 start();可复用的是任务逻辑,不是 Thread(线程)实例。工程上优先把任务实现为 RunnableCallable 并交给受控执行器,便于命名、限流、取消和观测。
    • 进阶追问:守护 Thread(线程)适合做什么,不能做什么?
    • 进阶回答:守护 Thread(线程)适合缓存清扫、指标聚合等可随进程退出而放弃的辅助工作;当只剩守护 Thread(线程)时 JVM(Java 虚拟机)可直接退出,因此它不适合付款落账、库存回滚、文件落盘等必须完成或必须补偿的业务。关键任务要有显式停止、等待和恢复协议。
  3. 问题(项目题):Runner(执行器)任务异常退出后,怎样避免资源和状态泄漏?

    • 考点:异常边界、资源清理、任务终止、可观测性。
    • 回答思路:从任务入口统一捕获、资源释放、结果落库和未捕获异常四层回答。
    • 详细答案:Runner(执行器)把每个任务包装为统一入口:记录任务标识、开始时间和配置版本;在 try 中执行业务;在 finally 中关闭文件、连接和临时上下文,并写入结束时间;对可恢复异常按幂等策略记录重试而不是吞掉。若异常越过 run(),Thread(线程)会进入 TERMINATED(终止),但并不自动关闭数据库连接、归还外部锁或更新任务表,因此资源清理不能依赖“线程结束”。执行器还要设置未捕获异常处理器,把任务标识、异常和 Thread(线程)名写入告警;停止流程要区分“不再接新任务”“请求运行中任务停止”“等待期限”“超时后人工处置”,避免静默遗留孤儿任务。
    • 进阶追问:为什么不能用 Thread.stop() 终止卡住的任务?
    • 进阶回答:强制终止可能在任意指令点释放监视器,使共享对象处于只完成一半的状态,其他 Thread(线程)随后读取到破坏的不变量。正确做法是中断协作、超时边界和幂等补偿;对不可中断的外部调用要缩短超时并把执行隔离到可回收的进程或任务单元。

2.2 等待、通知与 interrupt(中断)的真实语义

wait()notify()notifyAll() 必须在同一对象监视器内调用。wait() 会原子地释放该监视器并进入等待集合;被通知后并非立即继续,而是先回到竞争监视器的路径。条件必须写在 while 循环里,原因是通知不携带业务含义、可能被其他等待者抢先消费,并且允许虚假唤醒。

sequenceDiagram
    participant P as "生产 Thread(线程)"
    participant M as "监视器"
    participant C as "消费 Thread(线程)"
    C->>M: "取得锁,检查条件为假"
    C->>M: "wait(等待),释放监视器"
    P->>M: "取得锁,写入数据"
    P->>M: "notifyAll(通知全部)"
    P->>M: "退出同步块,释放监视器"
    C->>M: "重新竞争监视器"
    C->>C: "while(循环)复检条件后消费"

图解:正常路径是消费者先在锁内检查条件、wait() 释放监视器,生产者写完数据后通知并退出,消费者重新拿锁再复检。失败路径是使用 if 而非 while,或先通知后等待导致信号丢失;还有生产者通知后仍长期持锁,消费者虽被唤醒却无法前进。面试结论是:通知不是“把任务交给某个 Thread(线程)”,它只提示状态可能变化;状态本身和复检条件才是正确性来源。

interrupt(中断)不是强制杀死 Thread(线程),而是一个协作取消请求。interrupt() 设置中断标记;处于 wait()sleep()join() 的 Thread(线程)会抛出 InterruptedException 并清除标记;park() 返回但通常保留标记。捕获异常后若本层不能完成取消,就恢复标记并向上返回;吞掉异常会让上层永久不知道取消请求。

flowchart TD
    A["收到 interrupt(中断)请求"] --> B{"当前处于可中断阻塞?"}
    B -->|"wait(等待)/ sleep(睡眠)/ join(等待结束)"| C["抛出 InterruptedException(中断异常),清除标记"]
    B -->|"普通运行或 park(挂起)"| D["设置或保留中断标记"]
    C --> E{"本层能完成清理并退出?"}
    D --> E
    E -->|"能"| F["释放资源,结束任务"]
    E -->|"不能"| G["恢复标记或向上抛出"]
    G --> H["上层决定取消与补偿"]
    A --> I["错误路径:吞掉异常后继续无限循环"]

图解:节点 A 是请求而非命令;正常路径是阻塞调用以异常或标记把请求交给业务循环,业务在安全点释放资源并结束。失败路径 I 是捕获 InterruptedException 后只打日志,随后继续等待或重试,停止请求被抹掉。面试结论是:中断处理策略必须沿调用链一致,最底层知道资源,最上层知道是否取消;两者不能各自假设对方已处理。

方法进入的典型状态是否释放监视器如何返回与 interrupt(中断)的关系
wait()WAITING(无限等待)或 TIMED_WAITING(限时等待)通知、超时、异常唤醒后重新竞争锁抛出异常并清除标记
sleep()TIMED_WAITING(限时等待)到时或中断抛出异常并清除标记
join()WAITING(无限等待)或 TIMED_WAITING(限时等待)调用方未因它释放其他锁目标终止、超时或中断抛出异常并清除标记
park()常见为 WAITING(无限等待)或 TIMED_WAITING(限时等待)unpark()、中断、虚假返回或到时返回,标记通常保留
notify()不直接决定状态仅在退出同步块后其他人可得锁任选一个等待者待竞争不替代中断或取消协议

热门面试题

  1. 问题(基础题)wait()sleep() 的核心区别是什么?

    • 考点:监视器、状态、唤醒方式、中断。
    • 回答思路:从调用前提和是否释放锁讲起,再补充状态、恢复路径及业务风险。
    • 详细答案wait() 是对象监视器的条件等待,调用前必须持有同一对象的 synchronized(同步锁)监视器;它会释放该监视器,让能改变条件的 Thread(线程)进入临界区。被 notify()notifyAll() 唤醒后,它还要重新获得锁并在 while 中检查条件。sleep() 是当前 Thread(线程)的定时暂停,不要求持锁也不会释放已持有的监视器,所以在同步块内长时间 sleep() 会无谓阻塞其他 Thread(线程)。两者都会因 interrupt(中断)抛出 InterruptedException 并清除标记。业务上等待共享条件应使用 wait() 或更高层条件队列;节流或退避才使用 sleep(),且不能把睡眠当作“别人一定完成了”的同步保证。
    • 进阶追问:为什么 wait() 必须用 while 检查条件?
    • 进阶回答:通知只表示条件可能变化,不保证当前 Thread(线程)醒来时条件仍为真;多个消费者可能竞争同一份数据,且规范允许虚假唤醒。while 在重新获得监视器后复检真实状态,条件不满足就继续等待,避免空消费、数组越界或业务重复。
  2. 问题(原理题):如何设计正确的 interrupt(中断)传播策略?

    • 考点:中断标记、异常清除、协作取消、调用链责任。
    • 回答思路:说明请求到达不同阻塞点的行为,再给出底层与上层各自的责任。
    • 详细答案:先把 interrupt(中断)定义为取消意图,而不是错误或强杀。循环任务在每轮和每个可控阻塞点检查中断状态;若 sleep()wait()join() 抛出 InterruptedException,本层若能关闭资源并结束,就执行清理后返回;若不能决定是否取消,就恢复中断标记并返回失败,让调用方看见请求。不要捕获后继续无限重试,也不要把异常转换为普通成功。对数据库、网络和队列等外部调用,还要设置它们自己的超时,因为中断不保证立即打断所有底层调用。最终由任务控制面记录“已请求停止、已停止、超时未停”三个不同状态,避免把请求发出误报成任务已终止。
    • 进阶追问Thread.interrupted()isInterrupted() 怎样选择?
    • 进阶回答:前者读取当前 Thread(线程)的标记并清除,后者读取指定 Thread(线程)的标记且不清除。业务循环通常用不清除的检查,除非明确要消费该信号;已经由异常清除标记时,若需要继续向上传播,就要显式恢复,而不能假设标记还在。
  3. 问题(项目题):怎样让 Runner(执行器)在发布停止命令后可靠退出?

    • 考点:停止信号、队列阻塞、幂等清理、超时升级。
    • 回答思路:给出从拒绝新任务到中断、等待、补偿和告警的完整流程。
    • 详细答案:Runner(执行器)先将运行开关以 volatile(可见性关键字)或锁保护的状态切换为“不接新任务”,再停止拉取新任务并对工作 Thread(线程)发送 interrupt(中断)。任务循环既检查停止标记,也把阻塞队列、定时等待和外部调用设置为有限超时;收到中断后在安全点保存断点、释放租约和 ThreadLocal(线程本地变量)上下文,并把任务状态写为可重试或已取消。控制面使用 awaitTermination 风格的有限等待,不把 WAITING(无限等待)当作成功;超时则输出尚未退出的任务标识、栈、租约和资源,并触发人工隔离或进程级回收。停止动作须幂等,重复下发不会把已完成任务重新标成失败。
    • 进阶追问:为什么只用一个 volatile boolean stopped 不够?
    • 进阶回答:它能让轮询循环看见停止意图,却不能唤醒已在队列、锁或外部调用中无限等待的 Thread(线程),也不能替代资源释放和任务状态持久化。停止协议至少还需要中断、超时、清理和完成确认。

3. JMM(Java 内存模型):从抽象可见性到 happens-before(先行发生)规则

3.1 主内存、工作内存与三大可观察性质

JMM(Java 内存模型)把实例字段、静态字段和数组元素这类共享变量放在主内存抽象中;每个 Thread(线程)拥有工作内存抽象,保存自己使用过的共享变量副本或结果。局部变量、方法参数和调用栈中的中间结果本身不被其他 Thread(线程)直接共享。实现可把它们放入寄存器、CPU(中央处理器)缓存、写缓冲或优化后的机器代码,但必须满足 JMM(Java 内存模型)的规则。

flowchart LR
    M["主内存抽象:共享配置版本与快照引用"]
    A["Thread(线程)甲工作内存抽象"]
    B["Thread(线程)乙工作内存抽象"]
    A -->|"读取、使用、赋值"| A
    B -->|"读取、使用、赋值"| B
    M -->|"读入共享变量"| A
    M -->|"读入共享变量"| B
    A -->|"同步后写回"| M
    V["volatile(可见性关键字)写:发布引用"] --> M
    M --> R["后续 volatile(可见性关键字)读:取得引用"]

图解:主内存和工作内存都是抽象节点,不应画成某个固定缓存层。正常路径是 Thread(线程)甲先完成局部构造,再以同步动作发布引用,Thread(线程)乙通过匹配的同步动作取得它。失败路径是普通写和普通读之间没有同步边,乙可长期使用旧副本或观察到重排后的状态。面试结论是:缓存一致性只能帮助硬件传播某些写,不自动赋予业务“完整发布”语义;语言级同步边才是证明依据。

原子性是动作不能被拆开观察,例如引用读写、部分基本类型读写以及受锁保护的临界区;可见性是一个 Thread(线程)的写对另一个 Thread(线程)何时可见;有序性是观察到的操作顺序满足程序与同步约束。竞态条件是结果依赖时序,例如“先检查库存再扣减”;数据竞争是两个 Thread(线程)未用同步访问同一变量且至少一个写入。顺序一致性要求所有动作好像按单一全局顺序执行并保持各 Thread(线程)内顺序,Java(编程语言)只有正确同步的程序才可获得接近这一推理模型,普通数据竞争程序不能依赖它。

| 性质 | 反例 | 解决的最小问题 | 不能单独解决 | | --- | --- | --- | | 原子性 | 两人都把 100 扣成 99 | 单变量原子更新或临界区不可分割 | 新值对读者何时可见、多个对象的一致发布 | | 可见性 | 停止标记已写,循环仍读旧值 | 写入能被匹配读观察 | count++ 的读改写原子性 | | 有序性 | 引用先可见、字段尚未初始化 | 禁止破坏发布的重排 | 业务步骤的互斥与失败补偿 | | 顺序一致性 | 各 Thread(线程)观察到彼此不同顺序 | 简化正确同步程序的推理 | 数据竞争代码的偶然结果 |

as-if-serial(如同串行语义)只约束单 Thread(线程)可观察结果:编译器可重排两条互不影响的语句,只要该 Thread(线程)看起来像按原程序顺序执行。跨 Thread(线程)可见性要靠 happens-before(先行发生)规则,不能用“源代码写在前面”推导。

**数据演绎一:普通写为什么可能读到旧值。**初始 running=true。时刻一,控制 Thread(线程)普通写 running=false;时刻二,工作 Thread(线程)在本地工作内存抽象仍反复读到 true;时刻三,它继续空转或阻塞前的循环。问题不在布尔写是否单次原子,而在写和读没有 happens-before(先行发生)规则。把引用声明为 volatile(可见性关键字),或用同一把锁写和读,才建立发布边;若循环还会阻塞,则还需 interrupt(中断)或超时使它有机会检查。

热门面试题

  1. 问题(基础题):原子性、可见性和有序性分别解决什么问题?

    • 考点:三大性质、边界、反例。
    • 回答思路:每个性质配一个最小反例,再说明工具组合而非单一关键字万能。
    • 详细答案:原子性防止一个逻辑动作被其他 Thread(线程)从中间插入,例如两个请求同时执行读、减、写导致库存丢失更新;可见性保证一个 Thread(线程)已经完成的写,另一个 Thread(线程)通过正确同步后能读到;有序性限制编译器和处理器为了优化而改变跨 Thread(线程)可观察结果的重排。volatile(可见性关键字)适合单写多读的标记和已完整构造对象的引用发布,提供可见性及相应排序约束,却不能让 count++ 变成原子;锁既提供互斥也在解锁与后续加锁间建立可见性;不可变对象减少后续同步需求但仍要安全发布。面试中要从业务不变量反推:库存余额与冻结量必须一起变化就选临界区或数据库条件更新,停止标记才可单独使用 volatile(可见性关键字)。
    • 进阶追问:64 位数值是否一定需要额外同步?
    • 进阶回答:不能把问题简化为位数。现代 Java(编程语言)对基本读写有明确规则,但业务仍可能需要可见性和复合原子性;即使单次读取不撕裂,读到旧值或“余额与版本不匹配”仍会出错。以共享协议而非数据类型大小决定同步方案。
  2. 问题(原理题):数据竞争和竞态条件有什么区别?

    • 考点:定义边界、时序依赖、同步缺失。
    • 回答思路:先给严格定义,再用库存检查扣减说明两者可重叠但不等价。
    • 详细答案:数据竞争是语言内存模型层术语:两个 Thread(线程)并发访问同一内存位置、至少一个写、且没有建立正确同步关系。竞态条件是更宽的业务问题:结果取决于操作交错时序,即使每个单变量访问都可见也可能发生。例如库存先读 available=1 再扣减,两个请求都通过检查后分别写回,既可能有数据竞争,也一定存在检查与执行未原子化的竞态;若把读取和写入都改为 volatile(可见性关键字),可见性变好,但两个请求仍能交错,所以竞态仍在。解决数据竞争可用安全发布或同步;解决竞态要把检查与状态迁移合并为原子事务、锁或条件更新。
    • 进阶追问:正确同步后是否绝不会有竞态?
    • 进阶回答:不会。同步只保证你定义的临界区或发布边;若锁粒度没有覆盖“检查、扣减、写审计”这一完整业务不变量,或者跨数据库与消息系统缺乏幂等和补偿,业务竞态仍可能发生。正确性边界必须对齐业务状态机。
  3. 问题(项目题):配置热更新如何避免读者看到旧值或半成品?

    • 考点:不可变快照、局部构造、引用发布、失败回退。
    • 回答思路:说明构造和发布分离、读路径只取一次引用、失败保留旧版本。
    • 详细答案:热更新 Thread(线程)不能边拉取边修改全局可变 Map(映射接口)。它应先在局部变量中校验版本、签名、字段范围和路由规则,构造不可变 ConfigSnapshot;全部成功后再一次性写入 volatile(可见性关键字)快照引用,或在写锁内替换引用。请求 Thread(线程)开头只读取一次快照引用,整次请求使用同一版本,避免路由和限额来自两个版本。更新失败时引用不变、记录失败原因和陈旧度;更新成功后记录新旧版本、发布时间和读取命中版本。这样普通读无锁而且能通过 volatile(可见性关键字)写读关系看到构造完成的快照;动态资源若不可变封装不了,则把它们的生命周期另行同步,不能误以为替换外层引用会自动修复内部可变状态。
    • 进阶追问:为什么读路径要只取一次快照引用?
    • 进阶回答:两次读取之间可能发生热更新,单个请求会把版本一的路由和版本二的限额拼成不存在的组合。将引用存为局部变量既明确请求级一致性,也减少 volatile(可见性关键字)读次数。

3.2 happens-before(先行发生)规则:证明,不是时间线

happens-before(先行发生)规则描述“前一个动作的结果必须对后一个动作可见,并且前者排序在后者之前”的规范关系;它不是墙上时钟的先后。两个动作即便真实时间上先后发生,若没有规则连接,后一个读仍不一定观察前一个普通写。反过来,只要规则建立,即使底层实现采用不同指令,程序都能按该关系推理。

flowchart LR
    A["初始化字段"] --> B["volatile(可见性关键字)写:发布引用"]
    B --> C["volatile(可见性关键字)读:取得引用"]
    C --> D["读取完整字段"]
    E["同一 Thread(线程)程序顺序"] --> A
    F["锁解锁"] --> G["同一锁后续加锁"]
    H["start(启动)前动作"] --> I["新 Thread(线程)内动作"]
    J["Thread(线程)内全部动作"] --> K["join(等待结束)返回"]

图解:A 到 B 到 C 到 D 是安全发布的传递推导,E 是程序顺序规则;F 到 G、H 到 I、J 到 K 分别展示锁、启动与终止规则。正常路径中读者通过同一同步机制取得引用;失败路径是甲用锁发布、乙完全不加锁地读,图中没有连接边,不能推导可见性。面试结论是:写下每条边并用传递性连通,比背“volatile(可见性关键字)很快”更能回答高级追问。

规则形式化直觉工程用途
程序顺序单一 Thread(线程)内前项先于后项先初始化字段,再发布引用
监视器锁解锁先于同一监视器后续加锁临界区交接复合状态
volatile(可见性关键字)对同一变量的写先于后续读开关、版本、不可变快照引用
Thread(线程)启动start() 先于新 Thread(线程)内动作启动前准备任务参数
Thread(线程)终止Thread(线程)内动作先于 join() 返回或观察终止汇总结果、回收资源后的读取
传递性A 先于 B、B 先于 C,则 A 先于 C组合构造、发布、消费链路
对象初始化构造器对最终字段的写有特殊保证不可变值对象的字段初始化
类初始化类初始化完成先于后续静态成员使用静态最终实例的安全发布

**数据演绎二:两条规则如何推导配置发布。**时刻一,刷新 Thread(线程)在程序顺序中写完 Snapshot 的字段;时刻二,执行 volatile 引用写;时刻三,请求 Thread(线程)执行同一引用的 volatile 读;时刻四,读取字段。时刻一先于时刻二,时刻二先于时刻三,时刻三先于时刻四,按传递性可得字段初始化先于字段读取。若时刻二是普通写,链条断裂;若读者读取的是另一份可变对象,链条也不能证明那份内部状态。

热门面试题

  1. 问题(基础题):happens-before(先行发生)规则是否等于真实时间先后?

    • 考点:规范关系、可见性、时间顺序误区。
    • 回答思路:先否定等同关系,再解释它提供的是可见性加排序保证,并举普通写反例。
    • 详细答案:不等于。happens-before(先行发生)规则是 Java(编程语言)内存模型中的偏序关系,含义是前者的结果必须对后者可见,并且不能把前者排到后者之后;它不记录两个 Thread(线程)在物理时钟上的绝对先后。控制 Thread(线程)即使先执行普通写 stopped=true,工作 Thread(线程)若没有通过锁、volatile(可见性关键字)或其他规则读取,就不能据此证明它会看到新值。反之,锁解锁与后续同锁加锁建立的关系允许不同 CPU(中央处理器)实现用各自机制满足可见性,而业务代码不必绑定某条机器指令。面试中应把“谁先运行”替换成“哪条规则把写与读连接起来”,这也是审查并发代码最可靠的方法。
    • 进阶追问:没有 happens-before(先行发生)规则是否一定读到旧值?
    • 进阶回答:不一定,可能读到新值、旧值或其他允许的结果;危险正是结果不可依赖。测试偶尔通过不能成为正确性证明,必须补上同步关系。
  2. 问题(原理题):怎样用传递性证明安全发布?

    • 考点:程序顺序、volatile(可见性关键字)规则、传递性、读路径。
    • 回答思路:按构造字段、发布引用、读取引用、使用字段四步建立边。
    • 详细答案:先限定对象在构造完成前不逃逸。发布 Thread(线程)中,字段赋值在程序顺序上先于 volatile(可见性关键字)引用写;该引用写先于消费者对同一引用的后续 volatile(可见性关键字)读;消费者读到引用后,在自己的程序顺序中再读字段。四段关系经传递性连接,消费者必须看到发布前完成的字段写。锁发布也同理:构造在解锁前,解锁先于同一锁后续加锁,加锁后读取字段。关键是消费者必须走匹配读:发布方用锁而消费者裸读,或发布方替换引用后仍修改对象内部可变集合,证明都会断裂。安全发布是全链路协议,不是给字段随手加一个修饰符。
    • 进阶追问:线程池提交任务能否形成发布边?
    • 进阶回答:任务提交前的动作与任务执行中的动作存在库规范定义的并发语义,任务执行完成与结果取得也有相应关系;但不要据此把任务内后续任意共享修改都视为已发布。任务运行期间向其他 Thread(线程)交接状态仍需自己的同步协议。
  3. 问题(项目题):异步任务上下文怎样既正确传递又不泄漏?

    • 考点:安全发布、不可变上下文、ThreadLocal(线程本地变量)清理、线程复用。
    • 回答思路:说明提交前冻结上下文、任务内显式安装、finally(最终清理)移除和异步边界。
    • 详细答案:请求 Thread(线程)在提交异步任务前提取追踪标识、租户、操作人和配置版本,复制为不可变上下文对象并作为任务构造参数,而不是让子任务事后读取会变化的全局变量。执行 Thread(线程)开始时安装上下文;因为线程池会复用 Thread(线程),必须在 finally(最终清理)中 remove(),避免下一个任务继承上一个租户或追踪标识。提交动作负责把提交前准备的数据交给任务,但上下文对象内部仍必须不可变或深度隔离;对 CompletableFuture(异步编排)等多级异步链,还要在每个切换执行器的边界显式传递。日志中记录任务标识、上下文版本和执行 Thread(线程)名,才能在串扰发生时证明是传播缺失还是清理缺失。
    • 进阶追问:为什么不能只依赖 InheritableThreadLocal(可继承线程本地变量)?
    • 进阶回答:它主要在创建子 Thread(线程)时复制上下文,线程池工作 Thread(线程)往往早已创建且被复用,既可能拿到过期值也可能污染后续任务。显式不可变参数或受控上下文包装器更可审计。

4. 安全发布:让对象先完整,再被看见

4.1 最终字段、不可变对象与双重检查锁

安全发布的目标不是“读者最终会看到”,而是读者第一次取得引用时就能依据规则看到完整、满足不变量的对象。可靠方式包括:在锁保护下构造并读取;将引用写入 volatile(可见性关键字)字段;类初始化期间创建静态最终实例;放入正确使用的并发容器;或利用最终字段初始化语义构造真正不逃逸的不可变对象。最终字段的特殊语义保护构造器写入的最终字段对正确取得对象引用的读者可见,但不能把构造中 this(当前对象引用)泄漏出去,也不让字段指向的可变集合自动不可变。

sequenceDiagram
    participant A as "Thread(线程)甲"
    participant R as "共享引用"
    participant B as "Thread(线程)乙"
    A->>A: "分配对象内存"
    A->>A: "构造字段:状态=READY(就绪)"
    alt "不安全:普通引用发布允许重排"
        A->>R: "先写引用"
        B->>R: "读到非空引用"
        B->>B: "可能读到默认状态"
    else "安全:volatile(可见性关键字)发布"
        A->>R: "写 volatile(可见性关键字)引用"
        B->>R: "读 volatile(可见性关键字)引用"
        B->>B: "读取 READY(就绪)状态"
    end

图解:正常路径是甲完成构造后以 volatile(可见性关键字)引用写发布,乙以同一引用读取得对象,构造写通过传递关系对乙可见。失败路径是普通引用写可与构造中的字段写发生对外可观察的重排,乙见到非空却读到默认字段。面试结论是:双重检查锁的外层空判断只是性能优化,真正的正确性来自内层锁和被 volatile 修饰的实例引用。

发布方式建立的关系适用场景边界
synchronized(同步锁)解锁先于同锁后续加锁多字段可变状态读写双方必须使用同一锁
volatile(可见性关键字)引用写先于后续同变量读不可变快照、停止标记不保护复合更新
静态初始化类初始化先于后续使用固定单例、常量表初始化逻辑失败会使类初始化失败
ConcurrentHashMap(并发哈希映射)容器操作的并发语义按键发布独立值对象值对象自身可变仍需协议
最终字段构造完成的最终字段可见性不可变值对象不允许构造期逃逸,深层可变状态仍危险

**数据演绎三:错误双重检查锁如何出现半初始化对象。**初始 instance=null。时刻一,甲检查为空并分配对象;时刻二,若实例引用不是 volatile(可见性关键字),优化可能让“写入引用”先于“构造字段”;时刻三,乙经过外层检查得到非空引用;时刻四,乙读取到 state=0 或空依赖,随后发生偶发空指针。修正后,实例字段声明为 volatile(可见性关键字),甲在同步块内再次检查并完成构造再发布;乙读到非空引用时,发布前字段写对它可见。外层检查减少初始化后每次取锁,内层检查防止两个 Thread(线程)都创建实例。

/** 配置单例,演示双重检查锁的安全发布边界。 */
public final class ConfigRegistry {
    private static volatile ConfigRegistry instance;
    private final String version;

    /** 创建完整且不可变的配置实例。 */
    private ConfigRegistry() {
        this.version = "v1";
    }

    /**
     * 返回唯一实例。
     * @return 已安全发布的配置实例
     */
    public static ConfigRegistry getInstance() {
        ConfigRegistry local = instance;
        if (local == null) {
            synchronized (ConfigRegistry.class) {
                local = instance;
                if (local == null) {
                    local = new ConfigRegistry();
                    // 只在对象完整构造后发布引用,volatile 保证发布语义。
                    instance = local;
                }
            }
        }
        return local;
    }
}

热门面试题

  1. 问题(基础题):最终字段为什么不能简单等同于不可变对象?

    • 考点:引用不可重新赋值、深层可变性、防御性复制。
    • 回答思路:区分字段引用和对象内容,再说明构造输入与访问输出都要隔离。
    • 详细答案:最终字段限制的是字段引用不能在构造后重新赋值,不限制引用所指对象的内部变化。final List 仍可通过别名增删元素,final byte[] 仍可修改数组格;因此真正不可变对象还要在构造时防御性复制可变输入、只暴露不可修改视图或副本、确保元素本身不可变,并且不提供改变状态的方法。最终字段还有构造期可见性的特殊语义,但前提是对象在构造完成前没有把 this(当前对象引用)交给其他 Thread(线程)。不可变对象将后续读共享变得简单,却不能挽救构造期逃逸或内部可变对象被外部同时修改的问题。
    • 进阶追问:静态最终单例为什么通常安全?
    • 进阶回答:类初始化由 JVM(Java 虚拟机)串行执行,初始化完成先于后续对该类静态成员的使用,因此完整构造的静态最终实例能安全发布。前提仍是构造逻辑不让 this(当前对象引用)逃逸,且实例内部可变资源另有同步策略。
  2. 问题(原理题):双重检查锁为什么必须使用 volatile(可见性关键字)?

    • 考点:重排序、半初始化对象、发布语义、内层检查。
    • 回答思路:拆分对象创建步骤,演绎读到非空但字段默认值的路径,再说明修复关系。
    • 详细答案:对象创建至少可概念化为分配空间、执行构造器写字段、把引用赋给共享变量。若共享变量是普通字段,编译器和处理器可在不影响创建 Thread(线程)单线程语义的前提下,让其他 Thread(线程)先观察到引用赋值,后观察到字段初始化。乙线程外层看到 instance != null 就跳过锁,可能使用半初始化对象。把实例引用声明为 volatile(可见性关键字)后,甲对它的写与乙对同一变量的后续读建立 happens-before(先行发生)规则,构造期间的写经程序顺序和传递性对乙可见。同步块内第二次检查保证竞争的两个 Thread(线程)不会都创建实例,外层检查只是避免已初始化后每次加锁。
    • 进阶追问:用静态内部类单例能避免双重检查锁吗?
    • 进阶回答:能。把实例放在静态内部类的静态字段中,利用类初始化的线程安全与延迟加载语义,代码更短且不需显式 volatile(可见性关键字)。若需要可替换、可重载的实例,仍要设计引用发布和生命周期。
  3. 问题(项目题):配置热更新中构造期间 this(当前对象引用)逃逸会造成什么事故?

    • 考点:构造逃逸、回调注册、半对象、发布顺序。
    • 回答思路:描述逃逸路径,再说明读者可见的半状态和改造方式。
    • 详细答案:常见逃逸是构造器中把 this(当前对象引用)注册到监听器、提交异步回调、放入静态集合,或调用可被子类重写的方法。另一个 Thread(线程)可能在构造器尚未写完路由表、超时和版本字段时取得引用,读到默认值或不一致组合;最终字段的初始化安全性也会被破坏。改造方法是让构造器只完成无副作用的字段赋值,把注册、启动和回调放到工厂方法中,并在完整对象创建后通过 volatile(可见性关键字)引用、锁或容器发布。配置对象本身保持不可变,动态订阅器由外层生命周期管理器持有。排查时比对异常请求的配置版本、构造日志与首次被消费时间,能确认是否存在发布早于初始化的路径。
    • 进阶追问:调用私有方法也会造成构造逃逸吗?
    • 进阶回答:私有方法本身不能被子类覆盖,风险较低;但只要它把 this(当前对象引用)传给其他 Thread(线程)、静态结构或延迟回调,仍会逃逸。判断标准是引用是否能在构造完成前被外部观察,而不是方法是否写在构造器内部。

5. 工程落地与线上排查:把状态变成证据链

5.1 两个完整案例与诊断 SOP(标准操作流程)

**案例一:WMS(仓储管理系统)库存快照。**刷新任务从数据库读取库存事实后,在本地建立不可变快照,校验版本单调递增和可售量非负,再用 volatile(可见性关键字)替换全局引用。下单读路径取一次本地引用,展示与预校验都使用相同版本;真正扣减使用数据库条件更新,例如“可售量大于零才扣减”,并记录预占流水。刷新超时或校验失败时保留上一个成功快照、暴露陈旧秒数并告警。这样快照的安全发布解决“读到半配置”,数据库条件更新解决“不能超卖”,两者不能互相替代。

**案例二:Runner(执行器)异步任务停止。**调度器先关闭接单开关,再向工作 Thread(线程)发送 interrupt(中断);任务把配置和上下文作为不可变参数传入,对阻塞队列、网络和数据库调用设置有限超时。每次循环检查停止标记,finally(最终清理)归还租约、清除 ThreadLocal(线程本地变量)并持久化断点。控制面在截止时间后采集未退出任务的 jstack(线程栈工具)、任务标识和锁信息,按业务类型决定重试、补偿或隔离;绝不把中断请求本身当作任务已经安全停止。

flowchart TD
    A["告警:任务不退出或延迟升高"] --> B["采集线程状态分布与任务指标"]
    B --> C{"BLOCKED(阻塞)是否暴增?"}
    C -->|"是"| D["jstack(线程栈工具)关联 waiting to lock 与 locked"]
    C -->|"否"| E{"WAITING(无限等待)是否有合法等待方?"}
    E -->|"否"| F["检查队列、条件、通知和停止信号"]
    E -->|"是"| G["核对吞吐、队列积压和目标任务状态"]
    D --> H["定位锁拥有者与临界区耗时"]
    F --> I["执行中断、超时、补偿或修复条件"]
    G --> I
    H --> I

图解:正常路径先按状态分流,再把状态和业务指标、栈、锁对象关联;WAITING(无限等待)可能是健康的消费者待命,必须看是否存在生产者与积压。失败路径是只看到 BLOCKED(阻塞)就盲目重启,未找到锁拥有者;或只看到 WAITING(无限等待)就误报死锁,忽略空闲队列。面试结论是:线程状态是索引,不是根因;至少需要状态分布、连续栈、锁关系、任务生命周期和业务指标五类证据。

症状jstack(线程栈工具)证据jcmd(JVM 诊断命令)/指标补证初步动作
BLOCKED(阻塞)暴增多个 waiting to lock 指向同一对象jcmd <pid> Thread.print -l、锁等待时间、请求延迟找唯一持锁者,缩短临界区或修复锁顺序
WAITING(无限等待)长期不变Object.waitpark、队列获取栈队列长度、生产速率、停止命令时间判断是空闲待命、遗漏唤醒还是取消未传播
RUNNABLE(可运行)高且 CPU(中央处理器)高相同热点栈反复出现top -H、多次线程栈、CPU(中央处理器)时间定位自旋、死循环、序列化或热点计算
Thread(线程)泄漏同类命名 Thread(线程)持续增长jcmd <pid> VM.native_memory summary、线程数监控查未关闭执行器、无限创建、阻塞外部调用
任务不退出中断后仍停在循环或外部调用停止耗时、在途任务、租约状态检查吞中断、无超时、finally(最终清理)遗漏

建议的 SOP(标准操作流程):第一步固定故障窗口并连续采集两到三份 jstack(线程栈工具),避免把瞬时状态当成常态;第二步用 jcmd <pid> Thread.print -l 获取锁信息并把线程标识与任务日志关联;第三步对 BLOCKED(阻塞)从等待者反向找持锁者,对 WAITING(无限等待)从等待条件正向找生产者、通知或取消源;第四步只做可逆止血,例如暂停新任务、降低并发、发中断、设置隔离,而不是直接强杀;第五步验证状态分布、积压、错误率和任务完成状态同时恢复,并保留栈与版本证据用于复盘。

从 JMM(Java 内存模型)到实现的边界要保持克制:缓存一致性协议负责让硬件缓存最终协同,memory barrier(内存屏障)限制某些读写和优化的重排,JVM(Java 虚拟机)会根据目标 CPU(中央处理器)生成合适实现;但不能把某条 happens-before(先行发生)规则硬说成永远对应某一条固定机器指令。不同架构、JDK(Java 开发工具包)版本和上下文可能不同,具体屏障类型与实现细节由同目录后续第 02 章展开。

热门面试题

  1. 问题(基础题):线上看到大量 WAITING(无限等待)是否就是故障?

    • 考点:状态语义、健康等待、证据链。
    • 回答思路:先说明合法等待场景,再给出区分空闲、积压和卡死的指标与栈证据。
    • 详细答案:不一定。空闲的消费者、线程池工作 Thread(线程)、定时协调器常在队列获取、park() 或条件等待处显示 WAITING(无限等待),这是节省 CPU(中央处理器)的正常行为。判断异常要看等待对象和业务背景:队列为空、吞吐平稳、等待者数量与核心线程数相称,通常健康;若队列积压持续增长、生产者已写入却消费者都停在同一条件、停止命令已发出却栈长期不变,则需要检查遗漏通知、锁顺序、吞掉的 interrupt(中断)和外部依赖。连续多份 jstack(线程栈工具)显示同一栈不变,加上队列长度、任务年龄和停止耗时,才足以把“等待”定性为故障。
    • 进阶追问:如何区分死锁和普通锁等待?
    • 进阶回答:死锁是等待环:甲持有锁一等锁二,乙持有锁二等锁一,线程栈工具通常能报告循环。普通 BLOCKED(阻塞)常有一个最终持锁者在运行或慢 I/O(输入输出);要沿锁对象回溯该拥有者及临界区耗时,不能只看等待者数量。
  2. 问题(原理题):为什么不能把 JMM(Java 内存模型)规则直接翻译成固定 CPU(中央处理器)指令?

    • 考点:语言规范、实现自由度、缓存一致性、内存屏障。
    • 回答思路:说明规范给出的是可观察结果,JVM(Java 虚拟机)再按平台选择实现。
    • 详细答案:JMM(Java 内存模型)是 Java(编程语言)对程序员的语义承诺,规定同步动作之间可见性和排序的结果;它没有要求所有平台以同一条汇编指令实现。JVM(Java 虚拟机)可能利用处理器的缓存一致性、不同强弱的内存模型、编译器屏障和 memory barrier(内存屏障)组合来满足承诺,同一 volatile(可见性关键字)读写在不同 CPU(中央处理器)架构、不同 JDK(Java 开发工具包)版本和不同优化上下文中都可能不同。把规范规则硬映射为单条指令会误导性能判断,也会让代码依赖非契约细节。工程代码只依赖 happens-before(先行发生)规则;做性能或汇编诊断时才限定版本、平台和测试条件分析实现。
    • 进阶追问:缓存一致性是否已经保证 Java(编程语言)可见性?
    • 进阶回答:不能这样推导。硬件一致性解决缓存行传播的一部分问题,不能替代编译器重排约束、复合操作原子性和语言级发布关系。Java(编程语言)代码必须使用其同步原语建立可证明语义。
  3. 问题(项目题):如何排查 Runner(执行器)发布停止后仍有任务不退出?

    • 考点:停止协议、中断吞没、线程栈、任务状态、分级处置。
    • 回答思路:按控制面记录、线程证据、阻塞点、资源状态和补偿决策顺序回答。
    • 详细答案:先确认停止命令是否真正投递到目标任务:记录命令时间、任务标识、Thread(线程)名和中断前后的状态。随后连续采集 jstack(线程栈工具),判断任务是在 sleep()、队列等待、锁竞争、网络调用还是业务循环;若栈显示捕获 InterruptedException 后继续循环,修复为清理后退出或恢复标记向上传播。若在外部调用,核对连接、读取和调用总超时,单靠中断未必能打断;若在锁等待,回溯持锁者。再检查 finally(最终清理)是否释放租约、清除 ThreadLocal(线程本地变量)并把状态持久化,避免“线程停了但任务仍运行”。最后按任务可重试性决定等待、隔离或补偿,验证在途数、租约、队列积压和线程数共同收敛。
    • 进阶追问:什么时候应该重启进程?
    • 进阶回答:当任务隔离和超时升级都无法回收、进程已无法提供可靠服务且已有幂等恢复与租约接管机制时,重启可作为最后止血。重启前要保留线程栈、命令时间、配置版本和在途任务清单;否则只会掩盖吞中断或锁泄漏根因,并可能重复执行副作用。

6. 项目面试话术与复习清单

6.1 可直接复述的项目话术

“在 WMS(仓储管理系统)里,我把库存读取和库存事实拆开:刷新线程先在局部构造不可变快照,校验完版本和数量后通过 volatile(可见性关键字)引用一次发布,请求只取一次版本;真实扣减仍由数据库条件更新和预占流水兜底,所以不会用内存快照承诺不超卖。在线上我会同时监控快照陈旧度、版本命中率、条件更新失败率和任务线程状态。另一个是 Runner(执行器)停止治理:我们不使用强杀,而是关闭接单、发送 interrupt(中断)、给阻塞调用设超时,并在 finally(最终清理)归还租约、清理上下文、保存断点。若任务不退出,就用 jstack(线程栈工具)和任务标识定位它在等锁、等队列还是吞掉了中断,再按可重试性做补偿。”

复习时可用四个自检问题:这份状态谁拥有修改权?发布边是什么?读者通过哪条规则取得完整状态?停止或失败后谁负责释放资源和补偿?能连续回答四个问题,才算真正掌握并发语义,而不是只会背关键字。

热门面试题

  1. 问题(基础题):如何用一句主线解释本章的并发正确性?

    • 考点:生命周期、同步边界、安全发布、可观测性。
    • 回答思路:把状态与内存语义合并为“完整状态的受控交接”,再点出诊断证据。
    • 详细答案:本章的主线是:并发程序先让每项共享状态有清楚所有者,再在完整构造或完整修改后通过锁、volatile(可见性关键字)、线程启动终止关系或容器建立交接边,读者才能可靠看到正确状态;Thread(线程)生命周期告诉我们交接者为什么暂时不能前进。原子性解决动作被插入,可见性解决新值能否被读到,有序性解决发布会不会被观察成半成品。线上不凭感觉判断,而是用状态分布、连续线程栈、锁对象、任务版本和业务指标验证这条交接链是否断裂。
    • 进阶追问:为什么这套主线适用于不同硬件?
    • 进阶回答:因为它依赖 JMM(Java 内存模型)的语言契约,而非某个 CPU(中央处理器)的缓存细节。JVM(Java 虚拟机)负责把契约映射到平台实现,业务代码只需证明同步关系。
  2. 问题(原理题):怎样审查一段并发代码是否存在发布漏洞?

    • 考点:逃逸检查、读写匹配、可变内部状态、传递推导。
    • 回答思路:先列共享引用,再逐条找构造、发布、读取和后续修改的路径。
    • 详细答案:先找所有跨 Thread(线程)共享的引用,包括静态字段、容器值、回调参数和任务参数;再检查对象是否在构造完成前注册、启动回调或交给异步执行器。随后写出发布动作和读动作:是否同一把锁、同一 volatile(可见性关键字)字段、类初始化或容器语义;读者是否绕开了该动作。最后检查对象内部的集合、数组和依赖是否仍可被其他 Thread(线程)修改,以及发布后是否要求多字段一致更新。能够从字段初始化一路推导到消费者读取,且所有可变状态都有自己的同步协议,才算没有明显发布漏洞。测试只能补充证据,不能代替这条证明。
    • 进阶追问:审查中最危险的代码形态是什么?
    • 进阶回答:构造器注册监听器、把 this(当前对象引用)传入异步回调、双重检查锁缺 volatile(可见性关键字)、全局可变 Map(映射接口)原地热更新,以及线程池中未清理 ThreadLocal(线程本地变量)。它们都让状态所有权或发布边变得模糊。
  3. 问题(项目题):面试官追问“如何证明你的并发改造有效”时怎么回答?

    • 考点:正确性证明、压测、观测、故障演练。
    • 回答思路:分为代码层证明、测试层覆盖、线上指标和可回滚发布四部分。
    • 详细答案:我先说明代码层的可证明关系:快照只在局部构造,发布引用是 volatile(可见性关键字),请求只读一次引用,因此字段初始化经 happens-before(先行发生)规则对读者可见;库存最终修改仍走数据库条件更新。测试层用并发读写和反复热更新覆盖版本一致性、停止信号和异常清理,不把一次成功当作结论。线上灰度时观测快照版本分布、陈旧度、库存条件更新冲突、线程数、BLOCKED(阻塞)比例、停止耗时和队列积压,并保留一键回退到上一个配置版本的能力。故障演练会模拟刷新失败、队列阻塞和中断到达,验证旧快照保留、任务清理和补偿链路。这让“有效”既有语义证明,也有运行证据。
    • 进阶追问:为什么压测通过仍不能证明没有内存可见性问题?
    • 进阶回答:数据竞争的错误依赖具体调度、优化和硬件时机,压测可能恰好没有触发。压测用于发现问题和验证指标,正确性仍要由同步关系和状态机边界证明。

题库使用说明

以下每题的口述答案均可独立练习,追问树用于检验边界条件;本小节不引入新的知识点。

7. 综合口述题与追问树

  1. 问题(线程状态):请完整说明 Java(编程语言)Thread(线程)六种状态,以及线上如何判断状态异常。

    • 口述答案:Java(编程语言)层的 Thread(线程)状态有 NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(无限等待)、TIMED_WAITING(限时等待)和 TERMINATED(终止)。NEW(新建)表示对象创建未启动;start() 后进入 RUNNABLE(可运行),它既可能在占用 CPU(中央处理器),也可能只是等待操作系统调度,因此不能把它直接当成运行中。BLOCKED(阻塞)专指等待 synchronized(同步锁)监视器;WAITING(无限等待)常来自 wait()、无超时 join()park();TIMED_WAITING(限时等待)常来自 sleep()、超时等待或定时挂起;run() 正常返回或异常逃逸后是 TERMINATED(终止)。排查时我不只看状态,而是连续采集线程栈:BLOCKED(阻塞)要把等待者和持锁者按对象关联,WAITING(无限等待)要看队列是否为空、是否有生产者和停止信号,RUNNABLE(可运行)要结合 CPU(中央处理器)线程占用和重复热点栈。还要把 Thread(线程)名、任务标识、队列积压和错误时间线关联:若状态长期不变而吞吐下降,才进入根因分析;若状态变化正常且业务无积压,就不应为了“状态好看”盲目干预。状态是定位索引,栈、锁、任务指标和连续性才构成根因证据。
    • 追问树
      • RUNNABLE(可运行)高是否一定 CPU(中央处理器)高?答:不一定,还可能是就绪等待调度或本地调用,要用 top -H 和连续栈验证。
      • WAITING(无限等待)何时正常?答:空闲消费者和线程池待命时正常,前提是队列、吞吐和任务年龄都健康。
      • BLOCKED(阻塞)与等待 Lock(锁接口)是否完全等价?答:不等价,BLOCKED(阻塞)专指监视器竞争,其他同步器常表现为 WAITING(无限等待)或 TIMED_WAITING(限时等待)。
    • 详细章节
  2. 问题(中断):interrupt(中断)为什么不是强制停止,正确处理方式是什么?

    • 口述答案:interrupt(中断)本质是协作取消请求,不是把 Thread(线程)在任意指令点杀掉。调用 interrupt() 后,普通运行中的 Thread(线程)获得中断标记;处于 wait()sleep()join() 的 Thread(线程)通常会抛出 InterruptedException 并清除标记;park() 会返回但标记一般保留。因此正确处理不是捕获异常后打印日志继续跑,而是先判断当前层能否安全结束:能结束就释放连接、租约和上下文,保存可恢复断点并返回;不能决定就恢复中断标记或向上抛出,让掌握任务语义的上层处理。对于网络、数据库等外部调用,还必须设置超时,因为中断不保证能立刻打断所有底层阻塞。Runner(执行器)的停止流程应包含停止接单、发送中断、等待期限、采集未退出栈和幂等补偿,最终确认任务状态,而不是把“已发送中断”误报为“已安全停止”。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 为什么捕获异常后要恢复标记?答:异常已清除标记,当前层若不退出又不恢复,上层会丢失取消意图。
      • 能否用 Thread.stop()?答:不能,它可能在不变量未完成时释放监视器,留下损坏共享状态。
      • isInterrupted()interrupted() 区别?答:前者不清除标记,后者读取当前 Thread(线程)并清除标记。
    • 详细章节
  3. 问题(内存模型):JMM(Java 内存模型)解决什么问题,主内存和工作内存是否是真实硬件?

    • 口述答案:JMM(Java 内存模型)解决的是多个 Thread(线程)读写共享变量时的可见性、原子性和有序性问题,它是 Java(编程语言)的并发语义规范,不是 JVM(Java 虚拟机)的堆栈划分,也不是某级 CPU(中央处理器)缓存说明。它用主内存和每个 Thread(线程)的工作内存抽象来表达共享变量怎样被读取、修改和对外可见;局部变量和调用栈数据通常天然私有。实际实现可能涉及寄存器、缓存、写缓冲、编译器优化和缓存一致性,但应用代码不能把抽象一一映射到硬件。JMM(Java 内存模型)的价值是让同一段正确同步的 Java(编程语言)代码在不同硬件上仍有一致的可推理结果:锁、volatile(可见性关键字)、线程启动终止和类初始化会建立可见性关系。排查“新配置偶发未生效”时,应检查对象是否完整构造、发布和读取是否走匹配同步边,而不是只猜测某台机器缓存没有刷新。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 局部变量需要 volatile(可见性关键字)吗?答:不需要,它不被其他 Thread(线程)直接共享。
      • 缓存一致性为何不够?答:它不替代编译器排序约束、复合原子性和语言级发布协议。
      • JMM(Java 内存模型)与 JVM(Java 虚拟机)内存区域关系?答:前者规定并发可观察语义,后者描述运行时数据组织。
    • 详细章节
  4. 问题(可见性):为什么一个普通停止标记可能已经写入却仍被工作 Thread(线程)读成旧值?

    • 口述答案:普通布尔写本身也许是单次原子动作,但原子不等于可见。控制 Thread(线程)把 running 从真改为假后,工作 Thread(线程)若在紧凑循环中反复读取普通字段,编译器可以把读取优化,处理器也可能继续使用工作内存抽象中的旧结果;两者之间没有 happens-before(先行发生)规则,程序就不能要求工作 Thread(线程)及时看到新值。修复取决于协议:单纯的单写多读停止标记可声明为 volatile(可见性关键字),让写与后续读建立可见性和排序关系;若停止还伴随多个资源状态变化,就把状态修改和读取放在同一锁协议内。还要注意工作 Thread(线程)若堵在队列或外部调用,仅让标记可见不够,它必须有机会返回检查,所以要配合 interrupt(中断)或有限超时。真正可运行的停止方案是可见性、唤醒、清理和完成确认四件事同时具备。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • volatile(可见性关键字)能保护 count++ 吗?答:不能,读改写是复合动作,仍会丢失更新。
      • 为什么循环里加日志后问题消失?答:日志可能偶然改变时序或引入同步,不能作为正确性修复。
      • 若 Thread(线程)在网络读取怎么办?答:设置读取超时、关闭可中断资源或隔离执行单元,不能只轮询标记。
    • 详细章节
  5. 问题(先行发生):请列出常用 happens-before(先行发生)规则并说明如何做传递推导。

    • 口述答案:常用 happens-before(先行发生)规则包括:单个 Thread(线程)内的程序顺序;解锁先于同一监视器的后续加锁;同一 volatile(可见性关键字)变量的写先于后续读;start() 前的动作先于新 Thread(线程)内动作;Thread(线程)内全部动作先于其他 Thread(线程)通过 join() 返回或观察到它终止;类初始化完成先于后续静态成员使用;以及传递性。传递推导最典型的是安全发布:甲先在程序顺序中写完快照字段,再对 volatile(可见性关键字)引用写;乙读取同一引用后再按程序顺序读字段,所以字段写先于字段读。这里的关键是每一步都必须真实存在,甲用锁写而乙裸读就缺少中间边,不能凭“甲确实先执行了”补上。happens-before(先行发生)规则是可见性和排序证明,不是时钟先后;没有关系时测试读到新值也只是偶然,不能写成业务前提。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 它是全序还是偏序?答:是偏序,并非所有动作都可比较。
      • 线程池提交前的写能否直接替代所有同步?答:不能,任务运行期间再向其他 Thread(线程)交接状态仍需要协议。
      • 发布方加锁、读方不加锁是否安全?答:不能证明,读方必须通过同一锁或其他匹配同步动作取得状态。
    • 详细章节
  6. 问题(安全发布):什么是安全发布,常用方式各有什么边界?

    • 口述答案:安全发布是让一个对象在完整构造后,被其他 Thread(线程)第一次取得引用时就能看到满足不变量的字段状态。常用方式有:在同一把锁的解锁和后续加锁之间交接;把完整构造的对象引用写入 volatile(可见性关键字)字段,读方读取同一字段;利用类初始化发布静态最终实例;通过 ConcurrentHashMap(并发哈希映射)等容器发布按键值对象;以及构造期不逃逸的最终字段语义。它们各有边界:volatile(可见性关键字)引用不保护后续多字段原地修改,锁要求读写双方都遵守同一把锁,容器只保护容器内部操作而不自动让值对象深度不可变,最终字段只保证构造期字段初始化而不防止数组和集合被别名修改。工程上最稳妥的模式是局部构造不可变快照,再一次替换共享引用;读者一次取引用后在本地使用,从而避免半对象和同一次请求混用两个版本。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 为什么不可变对象仍需发布?答:不可变只约束发布后不修改,不保证首次读取已见到构造期全部写。
      • 静态初始化有什么风险?答:初始化失败会导致类初始化失败,且内部可变资源仍需同步。
      • 并发容器的值可以随意改吗?答:不可以,容器发布不替代值对象自身的同步协议。
    • 详细章节
  7. 问题(双重检查锁):双重检查锁为什么会产生半初始化对象,如何修复?

    • 口述答案:双重检查锁把第一次空判断放在锁外以减少初始化完成后的加锁成本,再在锁内做第二次判断以防两个 Thread(线程)同时创建实例。它的风险不在第二次判断,而在对象创建可以概念化为分配空间、执行构造字段写、把引用赋给共享变量;若共享实例字段是普通字段,优化可能让其他 Thread(线程)先看到非空引用、后看到构造字段,于是乙绕过锁并读到默认值或空依赖。修复是把实例引用声明为 volatile(可见性关键字),甲在锁内完整构造后写引用,乙读同一引用;构造写经程序顺序、volatile(可见性关键字)写读和传递性对乙可见。更简单的固定单例可用静态内部类,让类初始化承担延迟且安全的发布。无论哪种方式,构造器都不能把 this(当前对象引用)注册到回调或异步任务,否则即使字段是最终字段也会破坏初始化边界。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 外层空判断是否负责正确性?答:不是,它主要是性能优化,正确性来自内层锁和 volatile(可见性关键字)发布。
      • 为什么内层还要再判空?答:避免多个竞争 Thread(线程)依次进入锁后重复创建实例。
      • 可否用枚举单例?答:可以,适合固定单例,但可替换配置仍需独立发布方案。
    • 详细章节
  8. 问题(构造逃逸):构造期间 this(当前对象引用)逃逸有哪些形式,为什么危险?

    • 口述答案:构造逃逸指对象尚未完成字段初始化时,this(当前对象引用)已经能被其他 Thread(线程)观察。典型形式包括构造器把自己注册到监听器或静态集合、提交包含自己的异步回调、启动内部 Thread(线程)、调用可被子类覆盖的方法,或把引用交给外部组件保存。危险在于消费者可能立刻读到默认数值、空集合或彼此不匹配的字段组合;最终字段的特殊初始化可见性也依赖对象没有在构造期逃逸。修复策略是让构造器只做无副作用的字段赋值,把注册、启动和订阅移到工厂方法或生命周期方法,先在局部完成不可变对象再用锁、volatile(可见性关键字)或容器发布。排查时我会寻找构造器中的回调、注册和线程启动,并把对象首次被消费的日志与构造完成日志对齐;如果消费先于完成,就不是偶发业务错误,而是发布边界错误。对复杂依赖,我会把对象创建、依赖注入和启动拆成明确阶段,并为每一阶段写可观测事件;这样既能避免构造器里产生副作用,也能在启动失败时按已完成阶段逆序释放资源。代码审查时尤其关注把 this(当前对象引用)传入集合、线程池、监听器和可覆盖方法的语句,因为它们最容易把一次偶发初始化异常变成长期并发缺陷。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 私有方法调用是否绝对安全?答:只有不向外部泄漏引用才安全,私有不等于没有异步副作用。
      • 最终字段能否修复逃逸?答:不能,逃逸破坏它依赖的构造完成前提。
      • 回调必须取消吗?答:不必取消,但必须在完整构造并安全发布后再注册。
    • 详细章节
  9. 问题(配置热更新):怎样设计安全、可回滚的配置热更新?

    • 口述答案:配置热更新的关键是把“构造新版本”和“发布新版本”分离。刷新 Thread(线程)先在局部变量中读取并校验版本、签名、字段范围和路由规则,构造不可变快照;全部成功后才通过 volatile(可见性关键字)引用一次替换,或在写锁内替换。请求 Thread(线程)在入口读取一次快照到局部变量,整次请求都使用该版本,避免路由来自旧版本而限额来自新版本。更新失败时不修改全局引用,保留最后成功版本,记录失败原因、陈旧度和来源;更新成功后记录新旧版本、发布时间和请求命中版本,支持回滚到已验证快照。若快照含有连接池等动态资源,则把资源建立、切换和关闭设计成独立生命周期,不能一边原地改集合一边让读者遍历。上线时我会先在少量实例灰度,比较新旧版本请求成功率、延迟、拒绝率和错误样本;达到阈值才扩大发布,任一关键指标回退就把引用切回上一个已验证快照。这样安全发布保证内存语义,版本化、灰度和回滚保证业务可控,也避免配置系统短暂不可用时放大为全量请求故障。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
    • 追问树
      • 为什么不原地修改全局 Map(映射接口)?答:读者可能遍历到半更新内容,且多字段规则难以形成一致版本。
      • 更新成功后旧资源何时关闭?答:等引用切换且在途请求完成或超时后按引用计数、租约或延迟回收关闭。
      • 读两次快照有何风险?答:两次之间可更新,单请求可能混用不存在的版本组合。
    • 详细章节
  10. 问题(库存快照):WMS(仓储管理系统)库存快照怎样与防超卖方案配合?

  • 口述答案:库存快照服务于读性能和预校验,不能成为库存扣减的最终裁决。刷新 Thread(线程)从数据库或库存服务获取事实,在局部校验可售量、冻结量和版本后创建不可变快照,再以 volatile(可见性关键字)替换引用;请求只读取一次引用,因此不会读到半构造对象或同请求混用版本。真正的防超卖放在持久化边界,例如带可售量条件的更新、库存预占流水、幂等键和订单状态机,使多个请求即使都从快照看到有货,也只有满足条件者能完成扣减。刷新失败时保留旧快照并暴露陈旧度,必要时降级为直查或限制下单;不能用空快照覆盖旧值。线上我会把快照版本、命中版本、条件更新失败率、预占超时回滚和库存差异对账串起来,并对高风险商品按库存阈值切换到更严格的预占或人工审核路径。故障恢复后需要以预占流水和订单状态做对账,发现快照与事实偏差不能直接覆盖,而要由明确的补偿任务修正。这样 JMM(Java 内存模型)负责快照完整可见,数据库事务负责最终不变量,两个层次边界清楚。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
  • 追问树
    • 快照可售量为正却扣减失败正常吗?答:正常,其他请求可能已抢先扣减,最终以条件更新为准。
    • 为什么快照对象应不可变?答:避免读者与刷新者并发修改同一对象,简化发布和版本一致性。
    • 如何处理陈旧快照?答:监控陈旧度,超过阈值降级直查、限流或暂停高风险下单。
  • 详细章节
  1. 问题(异步上下文):线程池中如何正确传递异步上下文并避免泄漏?
  • 口述答案:在线程池中,工作 Thread(线程)会复用,所以不能把请求上下文简单留在 ThreadLocal(线程本地变量)里并期待它自然消失。正确做法是提交任务前提取追踪标识、租户、用户和配置版本,复制为不可变上下文对象作为任务参数;工作 Thread(线程)开始时显式安装需要的上下文,在 finally(最终清理)里无条件 remove()。这样提交前的数据有清楚交接边,任务内读取的是固定版本,后续任务也不会继承前一个租户。对于多级异步编排,每次切换执行器都要继续传递,不能依赖 InheritableThreadLocal(可继承线程本地变量),因为线程池的工作 Thread(线程)通常早已创建。诊断时将任务标识、上下文版本和 Thread(线程)名写入日志;若发现跨租户日志或权限串扰,就能区分是提交时没冻结、执行时没安装,还是 finally(最终清理)没移除。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
  • 追问树
    • 为什么 finally(最终清理)必须移除?答:异常和取消同样会走 finally(最终清理),才能阻止复用 Thread(线程)污染。
    • 上下文可变会怎样?答:异步执行时可能读到提交后被修改的值,应使用不可变副本。
    • CompletableFuture(异步编排)需要额外处理吗?答:需要,每次异步切换都可能换执行器,应在边界显式包装。
  • 详细章节
  1. 问题(锁阻塞):如何处理线上 BLOCKED(阻塞)暴增?
  • 口述答案:BLOCKED(阻塞)暴增表示大量 Thread(线程)在等待 synchronized(同步锁)监视器,但它本身不是根因。我的处理顺序是先固定故障窗口并连续抓两到三份 jstack(线程栈工具),用 jcmd <pid> Thread.print -l 补充锁信息;从每个 waiting to lock 的对象标识反查 - locked 的持锁 Thread(线程),再看持锁者是在慢 I/O(输入输出)、大循环、外部调用、递归锁还是发生了锁顺序循环。若是一个全局锁把缓存刷新、数据库调用和序列化包在一起,先做可逆止血:暂停高风险刷新、限制并发、缩短外部超时;根治是缩小临界区、把外部调用移出锁、按数据分片或改用不可变快照。若工具报告死锁,则保留栈和对象证据,修正统一锁顺序而非简单加超时。恢复后必须验证 BLOCKED(阻塞)比例、请求延迟、队列积压和持锁耗时一起下降。 工程处置还必须遵循“先证据、后动作、再验证”的节奏:先保存连续线程栈、锁对象、任务标识和变更时间,避免重启或唤醒动作覆盖现场;再选择暂停入口、限流或隔离等可逆措施;最后同时核对状态分布、吞吐、延迟、队列积压和错误率是否收敛。若锁内涉及库存、支付或外部调用,还要核对幂等记录和补偿结果,不能只因线程数下降就宣布恢复。这样才能把一次锁竞争分析沉淀为可复现、可审计的运行手册。
  • 追问树
    • 能否直接重启?答:仅在服务无法恢复且已有恢复保障时止血,重启不能替代找持锁者。
    • 为什么连续抓栈?答:单次可能处于短暂竞争,连续相同栈才能证明长期阻塞。
    • Lock(锁接口)等待会显示 BLOCKED(阻塞)吗?答:不一定,常见为 park 的 WAITING(无限等待),需按栈判断。
  • 详细章节
  1. 问题(无限等待):怎样判断 WAITING(无限等待)是正常空闲还是任务卡死?
  • 口述答案:WAITING(无限等待)先要看等待语义而不是数量。线程池消费者在队列为空时停在 park() 或队列获取处通常健康;此时队列长度低、生产速率与消费速率平衡、任务年龄正常。若队列持续积压、生产者已经成功提交、消费者却全都停在同一条件,或停止命令已发出但 Thread(线程)长期不变,就需要沿等待条件找生产者、通知者或取消链。jstack(线程栈工具)能告诉我是在 Object.wait、AQS(抽象队列同步器)挂起、无时限 join() 还是外部库等待;再结合任务状态、队列指标、租约和日志判断是否遗漏 notifyAll()、条件从未变真、interrupt(中断)被吞掉或外部调用无限等待。处理上先让等待具备超时和可取消性,再修正条件状态与通知协议;不能为了让 WAITING(无限等待)数量好看而盲目唤醒或频繁轮询,那会把潜在死等变成 CPU(中央处理器)空转。 工程处置还必须遵循“先证据、后动作、再验证”的节奏:先保存连续线程栈、锁对象、任务标识和变更时间,避免重启或唤醒动作覆盖现场;再选择暂停入口、限流或隔离等可逆措施;最后同时核对状态分布、吞吐、延迟、队列积压和错误率是否收敛。若等待条件涉及库存、支付或外部调用,还要核对幂等记录和补偿结果,不能只因线程数下降就宣布恢复。这样才能把一次等待异常分析沉淀为可复现、可审计的运行手册。
  • 追问树
    • notify() 是否保证唤醒目标消费者?答:不保证,只随机选择一个等待者且其仍要竞争锁。
    • 为什么条件要用 while(循环)?答:通知和虚假唤醒不保证条件为真,必须重新检查。
    • 有超时是否一定安全?答:不一定,超时后要定义重试、失败或补偿,不能当作业务完成。
  • 详细章节
  1. 问题(线程泄漏):Thread(线程)泄漏如何发现和治理?
  • 口述答案:Thread(线程)泄漏是创建速度大于终止或复用速度,表现为进程线程数、内存占用和上下文切换持续增长,最终可能触及操作系统资源上限。发现时我会按线程命名前缀统计数量和增长率,连续抓 jstack(线程栈工具)看新增 Thread(线程)是否停在相同外部调用、无限等待或未关闭执行器,再用 jcmd(JVM 诊断命令)辅助观察进程线程与本地内存趋势。常见根因包括每个请求都 new Thread、为超时请求重复创建执行器、定时任务异常后重复注册、网络调用没有超时,以及守护 Thread(线程)误用于关键业务。治理上统一使用有界、命名的执行器,服务关闭时显式停止并等待;给外部调用设置连接和读取超时;对周期任务做单例注册和幂等保护;把线程数、活动数、队列长度、拒绝数与任务年龄纳入告警。还要区分 Thread(线程)泄漏和正常高并发:前者在流量下降后线程数仍不回落、栈长期固定,后者会随请求释放并回到基线。虚拟 Thread(线程)降低的是每个 Thread(线程)的承载成本,不取消无限创建任务、无限等待外部资源和缺少背压的风险;下游连接、数据库会话和文件描述符仍必须有独立上限。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
  • 追问树
    • 守护 Thread(线程)会自动解决泄漏吗?答:不会,它仍消耗资源,只是在进程退出时可被放弃。
    • 虚拟 Thread(线程)是否无需限流?答:不需要限线程不等于不需要限数据库、连接和下游并发。
    • 如何验证治理有效?答:验证线程数平台化、关闭后归零趋势、任务拒绝可见且延迟不劣化。
  • 详细章节
  1. 问题(综合设计):请设计一个可停止、可热更新、可排障的 Runner(执行器)。
  • 口述答案:我会把 Runner(执行器)拆成配置发布、任务执行和控制观测三条边。配置边由刷新 Thread(线程)局部构造不可变快照,校验版本后用 volatile(可见性关键字)替换引用;每个任务入口只取一次快照和不可变上下文,保证一次执行不混用版本。执行边使用有界队列和命名工作 Thread(线程),任务循环检查停止意图,所有队列、网络和数据库调用都有有限超时;收到 interrupt(中断)后在安全点保存断点、归还租约、清理 ThreadLocal(线程本地变量),finally(最终清理)保证异常路径同样完成。控制边先停止接单,再中断在途任务并等待期限,超时任务记录栈、锁、配置版本和租约进入补偿或隔离流程。观测上持续记录任务状态转换、队列积压、活动 Thread(线程)、BLOCKED(阻塞)比例、停止耗时和配置陈旧度;故障时按状态分布抓 jstack(线程栈工具),关联持锁者、等待条件和任务日志。发布和停止命令都要具备审计编号,便于证明某个任务看到的是哪一版配置、在哪个时刻收到取消请求;状态机只允许已领取、运行、停止中、完成、可重试等受控迁移。这样内存可见性、生命周期和业务补偿都有明确所有者,而不是靠一次重启碰运气。 在工程验证上,我会记录触发时刻、任务标识、配置版本、Thread(线程)状态和关键业务指标,并连续采样而非只看一次现象;修复先采用可回滚的限流、暂停或版本回退,再以线程栈、队列积压、错误率和最终业务对账共同确认效果。对会产生外部副作用的路径,还必须用幂等键、状态机和补偿记录约束重试,避免技术止血把一次故障扩大成数据重复或遗漏。这样既能区分偶发调度与稳定缺陷,也让变更在复盘中有可审计证据。
  • 追问树
    • 配置更新失败怎么处理?答:保留最后成功快照、记录失败与陈旧度,必要时降级或暂停接单。
    • 任务停止后如何防重复执行?答:用任务状态机、幂等键、租约和断点,把停止与重试的所有权持久化。
    • 如何避免全局锁?答:用不可变快照替换、按任务或键分片,并把外部调用移出临界区。
  • 详细章节

8. 本章复习清单

  • 能说出六种 Java(编程语言)Thread(线程)状态,并解释 RUNNABLE(可运行)与操作系统运行态的差异。
  • 能区分 wait()sleep()join()park() 的锁释放、唤醒和 interrupt(中断)行为。
  • 能解释 JMM(Java 内存模型)不是运行时内存区域,主内存与工作内存是并发语义抽象。
  • 能用原子性、可见性、有序性、数据竞争和竞态条件分析一个库存或停止信号问题。
  • 能完整列举 happens-before(先行发生)规则并从构造到读取做传递推导。
  • 能演绎普通引用发布、最终字段边界与 DCL(双重检查锁)半初始化对象风险。
  • 能给出 WMS(仓储管理系统)快照安全发布和 Runner(执行器)协作停止的项目表达。
  • 能按 SOP(标准操作流程)用 jstack(线程栈工具)与 jcmd(JVM 诊断命令)区分锁竞争、正常等待、线程泄漏和停止失败。