面试知识

04:CAS(比较并交换)、AQS(抽象队列同步器)与 Lock(锁接口)体系

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

04:CAS(比较并交换)、AQS(抽象队列同步器)与 Lock(锁接口)体系

学完本章,你能从一次原子更新讲到线程排队、挂起、唤醒和条件转移;遇到 CPU(中央处理器)飙高、锁等待、计数偏差时,也能给出证据链和止血动作。

1. 先定边界:原子更新不等于业务并发控制

1.1 CAS(比较并交换):三个值、线性化点与失败重试

CAS(比较并交换)把内存位置中的比较值 V(当前值)、期望值 E(预期旧值)和新值 N(拟写入值)交给硬件原子指令:仅当 V 等于 E 时写入 N,并返回成功;否则不修改并返回失败。成功写入那一瞬间是线性化点,即所有并发调用都可把该操作视为在这一点完成。它保证的是一个位置的条件更新,不会自动把“读取库存、校验订单、扣减余额、写审计”变成一个业务事务。

sequenceDiagram
    participant t1 as "线程甲"
    participant m as "内存值 V"
    participant t2 as "线程乙"
    t1->>m: "读取 V=100,准备 E=100、N=99"
    t2->>m: "读取 V=100,准备 E=100、N=98"
    t1->>m: "CAS(比较并交换)(100,99) 成功"
    t2->>m: "CAS(比较并交换)(100,98) 失败"
    t2->>m: "重新读取 99 后计算并重试"

图解:正常路径是线程甲在比较值仍为 100 时写入 99,线性化点在硬件完成条件写入时。失败路径是线程乙携带过期的期望值 100,失败后必须重新读取并按业务规则重新计算;不能把旧结果直接覆盖。结论是:CAS(比较并交换)适合短小、无副作用、可重试的内存状态变更,失败循环中不能执行扣款、发消息或远程调用。

维度CAS(比较并交换)互斥锁面试判断
冲突后的动作失败并由调用方重试排队或阻塞等待重试计算便宜时优先 CAS(比较并交换)
临界区范围一个位置或一个不可变引用可包住多步、多变量业务不变量跨字段时用锁或事务
线程成本高竞争时消耗 CPU(中央处理器)等待者通常挂起不能只比较“无锁”名称
副作用限制失败路径会重复执行临界区只成功执行一次重试体必须幂等且无外部副作用

下面伪代码省略具体内存屏障实现,强调为什么失败后必须重算:

int decrease(AtomicInteger stock, int amount) {
    for (;;) {
        int observed = stock.get();
        if (observed < amount) {
            return -1;
        }
        int next = observed - amount;
        // 只有观察值未被其他线程改变时,扣减才在线性化点生效。
        if (stock.compareAndSet(observed, next)) {
            return next;
        }
        // 失败说明依据已经过期;下一轮重新校验库存而非复用 next。
    }
}

热门面试题

  1. 问题(基础题):CAS(比较并交换)的三个参数和线性化点分别是什么?

    • 考点:条件写入、原子性边界、可观察顺序。
    • 回答思路:先定义 V、E、N,再说明成功与失败的返回语义,最后落到一次扣减何时生效。
    • 详细答案:V 是目标位置当前保存的比较值,E 是调用方基于读取结果带来的预期旧值,N 是希望写入的新值。硬件在不可分割的一步内比较 V 与 E;相等才写 N,否则不写。成功写入的时刻就是线性化点,其他线程只能观察到它之前或之后的状态,不能观察到半写入状态。以本地配额扣减为例,读取 10、计算 8 并成功写回 8 的瞬间才算占用成功;读取和计算本身都不算。若失败,说明这段期间已有别的成功者,必须重读并重新校验,不能把旧的 8 再写一次。
    • 进阶追问:CAS(比较并交换)是否让整个业务方法都原子?
    • 进阶回答:不是。它只原子保护指定内存位置的比较与写入。扣减后写数据库、调用支付渠道、发送 MQ(消息队列)仍可能失败或重复,必须由数据库条件更新、幂等键、状态机和补偿处理跨资源一致性。
  2. 问题(原理题):为什么 CAS(比较并交换)失败后不能只把新值再写一次?

    • 考点:过期快照、重算、业务约束。
    • 回答思路:说明失败代表什么,再用库存下限展示旧计算为何失效。
    • 详细答案:失败不是偶发噪声,而是“当前计算依据已经失效”的信号。假设可售库存为 1,线程甲与线程乙都要扣 1;甲成功写为 0 后,乙的期望值 1 已过期。乙若忽略失败直接写入自己算出的 0,表面数值没错,但若它在循环外已把订单标为成功,就产生了一笔无库存订单。更复杂的折扣、限额和状态迁移依赖当前值时,旧 N 更可能违背不变量。正确做法是在每轮读取后先验证剩余额度与状态,再计算 N;若无法安全重算,应退出循环改用锁、队列或持久化条件更新。
    • 进阶追问:无限重试有什么风险?
    • 进阶回答:会形成活锁(多个线程持续工作却没有整体进展)、饥饿(个别线程长期失败)和高竞争退化。应限定重试次数或时间,加入退避,把热点拆分,或转到排队锁;同时保留失败次数和重试耗时指标。
  3. 问题(项目题):支付渠道的单机并发额度能否只用 CAS(比较并交换)?

    • 考点:本地原子性、跨实例边界、失败回滚。
    • 回答思路:把本地快速预判与最终额度事实分层,并说明调用失败如何归还。
    • 详细答案:单实例内,渠道“在途请求数”可由 CAS(比较并交换)做短生命周期的预占:先判断未超过本机阈值,再加一,调用结束后在 finally(最终清理)中减一。它能避免一个进程内的突发放大,但不能代表集群总额度;多实例会各自成功预占,重启也会丢失内存状态。支付通道的最终日额度应由数据库条件更新或 Redis(远程字典服务)原子脚本维护,并按渠道流水做幂等。若远程调用超时,不能立即断言失败并归还全部额度,应先进入待查单状态,待渠道回调或主动查单确认后再做一次幂等释放。
    • 进阶追问:怎样避免 CAS(比较并交换)循环把渠道恢复后的流量瞬间打满?
    • 进阶回答:恢复时应采用漏斗式放量:按时间窗逐步提升并发阈值,失败率升高立即回退;本地计数只做舱壁,统一限流与熔断状态必须跨实例共享,并用渠道成功率、超时率和在途量驱动。

1.2 ABA(值变化后恢复问题)、自旋退化与版本戳

ABA(值变化后恢复问题)指线程甲先读到 A,暂停期间线程乙把 A 改为 B 又改回 A;甲的 CAS(比较并交换)看到值仍为 A 而成功,却不知道中间已经发生过一次语义变化。对只关心数值最终相等的计数器可能无害;对链表头、空闲对象栈、库存状态或账户版本则可能把已回收、已变更的对象当作原对象。AtomicStampedReference(带版本戳原子引用)把引用和值单调递增的版本戳一并比较,可识别 A→B→A。

sequenceDiagram
    participant a as "线程甲"
    participant r as "引用与版本戳"
    participant b as "线程乙"
    a->>r: "读取 A,版本 7"
    b->>r: "写入 B,版本 8"
    b->>r: "写回 A,版本 9"
    a->>r: "仅比较 A:会误成功"
    a->>r: "比较 A 与版本 7:失败并重读"

图解:正常路径是每次状态变更都推进版本戳,线程甲发现版本 7 已不再存在而重新读取。失败路径是只比较引用或数值,A 恢复后掩盖中间变更。结论是:版本戳解决“值相同但历史不同”,不解决跨实例、持久化或外部副作用问题。

现象根因证据处理方式
CAS(比较并交换)失败率高热点变量被大量线程竞争失败计数、CPU(中央处理器)火焰图中的自旋分片、退避、限次重试或改排队
个别请求极慢同一线程反复失败重试次数分位数、线程栈公平排队、降低热点、超时降级
值回到原值却状态错误ABA(值变化后恢复问题)版本与业务事件不连续版本戳、不可变对象或持久化版本号
所有线程都忙但无吞吐活锁(多个线程持续工作却没有整体进展)高 CPU(中央处理器)、成功率低随机退避、协调者或锁

热门面试题

  1. 问题(基础题):ABA(值变化后恢复问题)为什么不是所有 CAS(比较并交换)场景的缺陷?

    • 考点:值语义、历史语义、问题边界。
    • 回答思路:区分“最终值相等即可”和“对象身份或变更历史有意义”。
    • 详细答案:ABA(值变化后恢复问题)的危险来自业务把 A 当作“从未变化的那个 A”。若只是把一个统计值从 10 改到 11,而中间变化不会影响本次计算,重新回到 10 的历史未必重要;版本戳只会增加成本。反过来,链表节点可能在 A→B→A 期间被弹出、复用并重新压入,甲持有的 next(后继)指针已不属于原来的拓扑;库存状态从可售变冻结再可售也可能已经绑定不同订单。此时比较“值相等”不够,必须比较版本、唯一标识或持久化乐观锁版本。
    • 进阶追问:版本戳会不会溢出?
    • 进阶回答:理论上有限位数一定会回绕,所以它不是数学上无限历史。工程上选足够大的版本号、限制对象生命周期并在可控时间内不回绕即可;涉及持久化数据时通常使用数据库版本列或全局事件序列,而非依赖很小的内存戳。
  2. 问题(原理题):为什么“无锁”CAS(比较并交换)也可能比锁更慢?

    • 考点:缓存争用、自旋、调度成本、吞吐退化。
    • 回答思路:说明失败不阻塞线程但会反复占用 CPU(中央处理器),再比较排队挂起。
    • 详细答案:无锁描述的是算法不必由一个互斥锁守护,不代表没有协调成本。多个核心同时修改同一缓存行时,成功者会让其他线程携带的期望值全部过期;失败者重新读、重算、再发起原子指令,形成缓存一致性流量和 CPU(中央处理器)自旋。竞争越高,单位成功更新消耗的失败尝试越多,尾延迟也更差。锁在低竞争时也有开销,但高竞争时 AQS(抽象队列同步器)会让大多数等待线程 park(挂起),减少无效计算。因此应按临界区长度、冲突率和是否能安全重试选择,而不是把 CAS(比较并交换)当成永远更快。
    • 进阶追问:怎样识别饥饿(个别线程长期得不到进展)?
    • 进阶回答:把每次操作的重试次数、等待时长和成功率按线程或请求维度聚合;若总体吞吐正常但少数请求重试远高于平均值、长期超时,即可能饥饿。公平锁能改善等待上界,但会牺牲吞吐,热点拆分通常更根本。
  3. 问题(项目题):WMS(仓储管理系统)热点库存为什么不能依赖 AtomicStampedReference(带版本戳原子引用)解决超卖?

    • 考点:进程边界、持久化事实、版本校验。
    • 回答思路:先承认它能解决本地内存 ABA(值变化后恢复问题),再指出集群和数据库边界。
    • 详细答案:AtomicStampedReference(带版本戳原子引用)只能保护一个 JVM(Java 虚拟机)进程内的一对引用与戳,无法覆盖多个应用实例、缓存失效、服务重启、数据库写入和消息重复消费。WMS(仓储管理系统)库存的最终防线应是数据库条件更新,例如可售量大于等于扣减量时才更新,并以订单行唯一键保证重试幂等;内存 CAS(比较并交换)或版本戳只适合本机预筛和降冲突。若数据库更新成功而后续消息投递失败,应由事务外发件箱或补偿扫描恢复事件,不能因为内存戳正确就宣称库存链路一致。
    • 进阶追问:热点 SKU(库存单位)如何降低数据库冲突?
    • 进阶回答:可按仓库、批次或可分配桶拆分库存,先做有界本地预判和排队削峰,再批量或顺序写入数据库;但每个桶都必须有持久化条件约束,最终汇总不能突破总可售量。

