目录
(五)把锁用对的三件事:锁在谁身上、锁多大范围、锁里不许做什么
干货分享,感谢您的阅读!
"这里加把锁"这句话,在代码评审里几乎不会被追问。其背后有着六个性质完全不同的决定:护的是哪几个字段、谁来排队、怎么等、等不到怎么办、可见性靠什么成立、争用大了会怎样。把这六件事合成一句"加锁就行",等于在其中五件上放弃判断。
本次我们尝试深入:内置锁的四种状态与它换来的"不可能漏释放";显式锁把等待变成可编程对象之后多给的五件事与多担的一件事;比较并交换真正解决与没有解决的各四件事;读写锁、邮戳锁、原子类、分段累加器各自成立的窄区间;一次同步的开销究竟花在哪几笔上;以及一张能把"这段代码该用什么"走完的四步判定表。
真正贵的是争用,不是加锁这个动作。
一、"这里加把锁"被合并的六个决定
(一)展开说说六个决定
评审会上出现一句"这块并发有问题,加把锁就好了",通常不会有人追问它到底指什么。这半句话听上去像方案,实际上是六个独立问题被压成了一个印象。分不开这六个问题,讨论就会在"先加个 synchronized 顶一下"和"要不要换成无锁"之间来回摇摆,最后往往是前者取胜------因为它见效快,尽管它把代价推迟到了流量翻倍的那一天。
第一个决定是护什么。要保护的是哪几个字段,这个不变式跨越了几次方法调用,读的那一侧要不要也进入临界区,边界之外还有没有相关状态没被覆盖。这一问答不清楚,后面五问都是在一个说不清边界的范围上做文章------很多"加了锁还是错"的案例,根因就停在这里:写路径进了锁,读路径没进;或者不变式其实跨了两个对象,而锁只护住了其中一个。
第二个决定是谁排队,也就是互斥的粒度。锁对象是某个实例、某个类,还是按业务键分出来的一组段;同一把锁被多少条调用路径共用;存在热点键时,看似很细的粒度会不会在实际流量下退化回全局一把锁。粒度决定了争用的上限,而争用决定了后面所有关于代价的讨论。
第三个决定是怎么等。拿不到锁的线程是挂起还是自旋,挂起要不要设上限,重试要不要退避,等待期间能不能被取消。这一问在单机低负载下几乎观察不到差别,在高并发下则直接决定系统是"变慢"还是"停住"。
第四个决定是拿不到怎么办,也就是失败语义。无限等、超时失败还是立刻放弃走降级;超时之后返回给调用方的是什么;这次失败要不要留下一条可检索的记录;上游给这次请求的时间预算,够不够支撑这一段等待。没有失败语义的等待,等于把上游的超时控制权交给了锁的争用情况。
第五个决定是看得见吗,也就是可见性契约。解锁与加锁之间建立的那条次序边覆盖了哪些写;临界区之外的读走的是哪条路;把互斥换成无锁写法之后,这条边还在不在。这一条最容易在"优化"时被弄丢:把同步块换成一个普通布尔字段,互斥没了,可见性也一并没了。
第六个决定是扛得住吗。无争用与重争用的代价差几个量级,临界区停留多久,线程数上去之后吞吐是继续升还是掉头向下,有没有一条可对照的基线。这一条不作答,任何关于"哪个更快"的讨论都只是互相报印象。
这六个决定的顺序不能交换。护什么没想清,粒度就无从谈起;粒度没定,等待策略只是在猜;失败语义缺席,超时预算就形同虚设;可见性没确认,前面全部白做;代价没量过,最后一步的选型就没有依据。

(二)"内置锁慢、无锁快"为什么不是一句可用的结论
关于同步设施的选择,流传最广的几句话都有一个共同特点:它们把一个二维问题压成了一维排名。
第一句是"synchronized 是重量级锁,能不用就不用"。 重量级只是内置锁四种状态里的一种,而且是争用真正发生之后才会进入的那一种。无争用时的一次加锁解锁,量级与一次同步写相当,落在纳秒区间。把"可能升级为重量级"当成"一直是重量级",等于按最坏情况给所有代码定价。
第二句是"换成原子类就是无锁,一定更快"。 无锁并不是没有等待,而是把"等待"换成了"重试"。低争用时重试几乎不发生,这笔交易很划算;高争用时每次失败都要重新读取被别的核改脏的缓存行,重试次数随线程数上升,吞吐会掉头向下。它换掉的是代价的形态,不是代价本身。
第三句是"加了锁性能就差,所以要尽量减少加锁次数"。 真正贵的是争用,不是加锁这个动作。把十次无争用的加锁合并成一次,收益接近于零;把一次持有五毫秒的加锁缩短到五十微秒,收益是数量级的。方向搞反了,改动就会花在没有收益的地方。
第四句是"公平锁更合理,默认应该开公平"。 公平解决的是次序问题,不解决正确性问题。它用一次确定的移交换掉了抢占带来的吞吐收益,代价通常是量级的下降。业务不依赖次序时开公平,只是在为每一次移交多付一次唤醒。
把同一份负载按线程数展开,三种典型写法的排名会在不同区间里完全颠倒:低争用区谁都不算瓶颈,中争用区单点无锁到达峰值后回落,高争用区只有把单点拆成多点才能继续爬升。这条曲线本身就是对"哪个更快"这个问法的否定。

唯一可用的说法是:设施的代价取决于争用度、临界区停留时间与访问模式,而不取决于它写成哪个关键字。脱离这三者比较快慢,结论必然被下一次流量变化推翻。
(三)这次选型真正要覆盖的九件事
把六个决定展开,一次完整的同步设计实际要覆盖九项能力。它们各自独立,缺一项都会在某个阶段暴露。
第一是不变式识别 :说得出要保护哪几个字段,算得出它跨越了几次调用。第二是锁粒度设计 :锁对象与业务键的选择有依据,热点键有拆分方案。第三是等待语义建模 :阻塞、自旋与放弃三选一,且每一种都有上限与出口。第四是失败处置 :拿不到锁时返回什么,超时预算自上而下切分。第五是可见性推理 :指得出次序边建立在哪一步,换了写法之后这条边仍然成立。第六是代价估算 :区分无争用路径与争用路径,知道一次挂起唤醒的量级。第七是结构改造 :会用分段、副本与不可变把争用减掉,而不是绕开。第八是取证能力 :能拿到线程栈与锁的持有者,能证明瓶颈确实在这把锁上。第九是演进纪律:后来新增的字段仍在锁的覆盖范围内,换了运行时版本之后行为不变。

评审与走查里真正拉开差距的,几乎全落在第三、第六、第八项上。会写同步块、会用原子类的人很多;能说清"这条线程此刻停在哪一层、为什么停在那里、怎么证明"的人少得多。
(四)换设施之前,先把四个数算出来
关于"这里该用什么"的讨论,最容易变成立场之争,因为双方都没有数。下面四个数都能在一个下午内测出来,测出来之后,讨论通常会从"哪个更快"自动转向"争用从哪来"。
1、争用度
取同一把锁上等待线程的平均条数,或者加锁失败次数占总加锁次数的比例。低于百分之五可以按无争用处理------此时任何设施替换的收益都接近于零;高于三成才值得动结构,而且动的应该是粒度而不是设施。这个数最好按时间序列采集,因为它随流量变化,压测峰值与日常均值往往差一个量级。
2、临界区停留时间
从加锁成功到释放的耗时,以及其中同步等待占了多少。超过一微秒就不要再考虑自旋,超过一毫秒则必须先查临界区里到底做了什么------这个量级通常意味着里面藏着一次远程调用、一次落盘或者一次大对象序列化。很多"锁很慢"的结论,实测下来根因都在这一项。
3、读写比
读操作与写操作的次数之比,以及读之间是否真的互不影响。高于十比一才轮得到读写锁,低于五比一时读写锁多半亏本------它只降低了读之间的互斥,写与一切之间的互斥一点没少,而它自身的获取开销要由每一次读来分摊。
4、重试成本
一次冲突重试需要重算多少工作,重试路径上是否带有外部调用或副作用。重算成本高于一次挂起唤醒时,乐观路线就不再划算;循环体里带副作用时,乐观路线则是直接错误的------重试会让副作用执行多次。

