有一次我在排查某个后台服务的偶发卡顿线上日志停了差不多两分钟没有输出看起来就像整个进程睡着了。最后把线程转储导出来一看问题出在一个我自认为写得毫无问题的加锁逻辑上两个工作线程互相等着对方释放锁谁也走不下去。从那以后我对“多线程与锁”这五个字的敬畏程度一下子上升了一个台阶。这篇文章不打算写教科书式的并发教程也不打算堆术语。我想聊的是这些年实际写并发代码、排并发故障时关于锁最常被低估的几个问题锁到底在拦什么、为什么很多场景其实不需要锁、死锁到底怎么快速定位以及锁怎么设计才不会在高并发下把系统拖垮。适合正在写服务端代码、一直在用锁但偶尔觉得哪里不对劲的开发者。语言上我会以 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 prio5 tid0x00007f9d0c0e3800 nid0x1203 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 prio5 tid0x00007f9d0c0f2800 nid0x1204 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 要放在 finallyReadWriteLock读多写少写线程可能饥饿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 LocalCacheK, V { private final ReadWriteLock lock new ReentrantReadWriteLock(); private final MapK, 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避免无意义的双重开销。第二所有加锁逻辑都写在入口方法里内部不会再嵌套其他锁降低死锁风险。这种“单层锁、覆盖全部路径”的设计看起来简单却能在大量并发场景下保持稳定。最后分享一个我自己的习惯每次写锁相关代码我都会在锁对象旁边挂一段注释说明这到底在保护什么不变量以及获取锁的顺序约定。这个习惯救过我很多次。锁最大的问题从来不是“写错”而是“写完就没人敢碰”半年后回看代码谁都不确定改动会不会引入死锁。把锁的意图用文字钉在代码里后续维护的人才能放心地改也才能在引入第二把锁的时候主动去思考顺序问题。如果你也在写多线程代码这一条比任何技术优化都更值得先做起来。