2. 原子类与高竞争统计:精确读和高吞吐不是同一目标

2.1 Atomic(原子类)家族、Unsafe(非安全底层工具)与 VarHandle(变量句柄)边界

AtomicInteger(原子整数)、AtomicLong(原子长整型)和 AtomicReference(原子引用)提供单变量原子读改写;AtomicStampedReference(带版本戳原子引用)额外比较版本戳。JDK(Java 开发工具包)8 的原子类实现主要依赖 Unsafe(非安全底层工具)提供的内存偏移和 CAS(比较并交换)能力。JDK(Java 开发工具包)9 以后公开的 VarHandle(变量句柄)提供受支持的变量访问句柄与不同内存语义;库实现可以演进,但业务代码不应依赖 Unsafe(非安全底层工具)的私有字段或偏移量。JDK(Java 开发工具包)17、JDK(Java 开发工具包)21 使用时应面向公开 API(应用程序接口),不把内部字段名当作稳定契约。

工具原子对象适合场景关键边界
AtomicInteger(原子整数)一个 int(整数)状态位、本机短计数不能原子维护多个字段
AtomicLong(原子长整型)一个 long(长整数)精确序号、精确配额热点写会竞争同一位置
AtomicReference(原子引用)一个引用不可变配置快照替换引用内可变字段仍需隔离
AtomicStampedReference(带版本戳原子引用)引用与版本戳需识别 ABA(值变化后恢复问题)不是分布式版本控制
VarHandle(变量句柄)字段、数组元素等库级并发结构与内存语义不替代业务不变量设计
flowchart TD
    a["单个数值需要精确瞬时读取"] --> b["AtomicLong(原子长整型)"]
    c["替换完整不可变快照"] --> d["AtomicReference(原子引用)"]
    e["需要识别值恢复"] --> f["AtomicStampedReference(带版本戳原子引用)"]
    g["高频写、读取允许汇总误差"] --> h["LongAdder(长整型累加器)"]
    i["跨实例库存或额度"] --> j["持久化条件更新或分布式协调"]

图解:正常路径按读写语义选工具,精确单点读取走 AtomicLong(原子长整型),统计写热点走 LongAdder(长整型累加器)。失败路径是把本地原子类当成跨实例事务,或把汇总值当成精确余额。结论是:选择先问“读必须多准、写有多热、状态跨不跨进程”,再问 API(应用程序接口)名称。

热门面试题

  1. 问题(基础题):AtomicReference(原子引用)为什么常用于配置发布?

    • 考点:不可变快照、一次替换、安全发布。
    • 回答思路:把多字段配置封装为不可变对象,再用一次引用替换发布。
    • 详细答案:配置包含阈值、渠道路由、灰度比例和生效版本等多个字段时,逐字段更新会让读者看到混合版本。可先在局部构造并校验一个不可变配置快照,再通过 AtomicReference(原子引用)的条件替换或直接设置一次发布;读者只获取一个引用并按该快照执行,因此不会看到“新阈值配旧路由”。它解决的是进程内发布原子性,不解决配置中心多实例的一致到达;跨实例仍需版本号、拉取确认、回滚策略和最后成功快照。
    • 进阶追问:引用原子替换后,旧快照什么时候释放?
    • 进阶回答:当没有线程再持有旧引用时由 GC(垃圾回收)回收。快照本身不能持有需立即关闭的共享资源;若包含连接池或文件句柄,应设计引用计数、延迟关闭或生命周期管理,避免旧读者仍在使用时提前销毁。
  2. 问题(原理题):Unsafe(非安全底层工具)和 VarHandle(变量句柄)的使用边界是什么?

    • 考点:公开契约、模块边界、内存语义。
    • 回答思路:说明 JDK(Java 开发工具包)内部历史实现,再给出业务代码应依赖的公开能力。
    • 详细答案:Unsafe(非安全底层工具)暴露了内存偏移、对象字段访问和 CAS(比较并交换)等低层能力,早期 JDK(Java 开发工具包)并发包用它实现高性能原子操作,但它不是普通业务稳定接口,受模块封装和版本演进影响。VarHandle(变量句柄)是 JDK(Java 开发工具包)9 以后提供的公开抽象,能够表达普通、易失、获取、释放和原子更新等访问模式。业务开发优先使用 Atomic(原子类)、Lock(锁接口)和并发集合;确需自建基础组件时用 VarHandle(变量句柄)并明确内存语义。不要通过反射拿 Unsafe(非安全底层工具)或硬编码字段偏移,因为 JDK(Java 开发工具包)升级可能直接破坏。
    • 进阶追问:VarHandle(变量句柄)是否自动让复合业务操作原子?
    • 进阶回答:不会。它提供的是字段级访问和原子原语,仍需调用者围绕读、校验、写建立循环或锁,并保证失败重试没有副作用;跨字段约束仍要封装为不可变对象、锁或事务。
  3. 问题(项目题):Runner(执行器)如何用 AtomicInteger(原子整数)表达启动门闩?

    • 考点:状态机、并发启动、失败复位。
    • 回答思路:使用明确状态而非布尔值,并说明异常、重启和多实例边界。
    • 详细答案:Runner(执行器)可把本机启动状态定义为 0 未启动、1 启动中、2 已就绪,使用 AtomicInteger(原子整数)仅允许 0→1 的 CAS(比较并交换)成功者执行初始化,其他线程直接观察启动中或已就绪。初始化成功后写 2;失败必须记录原因并把状态恢复为 0 或进入失败态,不能永久卡在 1。这个门闩只防止同一进程内重复启动;集群中同一任务只能由一个实例领取时,应依赖任务表租约、唯一约束或分布式锁,并对持锁实例崩溃设计租约超时和接管。
    • 进阶追问:为什么不用一个 volatile(可见性关键字)布尔值?
    • 进阶回答:volatile(可见性关键字)可见但“判断未启动再置启动中”是两步,多个线程仍会同时通过。CAS(比较并交换)把比较和状态迁移合成一个原子线性化点;若启动还需要等待所有工作线程到位,则 CountDownLatch(倒计时门闩)更贴合语义。

2.2 LongAdder(长整型累加器):Striped64(条带化累加基类)与 CounterCell(计数单元)

LongAdder(长整型累加器)不是把一个计数器更新得更“原子”,而是把写热点分散。它继承 Striped64(条带化累加基类)的思路:低竞争时先更新 base(基础值);CAS(比较并交换)冲突后,按线程探针选择 CounterCell(计数单元)数组中的一个单元更新,必要时初始化或扩容单元数组。sum(求和)读取 base(基础值)和全部 CounterCell(计数单元)后相加,读取期间其他线程仍可写,因此它适合高吞吐统计而非“此刻余额必须精确”的判定。

flowchart LR
    t1["线程甲"] --> b["base(基础值)"]
    t2["线程乙"] --> c1["CounterCell(计数单元)一"]
    t3["线程丙"] --> c2["CounterCell(计数单元)二"]
    t4["线程丁"] --> c3["CounterCell(计数单元)三"]
    b --> s["sum(求和)汇总"]
    c1 --> s
    c2 --> s
    c3 --> s

图解:正常路径是低竞争写 base(基础值),冲突时写入不同 CounterCell(计数单元),读取再汇总。失败路径是把 sum(求和)结果用于库存阈值或发号,读取期间的并发写入会让结果不是单一时刻的精确快照。结论是:LongAdder(长整型累加器)用空间和读取汇总成本换取写入吞吐。

需求AtomicLong(原子长整型)LongAdder(长整型累加器)建议
全局递增序号每次读都精确不保证连续语义选 AtomicLong(原子长整型)或数据库序列
余额、库存判断可在单变量上精确 CAS(比较并交换)sum(求和)不是判定快照选条件更新或锁
QPS(每秒查询率)统计热点写会反复失败多单元降低冲突选 LongAdder(长整型累加器)
IoT(物联网)报警窗口计数单点设备可能热点分片写入吞吐好选 LongAdder(长整型累加器),定期归并

逐步演绎:四个线程同时把 0 加一。第一轮均尝试更新 base(基础值),仅甲成功;乙、丙、丁冲突后发现单元数组为空,某一线程初始化两个 CounterCell(计数单元);下一轮它们按各自探针落到不同单元,仍冲突的线程再换槽或扩容。读者执行 sum(求和)时读到 base=1、各单元为 1、1、1,得到 4;若读取和最后一次写并发,可能暂时读到 3,但不会出现结构损坏。

热门面试题

  1. 问题(基础题):LongAdder(长整型累加器)为什么不适合库存扣减?

    • 考点:精确读、汇总非原子、业务不变量。
    • 回答思路:说明写入分散的收益,再解释 sum(求和)不是可线性化的扣减前判断。
    • 详细答案:库存扣减要保证“判断余量足够”和“减少余量”对应同一个业务事实,否则两个请求都可能依据同一个旧余量放行。LongAdder(长整型累加器)的 add(累加)把写分散到 base(基础值)和多个 CounterCell(计数单元),sum(求和)需逐个读取并汇总,期间其他线程可以继续改变任意单元,因此无法作为一个严格瞬时值参与扣减决策。它非常适合请求量、错误数、报警次数等允许短暂统计滞后的指标。库存应通过数据库条件更新、带版本的持久化记录或受锁保护的完整业务状态保证最终不超卖。
    • 进阶追问:监控计数少一两次是否可以接受?
    • 进阶回答:先看指标用途。用于趋势、扩容和告警阈值时通常可接受短暂读偏差;用于计费、审计或限额处罚时不可接受,应写入可追溯事件或使用精确的持久化计数。不能仅因数据结构快就改变业务语义。
  2. 问题(原理题):Striped64(条带化累加基类)如何降低 CAS(比较并交换)冲突?

    • 考点:条带化、探针、单元数组、空间换时间。
    • 回答思路:先讲 base(基础值)快路径,再讲冲突后的单元路径和扩容边界。
    • 详细答案:Striped64(条带化累加基类)先保留一个 base(基础值)以服务低竞争场景;当多个线程对它的 CAS(比较并交换)频繁失败时,不让所有线程继续争同一位置,而是按线程探针散列到 CounterCell(计数单元)数组。不同线程大概率更新不同单元,缓存行争用显著下降;若槽位不足才在受控条件下初始化或扩展数组。该设计不追求每次读写都围绕一个全局线性化点,而是把写吞吐放在第一位。它会增加内存、汇总读和实现复杂度,所以只值得用于真实写热点。
    • 进阶追问:单元数量是否越多越好?
    • 进阶回答:不是。过多单元增加内存占用、sum(求和)遍历成本和缓存压力,且线程数下降后收益变小。实现会参考可用处理器数等上限;业务侧更应先识别是否存在不必要的全局热点。
  3. 问题(项目题):IoT(物联网)报警风暴治理中怎样使用 LongAdder(长整型累加器)?

    • 考点:本地统计、窗口归并、跨实例一致性。
    • 回答思路:区分本机观测计数与全局限流事实,描述窗口结束时的处理。
    • 详细答案:单个设备异常会在短时间内触发大量相同报警,本机可按设备类型或规则使用 LongAdder(长整型累加器)累加接收量、去重量和丢弃量,避免指标采集本身成为热点。定时任务把 sum(求和)结果作为观测快照上报,并在窗口切换时用双缓冲或替换引用避免长时间停写。若要决定“集群一分钟只通知一次”,不能只看本地 LongAdder(长整型累加器),应在 Redis(远程字典服务)或数据库中使用带过期时间的原子去重键。告警恢复后还要记录被聚合的样本数,避免为了降噪而失去审计线索。
    • 进阶追问:sumThenReset(求和后重置)能否保证绝不漏数?
    • 进阶回答:不能把它理解成与所有并发 add(累加)完全隔离的事务。窗口统计允许边界上的少量归属差异时可用;若审计要求严格,应追加事件日志,再异步聚合,或通过明确的窗口切换协议归属每条事件。

3. AQS(抽象队列同步器):状态加等待队列的可复用模板

3.1 state(同步状态)、Node(节点)与 CLH(克雷格兰丁赫格斯滕队列)变体

AQS(抽象队列同步器)把“能否获取资源”抽象为一个 volatile(可见性关键字) state(同步状态),把“暂时失败的线程”组织为 CLH(克雷格兰丁赫格斯滕队列)变体。head(头节点)通常是已获取成功后设置的哨兵节点,tail(尾节点)指向最后入队节点;Node(节点)保存前驱、后继、等待线程和等待状态等排队信息。AQS(抽象队列同步器)不规定 state(同步状态)代表什么:ReentrantLock(可重入锁)可把它作为持有重入次数,Semaphore(信号量)可把它作为许可证数量,CountDownLatch(倒计时门闩)可把它作为剩余计数。

JDK(Java 开发工具包)8、JDK(Java 开发工具包)17 与 JDK(Java 开发工具包)21 的私有 Node(节点)字段、状态编码和辅助方法会演进;面试应稳定掌握“同步队列与条件队列分离、前驱负责唤醒、取消节点要跳过、独占与共享路径不同”,不要背死某个私有常量值或字段名。

flowchart LR
    o["持有者:state(同步状态)大于 0"] --> h["head(头节点)哨兵"]
    h --> n1["Node(节点)乙:等待前驱信号"]
    n1 --> n2["Node(节点)丙:等待前驱信号"]
    n2 --> t["tail(尾节点)"]
    x["新线程丁"] --> q["CAS(比较并交换)追加 Node(节点)"]
    q --> t

图解:正常路径是新线程先尝试获取,失败后创建 Node(节点),通过 CAS(比较并交换)把 tail(尾节点)推进并连接前驱;前驱状态合适后当前线程 park(挂起)。失败路径是并发入队时 tail(尾节点)已变化或节点取消,线程必须重试连接或跳过取消前驱。结论是:AQS(抽象队列同步器)不是严格意义的“谁先到绝对先得”,而是用队列显著减少盲目争抢,并由具体同步器决定公平策略。

概念独占模式共享模式业务例子
获取成功一个线程成为持有者可让多个线程继续通过ReentrantLock(可重入锁)与 Semaphore(信号量)
state(同步状态)重入层数或占用标记剩余许可证或倒计时锁、许可、门闩
后继传播通常唤醒一个竞争者成功后可能继续传播读共享、许可证释放
队列作用失败者排队等待失败者排队并可级联唤醒降低高竞争自旋

热门面试题

  1. 问题(基础题):为什么 AQS(抽象队列同步器)能复用为锁、门闩和信号量?

    • 考点:模板方法、状态语义、等待机制复用。
    • 回答思路:把“资源规则”与“失败后的排队、挂起、唤醒”拆开说明。
    • 详细答案:不同同步工具的资源规则不同,但失败后的行为高度相似:先尝试改变一个状态,失败则排队,避免空转,资源释放后唤醒合适的后继。AQS(抽象队列同步器)把 state(同步状态)访问、同步队列、CAS(比较并交换)入队、park(挂起)、unpark(恢复)、取消清理等通用骨架做好,并让子类实现“尝试独占获取、释放、共享获取、共享释放”等钩子。ReentrantLock(可重入锁)把状态解释为重入层数,Semaphore(信号量)解释为许可证,CountDownLatch(倒计时门闩)解释为剩余事件数;因此复用的是排队协议,不是业务含义。
    • 进阶追问:AQS(抽象队列同步器)是否天然保证公平?
    • 进阶回答:不保证。公平性由子类在尝试获取时是否检查前面已有排队者决定。非公平 ReentrantLock(可重入锁)允许新到线程先快速 CAS(比较并交换)抢锁,以换取吞吐;公平版本会优先尊重队列前驱,等待更可控但上下文切换更多。
  2. 问题(原理题):AQS(抽象队列同步器)为什么让节点关注前驱状态?

    • 考点:局部协调、避免丢失唤醒、取消跳过。
    • 回答思路:说明后继如何确认可安全挂起,以及释放者为何只需找后继。
    • 详细答案:等待线程不能在刚入队时立即 park(挂起),否则可能错过释放发生的窗口。它先检查前驱 Node(节点)的等待状态;当前驱承诺在释放时负责 signal(通知)后继,当前节点才安全挂起。若前驱取消,则沿前驱链跳过失效节点并重新连接;若前驱尚未建立信号关系,则先设置而不是直接睡眠。这样每个节点只与相邻节点协调,释放者也只需从 head(头节点)寻找可唤醒后继,避免所有等待者同时争抢或依赖全局扫描。
    • 进阶追问:取消节点为什么不能简单留在队列里?
    • 进阶回答:超时和中断会让等待者放弃,若后继仍只认它为前驱,可能没有活线程负责传递唤醒,造成队列断链或无效遍历。AQS(抽象队列同步器)会把取消节点标记并在后继入队、唤醒查找时跨过它;这是并发队列必须处理的失败路径。
  3. 问题(项目题):Runner(执行器)并发槽位怎样映射到 AQS(抽象队列同步器)语义?

    • 考点:许可证、共享获取、释放时机、跨实例边界。
    • 回答思路:本机槽位用 Semaphore(信号量),任务事实仍由持久化领取控制。
    • 详细答案:每个 Runner(执行器)实例可使用 Semaphore(信号量)限制本机同时执行的任务数:许可充足时共享获取成功,任务完成后释放一个许可,等待任务再被唤醒。这正是 AQS(抽象队列同步器)的共享状态语义,避免为每个任务手写队列。许可必须在 finally(最终清理)中释放,并把超时、取消和初始化失败都视为释放路径;否则槽位泄漏会让队列永久堆积。它只限制单实例资源,不保证同一任务不被两台机器执行;任务领取仍须通过数据库租约、唯一键或分布式锁保证全局归属。
    • 进阶追问:为什么不把 Semaphore(信号量)当成分布式任务锁?
    • 进阶回答:Semaphore(信号量)状态在进程内存中,实例重启即丢失,其他实例也不可见。分布式归属需要可持久化、可过期、可审计的租约;本地信号量只是一层舱壁,用来保护线程、连接和下游并发。

3.2 acquire(获取)、release(释放)、park(挂起)与取消传播

独占 acquire(获取)主线可概括为:先调用子类的 tryAcquire(尝试获取)走快速路径;失败则将当前 Thread(线程)封装为 Node(节点)并用 CAS(比较并交换)追加到同步队列;只有当前节点前驱是 head(头节点)时才反复尝试获取,否则根据前驱状态建立“由前驱负责唤醒”的关系并 park(挂起)。release(释放)先由子类减少或清空 state(同步状态),若资源真正可用,则 unpark(恢复)合适的后继。超时或中断导致的取消必须从队列关系中剥离,后继会跳过取消节点;共享模式在一个节点成功后还可能传播唤醒,以提高许可证或读锁利用率。

flowchart TD
    a["线程调用 acquire(获取)"] --> b{"tryAcquire(尝试获取)成功?"}
    b -->|"是"| c["成为持有者并返回"]
    b -->|"否"| d["CAS(比较并交换)入同步队列"]
    d --> e{"前驱是 head(头节点)且可再试?"}
    e -->|"是"| b
    e -->|"否"| f["设置前驱信号关系"]
    f --> g["park(挂起)等待"]
    g --> h["unpark(恢复)或中断或超时"]
    h --> i{"已取消?"}
    i -->|"是"| j["跳过并断开取消节点"]
    i -->|"否"| e

图解:正常路径是快速获取成功,或排到 head(头节点)后被唤醒并再次获取。失败路径包括超时、中断和前驱取消,当前节点必须清理或被后继跳过,不能永远占住链路。结论是:unpark(恢复)只表示“你可以再竞争”,不保证唤醒线程立即拿到资源。

sequenceDiagram
    participant a as "持有线程甲"
    participant q as "AQS(抽象队列同步器)同步队列"
    participant b as "等待线程乙"
    a->>q: "release(释放)使 state(同步状态)可用"
    q->>b: "unpark(恢复)乙"
    b->>q: "再次 tryAcquire(尝试获取)"
    alt "获取成功"
        b->>q: "设置乙为 head(头节点)"
    else "又被竞争者抢先"
        b->>q: "重新判断前驱后 park(挂起)"
    end

图解:正常路径是甲释放资源、乙被恢复、乙重新尝试并成为新的 head(头节点)。失败路径是非公平策略下其他线程在乙真正运行前抢到资源,乙必须按队列协议继续等待,而不是认为 unpark(恢复)等于锁已交接。结论是:AQS(抽象队列同步器)的唤醒是协作信号,资源所有权始终以 tryAcquire(尝试获取)结果为准。

boolean acquireQueued(Node node, int arg) {
    boolean interrupted = false;
    for (;;) {
        Node predecessor = node.predecessor();
        // 只有排在头节点之后才有资格再次争抢,避免所有线程同时自旋。
        if (predecessor == head && tryAcquire(arg)) {
            setHead(node);
            return interrupted;
        }
        // 前驱承诺释放时通知后继后,当前线程才安全挂起。
        if (shouldParkAfterFailedAcquire(predecessor, node)) {
            park();
            if (Thread.interrupted()) {
                interrupted = true;
            }
        }
    }
}

热门面试题

  1. 问题(基础题):为什么 AQS(抽象队列同步器)被唤醒后还要再调用 tryAcquire(尝试获取)?

    • 考点:条件竞争、唤醒语义、所有权确认。
    • 回答思路:说明唤醒只是重新参与竞争,资源状态仍可能被其他线程改变。
    • 详细答案:unpark(恢复)解除的是 Thread(线程)的阻塞状态,不是把锁所有权直接转交给它。在线程从操作系统调度回来之前,非公平锁的新到线程可能已完成快速获取;即使公平队列中,释放、取消或共享传播也会改变可观察状态。因此等待节点恢复后必须依据当前 state(同步状态)再次执行 tryAcquire(尝试获取),成功才设置自己为 head(头节点)。这种“通知后重新校验”的模式也解释了为什么条件等待和 Object(对象基类)监视器等待都必须放在循环中。
    • 进阶追问:公平锁会不会完全消除再次失败?
    • 进阶回答:公平策略显著减少插队,但不能把调度、取消、中断和状态变化抹掉;实现仍要以实际获取结果为准。公平性的承诺通常是“已有合格前驱时不插队”,不是绝对实时到达顺序。
  2. 问题(原理题):AQS(抽象队列同步器)怎样处理超时或中断的等待线程?

    • 考点:取消节点、前后链接、队列可达性。
    • 回答思路:先说取消不会获得资源,再说如何避免后继被失效前驱卡住。
    • 详细答案:带超时获取或可中断获取返回前,当前 Node(节点)会进入取消路径:它不再等待资源,并尝试把自己从相邻链接中摘除或标记为取消。并发环境下不必追求瞬时把所有指针完全清空,关键是后继在寻找有效前驱、释放者在寻找可唤醒后继时都能跳过取消节点,队列继续向前推进。若忽略这一处理,超时线程可能留在关键位置,后继无人唤醒,最终表现为锁已释放而等待队列没有进展。排障时要区分业务超时导致的正常取消与大量超时暴露的资源饱和。
    • 进阶追问:中断后一定抛异常吗?
    • 进阶回答:取决于入口。lockInterruptibly(可中断加锁)和条件等待会按契约响应中断;普通 lock(加锁)可能记录中断并在获取后恢复中断标记。调用方必须选择符合取消语义的 API(应用程序接口),不能吞掉 InterruptedException(中断异常)后继续执行不可取消任务。
  3. 问题(项目题):支付账户互斥为何要避免长时间持有 ReentrantLock(可重入锁)?

    • 考点:临界区、远程调用、排队放大、降级。
    • 回答思路:锁内只维护本机短状态,渠道调用和数据库事务不能随意放入。
    • 详细答案:支付账户或渠道的同机互斥可用 ReentrantLock(可重入锁)保护内存中的短暂状态,例如同一账户正在生成一笔本地指令;但若把远程渠道调用、数据库慢查询或回调等待放进临界区,后续请求会全部进入 AQS(抽象队列同步器)等待,超时后还可能触发重试风暴。正确做法是锁内完成状态校验和本地占位,持久化唯一流水后立即释放;外部调用在锁外执行,回调再按状态机和幂等键更新。若必须串行账户级资金动作,应把串行语义落在可恢复的持久化任务队列,而不是依赖进程内长锁。
    • 进阶追问:怎样证明线上是锁内慢调用而不是锁泄漏?
    • 进阶回答:用 jstack(线程栈工具)找持锁线程及其栈帧,若栈停在 HTTP(超文本传输协议)客户端、数据库驱动或日志 I/O(输入输出),说明临界区过大;再对照锁等待时长、下游延迟和线程池队列。锁泄漏则常见持锁线程已异常退出前未释放或逻辑分支遗漏 finally(最终清理)。

4. Condition(条件队列)与 Lock(锁接口)工具语义

4.1 Condition(条件队列):等待、释放、转移、重新获取

一个 ReentrantLock(可重入锁)可创建多个 Condition(条件队列),每个 Condition(条件队列)保存等待某一业务谓词的 Node(节点)。await(等待)必须在已持锁时调用:当前 Thread(线程)先完整释放自己持有的重入层数,加入条件队列并 park(挂起);signal(通知)从条件队列转移一个等待节点到 AQS(抽象队列同步器)同步队列。被转移的线程不是直接运行,更不是直接持锁,它仍要像普通排队者一样等待前驱、被 unpark(恢复)并重新 acquire(获取)。

flowchart LR
    a["线程甲持有 ReentrantLock(可重入锁)"] --> b["await(等待):释放全部重入层数"]
    b --> c["Condition(条件队列)"]
    d["线程乙持有锁并改变谓词"] --> e["signal(通知)"]
    e --> f["转移到 AQS(抽象队列同步器)同步队列"]
    f --> g["等待前驱释放并 unpark(恢复)"]
    g --> h["重新 acquire(获取)后检查谓词"]

图解:正常路径是甲释放锁后等待,乙先改变共享状态再 signal(通知),甲被转移、重新获取锁并在循环中确认谓词。失败路径是先 signal(通知)后改变状态、使用 if(如果)而不是 while(循环)检查、或等待在错误的 Condition(条件队列),都会造成“看似丢信号”或错误继续执行。结论是:Condition(条件队列)传递的是重新检查条件的机会,不是业务事件的可靠消息。

维度Condition(条件队列)Object(对象基类)监视器等待
前提持有对应 Lock(锁接口)持有对应 synchronized(同步锁)监视器
等待队列一个锁可有多个 Condition(条件队列)一个对象只有一个等待集合
等待动作await(等待)释放 Lock(锁接口)全部重入wait(等待)释放对象监视器
通知后转同步队列再竞争锁转监视器竞争队列再竞争监视器
正确姿势while(循环)检查谓词while(循环)检查谓词

逐步演绎:有界队列为空时,消费者甲持锁检查为空,调用 await(等待)后释放锁并进入“非空”条件队列。生产者乙获取锁、放入元素、更新非空谓词,再 signal(通知)甲;甲只被转入同步队列。乙 unlock(解锁)后,甲被恢复、重新获取锁,最后在 while(循环)中再次检查队列非空后取元素。若乙先 signal(通知)再放元素,或甲用 if(如果)跳过复查,竞争和伪唤醒都会使消费者读到空队列。

热门面试题

  1. 问题(基础题):signal(通知)后等待线程为什么不能立即执行?

    • 考点:条件队列到同步队列、锁所有权、重新竞争。
    • 回答思路:说明 signal(通知)只转移节点,持锁线程仍未释放资源。
    • 详细答案:调用 signal(通知)时,当前线程仍持有 ReentrantLock(可重入锁),因此被选中的 Condition(条件队列)节点只会转移到 AQS(抽象队列同步器)同步队列,尚不能进入临界区。等发信号线程 unlock(解锁)后,队列中的合适后继被 unpark(恢复),该节点还要重新调用 acquire(获取)并成功,才能从 await(等待)返回。这个两阶段设计保证共享状态与通知在同一个锁保护范围内可见,也避免多个等待者同时越过临界区。把 signal(通知)理解为“直接运行”会误判锁等待和线程状态。
    • 进阶追问:signalAll(通知全部)是否更安全?
    • 进阶回答:它会把所有该条件上的等待者转入同步队列,能避免错误唤醒某类条件后无人继续,但会带来惊群、无效竞争和尾延迟。多个 Condition(条件队列)已能区分非空、未满、已完成等谓词,应优先精确 signal(通知)并配合 while(循环)复查。
  2. 问题(原理题):为什么 await(等待)和 wait(等待)都必须放在 while(循环)里?

    • 考点:伪唤醒、竞争、条件谓词。
    • 回答思路:唤醒不是条件成立的证明,返回前必须在持锁状态复验。
    • 详细答案:等待可能因 signal(通知)、中断、超时或伪唤醒返回;即使通知对应的状态曾成立,多个消费者竞争时前一个消费者也可能已经取走唯一元素。while(循环)把“共享状态是否满足业务谓词”作为唯一通行条件:条件不成立就继续等待,成立才在锁保护下执行。if(如果)只检查一次,后续任何抢占或伪唤醒都可能让线程在条件不满足时继续,形成空取、越界或状态机跳跃。所谓“信号丢失”常常不是 Condition(条件队列)丢消息,而是程序没有用状态谓词和锁把事件、检查、等待串起来。
    • 进阶追问:超时 await(等待)返回后能否直接当作失败?
    • 进阶回答:不能。超时返回只表示等待时间到达,返回前仍应在持锁状态复查谓词;条件可能恰好在超时边界成立。根据复查结果区分成功、超时和被取消,再决定是否补偿。
  3. 问题(项目题):Runner(执行器)启动门闩为什么常用 CountDownLatch(倒计时门闩)而不是 Condition(条件队列)?

    • 考点:一次性计数、可重置性、角色分工。
    • 回答思路:比较“等待多个初始化完成”的一次性语义与可反复条件等待。
    • 详细答案:Runner(执行器)启动时若要等待配置加载、连接预热和任务注册三个固定步骤完成,CountDownLatch(倒计时门闩)把 state(同步状态)表达为剩余数量,工作线程完成一步就 countDown(倒数),主线程 await(等待)到零后继续,语义直接且无须手写循环和条件队列。它是一次性的,计数归零不能重置;若需要每一轮批处理都等待一组参与者到齐并继续下一轮,应选择 CyclicBarrier(循环屏障)。Condition(条件队列)适合同一把锁保护的可变业务谓词,例如队列非空或容量未满,不能把所有协调问题都压成信号。
    • 进阶追问:任一初始化失败时如何避免主线程永久等待?
    • 进阶回答:使用带超时 await(等待),每个初始化任务无论成功失败都上报结果并在 finally(最终清理)中递减;主线程汇总失败原因后进入降级或停止,而不是让失败任务跳过 countDown(倒数)造成假死。

4.2 ReentrantLock(可重入锁)、ReadWriteLock(读写锁)与 StampedLock(戳锁)

ReentrantLock(可重入锁)基于 AQS(抽象队列同步器)提供显式 lock(加锁)/unlock(解锁)、可中断获取、超时 tryLock(尝试加锁)、公平与非公平策略以及多个 Condition(条件队列)。非公平模式会先尝试快速 CAS(比较并交换)获取,低竞争下吞吐通常更高;公平模式会尊重已有等待者,降低长期饥饿风险但增加排队和调度成本。ReadWriteLock(读写锁)让多个读者共享、写者独占,适合读多写少且读临界区短的内存结构;写入频繁或读取很慢时,它未必优于普通锁。

StampedLock(戳锁)提供悲观读、悲观写和乐观读。乐观读先拿到一个戳,在读取字段后 validate(校验)戳是否仍有效;失效就升级或重试悲观读。它不是可重入锁,获取写锁后再次获取可能自我阻塞;也不应把戳跨线程传递或在复杂控制流中遗漏 unlock(解锁)。因此它适合极短、读多写少的内存快照,不适合递归调用、条件等待或需要可中断公平语义的业务锁。

工具资源语义优势关键风险
ReentrantLock(可重入锁)独占、可重入中断、超时、多 Condition(条件队列)必须 finally(最终清理)解锁
ReadWriteLock(读写锁)读共享、写独占读多写少时并发读写饥饿、锁升级复杂
StampedLock(戳锁)乐观读与悲观读写读热点下减少阻塞不可重入,戳必须严格释放
Semaphore(信号量)共享许可证限制本机并发槽位许可泄漏与跨实例误用
CountDownLatch(倒计时门闩)一次性倒数到零等待固定初始化事件不能重置
CyclicBarrier(循环屏障)多方到齐后放行多轮并行阶段协作任一参与者失败会破坏屏障
flowchart TD
    a["需要保护多字段短临界区"] --> b{"需要中断、超时或多个条件?"}
    b -->|"是"| c["ReentrantLock(可重入锁)"]
    b -->|"否"| d["synchronized(同步锁)"]
    e["读远多于写且读逻辑短"] --> f["ReadWriteLock(读写锁)或 StampedLock(戳锁)"]
    g["限制本机并发数量"] --> h["Semaphore(信号量)"]
    i["等待一次性准备完成"] --> j["CountDownLatch(倒计时门闩)"]
    k["每轮等待多个参与者"] --> l["CyclicBarrier(循环屏障)"]

图解:正常路径按资源语义选择工具:互斥、读共享、许可、一次性计数和可循环集合分别对应不同模板。失败路径是用 StampedLock(戳锁)做递归业务锁、用本地 Semaphore(信号量)做集群排他,或把 CountDownLatch(倒计时门闩)误用于可重复批次。结论是:工具名称相近不代表状态生命周期相同。

热门面试题

  1. 问题(基础题):ReentrantLock(可重入锁)公平锁为什么吞吐常低于非公平锁?

    • 考点:插队、缓存局部性、上下文切换、等待上界。
    • 回答思路:先解释非公平快速路径,再说明公平策略为何减少机会性获取。
    • 详细答案:非公平 ReentrantLock(可重入锁)在锁刚释放或竞争较低时允许当前正在运行的新线程直接 CAS(比较并交换)获取,可能避开唤醒、调度和再次休眠的开销,也更利用当前 CPU(中央处理器)缓存状态。公平锁在发现已有队列前驱时会让新线程排队,即使锁此刻空闲也可能等待前驱被调度,因此吞吐和平均延迟常更差。它的收益是等待顺序更可控、长期饥饿风险更低。选择依据应是业务是否需要等待上界,而非默认“公平就更正确”。
    • 进阶追问:支付账户锁是否应该使用公平锁?
    • 进阶回答:先避免把长网络调用放在锁内,再看是否存在同一账户少量请求长期被饿死的证据。若锁持有极短,非公平通常更合适;若确有排队公平的业务要求,也要用持久化顺序或任务队列表达,不能只把公平锁当作资金顺序保证。
  2. 问题(原理题):StampedLock(戳锁)乐观读的正确使用顺序是什么?

    • 考点:读戳、字段读取、校验、降级。
    • 回答思路:先取戳,读取必要字段,校验失败后用悲观读重读全部依赖字段。
    • 详细答案:正确顺序是先获得乐观读戳,再把所需字段读到局部变量,随后 validate(校验)该戳;只有校验成功才使用这组局部变量。若校验失败,说明读取期间可能有写入,不能只重读其中一个字段,而应获取悲观读锁后重新读取整组相关字段,以避免新旧字段拼接。乐观读不阻塞写者,适合字段少、读取短、写少的快照;如果读取中包含远程调用、复杂遍历或递归,就会扩大校验失败概率并让优势消失。
    • 进阶追问:为什么 StampedLock(戳锁)不适合可重入业务?
    • 进阶回答:它不记录“同一线程已持有”的重入层数。一个方法持有写戳后再调用也要获取写锁的方法,可能等待自己释放而自我阻塞。需要层层调用的业务边界应使用 ReentrantLock(可重入锁)或重构临界区。
  3. 问题(项目题):WMS(仓储管理系统)库存查询能否用 ReadWriteLock(读写锁)替代数据库?

    • 考点:缓存快照、最终事实、读写放大。
    • 回答思路:将它定位为单实例内存快照保护,不替代持久化扣减。
    • 详细答案:ReadWriteLock(读写锁)可以保护某实例内的库存展示快照:大量查询共享读锁,刷新线程以写锁整体替换快照,从而避免读到半更新字段。但它不跨实例、不持久化,进程重启即丢,且多个实例的读写锁彼此不可见;因此不能替代数据库条件扣减、库存预占或分布式协调。热点 SKU(库存单位)若读写都很高,读写锁的排队和写者等待反而可能成为瓶颈,此时更适合不可变快照的 AtomicReference(原子引用)替换、分片缓存和数据库最终约束。
    • 进阶追问:读锁升级为写锁有什么风险?
    • 进阶回答:多个读者同时尝试升级时都在等待其他读者退出,容易形成死锁或长等待。常见做法是释放读锁后再获取写锁并重新校验,或使用明确的写路径;不能假设升级期间数据仍未变化。

4.3 Semaphore(信号量)、CountDownLatch(倒计时门闩)与 CyclicBarrier(循环屏障)状态语义

Semaphore(信号量)把 state(同步状态)看作可用许可证:acquire(获取)会消耗许可,release(释放)会归还许可,属于可反复使用的共享资源控制。CountDownLatch(倒计时门闩)把 state(同步状态)看作剩余事件数:countDown(倒数)只减不增,达到零后所有 await(等待)者通过,是一次性门。CyclicBarrier(循环屏障)维护“本轮尚未到达的参与者数”,最后到达者可执行屏障动作并唤醒同轮参与者;一方超时、中断或失败会使该轮屏障破坏,其他人需看到异常并整体重试或终止。

| 工具 | state(同步状态)含义 | 谁改变状态 | 是否可复用 | 失败语义 | | --- | --- | --- | --- | | Semaphore(信号量) | 可用许可证 | 执行者获取与归还 | 是 | 忘记 release(释放)会耗尽槽位 | | CountDownLatch(倒计时门闩) | 剩余完成数 | 工作者 countDown(倒数) | 否 | 少一次倒数会让等待者超时 | | CyclicBarrier(循环屏障) | 本轮未到达数 | 每个参与者 await(等待) | 是 | 任一方退出会破坏整轮 |

热门面试题

  1. 问题(基础题):CountDownLatch(倒计时门闩)和 CyclicBarrier(循环屏障)最大的区别是什么?

    • 考点:一次性门、循环阶段、参与者角色。
    • 回答思路:从状态是否可重置、谁倒数、到零后谁继续三个维度回答。
    • 详细答案:CountDownLatch(倒计时门闩)是一次性等待门:一组工作者完成各自任务后 countDown(倒数),一个或多个等待者在计数归零后继续,归零后不能复位。CyclicBarrier(循环屏障)强调一组参与者在每一轮都互相等待,最后到达者触发屏障动作后全体进入下一轮,下一轮自动重新计数。前者适合服务启动等待依赖就绪,后者适合并行计算分阶段汇合。两者都要设计超时与失败路径,否则一个卡死参与者会让其他线程无限等待。
    • 进阶追问:CyclicBarrier(循环屏障)破坏后为何不能悄悄继续?
    • 进阶回答:同一轮参与者对“大家都完成了本阶段”这一前提已不成立,若部分线程直接进入下一阶段,会混入不同轮次的数据。应让所有参与者观察到屏障破坏,统一回滚、重试或结束本轮。
  2. 问题(原理题):Semaphore(信号量)如何产生许可泄漏?

    • 考点:获取后异常、超时、中断、finally(最终清理)。
    • 回答思路:列举所有获取成功后的退出分支,强调只归还自己实际获得的许可。
    • 详细答案:许可泄漏常见于 acquire(获取)成功后,业务在调用下游、提交任务、序列化或中断处理时抛异常,代码没有进入 release(释放)。另一种错误是 acquire(获取)超时失败却仍在 finally(最终清理)中归还,导致许可凭空增加。正确结构是记录是否实际获得许可,成功后把整个受保护操作放进 try(尝试)范围,在 finally(最终清理)里只归还已获得的一次许可;同时监控可用许可、等待时长、超时数和活动任务数的关系。许可长期为零而活动任务已为零,就是强泄漏信号。
    • 进阶追问:是否应在监控发现泄漏后直接 release(释放)补足?
    • 进阶回答:不能盲补,因为可能仍有真实任务在执行,补足会突破并发上限。先用任务登记、线程栈和下游在途请求确认持有者;无法可靠恢复时,优雅摘流重启实例并用持久化任务状态接管更安全。
  3. 问题(项目题):Runner(执行器)启动与并发槽位如何同时使用两个同步器?

    • 考点:门闩和许可的职责分离、失败回收。
    • 回答思路:启动阶段用一次性门闩,运行阶段用可复用许可证,并保留任务持久化边界。
    • 详细答案:Runner(执行器)可先用 CountDownLatch(倒计时门闩)等待配置、连接和任务注册完成,主线程只在倒数归零或超时降级后对外接流;运行期则用 Semaphore(信号量)限制同时执行任务数。两者不能混用:门闩表达“本次准备是否完成”,许可证表达“当前还有多少执行容量”。任务真正领取仍落在任务表租约或唯一约束上;本地许可仅保护本机资源。启动步骤失败时必须上报结果并倒数,避免主线程永等;任务执行失败时必须在 finally(最终清理)归还许可,并由任务状态机决定重试或人工处理。
    • 进阶追问:实例重启时未归还的本地许可怎么办?
    • 进阶回答:内存许可随进程消失,不能依赖它恢复业务。重启后由持久化租约到期、心跳失效和任务检查点判定可否接管;任务处理必须幂等,避免旧实例其实仍在网络分区中执行时出现双跑。

5. 线上排障与项目表达:先证据,后换工具

5.1 CAS(比较并交换)自旋、锁等待、信号误解与计数偏差 SOP(标准操作流程)

并发问题排障不能看到 WAITING(无限等待)就改成 notifyAll(通知全部),也不能看到 CPU(中央处理器)高就盲目换公平锁。先确认症状是高自旋、锁等待、条件谓词未满足、许可泄漏还是统计口径不一致,再把线程栈、指标、日志时间线和业务状态对应起来。Condition(条件队列)所谓“丢信号”要先检查共享谓词是否在同一把锁下改变与检查;LongAdder(长整型累加器)“不准”要先确认是设计允许的瞬时汇总偏差,还是窗口重置和跨实例聚合错误。

flowchart TD
    a["现象:延迟或 CPU(中央处理器)异常"] --> b{"CPU(中央处理器)是否高?"}
    b -->|"是"| c["JFR(Java 飞行记录器)或火焰图查 CAS(比较并交换)循环"]
    b -->|"否"| d["jstack(线程栈工具)统计 BLOCKED(阻塞)与 WAITING(无限等待)"]
    c --> e{"失败率与热点变量是否同步升高?"}
    e -->|"是"| f["分片、退避、限次重试或排队"]
    d --> g{"持锁线程在慢 I/O(输入输出)吗?"}
    g -->|"是"| h["缩短临界区并隔离下游"]
    g -->|"否"| i["检查 Condition(条件队列)谓词、信号和取消"]
    j["统计偏差"] --> k["核对单机 LongAdder(长整型累加器)与全局事件"]

图解:正常路径是先用 CPU(中央处理器)与线程状态分流,再用失败率、持锁栈和业务事件收敛根因。失败路径是只凭一个线程状态下结论,例如把正常 park(挂起)当死锁,或把 LongAdder(长整型累加器)的汇总延迟当数据丢失。结论是:同步器问题必须同时有运行时证据和业务状态证据。

现象必取证据常见根因首个止血动作
CPU(中央处理器)高且吞吐下降JFR(Java 飞行记录器)、火焰图、CAS(比较并交换)失败率热点自旋、活锁限流、分片、退避、降低并发
大量 BLOCKED(阻塞)jstack(线程栈工具)持锁对象与持有者锁内慢 I/O(输入输出)、临界区过大摘除慢下游,缩短锁范围
大量 WAITING(无限等待)Condition(条件队列)谓词、await(等待)/signal(通知)日志未改变谓词、漏释放、错误条件队列超时保护,修正状态机
公平锁后 QPS(每秒查询率)下降等待时长、上下文切换、吞吐频繁交接与排队回归非公平并治理热点
统计与账单不一致窗口边界、实例维度、原始事件把 LongAdder(长整型累加器)当精确账本账本改用持久化事件

热门面试题

  1. 问题(基础题):CAS(比较并交换)自旋导致 CPU(中央处理器)高时,第一步看什么?

    • 考点:热点定位、失败率、证据优先。
    • 回答思路:先确认热点循环和同一变量竞争,再决定降载、分片或换排队。
    • 详细答案:第一步不是直接增加线程或替换锁,而是用 JFR(Java 飞行记录器)、采样火焰图和线程栈确认 CPU(中央处理器)时间是否集中在 compareAndSet(比较并设置)失败循环、原子更新或相关业务重算;同时记录目标计数器的失败次数、重试次数和成功吞吐。若失败率随某个热点 SKU(库存单位)、渠道或设备上升,说明是同一内存位置的争用,应先限流、按键分片或把高冲突操作排队。若循环里含复杂计算,应把计算移出重试体或限制次数。没有证据就改公平锁,可能把自旋问题变成更低吞吐的排队问题。
    • 进阶追问:为什么增加 CPU(中央处理器)核心不一定有效?
    • 进阶回答:热点仍是同一缓存行和同一线性化点,核心越多可能带来更多同时失败者与缓存一致性流量。先消除单点协调,再谈扩容。
  2. 问题(原理题):怎样判断 Condition(条件队列)是“丢信号”还是条件本来不成立?

    • 考点:谓词、锁边界、事件顺序、可观测性。
    • 回答思路:审计检查、修改、等待、通知是否都围绕同一锁和同一状态版本。
    • 详细答案:为条件增加可观测的状态版本:记录线程在持锁下检查到的谓词、进入 await(等待)的版本、生产者在持锁下修改谓词的版本及 signal(通知)对象。若通知发生时谓词已为真而等待者仍卡住,要检查是否等在另一把锁或另一个 Condition(条件队列)、是否未被转移或被取消;若通知前后谓词始终为假,则不是丢信号,而是生产者没有产出、状态被其他消费者抢走或业务条件无法满足。所有等待都应有超时和循环复查,防止诊断线程永久挂起。
    • 进阶追问:为什么 notifyAll(通知全部)通常不是根治?
    • 进阶回答:它可能让更多线程醒来重新竞争,但若谓词没有正确维护、锁对象不一致或状态机错误,所有线程醒来后仍会继续等待,只会增加惊群和日志噪声。
  3. 问题(项目题):支付渠道并发额度出现“本机正常、全局超限”如何复盘?

    • 考点:跨实例边界、指标口径、最终约束。
    • 回答思路:对比每实例本地计数、统一额度账本和渠道实际流水,定位缺失的全局协调。
    • 详细答案:先按实例聚合本地 AtomicInteger(原子整数)或 Semaphore(信号量)在途量,再与 Redis(远程字典服务)或数据库中的渠道全局额度、渠道实际受理流水对齐。如果每台实例都未超本机阈值而总和超限,根因就是把本地同步器误当全局配额;如果全局账本未超而渠道拒绝,还要核对渠道侧窗口、时钟和待查单的占位是否释放过早。修复应把最终额度判断放到共享原子脚本或数据库条件更新,本地同步器只保留舱壁作用,并对超时请求采用查单后释放的状态机,不能由 finally(最终清理)一律归还。
    • 进阶追问:全局额度服务不可用时如何降级?
    • 进阶回答:宁可按保守本地上限、缓存的最后成功配额或直接拒绝高风险渠道,也不要退化为无限放行;关键支付路径应记录降级原因并在恢复后对账补偿。

