从synchronized到CAS:高并发下的锁优化与实战选型 前阵子帮一个电商中台团队排查生产问题现象很典型每逢整点秒杀流量进来服务端TPS从2000直接掉到不到500CPU占用没跑满但接口RT明显上涨调用链上的热点全部落在一个用synchronized包裹的库存扣减方法上。把线程dump拉下来一看大部分线程卡在monitor enter少部分处于BLOCKED状态等待锁释放。那一刻我意识到很多人对synchronized的理解还停留在它被优化过、能用的层面但真正到高竞争场景下它依然会变成系统的瓶颈。这逼我系统梳理了一遍从synchronized到 CAS 的锁优化路线今天这篇就把底层的演进逻辑、适用场景和实战选型一次讲透。整篇文章不会只讲概念我会从一次真实的事故排查切入结合锁升级机制、CAS原理、JUC工具类源码分析和几个可以直接落地的调优参数把什么时候该用synchronized什么时候该换 CAS、换LongAdder这件事说明白。目标读者是有一定并发编程基础、正在做性能调优或准备 Java 面试的人看完之后能建立起自己的锁选型判断框架。1. 一次线上卡顿synchronized高竞争下的真实瓶颈1.1 事故现场的线程状态与性能数据先说当时的具体表现。系统是标准的微服务架构库存服务部署了 6 个实例单实例 4C8G业务高峰期前平均RT在 20ms 左右。秒杀活动开始后接口RT阈值报警持续攀升到 800ms 以上部分请求开始超时。我们用 Arthas 做了线程采样结果很清晰大量业务线程处于BLOCKED状态阻塞点在同一个对象监视器上monitor enter的调用热度占比超过 70%从火焰图看锁竞争导致的线程上下文切换次数每秒超过 5 万次GC 表现反而正常Young GC 频率没有明显异常说明问题不在内存。这里要解释一个很多初级工程师容易忽略的点synchronized在无竞争时开销确实很小JVM 做了大量优化比如偏向锁、轻量级锁这些优化让单线程或低竞争场景下几乎可以忽略锁成本。但当大量线程同时争抢同一个监视器锁时锁会升级为重量级锁线程进入操作系统层面的挂起与唤醒流程这个成本是微秒甚至毫秒级的而且线程切换本身要消耗CPU和系统调用资源。注意这里的大量线程不是指几百上千实际上几十个线程同时激烈竞争就可能触发重量级锁升级。1.2 悲观锁思路在高并发下的结构性缺陷ReentrantLock也是典型的悲观锁实现它和synchronized的阻塞语义类似只是提供了更多能力比如可中断、可超时、公平性控制。在那个事故场景里即使把synchronized换成ReentrantLock也解决不了根本问题。为什么因为悲观锁的核心思路是先拿锁再操作拿不到就等这个等待机制本身就是瓶颈来源。真的去分析当时那个库存扣减方法业务逻辑其实很短查库存、判断是否足够、扣减数量、更新数据库整段逻辑的执行时间不超过 1ms。但就是这 1ms 的临界区在高并发下成了共享资源的单行道所有线程必须排队进入。排队策略无论做得多好吞吐量都被锁的粒度限制住了。很多团队的解决方案是把synchronized改成ReentrantLock加tryLock超时这可以缓解一部分线程无限等待的问题但本质上还是排队模型只能算是治标不治本。我当时的选择是先确认这个库存扣减能不能用乐观锁思路重构也就是用 CAS 配合版本号/库存比对来避免阻塞。重构之后同样的流量下TPS从 480 回升到 1700线程阻塞率基本归零。这个结果让我意识到锁优化的核心不是换一把更好的锁而是尽量避免锁竞争本身。1.3 为什么大家都在提synchronized和 CAS 二选一面试题里经常出现synchronized和 CAS 的对比本质上是在问你能否在阻塞式同步和非阻塞式同步之间做出正确选择。这背后是两个完全不同的设计哲学。synchronized的背后是监视器锁是一条你不行就先让开等别人用完再叫你的阻塞路线。它最大的优势是实现简单、语义清晰、不容易出并发bug缺点是线程挂起唤起的开销大且无法保证公平性高竞争下吞吐量下降明显。CAS 的背后是 CPU 提供的比较交换原子指令是一条你觉得行就直接试不行就再试一次的自旋路线。它避免了线程上下文切换在短临界区场景下吞吐量非常高但缺点也很突出它要求调用方自己处理竞争失败后的重试逻辑并且在长时间竞争时会空转浪费CPU还会遇到 ABA 等边界问题。所以真正该做的事是根据临界区长度、竞争强度、操作语义用数据说话选择性的使用锁或CAS甚至二者混用。不是非黑即白。2.synchronized的内部演进从重量级到锁升级机制2.1 JDK 1.6 之前为什么慢先说一个背景Java 1.6 之前的synchronized确实重因为它完全依赖操作系统的 Mutex 互斥量来实现。线程获取不到锁时会被操作系统挂起进入内核态锁释放后又要被唤醒再次切入内核态。用户态到内核态的切换成本很高一次切换大概需要几百纳秒到几微秒频繁切换会带来严重的性能损耗。更麻烦的是当多个线程在竞争同一个锁时操作系统还需要维护一个等待队列这个队列的唤醒顺序、调度策略都不是 JVM 能完全控制的。所以在那个年代synchronized被称为重量级锁是实至名归的。也正因如此早期 Java 并发编程中存在大量用volatile或ThreadLocal绕过锁的做法甚至有人在无竞争场景下仍然宁可自己写一个Unsafe的 CAS 循环也不愿意用synchronized。这种做法现在回头看没有太大必要因为 JVM 已经从根本上改变了synchronized的实现策略。2.2 锁升级路线无锁 → 偏向锁 → 轻量级锁 → 重量级锁从 JDK 1.6 开始HotSpot 虚拟机对synchronized做了重大改进引入了一套可升级的锁状态模型。这个模型的核心思想是JVM 根据实际竞争情况动态调整锁的实现方式让锁在大部分情况下都不是重量级的。具体来说对象头中的 Mark Word 记录了锁状态锁的升级路径如下锁状态触发条件核心实现主要开销无锁没有任何线程访问普通对象头无偏向锁第一次被某个线程获取记录线程ID下次无需CAS线程ID写入轻量级锁第二个线程尝试获取栈帧中建立锁记录CAS替代阻塞CAS自旋重量级锁竞争加剧自旋失败操作系统Mutex线程挂起唤醒偏向锁解决的是多线程并发执行但只有一个线程实际获取锁的场景。比如 ArrayList 的同步包装类在实际使用中经常被单线程调用这时 JVM 会偏向这个线程后续该线程重新进入同步块时只需要检查线程ID是否一致不需要再做原子操作从而降低开销。轻量级锁解决的是不同线程交替执行、竞争不激烈的场景。此时线程不直接进入阻塞态而是先在栈帧中建立锁记录通过 CAS 尝试将对象头中的 Mark Word 替换为指向锁记录的指针。如果失败说明存在竞争则膨胀为重量级锁。提醒一下偏向锁在 JDK 15 中被标记为废弃JDK 18 中默认关闭。原因是偏向锁在现代应用中的收益降低且维护成本高。如果你的线上环境是 JDK 17就不需要再考虑偏向锁参数了。2.3 重量级锁的 Monitor 机制到底做了什么当轻量级锁 CAS 失败后JVM 会执行锁膨胀将对象头替换为指向 Monitor 的指针。Monitor 是 HotSpot 内置的线程同步机制内部维护了 Owner当前持有锁的线程、EntryList等待获取锁的线程队列、WaitSet调用了wait()的线程队列三个关键结构。线程进入synchronized块的底层逻辑是尝试成为 Monitor 的 Owner如果失败就进入 EntryList 阻塞等待锁释放时被唤醒。这里的唤醒不是 JVM 能完全控制的需要依赖操作系统的线程调度。所以重量级锁的性能瓶颈主要集中在两点一是线程阻塞与唤醒带来的上下文切换成本二是操作系统调度器在大规模等待线程下的公平性和延时不可控。高竞争场景下锁释放的那一时段会有一批线程同时被唤醒它们再次竞争锁又一次大量阻塞形成惊群效应。这一点是悲观锁天然的劣势。2.4 锁消除和锁粗化JVM 自己做的优化除了锁升级JVM 还在编译期做了两个容易被忽略的优化锁消除和锁粗化。锁消除Lock Elision依赖逃逸分析。如果 JVM 判断某个锁对象不会被其它线程访问到也即它没有逃逸出当前线程的作用域那么对这个对象的加锁操作会被直接去掉。比如在某个方法内部创建了一个局部变量对它加锁但该变量从未暴露给外部线程JVM 就会把这个锁消除掉。这也是为什么我们在单线程代码里写synchronized在很多时候不会带来额外开销的原因。锁粗化Lock Coarsening则是把连续的、对同一个对象的加锁解锁操作合并成一次加锁。比如在循环体内反复加锁解锁JVM 会尝试把整个循环的锁合并减少频繁的锁获取释放。但要注意锁粗化的前提是 JVM 认为合并不会影响并发语义有些场景下它反而会延长临界区引发更多竞争。这两个优化的存在意味着评估锁性能时不能只看代码层面写了什么JVM 的 JIT 编译优化也会参与决策。我们做的优化要和 JVM 的优化方向保持一致比如锁临界区尽量短、避免不必要的锁嵌套这些习惯 JIT 会帮你进一步放大收益。3. CAS 原理深度拆解CPU 指令、Unsafe与 ABA 问题3.1 CAS 的底层原子性来自哪里CAS 全称是 Compare And Swap核心语义是比较某个内存位置的当前值是否与期望值一致如果一致则替换为新值整个操作是原子的。Java 里最典型的入口是java.util.concurrent.atomic包下的原子类这些类底层的实现依赖sun.misc.Unsafe提供的compareAndSwapInt、compareAndSwapObject等方法。Unsafe的 CAS 方法最终会映射为硬件指令。在 x86 架构上核心是cmpxchg指令在多核处理器上还需要加上lock前缀来锁住总线或缓存行保证比较交换两步不会被打断。换句话说CAS 的原语级原子性是硬件层面保证的JVM 只负责把这层能力暴露给 Java 代码。这里有个经常被误解的点CAS 不是完全没有开销它只是把原子性的成本转移到 CPU 指令层面。在高并发下大量线程同时执行 CAS总线上的缓存一致性流量会增加甚至可能出现缓存行颠簸这部分性能损耗在低竞争时看不出来竞争激烈时才会暴露。import java.util.concurrent.atomic.AtomicInteger; public class CasDemo { private final AtomicInteger count new AtomicInteger(0); public void incrementWithCas() { // 自旋重试CAS失败说明有并发修改循环重新读取最新值再尝试 while (true) { int current count.get(); if (count.compareAndSet(current, current 1)) { break; } } } }3.2 volatile 与 CAS 的搭配为什么是黄金搭档CAS 操作能够工作的前提是调用方可以看到其他线程对目标变量的修改。这就必须提到volatile。volatile保证了两件事一是可见性二是禁止指令重排序。可见性意味着一个线程对变量的写入对其他线程是立即可见的禁止重排序保证了代码的执行顺序符合预期。在 JUC 的原子变量中value字段都是用volatile修饰的。以AtomicInteger为例它的value声明如下private volatile int value;为什么必须是volatile因为如果这个字段不是volatile一个线程修改了值之后另一个线程可能在缓存中继续读取旧值导致 CAS 的比较总是失败或者基于过期数据操作最终结果就会偏离预期。volatile保证了 CAS 的读是最新的写是可以即时被其他线程感知的。所以当你自己实现一个 CAS 同步逻辑时第一个要检查的就是操作的目标字段是否被volatile修饰。这是很多自研并发组件出bug的头号原因。3.3 ABA 问题和解法AtomicStampedReference与AtomicMarkableReferenceCAS 有一个非常经典的问题叫 ABA一个值从 A 变成 B又在其他线程操作完成之前变回 A此时 CAS 会认为它没有被修改过从而执行成功。这会导致一些逻辑上的错误。举一个实际例子你在用 CAS 模拟一个链表栈的入栈操作栈顶指针从 A 变成 null弹出了紧接着又变成 A新节点入栈另一个线程检查栈顶发现还是 A就认为栈没有变化继续做 CAS 更新结果可能破坏链表结构。解决 ABA 的标准方案是引入版本号或标记位。AtomicStampedReference内部维护了一个[reference, stamp]对象对每次修改引用时同时更新版本号。比较时不仅要比较引用值还要比较版本号这样就避免了值没变但实际变了的语义丢失。import java.util.concurrent.atomic.AtomicStampedReference; public class AbaDemo { // 初始引用为null版本号为0 AtomicStampedReferenceString ref new AtomicStampedReference(null, 0); public boolean update(String expectedRef, String newRef, int expectedStamp) { return ref.compareAndSet(expectedRef, newRef, expectedStamp, expectedStamp 1); } }如果你的业务是只关心值是否被改过中间过程不重要用AtomicMarkableReference会更合适它用一个布尔标记位记录是否被修改过。不过说句实话大多数业务场景下 ABA 并不会造成实质错误很多工程师提 ABA 只是为了应付面试实战中遇到真正需要解决 ABA 的地方并不多。但是高并发的基础组件开发比如无锁队列、无锁栈这个问题就必须深入考虑。3.4 自旋锁与自适应自旋的取舍CAS 的典型使用方式是自旋当前线程不断重试 CAS 操作直到成功。自旋的好处是避免了线程阻塞和上下文切换在临界区很短的时候自旋等待的成本比阻塞唤醒低得多。坏处是如果临界区过长大量线程都在空转CPU 使用率会飙升系统吞吐量反而下降。JVM 内部对synchronized的轻量级锁也做了类似的优化自旋不是固定的而是自适应自旋。JVM 会根据同一个锁上一次自旋的成功率、以及当前锁的持有者状态动态调整自旋时间。如果上一次自旋成功了JVM 倾向于多等一会儿如果上一次自旋失败率高则提前放弃自旋直接进入阻塞。在写我们自己的 CAS 逻辑时也应该引入类似的退避策略避免无限自旋。一个常见的做法是记录自旋次数超过阈值后让出CPU通过Thread.yield()或短暂sleep来降低竞争热度。public final int incrementWithBackoff(AtomicInteger counter, int maxSpin) { int spins 0; while (true) { int current counter.get(); if (counter.compareAndSet(current, current 1)) { return current 1; } if (spins maxSpin) { Thread.yield(); // 让出CPU降低对缓存行的争抢 spins 0; } } }4. 实战选型四种并发场景下怎么选锁4.1 关键维度临界区长度、竞争强度与操作语义选锁之前先问三个问题临界区有多长如果锁内操作是几次内存读写纳秒级搞定CAS 几乎是天然最优解如果锁内涉及 IO、远程调用、数据库操作时间是毫秒级CAS 自旋会严重浪费 CPU此时必须用阻塞锁。竞争有多激烈低竞争线程少、访问频率低时synchronized和 CAS 差别不大高竞争大量线程同时访问时 CAS 的自旋可能让 CPU 飙高LongAdder这类分段设计才有意义。操作是简单值更新还是复合状态流转简单计数、累加、状态位切换适合 CAS涉及多个字段的联合更新、复杂的条件判断和操作序列建议直接用锁代码可维护性优先。这三个维度基本决定了你最后的选型结果。4.2 经典对照表格我整理了一张实战选型对照表方便你归档到自己的技术笔记里。场景推荐方案原因单线程写、多线程读volatilesynchronized读锁或ReentrantReadWriteLock写入用锁保护读取用锁或直接读 volatile高频计数、统计LongAdder或AtomicLong低竞争用AtomicLong高竞争用LongAdder库存扣减、限额控制CAS 配合版本号或库存值校验避免线程阻塞吞吐量高多字段复合更新ReentrantLock或synchronizedCAS 难以保证多字段原子性代码也复杂无锁队列、无锁栈AtomicReference 版本号需要处理 ABA适合基础组件分布式场景数据库乐观锁 / Redis LuaJVM 内 CAS 无法跨进程这里想多说一句关于ReentrantLock和synchronized的选择。两者在性能上已经非常接近ReentrantLock最大的优势是灵活支持公平锁、可中断、可超时等待、多个条件队列。如果业务需要这些特性就选它。如果业务只是简单的临界区保护synchronized写起来更简洁配合 JVM 的锁优化也完全够用。过度设计在这类问题上往往是负面收益。4.3 案例一秒杀库存扣减的 CAS 重构回到开头那个事故案例。原代码是public synchronized boolean tryDeductStock(int stockId, int quantity) { Stock stock stockMapper.selectById(stockId); if (stock.getCount() quantity) { stock.setCount(stock.getCount() - quantity); stockMapper.updateById(stock); return true; } return false; }这段代码把查库存、判断、扣减、更新整个放进了临界区而且用的是数据库查询和更新一个事务操作至少几毫秒。高并发下所有线程都堵在这个synchronized上性能跌得厉害。重构思路有两个方向。如果你用的是支持乐观锁的数据库比如 MySQL 自带版本号机制可以直接在 SQL 层做 CASUPDATE stock SET count count - #{quantity}, version version 1 WHERE id #{stockId} AND count #{quantity} AND version #{version};这样数据库的行锁本身就是乐观锁实现业务代码里根本不需要 JVM 级别的锁。SQL 返回受影响行数为 1 表示扣减成功否则说明库存不足或版本冲突重试或直接返回失败。如果你不想依赖数据库的乐观锁希望在 JVM 内存层面做限流或库存预扣可以用AtomicInteger配合谓词判断模拟先验证后更新的原子操作public boolean tryAcquire(AtomicInteger stockCounter, int quantity) { while (true) { int current stockCounter.get(); if (current quantity) { return false; // 剩余不足直接失败无需自旋 } if (stockCounter.compareAndSet(current, current - quantity)) { return true; } // CAS失败说明有并发扣减重新读取 } }这个方案只保护内存计数配合实际DB扣减的事务操作可以达到前置挡流量后续落库的效果。我在实际项目中经常把这种内存 CAS 计数放在网关层或微服务入口能承担极高的并发判断。4.4 案例二统计场景为什么不该死磕 CAS 自旋如果说扣减库存这种场景用 CAS 是正向收益那高频的累计统计场景就未必了。假设你有一个全局访问量计数器每次请求进来都要count。用AtomicLong实现低并发时没问题一旦出现高并发大量线程都去 CAS 同一个变量缓存行被频繁更新总线流量激增性能开始下滑。这时候再靠自旋去死磕就不是优化而是火上浇油。JDK 8 提供了LongAdder它的设计思路是放弃单一变量原子更新的执念改成分段计数内部维护一个 base 和多个 Cell 数组并发高时不同线程可以分散到不同 Cell 上做计数最后汇总时把 base 和所有 Cell 的值加总。这样就把一个热点拆成了多个热点竞争被显著降低。import java.util.concurrent.atomic.LongAdder; public class CounterWithLongAdder { private final LongAdder count new LongAdder(); public void increment() { count.increment(); } public long getCount() { return count.sum(); } }要注意的是LongAdder不是完全等价于AtomicLong的无锁计数器。它的sum()方法在并发更新期间可能返回略有偏差的中间值因为它没有全局加锁。在业务允许最终一致的场景下可以接受如果要求严格的精确计数必须换回AtomicLong或者引入锁。5. 锁优化进阶JVM 参数、伪共享与无锁数据结构设计5.1 偏向锁与轻量级锁相关的 JVM 参数虽然偏向锁在 JDK 18 被默认关闭但理解它的历史参数仍然有帮助尤其在维护老版本 JDK 项目时。-XX:UseBiasedLocking启用偏向锁默认开启JDK 15废弃标记。-XX:BiasedLockingStartupDelay偏向锁在 JVM 启动后延迟多少毫秒才生效默认是 4000ms目的是避免启动阶段大量锁竞争带来的偏向锁撤销开销。-XX:UseLockElision、-XX:UseLockCoarsening等 JIT 参数实际通常由编译器自动决策不需要手动调。如果你在维护 JDK 8/11 项目且通过压测确认大量线程存在交替获取锁、竞争不强的场景可以考虑保留偏向锁的内置策略。但如果你已经在用 JDK 17就不用关心这些参数了JVM 默认的锁实现已经足够高效。这里有个容易踩的坑在生产环境调 JVM 锁参数之前一定要先做 A/B 压测。有些参数在单场景下有效切换到混合负载反而会拖慢整体。锁参数的调优优先级永远排在代码架构优化之后。5.2 伪共享CAS 优化里最隐蔽的性能刺客用 CAS 自旋提升性能时你最该担心的问题不是 ABA而是伪共享False Sharing。现代 CPU 的内存模型以缓存行Cache Line为基本单位典型大小为 64 字节。当多个线程操作不同的变量但这些变量恰好落在同一个缓存行里时一个线程修改自己的变量会触发整个缓存行失效导致其他线程的缓存行被强制刷新即使它们根本没有修改同一个变量。这个现象就是伪共享它会让 CAS 操作付出远高于预期的缓存一致性开销。Java 里最常见的伪共享场景就是多线程的计数器数组或状态变量。简单来说如果你定义了这样一个类class SharedState { volatile long stateA; volatile long stateB; }多个线程一个操作 stateA另一个操作 stateB表面上没有竞争但由于它们处于同一个缓存行实际的读写会互相干扰性能损失可能高达一个数量级。解决方式有两种一是让不同变量之间填充 padding把它们隔离到不同缓存行二是在 JDK 8 使用sun.misc.Contended注解需要 JVM 参数-XX:-RestrictContended支持。LongAdder的 Cell 数组就内部使用了Contended来避免伪共享这是它能在大并发下保持优秀性能的关键设计之一。5.3 无锁数据结构设计的三个原则如果你已经走到了自己实现无锁队列、无锁计数器这一步说明并发量真的不小。这里分享三个从实践中总结出来的设计原则。第一操作必须可以被重试。无锁数据结构的核心假设是CAS 失败是正常的业务逻辑必须能从竞争失败的状态安全恢复。因此任何 CAS 操作前面都不能有不可回滚的副作用。比如入队操作必须先完成节点链接再 CAS 移动尾指针如果 CAS 失败要能安全重试。第二状态机的粒度要尽可能小。用单个引用承载完整状态避免多个状态之间互相约束。比如用AtomicReference同时承载数据版本和标记位比分别用两个原子变量更安全因为单个引用的 CAS 是原子的多个变量的组合CAS不具备原子性。第三不能回避边界情况的思考。无锁算法最难处理的往往是空队列、恰好一个元素、尾指针落后于头指针这些边界。建议在小并发下先用暴力压测验证正确性再上高并发验证性能否则一旦出现数据错乱排查成本比用锁高得多。5.4 什么时候应该放弃 CAS 回到锁最后聊一个很多人不愿意面对的问题CAS 并不总是优于锁。如果你的锁临界区涉及复杂业务逻辑、多个资源联动、或有强一致的顺序要求CAS 带来的复杂度和脆弱性会远超它省下的那点上下文切换成本。我之前参与过一个支付网关的金额冻结/解冻模块每个账户同时只能有一个生效的冻结操作且冻结和解冻之间存在严格的状态流转依赖。当时团队尝试用 CAS 做无锁化改造结果是代码里堆满了各种 retry 和临时状态检查Bug 率反而上升了。后来改回ReentrantLock配合条件队列代码量少了三分之一稳定性明显改善。这个案例给我的教训是锁优化的最终目标是系统性能与代码可维护性的平衡不是消灭所有锁。在合适的场景用锁在真正有收益的场景用 CAS才是资深工程师应有的判断力。我在实际项目中的默认策略是单机短临界区、简单状态更新优先AtomicInteger/LongAdder涉及多步骤联动、IO、事务直接用synchronized或ReentrantLock底层中间件开发才考虑自研无锁算法。这套规则帮我避掉了很多为了优化而优化的坑你在做技术决策时也可以参考。