
又是一次锁等待引发的线上事故synchronized 与 Lock 的底层逻辑差异值得每个JUC多线程开发认真过一遍前不久帮朋友排查一个抢购接口的性能问题现象很典型压测从 1800 TPS 掉到 600线程耗时出现大量尖刺CPU和磁盘都不高但线程 dump 拉下来一看大量线程卡在同一个 monitor 上——一段被 synchronized 修饰的业务方法成了整个系统的单点瓶颈。那会儿我才意识到很多人对流出的印象还停留在“Java 自带的关键字能用就行”但对 JUC 体系里的 Lock 到底强在哪、瓶颈在哪底层原理完全是个黑盒。这篇文章不做 API 层面的罗列而是把两种锁机制拆开看synchronized 在 JVM 里是怎么一步步优化的Lock 依赖的 AQS 队列又是如何调度线程的。搞懂这些你才能回答“什么时候该用哪个”这个问题而不是凭面试题硬背八股。文中涉及的原理、源码分析和线上排查经验大家可以拿自己的项目做对照验证。1. 先从一次线上问题说起锁选型为什么能决定系统的上限那次抢购问题的根因不算复杂。业务上用了一个静态的SimpleDateFormat做时间格式化并发一高就报错后面图省事直接把方法整体加了synchronized串行化后线程全堵在一处。线程 dump 里能看到大量java.lang.Thread.State: BLOCKED的记录阻塞对象清一色指向同一个 monitor。关键点在 JVM 底层当 synchronized 升级到重量级锁之后线程的阻塞和唤醒都要靠操作系统互斥量完成也就是常说的用户态与内核态切换。每次竞争失败线程从用户态陷进内核态挂起被唤醒时再切回来。这种上下文切换对高频短小临界区的打击是毁灭性的TPS 掉下来毫不意外。而 JUC 里的 Lock 在 JDK 5 之后被频繁推荐核心不只是“功能更丰富”而是它把线程的排队和阻塞逻辑收到了 Java 层掌控用LockSupport.park/unpark来挂起和唤醒线程HOTSPOT 底层虽然最终还是会走进内核态但在锁竞争不激烈时能大量避免系统调用整体可扩展性比重量级 synchronized 好得多。这次事故之后我把团队里所有核心链路的临界区都过了一遍。简单到只保护一个变量的继续用 synchronized 反而最省心涉及等待超时、可中断、多个条件队列、公平性选择的才换成ReentrantLock或 JUC 里的其他同步工具。这个判断标准需要你先把两者的原理吃透下文逐个拆开。2. synchronized 的底层机制从对象头到 Monitor 再到锁升级2.1 Mark Word锁状态都记录在对象头里Java 中任意一个对象都可以作为同步锁这个能力依托的是对象头里的 Mark Word。HotSpot 虚拟机里普通对象的对象头包含两部分Mark Word64 位 JVM 下占 8 字节和 Klass Pointer指向类元数据的指针默认开启压缩指针时占 4 字节。如果对象是数组还会多一个数组长度字段。Mark Word 的设计很巧妙——它不是固定存一种数据而是根据对象当前状态复用存储空间。无锁时它存对象哈希码和分代年龄偏向锁时存持有线程 ID轻量级锁时存栈中锁记录的指针重量级锁时存 ObjectMonitor 的地址。64 位 JVM 下 Mark Word 的位分布大致如下不同 JVM 版本、不同位数会有细微差别但核心逻辑相同锁状态Mark Word 内容64位锁标志位无锁unused(25) identity_hashcode(31) unused(1) age(4) biased_lock(1)01偏向锁threadId(54) epoch(2) age(4) biased_lock(1)01轻量级锁指向栈中 Lock Record 的指针(64)00重量级锁指向 ObjectMonitor 的指针(64)10GC标记空被GC使用11锁标志位就两位却演化出五种状态。JVM 正是靠这几位标志判断当前锁处于什么阶段再决定走哪条加锁路径。理解这段二进制布局对后面锁升级的判断很有帮助——当你看到 biased_lock1、lock01 时就知道当前对象处于可偏向但尚未偏向任何线程的状态。2.2 Monitor 机制synchronized 加锁的本质是进入监视器synchronized 在字节码层面由monitorenter和monitorexit两条指令实现。反编译一段同步代码块你会看到进入同步块的位置多了monitorenter正常退出和异常退出路径上各有一个monitorexit。异常路径的那个 ensure 释放锁回答了很多人的疑问为什么 synchronized 不会死锁——它不是不会死锁而是异常时 JVM 会自动释放 monitor不需要开发者手动处理。HotSpot 里每个对象都支持关联一个 ObjectMonitor 对象。它的核心字段包括_owner持有当前 monitor 的线程_WaitSet调用了wait()的线程队列_EntryList等待获取锁的线程队列_count与_recursions重入计数加锁流程大致是线程执行 monitorenter 时先尝试把对象的 monitor 的 owner 设为自己若 owner 已经是自己则_recursions加一实现可重入否则进入_EntryList排队等待。释放时_recursions减一减到 0 才真正释放 owner唤醒等待线程。monitor 在重量级锁阶段才会完整创建。偏向锁和轻量级锁阶段并不依赖 ObjectMonitor直接操作 Mark Word 和栈帧里的 Lock Record这就是为什么它们在低竞争下那么轻快——省掉了建立 monitor 和操作系统互斥量的开销。2.3 锁升级的全过程无锁到重量级锁的完整路径synchronized 在 JDK 6 之前是“慢锁”的代名词因为当时只有重量级锁任何竞争都会陷入操作系统。JDK 6 之后引入了锁升级机制把加锁路径分成了四段。第一段是无锁状态。线程进入同步块发现对象处于无锁状态且允许偏向就通过 CAS 把 Mark Word 中的 threadId 设置为自己这就是偏向锁。偏向锁的核心思想是“大部分同步块只有一个线程访问”既然只有一个线程不如把这个线程 ID 记在对象头上下次再来无需任何原子操作。这个阶段效率最高。第二段是偏向锁竞争。另一个线程来抢同一把锁时偏向模式失效。原持有线程可能已不在临界区此时 JVM 需要等到安全点SafePoint撤销偏向对象头恢复到无锁状态或直接升级为轻量级锁。第三段是轻量级锁。线程在自己的栈帧中创建 Lock Record用 CAS 把 Mark Word 替换为指向 Lock Record 的指针。成功则持有锁失败说明存在竞争线程会自旋等待一段时间默认自旋次数或自适应自旋策略反复尝试 CAS。自旋期间不会挂起线程避免上下文切换适合临界区执行时间很短的场景。第四段是重量级锁。当自旋超过阈值或者竞争线程数过多时轻量级锁膨胀为重量级锁Mark Word 指向 ObjectMonitor未抢到锁的线程进入 _EntryList 阻塞后续唤醒都走系统调用。上面的抢购事故正是走到了这一层只是线程数量没到机器极限但锁竞争让吞吐量断崖式下跌。锁升级方向是单向的只能从轻到重不能从重回轻。这也是面试里经常被追问的细节为什么不能降级因为锁竞争一旦激烈线程阻塞和唤醒本身就伴随巨大的状态开销如果频繁升降级会引入更多复杂性和性能损失。值得提醒的是JDK 15 的 JEP 374 已经把偏向锁默认禁用JDK 17 里偏向锁被标记为废弃。原因是偏向锁在 Java 生态中大量使用并发类库的前提下收益不明显反而给 JVM 实现增加了大量复杂度。所以如果你在用 JDK 17默认情况下走的是无锁直接到轻量级锁的路径不要再死记“四个阶段必然依次出现”。2.4 锁消除与锁粗化JIT 在背后默默做的事除了锁升级JVM 的即时编译器还会做两项与锁相关的优化。锁消除基于逃逸分析。如果 JIT 确认一个锁对象只会被当前线程访问根本不会逃逸到其他线程那么加锁毫无意义编译器会直接把锁去掉。一个常见例子是局部变量作为锁对象加锁比如synchronized (new Object())这种代码在当前线程内自弹自唱编译阶段就会把锁消除掉。锁粗化则相反。当 JVM 发现在一段连续代码里反复对同一对象加锁解锁比如循环体内对同一个锁对象执行 synchronized虽然现实中少这么写或者多个相邻的同步块操作同一个锁JIT 会把范围扩大成一个大的同步块减少加锁解锁次数。这个逻辑和批量拿锁替代频繁单次拿锁的思路一致核心是减少不必要的锁操作开销。3. Lock 锁机制的核心AQS 队列与 Condition 协作3.1 ReentrantLock 的基本用法与可重入原理ReentrantLock 是 Lock 体系里最有代表性的实现。用法上和 synchronized 最直观的区别是显式地加锁、显式地释放锁Lock lock new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }finally里的unlock()是硬性要求。synchronized 由 JVM 保证异常时释放锁ReentrantLock 没有这个保障一旦临界区抛异常导致解锁代码没执行锁就永远被当前线程占用其它线程全部饿死。你在很多生产事故复盘里看到的“ReentrantLock 卡死”十有八九是没写 finally 或解锁条件被提前 return 跳过。可重入原理方面ReentrantLock 内部会记录当前线程和加锁次数。同一线程再次调用lock()时不会排队而是直接成功并将锁计数加一对应每次unlock()计数减一减到 0 才算真正释放。这正是它名字里“Reentrant”的由来。3.2 AQS 的设计核心state 状态位与 CLH 等待队列ReentrantLock 的底层是AbstractQueuedSynchronizer也就是常说的 AQS。它是 Java 并发包的基石Semaphore、CountDownLatch、ReentrantReadWriteLock都建立在它的骨架之上。AQS 内部只有两个核心成员一个volatile int state用来表示同步状态一个双向链表组成的同步等待队列。以 ReentrantLock 为例state 表示锁被重入的次数。线程加锁成功state 加一释放锁state 减一。其它同步工具对 state 的解释不同——比如 Semaphore 里 state 表示剩余许可数CountDownLatch 里表示剩余等待计数值。理解这点AQS 的通用性就明朗了它把“判断是否可以获得同步许可”的逻辑留给子类实现把“拿不到许可时如何排队、如何阻塞唤醒”的复杂机制统一封装好。等待队列是一个双向链表每个节点Node封装一个线程及等待状态。当线程获取锁失败AQS 会把该线程包装成 Node 追加到队尾然后通过LockSupport.park挂起当持有锁的线程释放锁AQS 会从队头找到第一个未取消的节点调用LockSupport.unpark唤醒对应线程。整个过程避免了让所有失败线程直接用 ObjectMonitor 阻塞也支持了中断、超时等更细的控制。3.3 公平锁与非公平锁的源码差异ReentrantLock 构造器传true表示公平锁默认是非公平锁。日常使用大多数场景选择非公平因为它的吞吐量通常更高但某些需要严格按序执行的场景比如任务调度才会考虑公平锁。差异核心在上锁入口。非公平锁的lock()方法先直接尝试 CAS 抢占 state不关心队列里还有没有线程在排队final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } }而公平锁的lock()直接走acquire(1)tryAcquire里先用hasQueuedPredecessors()判断队列中是否有前驱节点只要有线程排在前面就老老实实去队尾排队protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 重入逻辑... }这个差异在实践中会造成一个现象非公平锁下新来线程可能插队成功队列里排队的线程继续等着极端情况但不常见下老线程可能长时间得不到调度即所谓的“饥饿”。公平锁虽然解决了饥饿问题但每次加锁都要先查队列、再 CAS多一层开销吞吐量表现通常低于非公平锁。3.4 Condition比 wait/notify 更精确的线程协作方式Object 的wait/notify功能有限一把锁只有一个等待集合所有等待同一个锁的线程被唤醒时只能“一锅端”用notifyAll全部唤醒再自己抢锁效率低。Condition 把这个能力拆成了多个独立的等待队列。Lock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); Condition notFull lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() MAX_SIZE) { notFull.await(); } queue.add(data); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } data queue.poll(); notFull.signal(); } finally { lock.unlock(); }每个 Condition 内部维护自己的单向等待队列。await()会让当前线程释放锁、进入该 Condition 的等待队列signal()只把当前条件队列里的一个节点移到 AQS 的同步队列里再唤醒它参与锁竞争。这样生产者和消费者各等各的条件互不误伤代码语义也比 wait/notify 清晰得多。另一个容易被忽略的坑是await()之后线程从LockSupport.park返回时需要重新竞争锁如果使用signal()唤醒被唤醒线程要先重新获取锁从await()中返回时锁一定是持有的。所以等待条件必须放在循环里防止“伪唤醒”导致条件不满足却继续执行。4. synchronized 与 Lock 的全面对比先看差异再谈选型4.1 使用方式维度自动释放与手动释放两者最表面的差异就是加解锁方式。synchronized 是隐式锁进入同步块自动加锁正常执行完、return、break、抛异常时都自动释放。Lock 是显式锁必须调用lock()加锁也必须调用unlock()释放release 需要开发者自己保证。这个差异听起来只是代码风格问题实际影响很大——Lock 使用不当的后果是线程泄露、接口雪崩而 synchronized 除了死锁外很少因释放问题出事。4.2 功能维度可中断、超时、公平性、多条件队列功能上的差异是 Lock 的关键优势维度synchronizedLock (ReentrantLock)锁获取方式隐式获取显式调用 lock()锁释放方式同步块结束或异常时自动释放必须调用 unlock()通常配合 finally可中断不支持支持 lockInterruptibly()超时不支持支持 tryLock(time, unit)公平性非公平可配公平/非公平条件队列每把锁只有一个等待集合可创建多个 Condition底层实现Monitor 锁升级AQS CLH 队列 LockSupporttryLock(time, unit)能解决一个实际痛点线程拿不到锁时不必无限等待超时后可以释放当前资源、记录错误、快速失败。比如分布式任务里多个实例竞争同一把 JVM 锁做本地缓存预热拿不到锁的实例直接放弃等重试这在 synchronized 里需要额外开一个守护线程去 interrupt 才能实现非常别扭。可中断能力同样重要。一次长时间持锁导致其他线程一直阻塞时运维希望 dump 线程或者上下线时能主动打断等待线程lockInterruptibly()可以做到synchronized 对这一点无能为力。4.3 性能维度低竞争和高竞争结论不完全一样很多人以为 Lock 一定比 synchronized 快这是一个误区。JDK 6 之后synchronized 有偏向锁、轻量级锁、自适应自旋加持低竞争场景下它的性能并不比 ReentrantLock 差甚至因为省去了 AQS 队列维护的开销略占优势。我自己用 JMH 压测的结果与此吻合单线程反复进入同一把锁synchronized 比 ReentrantLock 快 5% 到 10%四个线程低竞争轮询两者基本持平。高竞争场景多线程频繁抢同一把锁、临界区执行时间又长则相反。synchronized 的锁升级到重量级锁后每次竞争失败都走系统调用阻塞ReentrantLock 的 AQS 在 Java 层做了排队、自旋和更精细的中断控制线程状态转换开销更可控吞吐量在高竞争下往往更好。新 JDK 下情况又有变化。JDK 15 默认关闭偏向锁后synchronized 的低竞争优势被削弱了一部分但它在简单场景下仍是不二之选。性能永远不是选择锁机制的唯一标准可维护性和实现成本占的比重更大。5. 实战中的锁坑从死锁定位到锁粒度控制的经验记录5.1 ReentrantLock 忘了释放锁最典型的线上灾难有一次新同事写缓存刷新的定时任务用 ReentrantLock 保护刷新的临界区逻辑是if (lock.tryLock()) { doRefresh(); }问题在于它没有在 finally 中 unlock。如果doRefresh()里的一个下游接口超时抛出异常这个方法直接返回锁没释放。下一次定时任务来临时tryLock()永远返回 false缓存就一直不刷新。最终表现是数据滞留、用户看到旧内容排查时才发现锁被上一次异常任务死死攥住。正确写法只有一套if (lock.tryLock()) { try { doRefresh(); } finally { lock.unlock(); } }规则很简单所有 Lock 对象的获取必须在方法入口就配好对应的 finally 处理不留给异常任何缝隙。5.2 死锁的定位经验jstack 一眼看出环经典死锁场景是线程 A 持锁1等锁2线程 B 持锁2等锁1。真实项目里锁往往不止两把锁的获取顺序散落在不同方法里肉眼排查效率极低。推荐的做法是直接用 JDK 自带工具jps -l jstack pidjstack 输出中死锁检测部分会直接给出“Found one Java-level deadlock”的提示列出等待关系。如果没有一眼看到“deadlock”字样也可以搜索“waiting to lock”和“locked”对照线程 ID 找到环。我印象很深的一次死锁排查根源是两个服务用同一套工具类做分布式锁但在不同业务分支里获取锁的顺序不一致。A 分支先锁 user 再锁 orderB 分支先锁 order 再锁 user并发两个请求就直接抱死。避免死锁没有银弹只能靠两条基线一是所有涉及多把锁的地方必须规定全项目统一的加锁顺序二是尽量缩短持锁时间把锁粒度压到最小。5.3 锁粒度的选择粗粒度覆盖代码更 安全但吞吐量的代价很大很多人倾向于“一把大锁包住整个方法”理由是省心、不容易漏。这在低并发系统里没毛病到了高并发线上就是事故源头。抢购事故的修复方案就是细粒度化把原来 synchronized 在方法上的锁下调到只锁真正需要保护的共享资源更新操作格式化的线程安全问题则改为ThreadLocalSimpleDateFormat或直接换成DateTimeFormatter它是线程安全的压力测试恢复到了 1700 的 TPS。实际编码中锁粒度优化有几种常用手法使用ConcurrentHashMap做分段缓存避免整表加锁使用LongAdder替代AtomicLong在高并发下减少 CAS 竞争只对可变状态加锁把无状态只读逻辑隔离到临界区外考虑用读写锁ReentrantReadWriteLock或StampedLock读多写少的场景提升明显但要注意 StampedLock 不支持重入、中断语义也特殊版权属于高级技巧线上使用前必须测试清楚5.4 一个小技巧用 JMH 证明你的锁选择选型不是靠嘴说压测数据最有说服力。推荐用 OpenJDK 的 JMH 写基准测试简单示例Benchmark public void testSynchronized() { synchronized (lock) { counter; } } Benchmark public void testReentrantLock() { lock.lock(); try { counter; } finally { lock.unlock(); } }分别在线程数 1、4、16、64 的环境下跑一轮观察 Throughput 和平均响应时间。结果往往会让你对“synchronized 一定慢”的刻板印象产生修正。我在自己的服务上测过4 线程低竞争时 synchronized 比 ReentrantLock 快约 6%64 线程高竞争时 ReentrantLock 高出约 12%。差距不算离谱但足以指导选型方向。6. 写在最后的一点经验体会从那次抢购事故到现在我在团队里立了一条规矩业务代码里每出现一把锁都要能在 code review 时解释它保护的数据是什么、锁的边界在哪、锁竞争激烈时系统行为会变成什么样。回答不上来的锁大概率是隐患。如果你正准备深入学习这块我的建议是不要只看概念动手验证三条链路用jstack看 synchronized 锁竞争后线程状态的变化用 debug 模式单步跟一次 ReentrantLock 的lock()到底走了哪些 AQS 方法再用 JMH 对自己的实际代码做一次压测对比。原理讲再多都不如自己看一眼线上行为来得扎实。等你能准确说出“此刻线程为什么停在这里、它在等哪把锁、持锁线程是谁”的时候JUC 这块就算是真正入了门。