ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java并发锁优化:从synchronized到CAS的演进与实战

Java并发锁优化:从synchronized到CAS的演进与实战 先从一个我上个月在客户现场遇到的故障说起。一个订单接口在并发峰值时频繁超时一堆线程卡在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 变回 ACAS 比较时发现值是 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 AtomicStampedReferenceInteger 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结果代码复杂度爆炸多个字段的一致性问题、状态间流转的校验逻辑全部堆在业务层最后维护成本远超那点性能收益。锁粒度细化的前提是字段之间没有强一致性要求。如果一个订单的状态、金额、支付时间必须同步变更整体封装成一个不可变对象再替换远比拆成多把细锁合理。AtomicReferenceOrderState一把锁解决状态变更时整个对象替换逻辑清楚还天然避免了多字段原子性难题。过度优化的另一个表现是过早引入无锁数据结构。ConcurrentLinkedQueue在高并发下表现不错但如果是生产者消费者模式业务上需要“最多一次”或“恰好一次”的保证无锁队列的弱一致性反而会在边界场景制造麻烦。普通BlockingQueue加锁的版本在这种场景更可靠。我的建议是先用最简单可靠的synchronized或ReentrantLock把功能做对压测发现瓶颈后再用工具定位到热点代码最后才考虑 CAS 或无锁队列。一套性能分析流程走完80% 的锁问题在第一步就能定位根本不值得引入额外的复杂度。最后分享一点个人经验锁优化看起来是在比技术细节实际上比的是对业务场景的理解。面试官喜欢问synchronized和 CAS 的区别但你真正到了线上排查问题时会发现理论背得再熟不如抓一份线程 dump 有用。我在实际项目中养成了一个习惯每次优化锁之前先把并发度、临界区耗时、允许的脏读程度整理成一张表贴在工位上写完代码对照检查一遍。这个习惯帮我避开过不少为了优化而优化的坑。最后再分享一个小技巧压测时别只盯着平均耗时看 P9999% 请求的耗时和线程阻塞次数。平均耗时被少数快的请求拉低P99 才能反映并发高峰期的真实体验。锁优化不见得每次都要轰轰烈烈能把 P99 从 2 秒降到 200ms就是一次很成功的改造。工具和方法都在这里了剩下的就是动手跑一轮压测让数据告诉你答案。
返回列表