5.2 设计思想:乐观与悲观、排队与自旋、分片降低协调

CAS(比较并交换)体现乐观并发:假设冲突少,先做后验证,失败再重试;锁体现悲观并发:冲突成本高或临界区无法安全重做时,先取得排他权再执行。自旋让线程留在 CPU(中央处理器)上等待,适合预计极短的等待;排队与 park(挂起)让等待者让出 CPU(中央处理器),适合竞争强或等待不可预测。LongAdder(长整型累加器)的条带化则展示了另一种策略:不是让所有人争同一个正确点,而是拆分状态、最后合并,降低协调次数。

设计选择适用前提收益代价与边界
乐观 CAS(比较并交换)冲突低、可无副作用重试少阻塞、低延迟高竞争会自旋退化
悲观 Lock(锁接口)临界区多步、冲突高易维护业务不变量队列、死锁和锁粒度风险
排队 park(挂起)等待可能较长节省 CPU(中央处理器)唤醒和调度延迟
条带化写多读少、可汇总降低热点争用读不再是单点精确快照

热门面试题

  1. 问题(基础题):乐观并发和悲观并发应该如何选?

    • 考点:冲突概率、重试成本、临界区长度、失败副作用。
    • 回答思路:不要按名称选,先评估冲突和失败后能否安全重做。
    • 详细答案:当冲突少、更新很短、失败后只需重新读取内存并计算且没有外部副作用时,CAS(比较并交换)的乐观路径通常更轻。若一次操作涉及多个字段、需要保持复杂不变量、失败重算昂贵,或临界区内必须串行执行,则应选 Lock(锁接口)等悲观控制,并把临界区尽量缩短。高竞争下 CAS(比较并交换)会把等待变成 CPU(中央处理器)消耗,锁则把等待显式排队;两者没有永久优劣。跨实例业务还应优先定义数据库、租约或消息顺序边界,本地乐观或悲观工具只能解决进程内问题。
    • 进阶追问:能否在一个流程里同时使用两种策略?
    • 进阶回答:可以,例如先用本地 CAS(比较并交换)快速预判配额,再用数据库条件更新作为最终事实;但必须明确谁是最终裁决者,失败回滚和幂等不能依赖两套状态碰巧一致。
  2. 问题(原理题):为什么说 AQS(抽象队列同步器)是“状态加等待队列”的模板?

    • 考点:抽象复用、资源规则、等待协议。
    • 回答思路:将状态意义留给子类,将失败后的排队和唤醒交给框架。
    • 详细答案:任何同步器都可拆成两件事:资源现在能否给你,以及不能给时你如何有序等待。AQS(抽象队列同步器)用 state(同步状态)承载第一件事,但不解释数值含义;用 Node(节点)、head(头节点)、tail(尾节点)、park(挂起)和 unpark(恢复)承载第二件事,并处理并发入队、取消和独占共享传播。子类只需实现尝试获取、释放时怎样修改状态,便能构造锁、许可和门闩。这种模板方法的价值是把最容易出错的等待协议集中复用,而不是让每个工具复制一套线程队列。
    • 进阶追问:为什么业务代码不宜直接继承 AQS(抽象队列同步器)?
    • 进阶回答:它适合实现少量基础同步器,错误的状态语义、取消处理或内存可见性会造成难复现死锁。普通业务优先组合 JDK(Java 开发工具包)成熟工具,并把跨进程一致性放在持久化和协议层。
  3. 问题(项目题):怎样把本章能力组织成库存防超卖的面试话术?

    • 考点:分层控制、失败路径、可观测性、跨实例。
    • 回答思路:从入口削峰、本机热点、数据库最终约束、异步补偿四层复述。
    • 详细答案:我会把库存防超卖拆成四层:入口按 SKU(库存单位)和仓库限流,避免突发流量直接打到库存行;单实例内对热点计数可用 CAS(比较并交换)或分片统计降低争用,但它只做预判;真正扣减由数据库“可售量足够才更新”的条件语句和订单幂等键裁决,多实例同时写也不会突破下限;扣减成功后的消息通过可靠外发和消费幂等处理,失败可扫描补偿。线上我监控 CAS(比较并交换)失败率、数据库行锁等待、条件更新影响行数、库存负值和消息积压。这样既能解释本地并发工具,也不会把它们误说成分布式一致性方案。
    • 进阶追问:热点 SKU(库存单位)数据库行锁等待持续升高怎么办?
    • 进阶回答:先限流和排队削峰,按仓库、批次或库存桶拆分可并行度,再检查事务是否包含慢调用;任何拆桶方案都要维护总量约束、可追溯预占和最终校验,不能只靠缓存计数。

5.3 过渡:从机制复习进入口述训练

本节只用于结束上一知识节,下面的清单和题库用于回忆、复述与自测,不新增同步机制结论。

6. 复习清单

  • 能写出 CAS(比较并交换)的 V、E、N,指出成功写入是线性化点,并说清失败为何必须重算。
  • 能用 ABA(值变化后恢复问题)解释“值相同不代表历史相同”,并说明版本戳只解决进程内版本观察。
  • 能区分 AtomicLong(原子长整型)的精确单值与 LongAdder(长整型累加器)的高吞吐统计。
  • 能从 state(同步状态)、Node(节点)、同步队列、条件队列讲出 AQS(抽象队列同步器)的获取、挂起、释放、唤醒与取消主线。
  • 能说明 signal(通知)后是转同步队列而非直接运行,await(等待)返回前必须重新获取锁并检查谓词。
  • 能按“状态生命周期”选择 ReentrantLock(可重入锁)、ReadWriteLock(读写锁)、StampedLock(戳锁)、Semaphore(信号量)、CountDownLatch(倒计时门闩)和 CyclicBarrier(循环屏障)。

7. 综合口述题与追问

  1. 问题(综合口述题):请完整解释 CAS(比较并交换),并说明它在库存扣减中的边界。

    • 口述答案:CAS(比较并交换)可以理解成一次带条件的原子写入,它有当前内存值、期望值和新值三个输入。线程先读到一个旧值,基于旧值计算新值,然后由硬件在不可分割的一步中判断当前值是否仍等于期望值;相等才写入新值,不相等就失败。成功写入的瞬间就是线性化点,因此单个位置不会出现半写入。失败的真正含义不是“再试一次就行”,而是计算依据已经过期,所以必须重新读、重新校验和重新计算。库存扣减时,单机内存计数可以用它做快速预判,例如库存大于等于数量才尝试减;但不能把它当作最终防超卖方案,因为多个实例、进程重启、数据库更新和订单幂等都不在一个内存 CAS(比较并交换)里。最终仍要由数据库条件更新或预占记录裁决,并用订单唯一键防重。高竞争时 CAS(比较并交换)会反复失败并消耗 CPU(中央处理器),这时要按热点拆分、限流或改为排队,不能执着于“无锁一定快”。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 为什么失败后必须重算?因为旧库存和业务状态可能已经被其他请求改变,复用旧结果会违反下限。
      • CAS(比较并交换)能保护多字段吗?只能直接保护一个位置;多字段不变量应封装为不可变对象、锁或事务。
      • 如何监控高竞争?记录失败次数、单次重试数、热点键、CPU(中央处理器)火焰图和请求尾延迟。
    • 回到 CAS(比较并交换)机制
  2. 问题(综合口述题):ABA(值变化后恢复问题)是什么?版本戳能解决到什么程度?

    • 口述答案:ABA(值变化后恢复问题)是 CAS(比较并交换)只比较当前值而不比较历史造成的风险。线程甲读取到 A 后暂停,线程乙把 A 改成 B,又改回 A;甲恢复时比较仍然成功,但它拿到的 A 已不是原先语义上的那个状态。对单纯统计数字、只关心最终数值是否相等的场景,中间历史可能没有影响;但对链表头、空闲对象、库存状态、账户版本等场景,中间变化可能已经改变了引用关系、归属或副作用,继续使用旧推理会出错。AtomicStampedReference(带版本戳原子引用)把引用和值对应的版本戳同时纳入比较,每次逻辑变更递增版本;甲带着旧版本提交就会失败,从而被迫重新读取。它解决的是一个 JVM(Java 虚拟机)内“值相同但历史不同”的观察问题,不是分布式版本控制。集群库存、支付状态还必须依赖数据库版本列、事件序列、幂等键和租约;内存版本戳会随重启消失,也不能覆盖外部渠道已经发生的动作。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 版本戳一定不会溢出吗?有限位数理论会回绕,工程上选足够大范围并控制对象生命周期。
      • 为什么链表更怕 ABA(值变化后恢复问题)?旧节点的 next(后继)指针可能已不属于原来的拓扑,引用相等不足以证明安全。
      • 数据库乐观锁与版本戳关系?思想相同,数据库版本列同时解决跨实例持久化更新冲突。
    • 回到 ABA(值变化后恢复问题)章节
  3. 问题(综合口述题):AtomicLong(原子长整型)和 LongAdder(长整型累加器)怎样选择?

    • 口述答案:选择重点不是谁更快,而是读写语义。AtomicLong(原子长整型)围绕一个值做 CAS(比较并交换),每次成功更新都对应一个明确线性化点,所以它适合全局序号、单变量精确配额和必须即时读取的状态。代价是高并发写同一个值时,所有线程都争同一缓存位置,失败重试会增加。LongAdder(长整型累加器)通过 Striped64(条带化累加基类)把写入分散到 base(基础值)和多个 CounterCell(计数单元),冲突线程大概率写不同单元,吞吐更好;读取 sum(求和)时再汇总所有单元。因为汇总期间写仍可发生,sum(求和)不是一个严格瞬时快照,所以它适合 QPS(每秒查询率)、成功失败次数、IoT(物联网)报警量等观测指标,不能用于余额、库存扣减、发号或精确限流。项目里我会把监控统计和业务账本拆开:前者使用 LongAdder(长整型累加器)降低采集开销,后者以数据库事件或条件更新为事实来源,避免把“统计近似”误用成“业务正确”。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • LongAdder(长整型累加器)为何写快?不同线程落到不同 CounterCell(计数单元),减少同一缓存行竞争。
      • sumThenReset(求和后重置)能做精确窗口吗?不能把它当全局事务,边界并发写可能归到相邻窗口。
      • 统计跨实例怎么办?本地累加只做观测,统一指标要汇聚事件或共享计数服务。
    • 回到原子类与统计章节
  4. 问题(综合口述题):请从源码主线解释 AQS(抽象队列同步器)获取锁失败后发生什么。

    • 口述答案:AQS(抽象队列同步器)先让具体同步器尝试快速获取,例如 ReentrantLock(可重入锁)通过 tryAcquire(尝试获取)修改 state(同步状态)。成功就直接返回;失败说明资源暂不可用,当前 Thread(线程)会被封装成 Node(节点),通过 CAS(比较并交换)追加到以 tail(尾节点)为入口的同步队列。它不是入队后立刻睡眠,而是先检查前驱:只有前驱是 head(头节点)时才有资格再次尝试获取;否则要先让前驱建立“释放时通知后继”的关系,之后才 park(挂起)。这样避免释放和挂起之间丢失唤醒。持有者 release(释放)成功把 state(同步状态)变为可用后,会 unpark(恢复)合适后继;后继恢复后仍要重新 tryAcquire(尝试获取),成功才设置为新的 head(头节点)。超时和中断会触发取消路径,后继会跳过取消节点。这个框架把资源规则交给子类,把排队、挂起、唤醒、取消这些最易出错的并发协议集中复用。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 为什么被 unpark(恢复)后仍要抢锁?唤醒只是解除阻塞,资源可能已被其他线程或状态变化占用。
      • head(头节点)为什么常是哨兵?它表示已成功获取后的队列起点,简化前驱与后继处理。
      • 取消节点如何处理?后继和释放者会跨过失效节点,避免队列链路被超时线程阻断。
    • 回到 AQS(抽象队列同步器)队列章节
  5. 问题(综合口述题):AQS(抽象队列同步器)为什么既支持独占又支持共享?

    • 口述答案:AQS(抽象队列同步器)的通用部分不是“锁”,而是状态变化失败后的等待协议。独占模式下,资源一次只给一个线程,例如 ReentrantLock(可重入锁)把 state(同步状态)解释为持有和重入次数;释放后通常唤醒一个合适后继。共享模式下,资源可以被多个线程同时通过,例如 Semaphore(信号量)的 state(同步状态)是可用许可证,CountDownLatch(倒计时门闩)的 state(同步状态)是剩余计数。共享获取成功后,AQS(抽象队列同步器)可以继续传播唤醒,使后续等待者也有机会通过,而不是每次只交接一个线程。两种模式都使用 Node(节点)队列、CAS(比较并交换)入队、park(挂起)和 unpark(恢复),区别在于 tryAcquire(尝试获取)或共享获取钩子怎样解释状态、成功后是否继续传播。面试中要强调 AQS(抽象队列同步器)复用的是“状态加等待队列”的模板,不是把所有工具做成同一种业务锁;状态的生命周期、失败语义和跨实例边界仍由具体工具和业务设计决定。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 读锁为什么属于共享模式?多个读者可同时进入,只要没有写者破坏读一致性规则。
      • CountDownLatch(倒计时门闩)为何能让多个等待者一起通过?计数归零后共享获取条件对所有后继成立。
      • 共享模式是否没有竞争?仍有 state(同步状态)更新和队列协调,只是资源可同时满足多个请求。
    • 回到 AQS(抽象队列同步器)模式对比
  6. 问题(综合口述题):Condition(条件队列)的 await(等待)和 signal(通知)完整流程是什么?

    • 口述答案:Condition(条件队列)解决的是“拿到锁后发现业务谓词不成立,该在哪里等待”的问题。线程必须先持有 ReentrantLock(可重入锁),检查例如队列非空、容量未满这类谓词;不成立时调用 await(等待),它会把当前 Node(节点)放进该 Condition(条件队列)的等待链,并完整释放该线程持有的重入层数,然后 park(挂起)。生产者在同一把锁保护下先修改共享状态,使谓词成立,再调用 signal(通知)。signal(通知)不是直接让消费者运行,而是把一个条件节点转移到 AQS(抽象队列同步器)的同步队列;生产者 unlock(解锁)后,该节点才会被 unpark(恢复)并重新 acquire(获取)锁。await(等待)返回前还必须在 while(循环)中复查谓词,因为可能伪唤醒、超时、被其他消费者抢先处理,或者通知时条件又被改变。所谓“丢信号”多数是谓词检查、状态修改和通知没有在同一锁边界内完成,而不是 Condition(条件队列)像消息队列那样漏投了一条可靠事件。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 为什么用 while(循环)不用 if(如果)?唤醒不等于条件成立,必须在重新持锁后复验。
      • signalAll(通知全部)何时使用?多个等待者都可能因同一状态变化满足条件时使用,但要评估惊群。
      • 一个锁为什么可有多个 Condition(条件队列)?可把非空、未满、已完成等不同谓词分队,减少错误唤醒。
    • 回到 Condition(条件队列)章节
  7. 问题(综合口述题):synchronized(同步锁)等待和 Condition(条件队列)等待怎样比较?

    • 口述答案:两者都要求先持有对应的互斥保护,等待时都会释放这把保护,收到通知后都必须重新竞争并在循环中检查业务条件。Object(对象基类)的 wait(等待)/notify(通知)使用对象监视器,一个对象只有一组等待者;synchronized(同步锁)适合简单、结构固定的临界区。Condition(条件队列)属于 Lock(锁接口),一个 ReentrantLock(可重入锁)可以创建多组条件队列,例如有界队列可以区分“非空”和“未满”,生产者只 signal(通知)等待未满的线程,消费者只 signal(通知)等待非空的线程,减少无关唤醒。两者都不是消息中间件:通知本身不保存业务事件,正确性来自共享谓词、同一锁和 while(循环)复查。选择上,如果只需简单互斥与一组等待,synchronized(同步锁)更易读且异常自动释放;若需要多个条件、可中断、超时或公平性,选 ReentrantLock(可重入锁)和 Condition(条件队列)。不管选哪种,不能在锁内做远程调用,也要给等待加超时和日志,避免把业务卡死误判为“线程没有被通知”。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • wait(等待)为什么必须在 synchronized(同步锁)中?它需要原子地释放并重新竞争同一个对象监视器。
      • Condition(条件队列)能跨不同 Lock(锁接口)使用吗?不能,await(等待)和 signal(通知)必须对应创建它的同一把锁。
      • notify(通知)后是否直接拥有监视器?不是,通知者退出同步块后等待者还要重新竞争。
    • 回到 Condition(条件队列)对比表
  8. 问题(综合口述题):ReentrantLock(可重入锁)的公平和非公平怎样权衡?

    • 口述答案:ReentrantLock(可重入锁)两种模式的共同点都是基于 AQS(抽象队列同步器)维护 state(同步状态)与等待队列;差别在新线程到来时是否允许先走快速获取。非公平模式会先尝试 CAS(比较并交换)拿锁,即使队列已有等待者也可能插队。低竞争或临界区极短时,这能减少唤醒、调度、再次休眠的交接成本,通常吞吐更高。公平模式会在发现有效前驱时让新线程入队,等待时间分布更可控,能降低长期饥饿风险,但会增加上下文切换,也会让刚运行在 CPU(中央处理器)上的线程放弃本可立即完成的工作。我的选择不是看“公平更好”,而是先缩小临界区、排除锁内 I/O(输入输出)和热点键;如果业务确实要求同一资源的排队顺序可预测,再考虑公平模式。支付或库存的最终顺序不能只依赖本地公平锁,因为多实例看不到同一个队列;要用数据库序列、任务表或持久化状态机表达全局顺序。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 公平锁保证绝对先到先得吗?不保证绝对时间顺序,取消、调度和实现边界仍存在。
      • 如何发现公平锁拖慢吞吐?对比切换前后的 QPS(每秒查询率)、持锁时长、等待分位数和上下文切换。
      • 非公平锁一定会饥饿吗?不是必然,但高竞争下个别线程长期失败的概率更高,需要指标验证。
    • 回到 ReentrantLock(可重入锁)章节
  9. 问题(综合口述题):ReadWriteLock(读写锁)和 StampedLock(戳锁)分别适合什么场景?

    • 口述答案:ReadWriteLock(读写锁)把访问分成读共享和写独占,适合单实例内存结构读远多于写、每次读写临界区都较短的场景,例如展示型配置快照或只读索引。它不能替代数据库库存事实,也不保证跨实例一致。StampedLock(戳锁)在此基础上提供乐观读:先取一个戳,读取所需字段到局部变量,再 validate(校验)戳;若校验失败,说明读取期间发生过写入,必须在悲观读锁下重新读取整组字段。乐观读不阻塞写者,在读热点且写很少时可降低阻塞,但它不提供可重入语义,错误地在持有写戳后递归获取会自我阻塞;戳也必须在正确路径释放,不能跨线程转交。若业务需要多个 Condition(条件队列)、可中断或复杂层层调用,我会选择 ReentrantLock(可重入锁);若读逻辑中含远程调用、长遍历或写入频繁,乐观读校验会频繁失败,普通锁或不可变快照替换通常更稳定。
    • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
    • 追问树与直接答案
      • 乐观读校验失败后为何要重读全部字段?避免把写前字段与写后字段拼成不一致快照。
      • 读锁能安全升级写锁吗?通常不能直接升级;应释放读锁、获取写锁并重新校验。
      • 为什么不用于库存最终扣减?本地锁不覆盖多实例和持久化约束,最终要靠数据库条件更新。
    • 回到读写与戳锁章节
  10. 问题(综合口述题):Semaphore(信号量)、CountDownLatch(倒计时门闩)和 CyclicBarrier(循环屏障)怎么区分?

  • 口述答案:我先看 state(同步状态)代表的资源生命周期。Semaphore(信号量)的状态是可用许可证,任务 acquire(获取)一个许可后才能进入受保护区域,结束时 release(释放)归还,因此它适合限制本机同时执行的渠道调用、文件处理或 Runner(执行器)并发槽位,并且可以反复使用。CountDownLatch(倒计时门闩)的状态是剩余完成数,多个初始化任务各自 countDown(倒数),等待者 await(等待)到零后继续,它是一次性门,归零后不能重新设置,适合服务启动等待配置、连接和注册完成。CyclicBarrier(循环屏障)则是固定参与者的阶段会合点,每个参与者到达后等待最后一人,全部到齐再一起进入下一轮,所以适合多线程分段计算或批处理阶段同步。它的失败语义更强:一个参与者超时、中断或异常,整轮屏障会破坏,其他人必须看到失败并整体重试或退出。三者都只是进程内同步器;全局任务归属、跨实例额度和重启恢复必须由持久化租约、唯一约束和状态机承担。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • Semaphore(信号量)许可泄漏怎么发现?可用许可长期为零但活动任务为零,结合线程栈和任务登记确认。
    • CountDownLatch(倒计时门闩)为何要超时?任一任务遗漏倒数会导致无限等待,超时才能降级和报错。
    • CyclicBarrier(循环屏障)破坏后能继续本轮吗?不能,阶段一致性已破坏,应统一重试或终止。
  • 回到同步工具状态语义
  1. 问题(综合口述题):如何设计 Runner(执行器)的启动门闩和并发槽位?
  • 口述答案:Runner(执行器)要把“是否准备好接流”和“当前还能执行多少任务”拆成不同状态。启动阶段通常有配置加载、连接预热、任务注册等固定步骤,我会用 CountDownLatch(倒计时门闩)让每一步无论成功失败都在 finally(最终清理)中 countDown(倒数),主线程使用带超时的 await(等待)汇总结果;成功才开放接流,失败则进入降级或停止,而不是永远等待。运行阶段使用 Semaphore(信号量)限制本机并发槽位,任务只有成功 acquire(获取)许可才开始,所有成功获取的路径都必须在 finally(最终清理)中 release(释放)。这层只保护本机线程、连接和下游容量,不代表集群里任务唯一执行。全局领取必须靠任务表租约、唯一键或分布式锁,租约要有心跳、过期接管和幂等检查点。任务超时也不能立即当作未执行:应记录在途状态并查验副作用后决定重试。这样本地同步器、持久化事实和失败恢复各有边界,实例崩溃也能恢复。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • 初始化失败为何仍 countDown(倒数)?让主线程及时汇总失败,不因遗漏信号永久挂起。
    • 许可为何不能在超时获取失败后释放?未获得许可就释放会凭空增加容量。
    • 任务双跑如何防?领取使用租约和幂等业务键,执行记录带所有者和检查点。
  • 回到 Runner(执行器)案例
  1. 问题(综合口述题):支付渠道并发额度如何同时处理本机性能与跨实例一致性?
  • 口述答案:支付渠道额度不能只靠一个本地计数器,因为每个应用实例都会有自己的内存视角。我会分三层:第一层是入口限流和熔断,按渠道、商户或风险等级尽早削峰;第二层是本机 Semaphore(信号量)或 AtomicInteger(原子整数)限制在途请求数,保护线程池和 HTTP(超文本传输协议)连接,避免单实例被慢渠道拖死;第三层才是共享的最终额度账本,用 Redis(远程字典服务)原子脚本或数据库条件更新保证所有实例合计不超过渠道窗口。调用成功、失败、超时的归还语义必须不同:明确失败可以幂等释放,成功记入已用额度;超时进入待查单,先由回调或主动查询确认,不能在 finally(最终清理)里一律归还,否则会在渠道已受理时重复放量。监控要同时有每实例在途数、全局已用量、渠道拒绝率、查单积压和实例重启后的补偿量。这样既避免把全局协调全压在远端,也不把本地同步误当成支付正确性。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • 全局额度服务不可用怎么做?按保守本地上限或拒绝高风险请求,不能退化为无限放行。
    • CAS(比较并交换)能做全局额度吗?只能做单进程内存位置,跨实例需共享存储原子操作。
    • 超时为何不能立即释放?远端可能已受理,过早释放会导致后续请求突破真实额度。
  • 回到支付额度案例
  1. 问题(综合口述题):WMS(仓储管理系统)热点库存怎样避免把 CAS(比较并交换)当成防超卖方案?
  • 口述答案:热点库存的难点在于请求集中到同一个 SKU(库存单位)和仓库,单机 CAS(比较并交换)会持续失败,多实例又会各自认为库存足够。我会按层设计:先在网关和业务入口按 SKU(库存单位)做有界排队、限流或预约,避免瞬时洪峰把所有线程拖进重试;应用内的 AtomicLong(原子长整型)或 CAS(比较并交换)只做快速预判和指标采集,失败后限次重试,不在循环中写数据库或发送消息;最终扣减必须使用数据库“可售量大于等于本次扣减量才更新”的条件语句,并用订单行唯一键保证重复提交不会重复减。对极热点 SKU(库存单位),可按仓库、批次或库存桶拆分以降低同一行争用,但每个桶的预占、释放和总量校验要可审计。数据库更新成功后的事件采用可靠外发和消费幂等,避免消息失败让订单和库存状态割裂。线上我会把 CAS(比较并交换)失败率、数据库锁等待、条件更新影响行数、库存负值和补偿积压连成一条证据链,而不是只看某一个本地计数。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • 为什么不能用 LongAdder(长整型累加器)扣库存?sum(求和)不是精确瞬时快照,无法原子地判断并扣减。
    • 拆库存桶会不会超卖?若桶预分配和总量约束设计错误会,必须有持久化扣减与对账。
    • 数据库锁等待高先做什么?先削峰、缩短事务、检查热点分布,再评估分桶,不盲目加锁等待时间。
  • 回到库存话术
  1. 问题(综合口述题):CAS(比较并交换)自旋引起 CPU(中央处理器)飙高的排障 SOP(标准操作流程)是什么?
  • 口述答案:我会先确认“CPU(中央处理器)高”是否真的来自 CAS(比较并交换)而不是 GC(垃圾回收)、序列化或下游超时。第一步用 JFR(Java 飞行记录器)、火焰图和多次 jstack(线程栈工具)采样,观察热点是否集中在 compareAndSet(比较并设置)、原子更新循环及其业务重算代码;第二步按变量、SKU(库存单位)、渠道或设备维度补充 CAS(比较并交换)失败数、每次请求重试数、成功吞吐和尾延迟,确认是否存在单一热点。若证据成立,先做可逆止血:对热点键限流、降低并发、给重试加次数上限和随机退避,把复杂计算移出循环;随后通过分片计数、批量合并或 AQS(抽象队列同步器)排队减少同一位置争用。不能简单增加线程或 CPU(中央处理器)核心,因为热点缓存行仍只有一个,更多核心可能制造更多失败者。最后验证止血后失败率、CPU(中央处理器)、吞吐和业务成功率是否一起改善,并检查限流是否造成新的积压和超时。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • 什么是活锁?线程持续重试并占用 CPU(中央处理器),但系统总体很少成功推进。
    • 退避为何有用?错开同时重试的时间,减少反复碰撞和缓存行争用。
    • 何时改为锁?临界区难重试、冲突高且等待可能较长时,排队挂起比自旋更省资源。
  • 回到排障 SOP(标准操作流程)
  1. 问题(综合口述题):大量锁等待时怎样从 jstack(线程栈工具)定位根因?
  • 口述答案:看到大量 BLOCKED(阻塞)或 WAITING(无限等待)时,我不会先把它当成死锁。先连续采集多份 jstack(线程栈工具),按锁对象或 ReentrantLock(可重入锁)关联等待者与持有者,确认是否是同一把锁长期占用;然后看持有线程栈帧是在业务计算、数据库调用、HTTP(超文本传输协议)调用、日志 I/O(输入输出)还是递归锁调用。若持锁期间等待下游,根因通常是临界区过大,应把状态校验和内存修改留在锁内,把慢调用移到锁外,再用状态机处理并发回调。若持有者已经不存在或队列长期无进展,要检查异常分支是否遗漏 unlock(解锁)、Semaphore(信号量)许可是否泄漏、Condition(条件队列)谓词是否永远不成立。与此同时对照锁等待分位数、持锁时长、下游延迟和线程池队列,判断是锁本身还是下游把锁放大。修复后必须复采线程栈并验证等待对象分布已消失,而不是只看一次重启后的短暂恢复。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • BLOCKED(阻塞)与 WAITING(无限等待)差别?前者常等 synchronized(同步锁)监视器,后者常等 park(挂起)、条件或队列。
    • 如何判断死锁?jstack(线程栈工具)可报告循环等待链,还要核对锁对象和线程状态。
    • 公平锁能解决锁内慢调用吗?不能,它只调整排队顺序,慢临界区仍会拖住所有后继。
  • 回到锁等待排障
  1. 问题(综合口述题):如何排查 Condition(条件队列)看似“信号丢失”的问题?
  • 口述答案:我会先把“信号丢失”改写成可验证问题:等待的业务谓词是什么,谁在什么锁下改变它,谁在哪个 Condition(条件队列)上等待,signal(通知)发生时节点是否真的在该等待链。给这几个点记录状态版本和时间线:消费者持锁检查谓词后进入 await(等待)的版本,生产者持锁修改谓词的版本、调用 signal(通知)的条件对象,以及消费者被转移到同步队列后重新 acquire(获取)的结果。若通知时谓词始终不成立,说明不是丢信号,而是生产者没有产出、状态被他人抢走或条件定义错误;若谓词成立却无人返回,检查是否等待在错误锁或错误 Condition(条件队列)、是否遗漏 unlock(解锁)、是否超时取消。代码结构必须是 while(循环)检查谓词、await(等待),生产者先修改状态再 signal(通知),两侧都持有同一把锁。notifyAll(通知全部)只能扩大唤醒范围,不能修复谓词和锁边界错误,还可能制造惊群。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • 为什么 signal(通知)前先改状态?被唤醒者重新获取锁后必须能观察到已成立的谓词。
    • await(等待)为何释放全部重入层数?返回后要恢复原有重入语义,不能留下部分持有。
    • 需要超时吗?需要,超时既是业务边界也是定位永久条件不成立的证据。
  • 回到 Condition(条件队列)流程
  1. 问题(综合口述题):为什么公平锁可能让系统 QPS(每秒查询率)下降?如何验证是否值得?
  • 口述答案:公平 ReentrantLock(可重入锁)会尽量让已有队列前驱先获得锁,因此新到且正在 CPU(中央处理器)上运行的线程不能像非公平模式那样直接快速获取。它减少了插队和长期饥饿风险,却增加了从释放者到等待者的唤醒、调度和上下文切换;如果临界区非常短,这些交接成本可能超过实际业务工作,表现为 QPS(每秒查询率)下降、平均延迟上升。验证时不能只做微基准:要在接近生产的并发、热点键、线程数和下游延迟下,对比两种模式的吞吐、P99(99 分位响应时间)等待时长、持锁时长、上下文切换和个体请求饥饿情况。若非公平模式没有明显长尾饥饿,优先治理锁粒度、热点拆分和锁内慢调用;若某些请求必须有明确等待上界,公平锁才可能值得。更重要的是,跨实例支付或库存顺序不由本机公平锁保证,真正顺序语义要由持久化队列、序列号或状态机表达。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • P99(99 分位响应时间)改善而平均吞吐下降能接受吗?取决于服务等级目标,关键交易可能更重视尾延迟与公平。
    • 非公平锁是否不排队?仍会在竞争失败后进入 AQS(抽象队列同步器)队列,只是保留快速插队路径。
    • 怎样减少交接成本?缩短临界区、减少竞争、按键分片,比单纯改锁策略更根本。
  • 回到公平性权衡
  1. 问题(综合口述题):计数不准时如何区分 LongAdder(长整型累加器)设计特征和真实数据问题?
  • 口述答案:首先定义“准”指什么。LongAdder(长整型累加器)的 sum(求和)要读取 base(基础值)和多个 CounterCell(计数单元),读取过程中并发 add(累加)仍可发生,所以它不承诺某个精确线性化时刻的全局值;用于 QPS(每秒查询率)、错误率趋势和报警量时,这种短暂汇总偏差是设计允许的。若同一时间窗的监控与账单、数据库事件或消息消费数差异很大,就不能简单归因于 LongAdder(长整型累加器)。我会核对窗口边界、重置方式、实例标签、采集间隔、进程重启、重复上报和丢失事件;再用原始事件或精确 AtomicLong(原子长整型)对小流量样本交叉验证。跨实例场景还要确认是否把多台机器的本地值误当总数。修复策略是按用途分层:观测指标保留高吞吐累加并明确近似语义,审计、计费和限额使用持久化不可变事件或事务计数。这样不会为了追求“监控绝对精确”重新制造热点,也不会让近似指标污染业务账本。
  • 补充落地原则:同步器只解决进程内共享状态的协调,不能替代数据库约束、分布式租约、消息幂等或人工补偿。每一条成功路径都要列出超时、取消、异常和重启后的反向路径,并把资源释放放在 finally(最终清理)中;每一条重试路径都要确认没有重复扣费、重复扣库存或重复发送。上线前同时验证失败率、等待时长、CPU(中央处理器)、队列长度、下游延迟与业务成功率,并按实例、资源键和时间窗口拆分观察。发现异常时先还原请求时间线、状态版本和持久化流水,再改参数、换工具或扩大容量,避免局部吞吐提升掩盖最终一致性和审计风险。
  • 追问树与直接答案
    • sum(求和)为何不锁住所有单元?锁住会抵消条带化写吞吐优势,也会增加协调成本。
    • 哪些场景必须精确?资金、库存、配额处罚、审计和发号必须有可追溯精确事实。
    • 如何避免窗口重置漏数?用双缓冲、事件流或明确归属协议,并记录窗口版本与实例标签。
  • 回到 LongAdder(长整型累加器)边界