四个数里最容易被跳过的是第二个。它有一个不讨喜的性质:一旦测出来,往往会证明这次讨论的前提是错的------瓶颈不在设施选择上,而在临界区里那一行谁都没注意的调用上。
二、三种范式的分层:不共享、乐观与互斥
(一)悲观与乐观不是两种锁,是两种关于冲突概率的假设
"悲观锁"与"乐观锁"这两个名字容易让人以为它们是两类设施,实际上它们是两种关于"冲突会不会发生"的假设,以及由这个假设推导出的两套代码结构。
悲观假设冲突会发生,所以先占位。 先取得独占权,再读、再算、再写,别人在这期间只能等。正确性由互斥保证,不需要重试,因此临界区里可以有副作用、可以调用不可重入的逻辑。代价花在等待上:排队、挂起与唤醒。它的失败形态是"等不到"------超时、被中断,或者最坏情况下的死锁;表现为响应变慢,而不是结果出错。
乐观假设冲突很少,所以事后再验。 先读、再算,提交时校验这期间有没有人改过,没改过才生效。正确性由校验与重试保证,因此循环体必须可以安全地重跑。代价花在重试上:重算与缓存行在核间来回搬运。它的失败形态是重试次数上升,表现为吞吐塌陷,而不是排队变长。

两者的分界不在语法上,而在三个问题上:这段逻辑要维护的不变式跨越几个变量(跨多个只能悲观);循环体里有没有副作用(有则不能乐观);实测的冲突率是多少(高则乐观退化)。这三个问题里,第二个最常被跳过------在重试循环里顺手加一行日志或者一次统计上报,是非常自然的动作,而它恰好会让这段代码在冲突时产生重复副作用。
(二)四层选型阶梯:能停在上面一层,就不要下到下面一层
比"悲观还是乐观"更靠前的问题是:这里到底需不需要同步。把它展开,得到一条四层的阶梯。
第一层是不共享。 线程封闭的状态不需要任何同步设施:局部变量、每线程一份的缓冲、请求作用域内的可变对象。它的好处不只是零代价,更在于它不产生并发问题,因此也不会被后来的人改坏。
第二层是不可变。 状态创建之后不再改变,读者一律安全:配置快照、值对象、消息体、写时复制容器。代价从读路径转移到了写路径的整体替换上------这恰好符合"读多写少"这个最常见的形态。
第三层是单变量原子。 一个变量的读改写:计数、状态位、单字段的乐观更新、引用的整体替换。原子类与比较并交换管这一层,它不保证多个变量之间的不变式。
第四层才是互斥临界区。 多个字段共同维护一个不变式,或者需要条件等待。内置锁与显式锁管这一层,无争用时代价中等,重争用时代价跳升。

选型顺序应当自上而下。理由不是性能,而是推理成本:越靠上的层次,需要人去推理的东西越少;越靠下的层次越依赖每个改动者都遵守约定,而约定会随人员流动失传。下降的理由必须是"上一层放不下这个不变式",而不是"上一层写起来不顺手"。
两处最常见的越级值得点名:把本该线程封闭的状态提成字段,然后再加锁保护它;把本该整体替换的不可变对象拆成几个原子字段,然后逐个更新。前者制造了本不存在的并发问题,后者制造了一个看起来很专业、实际会破的中间态。
三、内置锁:语言里那把不会忘记释放的锁
(一)锁记在哪里:对象头与它背后的监视器
任何一个对象都能当锁,这件事看起来理所当然,它的代价是每个对象都要留出记账的位置。
对象在内存里分成几段:对象头里的标记字、指向类元数据的类型指针、实例数据,以及对齐填充。锁的全部信息就压在标记字的那几十个比特里,并且随锁状态整体改写含义------未被当作锁使用时它存哈希码与分代年龄;被轻量级持有时它存一个指向持有者栈上锁记录的指针;膨胀之后它存一个指向监视器对象的指针。同一块比特位在不同状态下表示完全不同的东西,这是理解后面那条升级路径的前提。
标记字指向重量级监视器之后,等待被组织成两条队列。入口队列 里是抢锁失败的线程,它们的状态是阻塞,不消耗处理器;释放锁时从这里挑一个唤醒。等待集里是主动调用等待方法让出锁的线程,进入前必须先持有锁,被通知之后并不会直接继续执行,而是重新回到入口队列排队。这两条队列的区别,正是"被通知"与"拿到锁"不是一回事的原因。

(二)四种状态与它们之间的单向升级
内置锁并不是只有一副面孔。从没有任何线程碰过,到真正出现重叠的争用,它会经过几种状态,每一种的代价相差很远。
无锁 是对象从未被当作锁使用时的状态,标记字里放的是哈希与年龄,代价为零。偏向 状态把线程标识写进标记字,同一条线程再次进入时连比较交换都不需要,适合长期只有一条线程加锁的场景。轻量级 状态在线程栈上建立锁记录,靠一次比较交换抢占,失败则短暂自旋,适合多线程交替但不重叠的访问。重量级状态膨胀出监视器对象,抢不到就挂起,把调度交给操作系统,这才是"重量级"这个词真正指向的东西。

有两点需要更新认知。第一,升级是单向的 :一旦膨胀成重量级,通常不会在同一次使用中降回去,所以偶发的一次重叠争用会把后续所有访问都留在较贵的路径上。第二,偏向状态已经退场:它在较新的长期支持版本里被默认关闭并随后移除,原因是撤销偏向所需的停顿代价,在如今普遍多线程的负载下已经超过它节省的那点比较交换开销。
由此得到两条可以直接用的推论。其一,"把内置锁换成显式锁以避开重量级"是无效的------真正争用时两者都要挂起线程,走的是同一条系统调用。其二,值得优化的永远是争用本身:减少重叠、缩短停留、拆细粒度,而不是在关键字之间来回替换。
(三)可重入:让"同一条线程再次进入"不至于自我死锁
内置锁记的不是一个占用标志,而是持有者加层数。同一条线程连续进入两层同步块时,重入计数从一变成二;内层返回减回一,此时锁仍被本线程持有;外层返回减到零,锁才真正释放并唤醒队首。
可重入不是一项优化,而是让一类非常普通的代码得以成立:子类的同步方法调用父类的同步方法;同步方法内部递归,或者走进同一个对象上的另一个同步方法;持锁期间触发回调,而回调又绕回同一个对象。如果锁不可重入,这三种写法都会让线程等待自己释放,形成一种最难排查的自我死锁。

