CAS 和 synchronized 浅谈
Zhongjun Qiu 元婴开发者

简要谈一下 Java 里 CASsynchronized 的区别和适用场景。

对于现代 JDK 版本(17 及以上),CAS 和 synchronized 在“理论性能”层面其实已经非常接近了。synchronized 也早就不是以前大家印象里那个“又慢又笨”的悲观锁,在不少场景下,甚至是优于 CAS 的。

先从底层机制说起,这两者本质上走的是完全不同的路

CAS(Compare And Swap)是基于硬件指令实现的,它可以把「比较内存值是否等于预期值 A」和「把内存值更新为 B」这两个步骤合并成一个原子操作。在 Java 里,CAS 主要是通过 Unsafe 调用 native 方法完成的,AtomicIntegerAtomicLong 等原子类基本都是基于这一套机制。

但 CAS 并不是没有代价,它主要有下面几个问题。

第一,CAS 天生只适合单变量场景。 它一次只能保证一个变量的原子性(哪怕这个变量是引用类型,比如 byte[]),如果涉及多个变量之间的一致性,CAS 就显得非常无力了。

第二,CAS 存在典型的 ABA 问题。 线程 1 读取到的值是 A,在它真正执行 CAS 之前,线程 2 把值从 A 改成 B,又从 B 改回 A。 此时线程 1 执行 CAS,会发现“值还是 A”,从而误以为中间没有发生任何修改。 这个问题后来通过 AtomicStampedReference / AtomicMarkableReference 等方式引入版本号来缓解,但本质上说明 CAS 对历史变化是“无感知”的。

第三,也是最核心的问题:CAS 会自旋。 当 CAS 失败时,通常的做法是不断重试,如果并发竞争激烈,就会导致线程在 CPU 上空转,白白消耗算力。

在实际工程中,这个问题是必须显式处理的。比如我之前在一个高速网络流量测量系统里,用 CAS 更新桶计数时,就做了指数退避,避免无意义的 CPU 消耗(简化后的代码如下):

alt text

这里在 CAS 失败后,直接使用 LockSupport.parkNanos 让线程短暂阻塞,把线程状态切换为 WAITING,暂时不参与 CPU 调度,然后再重试,这样可以有效避免自旋把 CPU 打满。


再来看 synchronized

synchronized 的底层是基于对象 Monitor 的锁机制。细节很多,这里简单概括一下它在现代 JVM 里的执行路径。

当前的 synchronized 锁大致会经历几个阶段:

  1. 无锁状态 完全没有竞争,直接执行。

  2. 轻量级锁(现在已经没有偏向锁了) JVM 会尝试在对象的 MarkWord 中记录当前线程 ID,通过 CAS 判断是否能成功获取锁。如果 CAS 成功,线程就拿到了锁,不会发生阻塞。

  3. 重量级锁 当竞争激烈、CAS 频繁失败时,JVM 会主动升级为重量级锁,此时线程会被 park,真正阻塞起来,避免在轻量级锁阶段 CAS 空转。

可以看到,现代的 synchronized 早就不是“纯悲观锁”了。 它内部同样使用了 CAS,而且在竞争激烈时,会自动帮你处理“自旋 + 阻塞”的问题,这一点和我前面代码里的手动退避逻辑,本质上是一样的。

同时,synchronized 还能直接锁方法、锁代码块、锁类对象,在使用层面也更加灵活,而 JVM 在这块已经做了非常多的优化,性能并不比 CAS 差。


所以结论其实很简单:还是要看使用场景

  • 并发不高、逻辑简单、单变量更新: CAS 很合适,代码直观,开销也低。
  • 并发非常高、竞争激烈、逻辑复杂: 直接用 synchronized 反而更稳,JVM 会帮你处理好自旋、阻塞和唤醒,避免 CPU 被白白浪费。

当然,还有一大类是 基于 AQS 的锁ReentrantLockCountDownLatchSemaphore 等),这套机制完全由 Java 自己实现,底层依赖 park/unpark 来做线程调度,有机会的话可以单独展开聊一聊。

 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin