有一次我在排查某个后台服务的偶发卡顿,线上日志停了差不多两分钟没有输出,看起来就像整个进程睡着了。最后把线程转储导出来一看,问题出在一个我自认为写得毫无问题的加锁逻辑上:两个工作线程互相等着对方释放锁,谁也走不下去。从那以后,我对“多线程与锁”这五个字的敬畏程度一下子上升了一个台阶。
这篇文章不打算写教科书式的并发教程,也不打算堆术语。我想聊的是这些年实际写并发代码、排并发故障时,关于锁最常被低估的几个问题:锁到底在拦什么、为什么很多场景其实不需要锁、死锁到底怎么快速定位,以及锁怎么设计才不会在高并发下把系统拖垮。适合正在写服务端代码、一直在用锁但偶尔觉得哪里不对劲的开发者。语言上我会以 Java 为例展开,但锁的模型在 C++、Go、Python 里都是一套思路,概念完全相通。
1. 从一次线上卡顿说起:并发问题的真正面目
1.1 卡顿现场
那是一个很普通的下午,监控群里突然有人发消息说接口变慢了。服务没有重启,CPU 也不算高,但请求就是大面积超时。登录服务器之后,我先把线程转储拉了一份,发现线程池里的工作线程几乎全部停在java.lang.Thread.State: BLOCKED (on object monitor),并且所有停在同一个锁对象上。这是非常典型的热点锁症状,但当时让我意外的是:这段加锁代码看起来再简单不过,就是synchronized保护一个共享变量,为什么线上会坏成这样?
顺着栈往下查,才发现真正的问题是一个模块里把锁范围写得过大,临界区里不是简单读个变量,而是在持锁状态下调了远程服务。所有线程排着队等这一次远程调用完成,单个调用只要慢几百毫秒,线程池就整个堵死,外部表现就成了“服务卡住两分钟”。
1.2 掉进“加锁就安全”的坑
这个案例让我总结出一个很反直觉的经验:加锁本身不提供“安全”,它只是你给并发行为定的一套规则。规则定得不好,锁反而会成为灾难的中心。多数人刚开始写并发代码时,思路都是“看到共享变量就加锁”,但锁的使用方式和保护范围才是真正决定系统表现的东西。
从设计角度来看,并发代码要处理的无非是“多个执行流同时碰同一份数据”。锁的职责是让这段数据操作在同一时刻只有一个执行流能进去。但仅仅做到这一点远远不够,因为锁还牵扯到可见性、原子性、有序性,以及线程调度、排队策略、锁粒度、死锁风险等一系列问题。理解锁的第一步,是搞清楚它到底在拦什么。
2. 锁到底在拦什么:可见性、原子性与有序性
2.1 数据竞争的三个层面
我之前带过一个新人,他写了一个很常见的计数器:
class Counter { private int count = 0; public void increment() { count++; } public int get() { return count; } }然后他问:“这个代码加了锁就不会错了吧?”问题就出在,他以为并发只会导致逻辑上“两个线程同时写”这种冲突,但实际上底层有三个层面的竞争。第一个是原子性:count++看着是一行代码,编译之后其实是读变量、加一、写回三个步骤。两个线程同时执行时,可能都在“读”这一步读到同一个旧值,然后各自写回,等于一次加一被吞掉了,最终计数少了。第二个是可见性:线程在运行时会先把变量复制到自己的工作缓存里,一个线程改了count,另一个线程不一定能立刻看到这个改动,甚至可能一直看不到。第三个是有序性:为了优化性能,编译器和 CPU 可能会调整指令的执行顺序,单线程内不影响结果,多线程共享数据时就会暴露问题。
2.2 volatile 为什么替代不了锁
知道这三层之后,很多人会想到volatile。它确实能解决可见性和有序性,让变量改动直接刷到主存,并且禁止相关指令重排。但volatile不解决原子性。也就是说,volatile int count依然不能保证count++不丢数据,它的本质是一个“读通知机制”,而不是“互斥机制”。这两个概念之间的差别,是所有并发初学者最容易混淆的地方。
volatile的真正应用场景是那种“单线程写、多线程读”的开关变量,比如一个配置项、一个停止标志。Java 规范里对volatile的语义是:对一个volatile变量的写操作先行发生于后续对这个变量的读操作。这保证读线程能看到最新值。但如果你需要“先检查再修改”“读改写复合操作”这种步骤,只靠volatile是不够的,必须靠锁或者原子类。
2.3 锁的本质:让临界区变成一间只有一个入口的房间
把三个层面合在一起看,锁(无论是synchronized还是ReentrantLock)做了这样几件事:它让临界区的执行互斥,从而解决原子性;它通过内存屏障保证临界区内的写入在释放锁之后对其他线程可见,解决可见性;它同时约束临界区内的读写不能随意重排序,解决有序性。
我习惯用一个生活化类比来讲这个概念:临界区就像一间只有一个入口的房间,谁想进去处理共享数据,必须拿钥匙进门,处理完出来再把钥匙递给下一个。没有这把钥匙,你看到的只是乱序的账本,因为大家同时改动却没有协调。
但注意,这里有一个常常被误解的点:锁的开销不在于“加锁”本身,而在于“排队”。当一个线程持锁时间过长,后面所有线程都在阻塞等待,这就是第一章那个线上事故的根源。所以锁并不是“慢”的代名词,它真正的代价是“竞争”,而竞争又和临界区的大小、持锁时长、锁粒度直接相关。这也是为什么后面要专门拿出一章讲锁的优化,因为锁本身设计得好不好,决定了系统在压力下的表现。
3. 不是所有并发都要加锁:无锁方案与真实适用场景
进入这一章之前,先明确一个原则:锁不是并发的唯一答案,甚至不总是最优答案。无锁方案在很多场景下更简单、更快,也更不容易出死锁。关键要看你的数据操作属于哪种模式。
3.1 计数器场景:从互斥锁到 CAS
如果需求非常简单,就是多个线程对一个数字累加,那么synchronized显然能用,但它会让所有线程串行化。这种场景更合适的方案是原子类,比如AtomicInteger。它的底层用的是 CPU 的 compare-and-swap 指令,核心逻辑就是“先读出当前值,计算新值,然后把新值写回去,但写之前要确认内存里的值还是刚才读到的那个,如果不是就重来”。整个过程不需要阻塞线程,所以叫乐观锁。
AtomicInteger counter = new AtomicInteger(0); // 多线程并发调用也不会丢数据 int next = counter.incrementAndGet();在低竞争的计数场景下,AtomicInteger的性能比synchronized好很多。但如果竞争非常激烈,CAS 的自旋重试次数会飙升,CPU 空转反而更浪费。Java 在这个方向上又提供了LongAdder,它把单个热点数据拆成一组 Cell,不同线程分散写入不同的 Cell,最后求和。这对高并发统计类场景很友好,缺点是它在累加过程中给不出一致性的快照。所以选型时可以这样判断:计数器、序号生成、指标累计,用AtomicInteger或LongAdder;需要“读-改-写”强一致,用锁。
3.2 读多写少场景:volatile + 不可变对象
读多写少在业务代码里非常常见,比如配置中心推送过来的规则、特征开关、限流阈值。这类对象通常是整份更新,而不是改一个字段。我的做法是:把配置类做成不可变对象,所有字段都用final修饰,更新时直接构建一个新对象,再把引用用volatile发布出去。
class BizConfig { final int maxRetry; final int timeoutMs; BizConfig(int maxRetry, int timeoutMs) { this.maxRetry = maxRetry; this.timeoutMs = timeoutMs; } } volatile BizConfig currentConfig = new BizConfig(3, 1000);读线程拿到的currentConfig要么是旧的那份,要么是新的那份,绝不会看到一个写了一半的中间状态。整个读取过程不需要任何锁,因为对象本身不可变,没有字段可以被改写。这种方式比加锁更简单,也更难出错。它背后的逻辑是:即使多个线程同时读同一个引用,也没有竞态,因为大家读到的都是一份被完整构建好的数据。
3.3 线程私有场景:ThreadLocal 的潜力与风险
还有一种常见的不加锁方案是ThreadLocal。它的思路是“既然大家抢同一份数据会打架,那就给每个线程准备一份独立数据,老死不相往来”。典型例子是SimpleDateFormat,这个类不是线程安全的,如果多个线程共享同一个实例去做日期解析,会出现各种诡异结果。常规解法是每次新建,但太浪费;加锁又会降低吞吐。用ThreadLocal给每个线程缓存一份自己的格式化器,既安全又高效。
不过ThreadLocal有一个非常隐蔽的坑:如果使用线程池,线程是复用的,线程池里的 Thread 对象不会销毁,ThreadLocal里存的数据会一直留在线程上。如果不主动清理,轻则内存泄漏,重则产生脏数据。所以使用ThreadLocal的代码块,一定要在finally里调用remove(),或者用 try-with-resources 思路封装一层。
3.4 选型边界:什么时候还是得老老实实加锁
无锁方案看着很舒服,但它有边界。CAS 类接口适合单变量原子操作,不可变对象适合整体替换,ThreadLocal适合线程私有副本。一旦操作涉及多个共享变量的组合约束,比如“先扣余额再记账”,这两步必须作为一个整体对外可见,无锁方案的实现复杂度就会急剧上升。这时候老老实实加锁往往才是更可维护的选择。
我之前在一个模拟的库存服务里看到过一段花式无锁代码,把扣减、校验、日志三个操作拆成了三段 CAS,结果因为中间状态暴露给其他线程,最后数据对不上,调试了一整天才发现是“看起来无锁,实际上缺了一个事务边界”。锁的本质并不是“性能差”,它是“用可预测的阻塞换取正确性”。在需要强一致组合操作的场景里,锁就是最合适的设计,关键是后面要讲的锁优化,不要一上来就排斥锁。
4. 一次死锁的完整排查链路:从 jstack 到修复验证
如果说并发代码里有什么问题最让人头疼,死锁排第二没人敢排第一。它不是每次都会出现,而是只有在两个线程恰好走到互等路径的时候才爆发,所以线上往往表现为“偶尔卡一下”或“过一段时间才卡死一次”。下面用我一次真实的排查经历来拆解整个过程。
4.1 死锁发生的四个条件:先懂理论再动手
死锁不是玄学,它成立的四个必要条件在并发教科书里写得很清楚:
| 条件 | 含义 | 排查时的对应线索 |
|---|---|---|
| 互斥 | 同一资源同一时刻只能被一个线程占用 | 锁的排他性 |
| 持有并等待 | 线程持有至少一个资源,同时在等待另一个资源 | 线程栈中同时存在locked和waiting to lock |
| 不可剥夺 | 已持有的资源不能被强制拿回 | 普通锁不响应外部抢占 |
| 循环等待 | 若干线程形成首尾相接的资源等待环 | 两个线程互相等待对方的锁 |
这四个条件缺一不可,所以真正要做的不只是“找出死锁”,而是破坏其中一个条件。比如让锁支持超时获取,就破坏了“不可剥夺”;固定加锁顺序,就破坏了“循环等待”。理论部分看着简单,但排查过程里需要把栈信息和这四个条件逐一对上,才能确认不是其他问题。
4.2 现场取证:jstack 导出与关键栈识别
我当时的排查顺序是这样。第一步,确认进程还活着,只是线程卡住了。用jstack <pid>导出线程转储,马上就能看到所有工作线程的状态。第二步,聚焦到状态异常的线程。正常情况下线程池里应该大部分是WAITING(等任务)或者RUNNABLE(在干活),如果出现大量BLOCKED,而且blocked on的是同一个 monitor,说明发生了锁竞争。第三步,看具体的锁对象地址。
"pool-1-thread-1" prio=5 tid=0x00007f9d0c0e3800 nid=0x1203 waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) waiting to lock <0x000000076e2a3d20> (a java.lang.Object) locked: <0x000000076e2a3e58> (a java.lang.Object) "pool-1-thread-2" prio=5 tid=0x00007f9d0c0f2800 nid=0x1204 waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) waiting to lock <0x000000076e2a3e58> (a java.lang.Object) locked: <0x000000076e2a3d20> (a java.lang.Object)看到这两段栈,我心里已经明白了七八成:两个线程各持有一把锁,同时又在等对方手里的锁。JVM在导出转储时如果检测到 Java 层面的死锁,会在文件末尾直接输出Found one Java-level deadlock,并列出完整的等待环。这个提示非常宝贵,能省掉大量人工比对时间。
4.3 根因定位:加锁顺序不一致如何被坐实
找到死锁只是第一步,真正的难点在于“为什么会出现这种锁配对”。我顺着线程栈里的业务方法名回到代码,发现代码如下(简化脱敏后):
void methodA() { synchronized (lockA) { doSomething(); synchronized (lockB) { ... } } } void methodB() { synchronized (lockB) { doSomethingElse(); synchronized (lockA) { ... } } }线程一走methodA先拿lockA再拿lockB,线程二走methodB先拿lockB再拿lockA,只要两个线程恰好交错,就形成循环等待。这种问题在测试阶段很难发现,因为需要精确的时间交错才会触发。解决思路其实不复杂,本质是消除嵌套竞争:要么全局统一加锁顺序,让所有人都先拿lockA再拿lockB;要么干脆拆开嵌套,让methodA和methodB在持锁期间不要再去拿第二把锁。
我当时选择了“统一顺序”作为短期修复,因为改动最小,一行代码换位置就能解决。但我同时也记录了一个长期优化点:重新审视这两个方法的职责,看是否真的需要同时持两把锁。很多死锁的根源不是不会写并发,而是业务代码里顺手引入了过深的锁嵌套,需求一变,嵌套链路的顺序就乱了。
4.4 修复方案对比与回归验证
修复方案不能只停留在“能跑就行”,还要看是否能稳定验证。统一加锁顺序能解决循环等待,但会带来一个副作用:所有需要lockB的操作都得先经过lockA,可能让lockA的竞争压力变大。另一种方案是用ReentrantLock的tryLock(long timeout, TimeUnit unit),拿不到锁就超时放弃,然后做降级重试或记录告警,这能避免无限期等待,适合对外部响应时间有约束的场景。第三种方案是尽量缩小锁范围,把嵌套锁拆开,让每个临界区只持有一把锁,从根上消除循环等待的可能。
验证阶段我会做两件事。第一,把之前能稳定复现死锁的压测脚本多跑几轮,确认死锁日志消失。第二,用jstack在压测过程中隔几十秒采一次线程转储,统计BLOCKED线程的数量,确保修复不是靠运气躲过,而是真的让锁等待比例降了下来。这里有个经验:死锁修复后不要急着合代码,先用双倍并发、双倍时长的压测跑一轮,因为并发问题有一个特点,你不压到阈值,它就藏得好好的。
5. 高并发下的锁优化:把竞争降到最低
死锁是锁带来的极端问题,但在更多时候,锁不致命,只是拖慢系统。高并发场景下,判断一个锁设计得好不好,核心指标就是“竞争强度”。这里整理几条我在实际项目中验证过、收益最明显的优化思路。
5.1 缩小临界区是最低成本收益
很多人写锁时习惯把整个方法包住,图省事。但锁保护的应该是“共享数据的读写”,而不是“方法里的全部逻辑”。最常见的错误是在锁内做远程调用、数据库查询、JSON 序列化,这些操作又慢又不属于共享数据,纯粹是白占锁资源。
我的习惯是先画出临界区,把临界区里每一步问一遍:这一步访问了共享变量吗?如果没有,移出锁外。理想状态下,临界区里只有“读一下共享状态”“根据结果做判断”“改一下状态”这几条指令,耗时微秒级别,其他重活都在锁外干。这样即使有竞争,线程排队时间也极短。
5.2 锁粒度:从全局锁到分段锁
锁粒度越粗,什么操作都要过同一把锁,任何不相关的数据改动都会互相阻塞。这里最好的可借鉴案例就是 Java 的ConcurrentHashMap,它早年用分段锁实现,把整个 Map 分成若干段,每段一把锁,不同线程操作不同段时互不干扰,后来虽然实现演进了,但思路仍然值得学习:按照某种规则把共享数据切开,每片数据独立加锁。
如果自己写一个缓存组件,也可以用同样的思路。比如按 key 的 hash 值路由到 16 个 bucket,每个 bucket 一把独立的锁,更新不同 bucket 里的数据时完全并行。这样能显著提升吞吐,但也会让“跨 bucket 的一致性操作”变得很麻烦,所以使用分段锁之前要确认业务对全局一致性的要求不高。
5.3 读写锁与锁类型选择
读多写少的业务里,普通互斥锁有一个明显浪费:多个读取线程之间本来不会互相干扰,却还是要排队。ReentrantReadWriteLock就是为了解决这个问题。它把锁拆成读锁和写锁,读锁之间可共享,写锁独占,读写之间互斥。我在本地缓存、路由表、黑白名单这类场景中用过很多次,读多写少时吞吐量提升非常明显。
但读写锁不是银弹,它有个常见问题是写饥饿:如果读线程源源不断,写线程可能一直拿不到写锁。所以选锁类型时要做取舍:
| 锁方案 | 适用场景 | 注意事项 |
|---|---|---|
synchronized | 通用互斥、锁范围小 | 低竞争下性能很好,写法简单 |
ReentrantLock | 需要超时、可中断、公平性 | 记得 unlock 要放在 finally |
ReadWriteLock | 读多写少 | 写线程可能饥饿 |
StampedLock | 读多写少的极致优化 | 乐观读路径复杂,容易写错 |
| CAS 原子类 | 单变量计数、状态位 | 高竞争下自旋开销大 |
很多人一碰到高并发就想着把synchronized换成ReentrantLock,其实没有必要。现代 JVM 对synchronized做了大量优化,早期竞争不激烈时走偏向锁和轻量级锁路径,几乎没有系统调用开销;只有竞争剧烈时才膨胀为重量级锁。所以不要盲目替换,先看锁的竞争度,再决定要不要用更复杂的方案。
5.4 一个本地缓存从 synchronized 到 ReadWriteLock 的演进
举个例子,我之前在一个读多写少的配置模块里实现本地缓存,最初的版本很朴素:
synchronized V get(K key) { return cache.get(key); } synchronized void put(K key, V value) { cache.put(key, value); }压测发现,读请求量一上来,吞吐量被锁拖得很明显,因为所有读线程都在排队。后来我改成ReentrantReadWriteLock,读操作加读锁,多个读线程可以并行进入;只有写操作需要写锁:
class LocalCache<K, V> { private final ReadWriteLock lock = new ReentrantReadWriteLock(); private final Map<K, V> cache = new HashMap<>(); V get(K key) { lock.readLock().lock(); try { return cache.get(key); } finally { lock.readLock().unlock(); } } void put(K key, V value) { lock.writeLock().lock(); try { cache.put(key, value); } finally { lock.writeLock().unlock(); } } }改动不大,但在我的压测环境里,读多写少场景的吞吐量大概提升了近一倍。这里有两个值得说的细节。第一,HashMap本身不是线程安全的,但在这个组件里所有读写路径都被锁覆盖,所以不需要换成ConcurrentHashMap,避免无意义的双重开销。第二,所有加锁逻辑都写在入口方法里,内部不会再嵌套其他锁,降低死锁风险。这种“单层锁、覆盖全部路径”的设计,看起来简单,却能在大量并发场景下保持稳定。
最后分享一个我自己的习惯:每次写锁相关代码,我都会在锁对象旁边挂一段注释,说明这到底在保护什么不变量,以及获取锁的顺序约定。这个习惯救过我很多次。锁最大的问题从来不是“写错”,而是“写完就没人敢碰”,半年后回看代码,谁都不确定改动会不会引入死锁。把锁的意图用文字钉在代码里,后续维护的人才能放心地改,也才能在引入第二把锁的时候主动去思考顺序问题。如果你也在写多线程代码,这一条比任何技术优化都更值得先做起来。