显式锁同样可重入,但两者有一处关键差别:内置锁在退出同步块时自动递减,无论是正常返回还是抛出异常;显式锁的每一次获取都必须对应一次显式释放,少写一次就是永久泄漏。这也是后面反复出现的那条纪律的来源------释放必须写在最终块里。
需要补一句的是:可重入让"持锁调用"在语法上变得可写,但并不让它在设计上变得可取。持锁期间调用外部代码,等于把一段你无法控制其耗时的逻辑纳入了临界区。
(四)它给了什么,又明确不给什么
把内置锁的能力与边界摆在一起,选型的第一道岔路就清楚了。
它确实做到六件事:同一时刻只有一条线程进入临界区;同一条线程可以重复进入并逐层退出;退出时建立次序边,解锁之前的写对后续加锁者可见;无论正常返回还是抛出异常都必然释放;无争用时代价很低;配合等待与通知可以表达"条件不满足就让出"。
它明确不做四件事:不能设定等待上限,拿不到就一直等;不能在等待期间响应中断,取消不了;不能先尝试一下再决定,没有"试着获取"这一步;只有一个等待集,不同条件的等待者混在一起。

这四条边界不是缺陷,而是它换取"不可能忘记释放"所付出的代价。判据因此可以写成一句话:只要"等待"这件事需要被约束------限时、可取消、可放弃、可分类------就该换显式锁;只要不需要,内置锁在写法上更难出错。
(五)把锁用对的三件事:锁在谁身上、锁多大范围、锁里不许做什么
绝大多数被归因为"锁的性能问题"的案例,根因都在下面这三件事里,而不在设施的选择上。
1、锁对象的选择
有四种锁对象是明确错误的。锁字符串字面量 :同一字面量在整个进程里是同一个对象,你会与完全无关的代码互锁。锁装箱后的小整数 :缓存范围内的相同数值是同一个对象,锁的范围会莫名其妙地扩大到全局。锁一个会被重新赋值的字段 :换了引用之后,两条线程锁的其实是两个不同的对象,互斥悄悄消失。锁公开可见的对象本身:外部代码也能锁它,而它持有多久你无法控制。
只有一种写法不会出问题:私有的、终态的、专门用来当锁的对象。
java
private final Object balanceLock = new Object(); // 私有、终态、专用
void transfer(BigDecimal amount) {
synchronized (balanceLock) { // 只护余额与流水这一组不变式
balance = balance.subtract(amount);
journal.append(amount);
}
}
2、锁的粒度
从全局一把锁,到按对象一把锁,到按键分段,再到每线程一份,争用递减而内存与复杂度递增。停在哪一档由实测的争用度决定,而不是由"看起来是否高级"决定。这里有一个容易被忽略的性质:按键分段只有在键分布均匀时才有效,存在热点键时,再细的分段也会退化回全局一把锁------此时正确的动作是给热点键单独设计,而不是继续增加段数。
3、临界区里不许做的四件事
远程调用与数据库访问 :把网络抖动直接变成锁等待。日志落盘与大对象序列化 :一次刷盘就能把停留时间拉高两个量级。回调外部代码 :对方做什么你不知道,还可能反向申请你的锁。再去申请另一把锁:顺序不一致就是死锁,必须有全局锁序兜底。

这三件事有一个共同点:它们都在减少"需要排队的时间",而不是在减少"加锁的次数"。做完这三件事之后再讨论换设施,通常会发现已经没有必要换了。
四、显式锁:把等待变成一个可编程的对象
(一)地基:一个状态字段、一条等待队列、一对挂起与唤醒
显式锁、读写锁、信号量、倒计数器,以及线程池内部的等待逻辑,共用的是同一副骨架。理解这副骨架,比记住每个类的方法列表有用得多。
第一件构件是同步状态:一个整型字段,含义由使用者定义。互斥锁用零表示空闲、用正数表示重入层数;信号量用它表示剩余许可数;倒计数器用它表示剩余计数。所有对它的修改都走比较交换。
第二件构件是等待队列:一条双向链表,入队与出队都靠比较交换完成。每个节点记着线程引用与等待状态,并区分独占与共享两种模式。释放时通常只唤醒后继一个节点,而不是把所有等待者叫醒。
第三件构件是挂起与唤醒:一对许可式的原语。它的关键性质是"先唤醒后挂起也不会丢"------许可会被记住,因此不存在通知早于等待就永久丢失的问题。它同时支持限时与响应中断,这正是显式锁那几项额外能力的来源。
一次获取失败的完整路径是这样的:先尝试用比较交换把状态从零改成一,成功就直接持有,整条路径到此结束;失败则把当前线程包装成节点接到队尾;接着看前驱是不是队首,是则再试一次获取------很多时候第二次就成功了;仍然失败才把前驱标记为"需要唤醒后继",然后挂起;被唤醒后从挂起点继续,回到上一步重试,直到拿到锁或者被取消。

把状态字段的含义换一换,同一副骨架就变成了不同的设施:状态表示重入层数就是互斥锁,高低位分别表示读写计数就是读写锁,表示剩余许可就是信号量,表示剩余计数就是倒计数器。
理解这副骨架有两个实际用处。其一,看懂线程栈:一条线程停在挂起方法上,意味着它已经排进了队列,而不是在自旋空转------这两种状态的处置方式完全不同。其二,明白公平与否、可中断与否、能不能超时,都只是这条获取路径上的几个分支,而不是几种不同的锁实现;它们的差别在分支上,代价的主体是共同的。
(二)多给的五件事,与多担的一件事
相对内置锁,显式锁多给了五件事。尝试获取 :立刻返回成功与否,拿不到就走降级分支。限时获取 :把等待纳入超时预算,超时成为一个可预期的结果而不是意外。可中断获取 :等待期间响应中断,关闭流程能把线程拉回来。公平策略 :按到达次序移交,用吞吐换可预期的次序。多个条件队列:不同条件的等待者各挂各的,唤醒的都是该醒的那一批。
它多担的那一件事是释放。内置锁在退出同步块时自动释放,显式锁必须自己写,而且必须写在最终块里:
java
lock.lock(); // 获取写在 try 之外
try {
// 临界区
} finally {
lock.unlock(); // 少写这一行,这把锁就永远不会再被释放
}
限时获取的完整形态还要多一步------返回值必须参与控制流:
java
if (!lock.tryLock(80, TimeUnit.MILLISECONDS)) {
return Result.busy(); // 明确的失败语义,而不是继续往下执行
}
try {
// 临界区
} finally {
lock.unlock();
}

选择判据可以正着说,也可以反着说。正着说:只要出现"有响应时间预算""关闭时要让等待者退出""拿不到就走降级""两类等待者要分开唤醒""需要观察锁的状态"这五种诉求中的任何一种,就该选显式锁。反着说:如果这五件事一件都用不上,那么显式锁带来的只有一处可以漏写的释放。
(三)公平与非公平:一次插队省下的,和它换走的
默认非公平不是实现上的疏忽,而是把吞吐放在了次序前面。
锁被释放的那一瞬间,两种策略的处置完全不同。非公平 :队列里被唤醒的线程还处在调度延迟中,此时刚好到达的另一条线程直接一次比较交换成功,拿走了锁;被唤醒的那条醒来发现锁又没了,重新挂起。看起来很不讲理,但它省掉了一次完整的上下文切换,整体吞吐更高。公平:新到达的线程先看队列是否为空,非空就直接排到队尾,锁一定移交给等待最久的那一条;代价是每一次移交都必须唤醒,吞吐显著下降。

需要选公平的情形只有一类:次序本身就是业务语义的一部分,或者存在明确的尾部延迟约束而非公平已经出现饥饿。除此之外,处理饥饿的首选手段是限时获取加重新排队,而不是把整把锁改成公平------前者只影响出问题的那一小部分请求,后者会让所有请求都多付一次唤醒。
(四)读多写少的两级优化
1、读写锁:把"读之间不必互斥"这件事显式化
读写锁的相容规则只有三条:读与读可以并行进入;读与写必须互斥;写与写必须互斥。它带来的收益完全来自第一条,而它的代价由每一次读来分摊。
方向上有一条硬规则:写锁可以降级为读锁,读锁不能升级为写锁。降级的合法形态是在持有写锁的状态下先取得读锁,再释放写锁,最终只剩读锁------这中间没有任何一刻放开保护。反方向则是一种自我死锁:持有读锁再申请写锁,会等待包括自己在内的所有读者释放,而自己永远不会释放。

选它之前必须确认四件事:读确实远多于写(十比一以上才有明显收益);读临界区不是只有几纳秒(太短则同步开销盖过并行收益);写不会持续到来(写者密集会让读者长期等待);读路径里不会走进写路径(升级即死锁)。四条里任何一条不成立,普通互斥锁往往是更好的选择。
还有一条容易被忽略的性质:读写锁只降低了读之间的互斥,写与一切之间的互斥一点没少。如果瓶颈其实在写路径上,换读写锁不会带来任何改善。
2、邮戳式的乐观读:先不打扰写者,读完再回头校验
邮戳锁提供了第三种模式:乐观读。它的执行形态是固定的四步------取一个乐观戳(不加任何锁,只记下当前版本);把要用的字段读进局部变量(此刻读到的可能是半截状态);用戳校验这期间有没有发生写(通过则这份快照一致);校验失败则退化为悲观读锁再读一次。
java
long stamp = sl.tryOptimisticRead();
double cx = x, cy = y; // 先把字段读进局部变量
if (!sl.validate(stamp)) { // 再校验这期间有没有人写过
stamp = sl.readLock();
try { cx = x; cy = y; }
finally { sl.unlockRead(stamp); }
}
return Math.hypot(cx, cy); // 只使用校验过的那份副本
这段写法的每一步顺序都不能改:读必须在校验之前,使用必须在校验之后。校验之前读到的值不许参与任何判断,更不许触发副作用------因为它可能是一个从未真正存在过的组合。

它还有三条禁令:不可重入,同一条线程再次获取会把自己锁死;不支持条件等待,需要等待某个条件时不能用它;不响应中断,因此在需要优雅关闭的路径上要格外小心。它适合坐标、缓存快照这类"字段少、读极频繁、写很罕见"的结构,不适合当作通用锁使用。
(五)条件等待:三个必须同时满足的写法约束
等待与通知写错的后果不是变慢,而是永远醒不过来,或者醒来之后做错事。正确形态只有一种:
java
lock.lock();
try {
while (queue.isEmpty()) { // 必须是循环,不能是一次性判断
notEmpty.await(); // 等待期间自动释放锁,醒来后重新持有
}
return queue.poll();
} finally {
lock.unlock();
}
三条约束缺一不可。第一,必须持有锁才能等待或通知 :等待动作会先释放锁再挂起,醒来后要重新拿回锁才能继续;不持锁就调用会直接抛出监视器状态异常------这是三种错误里唯一会当场报错的一种。第二,判定必须写成循环 :唤醒不保证条件已经成立,可能是批量唤醒,也可能是虚假唤醒,醒来后必须重新检查再决定。第三,通知的对象要选对:单个唤醒省事但可能唤错人,两类等待者共用一个条件队列时,会出现大量线程醒来又睡回去。

还有一种更隐蔽的错误:通知早于等待。如果通知发出时还没有人在等,这次通知就直接丢失了,后来的等待者会一直挂着。防住它的办法不是加延时,而是把"条件是否成立"记录在共享状态里,让等待者在挂起之前先检查一次------这正是循环判定同时解决的第二个问题。
(六)显式锁上最常见的五个失误
第一,释放不在最终块里。 临界区一旦抛出异常就再也不释放,后续线程全部堆在获取处。这是所有失误里后果最严重的一个。
第二,获取写在最终块之前的位置不对。 把获取写进 try 内部,则获取失败时也会执行释放,抛出非法监视器状态异常。正确写法是获取在 try 之外,最终块只负责释放。
第三,忽略限时获取的返回值。 超时了却继续执行临界区,破坏的是正确性而不是性能,而且不会有任何报错。
第四,两处锁了不同的对象。 看起来都加了锁,实际上互不互斥,表现为偶发的数据错乱。
第五,把锁对象暴露出去。 外部代码也能长期持有它,于是停顿点出现在你控制不到的地方。

五个里只有一个会当场报错,其余四个都只在特定时序下才显形。这也是为什么这几条更适合交给静态检查与代码走查,而不是指望测试覆盖到。
五、比较并交换与原子类:把冲突留到最后一刻再处理
(一)执行环与它的四条边界
比较并交换不是"没有锁",而是把锁的粒度缩小到一个内存位置与一条指令。它的执行模型是一个环:读取当前值,基于它算出新值,然后比较并交换;如果交换时发现值已经被别人改过,就失败重来,成功一次就退出。
它确实解决四件事:读、比、写在硬件上不可分割,中间插不进别的线程;无争用时不需要挂起线程,省掉一次上下文切换;失败只是重来,不会因为等待而死锁;成功的一次交换同时带来次序边,可见性问题一并解决。
它也有四件事没有解决。只管一个内存位置 :两个字段要一起改,它帮不上忙------要么把两个字段合成一个不可变对象整体替换,要么老实用互斥。高争用下会退化 :失败率上升,自旋空转把处理器烧掉,吞吐反而低于直接加锁。只比较值不比较历史 ,由此产生取值反复的陷阱,下面单独展开。循环体必须可重跑:不能有副作用,不能有外部调用,否则重试会造成重复执行。

第四条在业务代码里踩得最多。下面三行都是重试循环里绝不能出现的语句:
java
log.info("retry once"); // 重试几次就打几条,日志量随争用放大
metrics.increment(); // 统计被重复计入,指标失真
notifier.send(event); // 重复通知,而且把整个环拖长
关于选型的总结只有一句:争用低时它比锁快,争用高时它比锁慢,拐点在哪里只能实测。所以"把锁换成无锁"不是一个无条件的优化,它是一次把等待代价换成重试代价的交易;只有当重试的代价确实低于一次挂起唤醒时,这笔交易才划算。
(二)原子类家族:六类构件各管一段
原子类不是一个类,而是一组分工明确的构件。标量原子类 管单个数值的读改写,用于计数器、状态位、序列号。引用原子类 管一个引用的整体切换,配合不可变对象使用时,可以把"多个字段一起变"降维成"一个引用变"。带戳记的引用 在值之外多比较一个版本,用于需要识别取值反复的场景。数组原子类 管单个下标元素的读改写。字段更新器 为已有类的某个字段补上原子性,代价是该字段必须可见且非终态。累加器与分段计数用空间换争用,适合高频写入、只在末尾读取一次的统计。

两个最常见的误用值得单独记住:
java
// 误用一:两个原子字段合起来维护一个不变式
low.set(a);
high.set(b); // 两次调用之间的中间态会被别人读到
// 误用二:把复合判断拆成两次原子调用
if (ref.get() == null) {
ref.set(create()); // 两条线程可能都判断为空,于是创建两次
}
// 正确写法:用一次原子的组合操作
ref.compareAndSet(null, create());
第二个误用比不用原子类更隐蔽,因为代码看起来已经"用上了并发工具"。真正的价值在于那些组合方法------比较并设置、若不存在则设置、按函数更新;先读一次再写一次,等于把两个原子操作之间的缝隙重新打开。
(三)把一个热点拆成很多个:分段累加的思路
同样是无锁,单点与分段在重争用下相差的是量级而不是百分比。
单点 的问题不在于指令本身,而在于缓存一致性:所有线程改同一个变量,每次成功的交换都要把缓存行从别的核搬过来;线程越多,失败重试越多,吞吐反而下降。分段把线程散列到不同的格子上,各改各的,几乎不再互相碰撞;读取时把所有格子求和,只在读的那一刻付出代价。格子数会随争用自动增长,而不是一个写死的常量。

代价也很明确:读一次要遍历所有格子;汇总期间其他线程仍在写,所以只保证最终一致而不是精确瞬时值;内存占用随争用增长;并且它不支持比较并设置这类条件更新。因此判据是:写多读少选分段,读频繁或需要精确瞬时值就用单点。
这条思路不止用于计数。并发映射表的分段、连接池的分片、限流器的按键分桶,用的是同一招------把一个所有人都要碰的位置,换成很多个只有少数人碰的位置。它的代价永远出现在"需要全局视图"的那一刻:求和、取最大、判断总量是否超限。
(四)只比较值,不比较历史
比较并交换看的是值,不是历史,由此产生一个有名字的陷阱:线程甲读到某个取值;线程乙把它改成另一个值;线程乙又改了回来;线程甲比较时发现"值没变",于是交换成功。线程甲全程不知道这中间发生过两次修改。

它真的会造成错误的场景有三类:值承载着对象身份而不只是数量,典型是无锁栈的头指针------节点被弹出、复用、又被压回,指针相同但结构已经变了;业务上依赖"这期间没人动过",并据此触发了外部动作;取值来自可回收的资源编号,编号回收后又被分配给了别人。
它其实无所谓的场景更多,而且占绝大多数:值就是一个纯粹的数量,例如计数器、累加器、统计量;只关心最终值对不对,不关心中间路过了哪些取值。这类场景不需要为它付出任何额外代价。
修法有三种。带版本戳的引用 :值与戳记一起比较,每次修改让戳记加一,代价是多一个字与一次比较。让状态只进不退 :把状态建模成单调递增的版本或序号,天然不会出现取值反复,比引入戳记更简单,很多业务场景可以这样重新建模。改用互斥:整段逻辑放进临界区,不再依赖值比较------在重争用下这往往还更快。
(五)自旋是一笔预付:省下一次挂起,代价是空转的时间
自旋与挂起的差别,可以摊开成两条时间线。自旋等待 :一段占着处理器的空转检查,然后立刻拿到锁开始执行。挂起等待:陷入内核挂起,一段不占处理器的等待,被唤醒并重新调度,然后拿到锁开始执行。临界区很短时,前者整体更快;临界区较长时,后者把处理器让给了别人,系统整体更划算。

判断依据有四项:临界区停留时间是否远小于一次挂起唤醒(挂起唤醒通常在微秒级);线程数是否超过可用核数(超卖时自旋是纯浪费);锁的持有者此刻是否还在运行(如果持有者自己都被换出了处理器,自旋等的是一个不会很快到来的释放);等待时长是否可预期(不确定就别自旋)。
工程上有三种相关手段。自适应自旋 由运行时按上一次在同一把锁上的成绩决定这次旋多久甚至旋不旋,不需要也不应该手工调参。自旋提示 在忙等循环里插入一条让核指令,降低对同核另一线程的挤占,只影响效率不影响正确性。退避在失败后短暂停顿再重试,把冲突在时间上错开;指数退避必须设上限,否则尾部延迟会失控。
写业务代码时几乎不需要手写自旋:它已经内置在锁的获取路径与原子类的重试循环里。看到手写的忙等循环,第一反应应当是确认有没有现成设施可用。
(六)访问模式的四档强度
同步不是只有开与关。运行时提供的受控访问接口把强度分成四档:普通访问 不保证任何跨线程可见性,编译器可以自由合并、消除、提升,这是字段没有任何修饰时的默认行为;不透明访问 保证访问不会被优化掉、同一位置的访问保持相对次序,但不与其他位置建立次序;获取与释放 是单向约束,释放侧之前的写对获取侧之后的读可见,必须成对使用;完全同步是双向约束,且此类访问之间存在全序,这就是同步字段与原子类默认给的语义。

从左到右,约束递增、代价递增、可推理性也递增。在业务代码里为了性能把完全同步降级成更弱的档位,几乎总是亏本买卖:省下的是纳秒级开销,付出的是一个只有作者本人能完成的正确性论证,而作者半年后也会忘记当时的推理。
值得了解它的场合只有三种:读并发库源码时(内部大量使用获取与释放语义);实现极端热点上的无锁结构时;以及在代码评审里判断别人的降级有没有依据时。这三种场合之外,知道有这一层就够了。默认用最强的一档,是一条省心且不亏的规矩。
六、争用代价账本:把"快慢"变成可以对账的数
(一)一次同步的开销都花在哪
"加锁很慢"这句话之所以难以反驳,是因为它没有指明慢在哪一笔上。把开销拆开之后,它就变成了一个可验证的问题。
无争用的一次比较交换 大约十来个时钟周期,通常可以忽略。无争用的一次加锁与解锁 与一次同步写相当,量级仍然是纳秒。一次缓存行在核间的迁移 是几十到上百纳秒,随处理器拓扑变化------这是无锁写法在多核上真正的开销来源。一次挂起加一次唤醒 跳到微秒级,比无争用加锁高出两三个量级。一次因等待引发的调度延迟 取决于队列长度与运行队列的拥挤程度,可能比挂起本身还长。临界区里的一次远程调用是毫秒级,把前面所有开销全部淹没。

由此得到三条推论:优化方向是减少争用,而不是回避加锁本身;把远程调用挪出临界区的收益,大于任何一次设施替换;线程数超过核数越多,第四笔与第五笔的占比越高。
对账时最容易记错的也有三件:把无争用加锁当成重量级操作,于是把力气花在减少加锁次数上;把等待时间算进业务执行时间,得出错误的耗时归因;只看平均值------争用的代价几乎全部体现在尾部分位上,平均值会把它平掉。
(二)伪共享:没有逻辑上的共享,也可能有物理上的争用
有一类问题"测得出、看不出":代码上没有任何共享,性能上却存在真实争用。
原因在于缓存的最小单位是行而不是字段。两个互不相干的变量如果落在同一条缓存行里,核一修改第一个变量,会让整条行在核二上作废;核二修改第二个变量,又让它在核一上作废。两条线程从不访问同一个变量,却一直在互相拖慢。

它最常出现在四个位置:按线程一份的统计数组;队列相邻的头尾指针;同一个对象里相邻的两个热字段;数组中被不同线程占用的相邻元素。识别方式是扩大间距后立刻好转,或者观察缓存一致性相关的事件计数。
处置办法是把它们隔到不同的缓存行------手工填充,或者使用运行时提供的对齐注解(后者通常需要显式开启相应选项)。但顺序不能颠倒:先证明它存在,再动手隔离。盲目给所有字段加填充,换来的是内存膨胀与更差的访问局部性,在多数场景里是净损失。
(三)换一个横轴再看一次:停留时间如何改变排名
第一章按线程数展开过一次曲线,这里换一个横轴:把临界区停留时间从几十纳秒一路拉长到一毫秒,看看排名怎么变。
结果是:停留时间在百纳秒以内时,单变量无锁写法优势明显;进入微秒区间,三者互相接近,此时改造的收益已经小于改动风险;超过十微秒之后,几条曲线基本重合------设施已经不是瓶颈,该去看临界区里到底做了什么。

把这张图与按线程数展开的那张放在一起,就得到了完整的判据:设施的选择由"争用度"与"停留时间"这两个坐标共同决定。任何只报一个坐标的结论------无论是"我们压测下来无锁更快"还是"我们线上换了锁之后好了"------都不足以采信,因为它无法被复现到另一个坐标点上。
(四)怎么把"快慢"变成可复现的数
没有基线的优化只是换了一种写法,无法证明它更好。做基准测试有五条纪律:预热要够(即时编译需要足够的调用次数,第一轮结果几乎一定失真);防止结果被优化掉(返回值必须被消费,否则整段循环可能被删除);线程数要覆盖两端(从单线程一直压到远超核数,拐点比峰值更有信息量);看分位不只看均值(争用的代价集中在尾部);固定环境(同一台机器、同一版本运行时,关掉会漂移的后台任务)。
线上则要采五项指标:锁等待时间的分位值(把等待与执行分开归因);加锁失败次数占比(直接反映争用度);线程状态分布(区分阻塞、等待与运行);上下文切换速率(反映挂起唤醒的频度);重试次数与重试耗时(衡量乐观路线的实际成本)。

三种最常见的测法错误是:只测单线程,于是得到"无锁更快"这个在任何争用下都不成立的结论;在有其他负载的机器上测,方差盖过了差异;只测吞吐不测延迟分位,把长尾问题直接测没了。
一份合格的对照结论应当包含四项:线程数区间、临界区停留时间、吞吐与延迟分位、机器与运行时版本。缺任何一项,这个结论都无法被别人复现,也就无法作为决策依据。
七、选型判据:从一段代码走到一个设施
(一)四步判定
把前面所有讨论收敛成一条可以照着走的路径,一共四步,顺序不能颠倒------前三步都在减少同步,只有第四步才开始挑锁。
第一步:这份状态真的需要被多条线程共享吗? 答"否"就停在线程封闭:局部变量、每线程一份、请求作用域内的可变对象,不需要任何设施。
第二步:它在创建之后还需要改变吗? 答"否"就做成不可变:配置快照、值对象、写时复制容器,读者一律安全。
第三步:要维护的不变式跨越几个变量? 只有一个就用单变量原子:原子类的组合方法,或者把多个字段合成不可变对象后整体替换引用。
第四步:等待需要被约束吗------限时、可取消、可放弃、可分类? 答"否"用内置锁,写法最短且不可能漏释放;答"是"用显式锁,配上限时获取、可中断获取与多条件队列,释放写在最终块里。

四步之外还有一个追加动作:如果实测争用度长期高于三成,那么正确的下一步是拆键、分段或者更换数据结构,而不是在设施之间继续挑选。设施替换只能改变代价的形态,减少不了争用本身。
(二)十二个常见场景与它们的推荐设施
把判定表落到具体场景上,可以得到一份可以直接查的对照。请求内的临时状态用局部变量,不共享;全局配置快照用不可变对象整体替换;接口调用量统计用分段累加器;生成单调递增编号用标量原子类;单例的延迟创建用静态持有类;账户余额与流水一起改用互斥临界区;库存扣减且不能超卖用乐观版本号加有限重试;有响应时间预算的写接口用限时获取的显式锁;生产者与消费者用现成的阻塞队列;读多写少的路由表用写时复制容器;跨进程的同一笔业务用存储层的悲观或乐观锁;定时任务的单实例执行用带租约的分布式锁。

这张表里没有一行的依据是"这个设施更快"。判据始终是不变式的形状、等待是否需要被约束、以及争用是否已经被证明存在。换句话说,性能只在争用被证明存在之后才进入判据;在此之前,可读性与不易写错才是主要考量。
(三)三条不该做的优化
第一条:为了减少加锁次数而扩大临界区。 把循环里的加锁提到循环外,加锁次数确实少了,但停留时间成倍增加,争用随之放大。除非确认加锁本身是瓶颈(这在无争用时几乎不可能),否则这笔交易是亏的。
第二条:为了避免加锁而改用无同步的"快路径"。 常见形态是先无锁读一个标志、判断为真就直接返回。这类写法把互斥与可见性一起去掉了,正确性只在特定时序下成立,而测试恰恰最难覆盖那些不成立的时序。
第三条:在没有测量的情况下把互斥换成乐观重试。 冲突率未知时,这等于把一个稳定的等待代价换成一个不封顶的重试代价。更糟的是它的失败形态更难识别:系统不会变慢,而是在某个流量阈值之后突然塌陷。
(四)进程内的锁在哪里失效:多副本部署那一刻
同一段代码从一个实例扩到三个实例,互斥的边界就从堆内挪到了外部存储。此时进程内的锁仍然"工作正常",只是三个实例各自持有一把互不相干的锁,都认为自己独占------同一笔业务被并行处理,互斥形同虚设。

跨进程互斥有四种常见手段,各自的语义来源不同。存储层悲观锁 的互斥来自数据库行锁,必须配套事务边界与精确且走索引的条件,典型失效方式是条件写得太宽导致锁范围扩大。版本号乐观锁 的互斥来自更新条件里的版本比较,必须检查影响行数并配上有限次重试与退避,典型失效方式是不看影响行数、把冲突悄悄吞掉。缓存中间件锁 的互斥来自键的独占占用,必须配套租约时长、持有者标识与最终块释放,典型失效方式是业务耗时超过租约、锁被别人拿走而自己毫不知情。唯一约束去重的互斥来自唯一索引,关键在于幂等键的定义粒度。
外部锁必须回答而进程内锁不必回答的问题有四个:持有者进程崩溃了,锁靠什么自动释放;业务耗时超过租约时长,怎么续期或安全放弃;释放时怎么保证释放的是自己那一把;仲裁者本身不可用时,是拒绝服务还是放行。这四个问题的答案共同构成了一把可用的分布式锁,缺任何一个都会在故障时暴露。
还有两条不能省的纪律:锁只是串行化手段,业务状态仍然要自己判定 ------拿到锁之后必须先检查这笔业务是否已经被处理过,再决定是否执行;释放放进最终块,且释放失败要有告警------释放失败意味着这把锁要等到租约过期才会消失,这段时间里所有后来者都会被挡住。
八、把锁写对:临界区的工程纪律
(一)死锁的四个必要条件,以及只需要打破其中一个
死锁需要四个条件同时成立:互斥占用 (资源同一时刻只能被一方持有)、持有并等待 (拿着一把再去要另一把)、不可抢占 (拿不到就一直等,不放手)、循环等待(甲等乙的锁,乙等甲的锁)。四个里打破任何一个,死锁就不成立。
第一个通常不能打破,那是锁的本质。第二个可以打破:一次性申请全部资源,失败就全部释放,代价是并发度下降。第三个可以打破:改用限时获取,超时后释放已持有的全部再重来,代价是要写重试与退避。第四个最容易打破,也是工程上的标准答案:给所有锁定义一个全局次序,按序申请。

循环等待最常见的形成方式是一对方向相反的操作同时到达:线程甲先锁账户一再锁账户二,线程乙先锁账户二再锁账户一。转账、库存调拨与批量更新是三类高发场景。解法是按一个稳定可比的键排序后再依次加锁:
java
long first = Math.min(fromId, toId); // 排序键必须稳定
long second = Math.max(fromId, toId);
lock(first);
try {
lock(second);
try { transfer();
} finally {
unlock(second);
}
} finally {
unlock(first);
}
排序键的选择有一个容易踩的坑:不要用对象哈希值。它在不同进程、不同次运行之间都可能不同,因此无法保证所有参与方看到同一个次序。要用业务主键这类在系统范围内稳定且可比的值。
死锁是设计问题而不是运行期的偶发问题:一旦锁序不统一,它就一定会在某个并发时序下发生,只是概率高低而已。因此正确的处理方式是设计期确立锁序、评审期检查锁序,而不是运行期靠检测工具兜底------检测只能告诉你已经死了,不能让它不死。
(二)活锁、饥饿与优先级反转
死锁之外还有三种停不下来的形态,它们的共同点是线程都在运行,系统却没有进展。
活锁是双方都在礼让:检测到冲突就退让重来,退让的节奏又恰好一致,于是永远互相错开。它比死锁更难发现,因为线程状态是运行而不是阻塞。解法是给退避加上随机抖动,打破节奏的一致性。
饥饿是某些线程长期抢不到锁。非公平策略下,短临界区的线程反复插队成功,长期等待者始终排在后面。解法优先考虑限时获取加重新排队,让被饿到的请求以可预期的方式失败并重试;把整把锁改成公平是最后手段,因为它对所有请求都收费。
优先级反转在混合了不同优先级线程的系统里出现:低优先级线程持有锁,中优先级线程抢占了它的处理器,高优先级线程于是被一个低优先级线程间接阻塞。在通用服务端场景里它并不常见,但一旦出现,表现会非常反直觉------最重要的那条路径最慢。
(三)等待要有上限:把超时预算自上而下切开
没有上限的等待,会把一次局部争用扩散成整条链路的雪崩:请求线程被拖住,线程池被占满,上游超时后重试,进一步加剧争用。
正确做法是让超时预算自上而下逐层切开:入口有一个总预算,扣掉本层处理需要的时间,剩下的一部分分给锁等待,一部分分给临界区内的调用,还要留一点余量给回程。每一层只能花掉自己那一段,下层的上限必须小于上层的剩余量。

锁等待超时之后应当发生四件事:返回一个明确的"系统忙",而不是继续执行临界区;记录一条可检索的事件,写清是哪把锁、等了多久、被谁持有;计入争用指标,让超时次数成为一条可观测的曲线;由上游据此决定重试、降级还是直接失败。
可取消性有三处落点:等待中(可中断获取让关闭流程能把线程拉回来);执行中(临界区里的阻塞调用同样要能被中断);退出时(中断状态要么被处理,要么原样恢复给上层,不能吞掉)。最后这一条是最常被忽略的------捕获中断异常之后既不处理也不恢复标志位,等于把关闭信号丢弃了。
超时值本身应当由上游预算推导,而不是拍一个常数。一个写死八十毫秒的锁等待上限,在入口预算是八百毫秒时是合理的,在入口预算是五十毫秒时则毫无意义。
九、诊断:怀疑锁出问题时怎么走
(一)三问判别法
在动手改代码之前,先用三个问题把方向确定下来。
第一问:吞吐随线程数增加是升、平还是降? 继续上升说明还没有到瓶颈;走平说明存在串行段,可能是锁也可能是别的共享资源;掉头向下则几乎可以确定是争用------要么是锁的排队,要么是无锁写法的重试。
第二问:延迟变差的是均值还是尾部? 均值整体抬高通常意味着临界区自身变慢了(里面多了一次调用、一次落盘);只有尾部分位抬高则意味着排队------大部分请求没等,少部分请求等了很久。
第三问:线程停在哪里? 阻塞态多说明在排队;运行态多而吞吐低说明在空转重试;等待态多说明卡在条件等待上,这往往是通知逻辑写错的信号。
三问都指向争用之后,再进入取证阶段。跳过这三问直接换设施,很容易把力气花在一个根本不是瓶颈的地方。
(二)取证路径与手段的边界
取证的顺序是固定的:先看现象(吞吐不随线程数上升,或延迟尾部异常抬高),再把等待与执行分开(线程状态分布、上下文切换速率),然后定位到具体的锁(多次采样线程栈,看停在哪、持有者是谁),接着看临界区里做了什么(有没有远程调用、落盘、大对象操作,停留时间如何分布),最后才决定改什么------先缩小临界区,再拆分粒度,最后才考虑更换设施。

每种手段都有它看不见的东西,混用时要清楚边界。线程栈快照 能回答此刻谁持有、谁在等,但看不出一段时间内的累计分布,因此至少要连采数次。死锁检测 能发现互相等待的环,但只覆盖同一运行时之内,跨进程与跨资源类型的环它看不到。运行时事件记录 能给出锁等待事件的持续时间与栈,但低于采样阈值的短等待会被漏掉。采样式剖析 能回答热点落在等待还是计算上,但给不出精确的调用次数,且存在采样偏差。业务指标能看趋势,但定位不到具体哪一行代码,而且需要提前埋点。
(三)四种典型症状与它们的归因
阻塞态线程多、上下文切换高:这是真争用,多条线程重叠进入同一段临界区。处置方向是缩小临界区或拆分粒度。
运行态线程多、处理器打满、吞吐却低:这是自旋空转或重试过多。它最容易被误判成计算瓶颈,因为处理器使用率很高。区分方法是看重试计数,而不是看使用率。处置方向是改回悲观,或者加上带抖动的退避。
等待时间长但上下文切换不高:临界区里有慢调用,线程一进去就出不来。处置方向是把慢调用挪出临界区。
线程数上去之后全部停住:死锁或锁泄漏。这是正确性问题不是性能问题,要查等待环与释放路径,而不是调参数。
十、演进:轻量线程改变了什么,没改变什么
(一)承载方式变了,互斥语义没变
轻量线程改变了三件事:阻塞的代价大幅下降,因为挂起的是轻量线程而不是昂贵的平台线程;线程数不再是稀缺资源,池化不再是必需品;同步块在较新版本中不再固定占用承载线程。
没变的有四件:互斥仍然是互斥,临界区仍然只能进一条线程;争用仍然按停留时间与并发度放大;死锁的四个条件一条没少;共享可变状态该拆的还是要拆。

其中最需要留意的是同步块与承载线程的关系。在早期实现里,在同步块内部发生阻塞会把承载线程一起钉住,从而让并发度不升反降------这是那段时间里"用轻量线程之前先把同步块换成显式锁"这条建议的来源。这一限制在较新的版本中已被解除,建议也就随之失效。
(二)不变的部分才是判据
线程数上限放开之后,有两处需要重新标定。队列与限流的参数 :过去线程数天然限制了并发度,现在这个隐式限制消失了,下游可能会被瞬间打穿,因此并发上限必须显式表达。线程局部状态:线程极多时,每线程一份的成本被放大,需要改用作用域内传值的方式。
更重要的是那条一以贯之的纪律:任何关于代价的判断,都要在目标运行时上验一次。设施的语义写在规范里,稳定且可引用;代价的量级写在实现里,随版本变化。把两者混为一谈,就会出现"按三年前的结论做了优化,结果更慢了"这类事故。
十一、落地:把判断固化成规约与闸门
(一)十二条评审项与六道自动化闸门
靠人记住的规则会随人员流动失传,靠工具挡住的不会。评审项写在文档里,闸门写在流水线上,两者缺一不可。
十二条评审项分两组。前六条关于写法:锁对象是私有终态的专用对象,没有对外暴露;释放写在最终块里,且获取在最终块之外;限时获取的返回值参与了控制流;临界区内没有远程调用、落盘与外部回调;多把锁的申请次序由一个稳定可比的键决定;条件等待写成循环判定而不是一次性判断。后六条关于选型与证据:原子类只用于单变量,多字段不变式走临界区;乐观重试的循环体没有副作用且有次数上限;跨进程场景没有依赖进程内锁;等待上限由上游预算推导;新增字段仍在原有锁的保护范围内;改动附带一份争用度与延迟分位的前后对照。

