
做 Java 并发这一块快十年了线上排查过不少锁导致的性能事故也面试过大量候选人。每次聊到锁优化这个话题我发现大多数人基本停留在背结论的阶段synchronized 在 JDK 1.6 之后变快了、CAS 是无锁操作所以性能更好、Atomic 类比加锁快……可一旦追问下去锁升级具体有几个状态、偏向锁什么时候撤销、CAS 失败后底层干了什么、线上高竞争场景到底该选谁能说清楚的人就很少了。这篇文章就针对 Java 锁优化这个话题把我这些年看源码、做压测、排线上问题积累的经验一次性讲透。内容会覆盖 synchronized 的底层演进、CAS 的实现原理、两者在不同竞争强度下的选型逻辑最后给出一套可以直接参考的计数器改造实战案例和踩坑记录。不管是准备面试还是做线上调优这套分析框架都能直接用上。1. 问题拆解synchronized 和 CAS 到底在比什么1.1 一个简单计数器的不同实现先把问题具体化。假设我们有一个非常常见的业务场景统计请求量、记录某个事件的次数要求多线程并发环境下计数准确。不同基础的同学可能写出三种典型版本// 版本 A不防护结果一定不对 public class RawCounter { private long count 0; public void increment() { count; } public long get() { return count; } } // 版本 Bsynchronized 保护 public class SyncCounter { private long count 0; public synchronized void increment() { count; } public synchronized long get() { return count; } } // 版本 CCAS 实现 public class CasCounter { private final AtomicLong count new AtomicLong(0); public void increment() { count.incrementAndGet(); } public long get() { return count.get(); } }很多人默认版本 C 一定比版本 B 快理由就是它是无锁的。这个认知在低竞争场景下基本成立但在高竞争、多线程同时写同一个变量的场景下还真不一定。我们后面会用实测数据说话这里先建立一个核心认知锁优化的本质是权衡加锁本身的开销和线程竞争导致的等待开销哪个更大然后想办法让总开销最小。1.2 锁优化到底在优化哪些成本要理解锁优化必须先搞清楚线程间同步有几类成本第一类是加锁和解锁本身的操作开销。早期的 synchronized 一加锁就调用操作系统互斥量mutex涉及用户态到内核态的切换这个过程非常昂贵哪怕只有一个线程访问也要白白付出这个代价。这就好比一个人过马路不管有没有车都要停下来等红灯问题是这个红灯是人工手动控制的每次都折腾半天。第二类是竞争导致的开销。多个线程同时抢同一把锁没抢到的线程被挂起阻塞等锁释放后再唤醒。挂起和唤醒同样是内核态操作而且还有上下文切换的额外开销。第三类是缓存一致性的开销。多核 CPU 各自有缓存一个线程修改了共享变量其他核的缓存行需要失效并重新同步这个过程由缓存一致性协议MESI 一类的机制保证。CAS 虽然不加锁但它本质上在做更频繁的原子更新缓存同步的流量一点不少高竞争时甚至比锁还大。理解了这三类成本再看 synchronized 和 CAS 的演进思路就清晰了synchronized 是在努力降低第一类和第二类成本CAS 则是把同步的重心转移到第三类成本上。没有绝对谁好谁坏只有匹配场景的问题。2. synchronized 的底层演进从重量级到锁升级2.1 早期实现为什么这么慢JDK 1.5 及以前synchronized 只有一种实现策略任何线程进入同步块JVM 直接请求操作系统分配一个互斥量。这个过程的代价包括系统调用、线程的阻塞和唤醒每次加锁解锁都可能触发一次用户态和内核态的切换。单独看一次可能也就微秒级但在高并发循环里累计起来非常恐怖。我早期做过的项目里就遇到过一个典型的例子一个缓存组件用 synchronized 保护整个读方法结果单机 QPS 超过两千就开始出现 CPU 飙升jstack 一看全是线程在等待锁。后来换成读写锁和细粒度分段性能直接翻了几倍。这就是重量级锁最大的问题它不是按需分配的而是不管竞争多低都按最坏情况处理。2.2 JDK 1.6 的锁升级机制JDK 1.6 对 synchronized 做了一次里程碑式的优化引入了锁升级的路径。核心思路是刚开始默认没有竞争先用最轻量的方式一旦发现有多个线程竞争再逐步升级到更重量级的方案。整个状态流转是单向的单向锁状态流转无锁 → 偏向锁 → 轻量级锁 → 重量级锁偏向锁解决的问题是只有一个线程反复进入同步块的场景。这个场景在现实里非常常见比如只被单个线程调用的方法却写了 synchronized。偏向锁会在对象头里记录持有线程的 ID后续这个线程再进来时不需要任何原子操作直接判断一下对象头的线程 ID 是不是自己就行开销几乎为零。当第二个线程开始竞争时偏向锁就撤销了升级成轻量级锁。轻量级锁的思路是线程在自己的栈帧里建立一个锁记录Lock Record然后通过 CAS 把对象头里的 Mark Word 拷贝到锁记录中再把自己替换上去。如果 CAS 成功说明抢到锁了如果失败说明有竞争线程会进入自旋状态也就是原地循环尝试而不是立刻挂起。自旋有次数限制JVM 的默认策略是自适应自旋前一次自旋成功拿到锁的线程下次会多自旋一会儿前一次白白空转的线程下次就减少自旋次数。如果自旋到一定次数还是拿不到锁就升级成重量级锁线程真正被挂起进入操作系统的等待队列。这里有个关键点值得强调锁升级是单向的、不可逆的。一旦升级成轻量级锁就永远不会降级回偏向锁升级成重量级锁后即使后续竞争完全消失了也一直是重量级锁。所以高竞争阶段一旦触发升级后面的加锁开销就会一直维持在高位。2.3 用 JOL 观察对象头的锁状态光说不练假把式。想看锁状态的真实变化推荐用 OpenJDK 提供的 JOLJava Object Layout工具它可以打印对象的内存布局包括对象头里的 Mark Word。// 引入依赖org.openjdk.jol:jol-core public class LockStateDemo { public static void main(String[] args) throws Exception { Object obj new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println(持锁中 ClassLayout.parseInstance(obj).toPrintable()); } } }默认情况下偏向锁有大约 4 秒的启动延迟这是 JVM 为了避免启动阶段频繁撤销偏向锁的折中方案所以在延迟内运行你会看到对象处于无锁状态等过了延迟期再跑配合-XX:BiasedLockingStartupDelay0参数关闭延迟就能观察到 Mark Word 里多了线程 ID 的信息说明偏向锁生效。等第二个线程介入竞争再用 JOL 看Mark Word 已经切换成轻量级锁的指针格式。这套观察方法能帮你直观理解锁升级比单纯背八股文深刻得多。另外插一句对象头里存锁状态的部分叫 Mark Word64 位 JVM 下它一共 64 bit。其中有几位是标记位用来区分当前状态是偏向锁、轻量级锁还是重量级锁。对象头不是只有这一块还有一个 Klass Pointer 指向类元数据这里不展开但理解了这个结构很多为什么对象头有开销的问题就自然有答案了。2.4 锁粗化与锁消除锁升级之外JIT 编译器还做了两个容易被忽略的优化叫锁粗化和锁消除。锁粗化说的是如果 JIT 发现同一个对象连续多次加锁解锁中间没有其他线程介入的机会它会把多次加锁合并成一次更大范围的加锁。比如循环里每次迭代都加锁JIT 可能直接把锁提到循环外面。这么做能减少加锁解锁的重复开销。锁消除更厉害如果 JIT 通过逃逸分析确认一个对象只在线程内部使用根本没有逃逸出当前线程那么对这个对象的 synchronized 加锁操作会被直接干掉因为没有其他线程能看到这个对象加锁毫无意义。我早期排查过一个看起来很荒谬的问题一个方法里锁一个局部对象理论上每次调用都是新建对象但压测发现加锁对性能影响很大。后来分析才发现是逃逸分析没有生效锁没被消除。检查逃逸分析是否开启、是否因为代码写法导致对象逃逸比盲目改代码更有效。这里也给读者一个实用建议不要因为听说 synchronized 优化过就在代码里随意使用。锁消除和锁粗化依赖 JIT 的实时分析效果不稳定线上 JIT 状态不一样结果可能天差地别。真正靠谱的做法还是把锁的粒度控制好让代码本身就不依赖这些优化。3. CAS 机制拆解无锁并不等于没有代价3.1 CAS 在硬件层面是怎么完成的CAS 全称是 Compare-And-Swap比较并交换。它的语义是如果某个内存位置的当前值等于预期值就把这个位置更新为新值如果不等于就什么都不做返回当前值。整个操作必须是原子的因为如果不原子检查和更新之间一旦插入其他线程的修改判断就失效了。原子性靠的是 CPU 指令。x86 平台对应的是cmpxchg指令在多核环境下还需要加lock前缀来锁定总线或锁住缓存行确保其他核心不能并发修改同一内存位置。也就是说CAS 并不是完全不用硬件锁它只是把锁的粒度下沉到了 CPU 指令级别并且不涉及线程的挂起和唤醒。CAS 操作里那个比较的步骤依赖的是共享变量的可见性因为要读取当前值再判断所以被 CAS 操作的变量基本都得声明成volatile保证每次读取都拿到最新值。Java 的Unsafe类里提供的compareAndSwapInt、compareAndSwapLong等方法底层就是直接调用 CPU 指令。日常开发中我们不会直接碰 Unsafe而是用 Atomic 系列类它们内部封装好了这些操作。3.2 从 Atomic 类看 CAS 的落地方式AtomicInteger 是学习 CAS 最好的教材。看它的incrementAndGet源码核心逻辑是一个 do-while 循环先读当前值然后尝试 CAS 改成新值如果失败就重读、再尝试直到成功。// AtomicInteger 内部逻辑概念示意非完整源码 public final int incrementAndGet() { for (;;) { int current get(); int next current 1; if (compareAndSet(current, next)) { return next; } } }这段代码暴露了 CAS 的第一个现实问题它可能会自旋很多次。当并发竞争激烈时每次只有一个线程能成功其他线程都在循环里重试。从 CPU 的角度看这些失败的 CAS 都是白忙活会大量消耗 CPU 周期。这就是为什么高竞争场景下AtomicLong 反而可能比 synchronized 更慢。我在真实项目中就踩过这个坑。当时有个订单号生成器用 AtomicLong 累加初始值平时每秒也就几千的量一切正常。后来做促销活动瞬时并发冲到每秒钟几十万AtomicLong 的线程瞬间挤在一起自旋CPU 使用率直接打满吞吐反而下降。后来换成 synchronized 配合批量预取的方式或改用 LongAdder问题才解决。这件事让我养成了一个习惯评估并发工具不能只看它理论上有多先进要看它在你实际的竞争烈度下表现如何。3.3 ABA 问题与版本号方案CAS 还有一个经典缺陷叫 ABA 问题。假设线程 A 读到一个值为 1线程 B 把值改成 2 又改回 1这时线程 A 再去 CAS发现当前值还是 1CAS 就成功了。但中间这个值其实被其他线程动过在某些场景下这会带来严重的逻辑错误。典型受害者是用 CAS 实现的栈或链表。比如一个无锁栈堆栈顶指针经历了顶元素被弹出再压入相同地址的新对象这个变化CAS 会误以为栈顶没变但实际上栈的结构已经不同了。解决 ABA 问题的标准方案是引入版本号Java 里对应的是AtomicStampedReference它把引用和版本号打包在一起进行 CAS每次修改版本号都加 1这样即使值变回了原样版本号对不上CAS 也会失败。// AtomicStampedReference 的使用方式 AtomicStampedReferenceInteger ref new AtomicStampedReference(1, 0); int[] stampHolder new int[1]; Integer currentValue ref.get(stampHolder); int currentStamp stampHolder[0]; boolean success ref.compareAndSet(currentValue, 2, currentStamp, currentStamp 1);说实话日常业务代码里用到 ABA 的场合并不多因为大多数计数、状态更新的场景对值中间有没有变过并不敏感。但如果你在写无锁数据结构比如无锁队列、无锁栈ABA 就一定要放在第一位考虑。这也是为什么我建议没有足够的把握不要自己写无锁数据结构直接用 Concurrent 包里的现成实现。4. 两者的实战选型什么时候用 synchronized什么时候用 CAS4.1 竞争强度是唯一的决策变量写并发代码做了很久之后我发现锁选型本质上是在回答一个问题这段代码的平均竞争强度到底有多高竞争强度低synchronized 的重量级锁开销几乎不用考虑偏向锁和轻量级锁的代价可以忽略竞争强度中等CAS 的自旋大概率快速成功体验很好竞争强度特别高CAS 会陷入大量失败自旋这时候就需要重新思考方案了。我给的场景判断逻辑是这三条基本覆盖了日常 80% 的情况第一临界区只有一个变量的简单更新比如计数、序号生成、开关状态翻转优先考虑 Atomic 类或 LongAdder。这类操作 CAS 的语义非常匹配代码也简洁。第二临界区包含多个变量、多个步骤或者需要保证一组数据的一致性比如转账要同时改两个账户余额CAS 单变量更新根本覆盖不了选 synchronized 或 ReentrantLock。第三读多写少、临界区耗时长的场景比如缓存读取、配置刷新synchronized 的互斥语义会放大成本和冲突优先考虑读写锁或分段锁。4.2 锁粒度与临界区长短的权衡选型不只要选用锁还是用 CAS还要选锁的粒度。加锁范围越大并发度越低但代码越简单范围越小并发越高但边界处理越复杂。很多线上问题是锁粒度选错了导致的把整个方法用 synchronized 包住方法里一半是 IO 操作所有线程被一把锁串行化吞吐惨不忍睹。锁粒度细化有几个常用手段。第一是把锁从方法级别缩到代码块级别。第二是锁分段ConcurrentHashMap 在 JDK 7 时就是分段锁JDK 8 改成 CAS synchronized 锁单个桶思路都是让不同数据走不通的锁。第三是读写分离用 ReentrantReadWriteLock 或 StampedLockReadWriteLock 适合读多写少StampedLock 还提供了乐观读配合 CAS 验证版本号在特定场景下性能更突出。这里还要提醒一点不要为了优化而优化。把锁拆得过细代码复杂度指数上升引入死锁和数据一致性 Bug 的概率也大幅增加。我的经验是先按最直观的方式写出正确版本再通过压测数据决定要不要精细化。优化必须建立在数据上而不是建立在想象上。4.3 我常用的场景决策表下面这个决策表是我日常选型时参考的框架。注意它不是金科玉律具体还要结合临界区耗时、线程数量、是否需要公平性等一起判断。场景特征推荐方案核心原因单变量原子更新、竞争低AtomicLong / AtomicIntegerCAS 自旋大概率一次成功开销最小单变量原子更新、竞争极高LongAdder分段累加分散竞争读时合并多变量一致性更新synchronized 或 ReentrantLockCAS 无法保证多个变量的原子整体性读多写少的长临界区ReentrantReadWriteLock读读不互斥提升并发吞吐需要公平等待、可中断等待ReentrantLock提供 tryLock、lockInterruptibly、公平队列锁对象只在方法内临时使用注意逃逸分析可能被锁消除但不要依赖对 ABA 敏感的数据结构AtomicStampedReference 或直接用并发容器防 ABA 需要版本号辅助ReentrantLock 这里多说一句。它和 synchronized 的底层实现方向类似也会有重量级转换但它提供了更多的灵活性可响应中断、支持超时、可实现公平锁、条件变量 Condition。公平锁在有大量线程排队时能避免饥饿但吞吐通常比非公平锁低因为非公平锁允许后来的线程直接抢锁。实际业务里我几乎不用公平锁除非有明确的需求比如任务排队必须按顺序执行。5. 实战案例计数器从 synchronized 迁移到 CAS 的完整改造5.1 基准测试环境与原始代码为了让大家能看到真实的性能差异我这里复现一个实验。环境是 JDK 8话虽如此锁升级机制在后续版本依然成立、8 核虚拟机、单机压测。被测对象就是第一节里的四个版本额外加上 LongAdder 版本。public class LongAdderCounter { private final LongAdder count new LongAdder(); public void increment() { count.increment(); } public long get() { return count.sum(); } }测试方式是固定线程数每个线程执行 1000 万次 increment统计总耗时。线程数从 1 到 16 之间取几档分别观察低竞争和中等竞争下的表现。为了保证公平加了足够的预热轮次让 JIT 完成编译然后取多轮的平均值。这里特别想强调预热的重要性。很多人在本地跑并发测试时不预热第一轮数据里 JIT 编译和偏向锁延迟的影响占比很大得出来的结论完全不可靠。我自己踩过这个坑曾经因为没预热得出LongAdder 比 AtomicLong 慢的错误结论后来加了预热才发现是 JIT 没有完全生效导致的假象。5.2 逐版本改造与数据对比整理后的趋势大致如下单位是毫秒数值越低越好具体数值受机器影响会有浮动重点看变化趋势版本1 线程4 线程8 线程16 线程RawCounter不防并发快但结果错误结果错误结果错误结果错误SyncCounter中等较慢很慢很慢CasCounterAtomicLong快快中等变慢LongAdderCounter略慢快快最快先看单线程情况。RawCounter 最快但不可用SyncCounter 因为有偏向锁的加持已经非常接近 RawCounter这是很多没读过锁升级原理的人想象不到的。CasCounter 也不慢但 CAS 指令本身有缓存同步开销单线程下反而不如偏向锁。再看 4 线程AtomicLong 开始体现优势因为竞争还不够激烈CAS 失败率不高自旋很快就能成功。而 SyncCounter 已经出现了线程切换和上下文切换的开销差距开始拉大。到了 8 线程和 16 线程情况反转了。AtomicLong 的失败重试次数激增大量 CPU 时间浪费在无意义的循环上性能下降明显。LongAdder 在这个阶段开始胜出它的原理是内部的 Cell 数组把累加分散到多个槽位上每个线程绑定或就近选择自己的槽位更新读的时候再汇总竞争被大幅摊薄。这个实验最有价值的结论是没有万能的方案只有匹配竞争强度的方案。如果你纠结某个并发类快还是慢最好的办法不是网上查答案而是写一个和线上负载模式一致的压测程序用数据说服自己。5.3 为什么 LongAdder 在高竞争下更强LongAdder 的设计思路其实就是数据库分库分表那一套单点写是瓶颈那就拆成多分片让并发流量分散到不同分片上。它在内部维护了一个 base 值和一个 Cell 数组。竞争低时直接 CAS 更新 base竞争变高时会把线程哈希到不同的 Cell 上各自累加最终结果等于 base 加所有 Cell 的和。这种设计的巧妙之处在于它把高频写、低频读的计数器场景优化到了极致。写操作完全分散读操作虽然要遍历所有 Cell 求和但读的频率通常远低于写所以总成本划算。LongAdder 也不是没有坑。它的 sum() 方法在并发写期间返回的是近似值因为遍历 Cell 的过程中可能有线程正在更新某个 Cell导致前后不一致。对于写后立即要精确值的场景比如严格的余额校验它就不合适而对于统计请求量、监控指标这类允许轻微误差的场景它是最优解。理解了这一点你就不会被网上各种LongAdder 吊打 AtomicLong的片面结论带偏。6. 踩坑记录与排查技巧6.1 偏向锁延迟引发的性能假象我在 2.3 节提过偏向锁有 4 秒左右的启动延迟这个默认值导致了一个很容易踩的坑应用刚启动的前几秒热点代码还没来得及进入偏向锁优化阶段性能曲线会出现一段明显低谷。如果你在启动瞬间做了压测或者流量预热不足很容易误判成应用启动慢或者代码有问题。我曾经排过一个线上问题服务重启后的一两分钟里某个接口的 P99 延迟偏高之后恢复正常。一开始怀疑缓存加载排查半天没收获。最后用 JFRJava Flight Recorder录制锁相关信息才发现罪魁祸首是偏向锁延迟大量锁竞争发生在偏向锁未生效的窗口期内线程走了轻量级锁甚至重量级锁的路径。解决方法是-XX:BiasedLockingStartupDelay0让 JVM 启动后立刻启用偏向锁。这里要提醒一下JDK 15 之后偏向锁被标注为废弃并默认关闭如果你的项目跑在新版本 JDK 上这个参数就不起作用了需要关注对应版本的实际锁实现变化。这类问题的排查思路是先用 jstack 抓线程状态看大量线程是不是在BLOCKED或WAITING再用 JFR 或 async-profiler 看锁事件的采样定位锁等待集中在哪个类、哪个方法最后结合启动时间点和 JVM 参数判断是不是窗口期问题。6.2 自旋导致的 CPU 飙升排查CAS 无限循环和高竞争下的自旋最容易出现的表象就是 CPU 使用率飚到接近 100%但线程 dump 里看不到明显的阻塞等待绝大多数状态是RUNNABLE。这时候很多人会懵RUNNABLE 不是没事吗其实 RUNNABLE 只是说明线程没有被 OS 挂起不代表它没在空转。Java 层面的自旋循环、CAS 重试在 OS 眼里都是正常运行状态都是 RUNNABLE。排查这类问题的标准操作是用top -H找出 CPU 最高的线程 ID转成十六进制的 nid然后用 jstack 找到对应线程的堆栈。如果看到一堆线程卡在某个compareAndSet或incrementAndGet方法上基本可以判定是这个并发变量竞争过载了。我遇到过一个比较极端的案例代码里用 AtomicBoolean 保证某个回调只执行一次但回调的执行时间本身就长大量线程长期自旋等待那个 boolean 翻转四核 CPU 被打满三个核。后来改成 CountDownLatch 一次性放行问题立刻消失。这个案例说明CAS 适合的是临界区极短的更新操作如果你的操作本身耗时较长CAS 的失败率会持续很高这时候用锁阻塞等待反而更合理。6.3 伪共享Cache Line 带来的诡异性能波动CAS 和并发修改还带出一个隐蔽的性能杀手叫伪共享False Sharing。现代 CPU 的缓存行一般是 64 字节如果两个共享变量恰好落在同一个缓存行里线程 A 修改变量 X 会导致线程 B 的缓存行失效即使线程 B 只关心变量 Y。这会让本来互不干扰的并发修改被迫在线程间频繁传递缓存行性能急剧下降。LongAdder 内部的 Cell 数组其实就被这个坑坑过所以它的作者特意给 Cell 加了 120 字节左右的填充早期版本保证每个 Cell 独占缓存行。我们在自己的代码里可以用Contended注解来做类似的隔离前提是 JVM 开启-XX:-RestrictContendedJDK 内部类默认可用用户自定义类默认受限。我在一个日志组件里遇到过伪共享的经典症状两个不同的计数器变量被定义在同一个对象里写日志的线程高频更新一个统计线程高频更新另一个压测吞吐只有预期的六成。把两个变量拆分到不同对象并加了填充后吞吐恢复。排查这类问题用 JFR 的内存布局采样或者 perf 分析缓存失效事件都可以定位实战中如果发现并发性能不符合预期且并发度越高越严重一定要把伪共享纳入怀疑列表。6.4 CAS 无法覆盖代码块时的处理最后一个常见的困境业务要求一组操作整体原子但你又不想用锁。比如更新一条记录的版本号同时更新内容两个字段要一起变。CAS 只能保证单个变量的原子性这时候强行用 CAS 就只能嵌套使用代码既复杂又容易出错。我的建议很简单不要硬扛。多变量一致性场景直接用 synchronized 或 ReentrantLock性能差不了太多但正确性稳定得多。如果你确实想要一点无锁的性能可以考虑单写者模式让所有写操作由一个线程负责写线程内部用 volatile 标记版本读线程用全局版本号判断是否需要重读。这种思路在读写比极高的缓存场景下很有效相当于用所有权的确定性换掉竞争的随机性比纠结用哪个原子类更有价值。7. 最后分享一点个人经验做并发优化的这些年我最大的体会是锁优化不是一个换工具的过程而是一个理解场景的过程。synchronized 从重量级走向锁升级告诉我们 JVM 在不断降低同步的隐形成本CAS 从单纯的原子指令走向 LongAdder 的分段设计告诉我们面对高竞争分散压力比硬碰硬更有效。两者不是对立关系ConcurrentHashMap 在 JDK 8 里就是 CAS 和 synchronized 协同作战这才是工程上的成熟姿态。如果让我给读者一句最实际的建议那就是先把代码写正确再用数据决定优化。遇到并发性能瓶颈优先看锁的竞争热点、锁的粒度、以及变量的缓存行为而不是上来就换工具。面试里能把锁升级的每一步说清楚、能画出竞争强度与方案选择的关系已经超过九成候选人。希望大家拿着这篇文章里的实验方法和排查思路在自己的项目里实际测一遍得到的理解会比任何面试答案都扎实。