先从一个我上个月在客户现场遇到的故障说起。一个订单接口在并发峰值时频繁超时,一堆线程卡在synchronized代码块上,CPU 飙到 90% 以上,日志里全是线程阻塞的告警。那个方法其实只做了库存校验和扣减,逻辑不长,但锁全压在一把“大锁”上。后来把核心计数逻辑替换成基于 CAS 的原子类,接口耗时从平均 80ms 降到 5ms 左右,效果立竿见影。这就是锁优化最典型的场景——不是所有并发问题都需要锁,也不是所有锁都该叫synchronized。
这篇内容想系统梳理 Java 并发中锁的演进路径,重点讲清楚synchronized从重量级锁到锁升级再到现在偏向锁退出历史舞台的过程,以及 CAS 为什么能在很多场景替代锁、又有什么坑。无论你是准备面试还是做线上性能优化,这份内容都值得完整读一遍。我会把原理、代码、选型逻辑和排查手段一起讲透。
1. synchronized为什么“重”:老锁的性能瓶颈在哪
1.1 锁的本质与操作系统的重量级代价
synchronized在 Java 早期版本中确实性能不佳,根本原因在于它在竞争激烈时依赖操作系统底层的互斥量(Mutex)实现。线程拿不到锁时会被挂起,进入阻塞状态;等锁释放了,又要由操作系统负责唤醒。这个挂起和唤醒的过程涉及用户态与内核态的状态切换,开销非常大。
你可以把操作系统互斥量想象成一个需要“交警到场处理”的堵车路口。线程拿不到锁,就像车辆被拦下来等交警指挥,交警一来一回,现场所有车都得停。如果临界区本身很短(比如一个 i++),拿锁的时间可能只有几十纳秒,但阻塞和唤醒的开销却要几十微秒,差距超过上千倍。这就是老版本synchronized被诟病“性能差”的根源。
除了线程切换本身,重量级锁还伴随上下文切换导致的缓存失效问题。线程被唤醒后,CPU 缓存里原本的热数据可能已经失效,重新加载缓存也需要时间。在高并发场景下,大量线程频繁阻塞、唤醒,系统吞吐量自然上不去。
1.2 JDK 1.6的锁升级机制:偏向锁、轻量级锁、重量级锁
JDK 1.6 对synchronized做了重大优化,引入了锁升级机制。所谓“升级”,是指synchronized不再一上来就动用操作系统互斥量,而是根据竞争程度动态选择合适的锁状态。Java 对象头中的 Mark Word 是这场演化的核心阵地,它内部保存了锁状态标志位、偏向线程 ID、轻量级锁指针等信息。
锁状态从低到高分为四档:
| 锁状态 | 加锁过程 | 适用场景 | 开销特点 |
|---|---|---|---|
| 偏向锁 | 第一次获取时通过 CAS 将线程 ID 写入 Mark Word,之后该线程再次进入同步块直接通过 | 单线程反复访问同一同步块 | 无竞争时近乎零开销 |
| 轻量级锁 | 线程在栈帧中创建锁记录,通过 CAS 将对象头 Mark Word 替换为指向锁记录的指针 | 多个线程交替进入临界区,无实际竞争 | 无阻塞,自旋等待 |
| 重量级锁 | 竞争加剧时锁膨胀,线程进入操作系统互斥量等待队列 | 多个线程同时争抢同一把锁 | 阻塞唤醒,开销最大 |
偏向锁解决的是“一个线程反复拿同一把锁”的场景。Java 代码里很多同步方法虽然被加锁,但实际运行时往往只有一个线程在访问,比如某些初始化方法或单例创建逻辑。偏向锁让同一线程后续进入同步块时几乎不需要任何原子操作,直接把线程 ID 和当前线程比对即可通过。
轻量级锁应对的是“线程交替执行、没有真正争夺”的场景。两个线程虽然都会进入同步块,但时间上错开,互不打照面。此时通过 CAS 操作尝试获取锁,失败时自旋而不是立即阻塞,自旋的意思是空转 CPU 不断重试。如果自旋一段时间后仍拿不到锁,说明竞争确实激烈,锁就会膨胀为重量级锁,线程转入阻塞状态。
这套升级机制的设计思路很像现实中的停车场管理。车少时道闸直接抬起(偏向锁);车稍多但有序排队时靠调度员快速疏导(轻量级锁);车流彻底堵死时,才需要交警现场接管(重量级锁)。锁不直接上重量级,是为了避免一上来就付出线程阻塞话的巨额成本。
1.3 JIT编译器的额外补偿:锁消除与锁粗化
除了锁升级,JIT(即时编译器)还有两个与锁优化相关的编译期优化:锁消除和锁粗化。锁消除依赖逃逸分析,如果 JIT 判断一个对象不会被其他线程访问,那么这个对象上的同步操作就是多余的,直接去掉。最典型的例子是局部变量上使用StringBuffer或Vector,这些类的方法内部有synchronized代码块,但实例本身只在当前线程内使用,JIT 会把锁消除掉。
锁粗化的方向刚好相反,它针对的是循环里反复加锁解锁的问题。比如在 for 循环内部调用某个加锁方法,每次迭代都要重新获取锁,开销被放大。JIT 会把锁的范围扩大到整个循环外,一次加锁完成所有迭代,相当于把“反复掏钥匙开门”变成“锁门进屋里干完再走”。这种优化很像批量操作的思路,减少重复开销。
需要说明的是,锁消除和锁粗化的前提是 JIT 能准确判断对象使用情况。如果代码中锁的使用模式过于复杂,JIT 无法做出可靠分析,优化就不会生效。所以代码层面还是要保持合理的锁粒度,不能把宝全押在 JIT 上。
1.4 偏向锁退出历史舞台:JDK 15之后的变化
有趣的是,偏向锁在 JDK 15 被标记为废弃,JDK 20 之后默认关闭。原因很简单:随着硬件发展,现代 CPU 的原子操作成本大幅下降,偏向锁的收益不再明显;同时偏向锁的实现复杂度高,在线程池场景中容易因为锁撤销触发大量 CAS 和安全点停顿,反而拖累性能。
另外,偏向锁本身有一个昂贵操作叫“批量撤销”。当大量线程争抢同一个偏向锁时,JVM 需要在这些线程到达安全点后批量撤销偏向状态,这个 Stop-The-World 停顿在延迟敏感的服务中不可接受。很多一线团队早就通过-XX:-UseBiasedLocking参数手动关闭偏向锁,新版 JDK 只是把这种方式变成了默认行为。
所以你在最新 JDK 上看到的synchronized,实际只保留轻量级锁和重量级锁两档。轻量级锁在 JDK 15 之后也做了改进,自旋次数不再固定,而是由 JVM 根据前一次自旋结果和 CPU 核数动态调整。这说明synchronized并没有落后,它像一把“会自动调档的扳手”,根据扭矩需求自动切换发力方式。
2. CAS:无锁并发方案的原理解析与经典落地
2.1 CAS的硬件原语与原子操作原理
CAS 是 Compare-And-Swap(比较并交换)的缩写,它是一条 CPU 层面的原子指令。操作逻辑是:先比较内存位置的当前值是否等于预期值,是则将该位置更新为新值并返回成功;否则不更新,返回失败。在 x86 架构上对应的是CMPXCHG指令,硬件保证了比较和交换过程不会被其他核心打断。
CAS 被称为“无锁方案”,并不是说它完全不需要协调,而是把协调成本降到了极低:线程不再被挂起,而是反复尝试更新,直到成功为止。你可以把 CAS 想象成“考试交卷前反复核对姓名”:先看看答题卡上的名字是不是自己的(比较),是就写下新名字(交换),不是就说明有其他线程改过,等下一轮再做一次。整个过程没有等待队列,没有阻塞,时间大多消耗在自旋重试上。
Java 层面的 CAS 封装在sun.misc.Unsafe中,compareAndSwapInt、compareAndSwapObject等方法对应硬件指令。虽然Unsafe不能直接被业务代码使用,但java.util.concurrent.atomic包下提供了大量基于 CAS 的原子类,我们通常通过这些类间接使用 CAS 能力。
2.2 从AtomicInteger到LongAdder:原子工具类家族
java.util.concurrent.atomic包是 CAS 最典型的落地方案。AtomicInteger是最基础的原子整型类,incrementAndGet()方法内部使用 CAS 循环实现自增。在多线程竞争不激烈时,它比synchronized高效得多,因为没有线程切换的损耗。
AtomicReference可以原子更新对象引用,适合状态流转类场景,比如“从待支付变更为已支付”。AtomicMarkableReference和AtomicStampedReference则解决了 CAS 的 ABA 问题,分别用布尔标记和版本号识别中间修改。
还有一个经常在面试中被问到的类:LongAdder。它从 JDK 8 开始提供,思路是“分段计数”。内部维护一个 base 变量和一组 Cell 数组,每个线程会被分散到不同的 Cell 上做累加,最终汇总时把各 Cell 值加起来。这就像多个收银台同时结账,把本来集中在一个柜台的压力分散开。LongAdder在超高并发写多读少的场景中,性能比AtomicInteger还要好,但其缺点是读取结果时要累加求和,可能拿不到严格实时的精确值。
2.3 CAS的三大痛点:ABA、自旋开销与单变量局限
CAS 并非万能,它有三个必须清楚的弱点。第一个是 ABA 问题:变量值从 A 变成 B,又从 B 变回 A,CAS 比较时发现值是 A,就误以为没人动过。解决方法是引入版本号,AtomicStampedReference就是干这个的,每次修改都带上一个版本号,只有值和时间戳都匹配才算成功。
第二个是自旋带来的 CPU 消耗。如果多个线程同时争抢同一个变量且持有时间较长,CAS 会一直循环失败,CPU 空转发热。当竞争线程数量超过 CPU 核心数的一半左右,自旋的浪费就非常严重。这也解释了为什么轻量级锁有升级阈值:自旋一定次数后还失败,JVM 会果断转向重量级锁,把线程挂起,避免空转。
第三个是只能保证“单个变量”的原子性。业务里经常出现多个字段必须一致更新的场景,比如库存的“冻结数”和“剩余数”要同时变化,CAS 无法对两个独立变量做复合原子操作。此时要么把多个字段封装成一个不可变对象,通过AtomicReference整体替换,要么老老实实用锁。
2.4 重新理解演进关系:轻量级锁本质也是CAS
把synchronized和 CAS 放在对立面看是个常见误区。实际上,synchronized的轻量级锁阶段就是基于 CAS 实现的:线程获取锁时,会以 CAS 方式将对象头 Mark Word 替换为指向锁记录的指针,成功即获得锁;失败则自旋重试,自旋过头才膨胀为重量级锁。也就是说,JVM 早就在内层把 CAS 用起来了。
演进关系更像这样:老一代的synchronized只提供重量级锁,后来 JVM 借鉴了无锁并发思想,用 CAS 实现了偏向锁和轻量级锁,减少了锁竞争时的阻塞开销;在java.util.concurrent包中,Doug Lea 则把 CAS 直接暴露给开发者,让大家能在合适的场景自行选择更细粒度的控制。
理解这一层,你会明白“用 CAS 替代 synchronized”这个命题并不是简单的替换,而是把控制粒度从 JVM 内部拿到了代码层。你选择 CAS 时,实际上是在说“我知道这里竞争不激烈,或者我有更复杂的处理逻辑需要绕过锁机制”。
3. 实战改造:从synchronized到CAS的关键步骤
3.1 场景定义与指标设计
为了把整个改造过程讲清楚,我构造一个典型的业务场景:一个请求计数器,统计系统每秒钟处理了多少请求。计数器需要线程安全,多个线程同时count++时不能出现丢失更新。这个场景简单,适合做对照实验,能清楚看到不同锁方案的性能差异。
指标设计中我会关注三个值:吞吐量(每秒完成的累加次数)、平均耗时(单次累加的时间)和线程竞争度(模拟并发线程数)。压测工具用 JMH 最合适,它是专门面向 JVM 微基准测试的框架,能正确处理 JIT 预热、指令重排等问题。如果你用简单的for循环跑结果,很容易被 JIT 优化掉,测出来全是假数字。
3.2 第一版:synchronized计数器及其性能基线
先看基于synchronized的实现:
public class SyncCounter { private long count = 0; public synchronized long increment() { return ++count; } public synchronized long get() { return count; } }这个实现逻辑上没问题,方法内临界区只有一行++count,执行时间极短。在低并发(比如 2 个线程)下,JVM 会启用轻量级锁甚至偏向锁,性能不会太差。但一旦线程数上升到 16 个、32 个,轻量级锁自旋失败率飙升,锁升级为重量级锁,线程开始频繁阻塞和唤醒。
我在一台 8 核 16 线程的机器上压测,32 个线程并发累加 100 万次,synchronized版本耗时约 320ms。如果按单线程跑同样的累加量,耗时只有 3ms,差距两个数量级。这个对比足够说明问题:线程上下文切换的开销,在临界区极短时会被无限放大。
3.3 第二版:AtomicInteger替换后的性能对比
换用AtomicInteger实现同样的计数器:
public class AtomicCounter { private AtomicInteger count = new AtomicInteger(0); public int increment() { return count.incrementAndGet(); } public int get() { return count.get(); } }incrementAndGet()内部是 CAS 自旋循环。32 线程并发累加 100 万次时,这个版本耗时约 40ms,比synchronized版本快了 8 倍。性能提升的来源很明确:线程始终运行在用户态,没有阻塞和唤醒,省去了系统调用和上下文切换开销。
但要注意,如果继续增大竞争强度,比如让每个线程在累加前后做一点耗时操作(模拟实际业务),CAS 的优势会缩小。原因在于临界区本身变长了,线程持锁时间增加,CAS 自旋失败次数同样上涨。所以 CAS 最适合的是“临界区极短、操作非常快”的场景。这就像超市自助结账柜台,只适合买一两件东西的顾客,买一整车商品的人还是得排人工收银通道。
实际改造时还要注意AtomicInteger的可见性。get()方法本身用volatile读取,能保证多线程下看到最新值,不需要额外同步。
3.4 第三版:处理ABA问题的版本化方案
如果计数器不是简单累加,而是校验状态的场景,ABA 问题就会浮现出来。比如一个用户积分账户,线程 A 准备把积分从 100 改成 200,读取时发现当前值是 100;但线程 B 在此期间把积分改成了 150,又改回了 100,线程 A 的 CAS 仍然能成功,但中间过程如果产生过其他业务动作(比如发放了优惠券),整个链路就出了问题。
用AtomicStampedReference可以避免这个坑:
public class StampedCounter { private AtomicStampedReference<Integer> countRef = new AtomicStampedReference<>(100, 0); public boolean increment(int expected, int newValue) { int[] stampHolder = new int[1]; Integer current = countRef.get(stampHolder); int stamp = stampHolder[0]; if (current != expected) { return false; } return countRef.compareAndSet( expected, newValue, stamp, stamp + 1); } }每次修改都把版本号加一,CAS 比较时同时检查值和版本号。即便值回到了 100,版本号也已不同,CAS 会失败,需要业务层重新决策。这里要特别提醒:AtomicStampedReference的版本号是 int 类型,极端高并发下可能出现整型溢出。虽然概率极小,但需要评估风险。如果非常在意,可以用AtomicLong自增版本号的替代方案。
3.5 决策方法:什么时候坚持synchronized,什么时候改用CAS
这是整篇内容最有决策价值的部分。我的基本原则是:锁粒度小于 10 条指令且竞争不激烈,优先 CAS;临界区逻辑较长或对公平性有要求,优先 synchronized。至于公平性,synchronized是隐式非公平锁,但不会发生线程饿死,因为重量级锁有阻塞队列;CAS 自旋则可能出现低优先级线程一直失败的情况。
具体决策可以按下面这个清单走:
// 决策伪代码,表述核心判断逻辑 boolean useCas(long criticalSectionCost, int threadCompetition) { if (criticalSectionCost < 100 && threadCompetition < 4) { return true; // 临界区极短、竞争低 } else if (threadCompetition > 16) { return false; // 竞争激烈,自旋会浪费 CPU } // 处于中间地带:压测结果说话 return benchmarkResultProvesCasBetter(); }另外还有一条场景判断:需要复合原子操作时,优先封装状态为不可变对象后用AtomicReference,封装不了就同步块。synchronized的现代实现(轻量级锁 + 自旋 + 自适应)已经足够高效,强制性地把一切并发问题改成 CAS,反而容易引入 ABA、活锁等问题。这就像做菜,不是所有食材都适合猛火爆炒,文火慢炖的工具该用还得用。
3.6 压测与结果验证:别被假数据骗了
做对比实验时,最容易被忽略的是 JVM 预热。JIT 编译是分层的,代码可能要经过数千次调用后才达到峰值性能。你不预热直接测,冷启动阶段的解释执行数据会严重污染结果。用 JMH 的话,要设置合理的@Warmup和@Measurement参数,例如预热 3 秒,正式测量 5 秒。
测试环境还要确保 CPU 频率稳定。笔记本的动态调频、云服务器的 CPU 限制,都会让测试结果失真。最好固定 CPU 频率,或者至少保证对比实验在同一环境下交替进行,避免环境漂移。
另一个细节是防止死代码消除。如果你在基准测试里只计算count但不消费结果,JIT 发现这个值不影响后续逻辑,可能直接把整个计算过程优化掉,测试就是空的。必须把累加结果累加到一个黑洞变量(Blackhole)中,强制 JIT 保留操作。
压测完成后还要看线程竞争曲线。同一方案在不同竞争级别下的表现可能完全不同,只测一个并发数就下结论,容易得出片面的答案。建议至少测 2、4、8、16、32 线程五档,画出一条性能曲线后再做选择。
4. 排查实录:锁优化中我踩过的五个坑
4.1 锁对象不一致导致并发失效
这是线上最隐蔽的问题之一。代码里写的synchronized确实生效了,但锁的对象不是大家共享的那一个。每个请求都会 new 一个对象,锁在各自的对象头上,完全失去互斥意义。最典型的错误是把锁加在方法内部的局部变量上,或者把锁对象放在 ThreadLocal 里。
String做锁对象是另一个雷区。synchronized("abc")表面看是对同一个字符串加锁,但字符串常量池和new String("abc")可能指向不同对象,锁就不一致。更危险的是,字符串常量池是全局共享的,其他无关代码如果也用同一个字符串做锁,会出现毫无业务关联的线程互相阻塞。
排查这类问题不要太依赖看代码,直接用线程 dump 更直观。发生并发事故时抓一份jstack日志,看阻塞线程到底卡在哪个对象的 monitor 上,再倒查该对象的所有引用路径。我见过一个团队查了两天的并发问题,最后发现锁对象被 Spring 的代理类换掉了。
4.2 自旋失控导致CPU飙高
有段时间我发现线上某服务的 CPU 使用率间歇性飙到 100%,但 GC 和流量都正常。用jstack查看后发现大量线程处于 RUNNABLE 状态,并且都停在一个AtomicInteger的自旋循环上。进一步查看代码发现,某个方法在更新失败后没有退避策略,直接进入while(true)重试,而对应的共享变量更新需要依赖另一个服务的结果,那个服务变慢后,这里就变成了无限空转。
自旋优化的正确姿势是加入最大重试次数或退避逻辑:
public boolean updateWithRetry(AtomicInteger counter, int expected, int newValue) { int maxSpins = 100; int spins = 0; while (spins++ < maxSpins) { if (counter.compareAndSet(expected, newValue)) { return true; } // 每次失败让出CPU时间片,给其他线程执行机会 Thread.yield(); } // 超过最大次数后返回失败,交给业务层处理 return false; }这里Thread.yield()是关键。它主动让出 CPU,避免一个线程霸占核心空转。在高竞争场景,这个改动能把 CPU 占用率降低 30% 以上。自旋的本质是“用 CPU 时间换线程切换时间”,但没有上限的自旋就是“用 CPU 时间换空气”。
4.3 ABA问题导致的“幽灵修改”
真实业务里我遇到过一个库存扣减的案例。库存表用版本号做乐观锁,但实现时只比较了库存数量,没比较版本号,导致高并发下出现超卖。线程 A 读取库存 10 件,线程 B 先扣成 8 件,再补回 10 件,线程 A 的 CAS 校验成功,把 10 改成 9,实际上中间已经产生了两次售卖,系统记录完全错乱。
解决 ABA 的标准手段是AtomicStampedReference,但也有人用数据库的乐观锁字段。不管是哪种方式,核心都是引入“版本”这个概念,值相同不代表状态没变。业务场景里,凡是带有状态机的对象(订单状态、审核流程、账户金额),都必须考虑 ABA 风险。
排查这类问题的技巧是给所有写操作增加审计日志,记录修改前后的值和时间戳。问题发生时回放日志,能直接看到中间被谁改过、改了几次。没有审计日志的 CAS 业务,出了问题只能靠猜,排查成本极高。
4.4 伪共享:隐形锁竞争者
伪共享是整个 Java 并发里最容易被忽视的性能黑洞。CPU 缓存以缓存行(通常 64 字节)为单位加载数据,当两个线程分别修改同一个缓存行里的不同变量时,缓存一致性协议会让这两个核心不断互相通知缓存行失效,性能大幅下降。
举一个实际例子:一个数组里存了两个AtomicLong,第一个记录成功数,第二个记录失败数,两个变量物理上紧挨着。两个线程一个只更新“成功数”,另一个只更新“失败数”,结果彼此不断拖累,性能比不加无锁方案还差。这就是伪共享。
解决手段是让两个变量“物理隔离”。JDK 8 提供了@Contended注解(需要加 JVM 参数-XX:-RestrictContended才能生效),更通用的做法是在变量之间填充无意义字段,把缓存行拆开:
public class PaddedCounter { public volatile long successCount; public volatile long p1, p2, p3, p4, p5, p6, p7; // 填充 public volatile long failCount; }LongAdder内部的 Cell 数组也用到了这个技术,每个 Cell 都被@Contended或填充字段隔开。在实际开发中,只要发现高并发数据结构的性能与预期不符,优先怀疑伪共享。
4.5 过度优化:锁粒度越细不一定越快
锁优化的终点不是“越快越好”,而是“够用且不过度”。我曾经在一个项目中把订单状态的每个字段都拆成独立的AtomicReference,结果代码复杂度爆炸:多个字段的一致性问题、状态间流转的校验逻辑全部堆在业务层,最后维护成本远超那点性能收益。
锁粒度细化的前提是字段之间没有强一致性要求。如果一个订单的状态、金额、支付时间必须同步变更,整体封装成一个不可变对象再替换,远比拆成多把细锁合理。AtomicReference<OrderState>一把锁解决,状态变更时整个对象替换,逻辑清楚,还天然避免了多字段原子性难题。
过度优化的另一个表现是过早引入无锁数据结构。ConcurrentLinkedQueue在高并发下表现不错,但如果是生产者消费者模式,业务上需要“最多一次”或“恰好一次”的保证,无锁队列的弱一致性反而会在边界场景制造麻烦。普通BlockingQueue加锁的版本在这种场景更可靠。
我的建议是:先用最简单可靠的synchronized或ReentrantLock把功能做对,压测发现瓶颈后,再用工具定位到热点代码,最后才考虑 CAS 或无锁队列。一套性能分析流程走完,80% 的锁问题在第一步就能定位,根本不值得引入额外的复杂度。
最后分享一点个人经验
锁优化看起来是在比技术细节,实际上比的是对业务场景的理解。面试官喜欢问synchronized和 CAS 的区别,但你真正到了线上排查问题时会发现,理论背得再熟,不如抓一份线程 dump 有用。我在实际项目中养成了一个习惯:每次优化锁之前,先把并发度、临界区耗时、允许的脏读程度整理成一张表贴在工位上,写完代码对照检查一遍。这个习惯帮我避开过不少为了优化而优化的坑。
最后再分享一个小技巧:压测时别只盯着平均耗时,看 P99(99% 请求的耗时)和线程阻塞次数。平均耗时被少数快的请求拉低,P99 才能反映并发高峰期的真实体验。锁优化不见得每次都要轰轰烈烈,能把 P99 从 2 秒降到 200ms,就是一次很成功的改造。工具和方法都在这里了,剩下的就是动手跑一轮压测,让数据告诉你答案。