六道闸门按成本从低到高排列:静态检查(锁对象为常量或装箱值、释放不在最终块,直接告警);构建期规则(禁止线程池工厂方法与无界队列,禁止业务代码创建临时线程池);单元测试(并发用例覆盖重入、超时与取消,断言失败路径的返回语义);压力测试(线程数覆盖单线程到远超核数,记录吞吐与延迟分位曲线);运行期指标(锁等待分位、失败占比、切换速率,越过阈值自动告警);死锁巡检(定期检测互相等待的环,发现即落线程栈快照)。
前两道就能挡掉绝大多数低级错误,而它们的实施成本最低------这是投入产出比最高的一段。
(二)四种假象与四个确定收益
先说四种假象,它们都会让人以为问题已经解决了。第一种是"加了锁就线程安全了" :锁只保证了它覆盖到的那一段,覆盖范围之外的读、后来新增的字段、跨对象的不变式,它一概不管。第二种是"用了并发容器就不用同步了" :容器只承诺单个方法的原子性,跨方法的复合操作仍然要自己圈起来。第三种是"压测没问题就是没问题" :并发缺陷是时序敏感的,压测覆盖的是一部分时序,不是全部。第四种是"换成无锁就更快了":这只在低争用区成立,而低争用区本来也不需要优化。
再说四个确定收益,它们都可以被观测。第一,变更的作用域被限定 :不变式与保护它的锁一一对应之后,新增字段时能立刻判断它该不该进临界区。第二,失败变得可预期 :等待有上限、超时有语义、降级有分支,故障从"系统卡住"变成"部分请求以明确方式失败"。第三,问题变得可归因 :有了争用度与等待分位这两条曲线,"系统慢"变成"某把锁在某个流量区间上争用超标"。第四,改动变得可证明:前后对照的基线让每一次优化都能拿出证据,而不是靠感觉。
(三)一页纸总结
把全文压成一张随时可以复查的清单:先问三件事------护的是什么(不变式跨几个变量)、怎么等(要不要限时、可取消、可放弃)、多挤(实测的争用度与停留时间);再选设施------能不共享就不共享,能不可变就不可变,单变量读改写用原子类的组合方法,多字段不变式用互斥临界区,等待需要被约束就用显式锁;最后交证据------改动前后的吞吐与延迟分位对照、争用度与加锁失败占比、线程状态分布与切换速率。

还有五条"先别换设施"的提醒:临界区里有远程调用,先把调用挪出去;争用度长期高于三成,先拆键或换结构;读远多于写且读很短,先考虑不可变对象整体替换;重试次数持续上升,先从乐观改回悲观;线程数上去后全部停住,先查等待环与释放路径------那是正确性问题。
十二、十六个高频误判
(一)关于设施本身
第一,认为同步块一定是重量级的。 它有四种状态,无争用时根本走不到重量级那一档,代价与一次同步写相当。
第二,认为显式锁一定比同步块快。 真正争用时两者都要挂起线程,走的是同一条路径;差别在于等待能不能被约束,而不在于快慢。
第三,认为原子类等于无锁等于更快。 它把等待换成了重试,高争用下重试的代价可能远高于一次挂起。
第四,认为读写锁总是优于互斥锁。 它只降低读之间的互斥,读写比不够高时,多出来的获取开销是净损失。
(二)关于写法
第五,锁了一个会被重新赋值的字段。 引用一换,两条线程锁的就是两个对象,互斥悄悄消失,且不会有任何报错。
第六,把释放写在最终块之外,或者把获取写进最终块之内。 前者在异常路径上永久泄漏,后者在获取失败时抛出非法状态异常。
第七,忽略限时获取的返回值。 超时之后仍然执行临界区,这是把互斥直接去掉了。
第八,条件判定写成一次性判断而不是循环。 唤醒不代表条件成立,醒来后必须重新检查。
(三)关于结构
第九,用两个原子字段维护一个本该一起变的不变式。 每个字段各自原子,合起来的不变式仍会破,而且这种代码看起来很专业,评审时容易放过。
第十,在重试循环里写日志、打点或发通知。 重试几次就执行几次,指标失真,通知重复。
第十一,为了减少加锁次数而扩大临界区。 次数少了,停留时间长了,争用反而放大。
第十二,多副本部署仍然依赖进程内锁。 每个实例各持一把,互斥形同虚设;这一条几乎总是在扩容当天才暴露。
(四)关于验证
第十三,只用单线程做基准测试。 得到的排名在任何真实争用下都不成立。
第十四,只看平均耗时不看分位。 争用的代价集中在尾部,平均值会把它抹平。
第十五,把等待时间算进业务耗时。 归因错了,后续所有优化都会走偏。
第十六,认为压测通过就等于并发正确。 并发缺陷是时序敏感的,压测能证明性能,证明不了正确性------后者要靠不变式分析、代码走查与专门的并发测试工具。
十六条背后是同一个认知偏差:把同步理解成"给这段代码加一个开关",而不是"为一组不变式选择一种保护方式,并为它的等待与失败定义语义"。 前者只有一个动作,后者有三个决定;把三个决定压成一个动作,剩下两个就会以缺陷的形式在某个流量区间上冒出来。
结语
回到标题那句话:加了锁不等于扛得住争用。
这句话有两层意思。第一层是技术事实 :加锁只解决了"这段代码同一时刻只有一条线程在跑"。从这一点出发,到系统在真实并发下仍然稳定,中间还隔着粒度、等待语义、失败处置、代价量级与可观测性这几段。理解这一层,就能把内置锁的状态升级、显式锁的队列、比较并交换的重试环、分段累加的散列、伪共享的缓存行这些看似零散的知识点串成一条链------它们都在回答同一个问题:这一刻,谁在等,等多久,代价记在谁头上。
第二层是工作方式:关于并发的争论之所以经常没有结论,是因为双方交换的是印象而不是坐标。一旦把"争用度"与"临界区停留时间"这两个坐标测出来,绝大多数争论会自动结束------因为在确定的坐标上,答案通常是唯一的。这也意味着,比记住哪个设施更快更有价值的,是养成先测再改的习惯,以及每次改动都留下一份可以被别人复现的对照。
最后留一条可以立刻执行的动作:找出系统里争用最高的那把锁,测一下它的停留时间,再看一眼临界区里都做了些什么。绝大多数情况下,你会发现要改的既不是 synchronized,也不是显式锁,更不是原子类------而是临界区里那一行谁都没注意到的调用。
参考资料
语言规范与虚拟机规范
并发设施接口文档
原子操作与内存访问
• 原子变量包总览
• 忙等提示方法)
演进提案
测量、取证与延伸阅读
• 微基准测试工具