简要谈一下 Java 里 CAS 和 synchronized
的区别和适用场景。
对于现代 JDK 版本(17 及以上),CAS 和
synchronized
在“理论性能”层面其实已经非常接近了。synchronized
也早就不是以前大家印象里那个“又慢又笨”的悲观锁,在不少场景下,甚至是优于
CAS 的。
先从底层机制说起,这两者本质上走的是完全不同的路。
CAS(Compare And
Swap)是基于硬件指令实现的,它可以把「比较内存值是否等于预期值
A」和「把内存值更新为 B」这两个步骤合并成一个原子操作。在 Java 里,CAS
主要是通过 Unsafe 调用 native
方法完成的,AtomicInteger、AtomicLong
等原子类基本都是基于这一套机制。
但 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 消耗(简化后的代码如下):
这里在 CAS 失败后,直接使用 LockSupport.parkNanos
让线程短暂阻塞,把线程状态切换为 WAITING,暂时不参与
CPU 调度,然后再重试,这样可以有效避免自旋把 CPU 打满。
再来看 synchronized。
synchronized 的底层是基于对象 Monitor
的锁机制。细节很多,这里简单概括一下它在现代 JVM
里的执行路径。
当前的 synchronized 锁大致会经历几个阶段:
无锁状态 完全没有竞争,直接执行。
轻量级锁(现在已经没有偏向锁了) JVM 会尝试在对象的
MarkWord中记录当前线程 ID,通过 CAS 判断是否能成功获取锁。如果 CAS 成功,线程就拿到了锁,不会发生阻塞。重量级锁 当竞争激烈、CAS 频繁失败时,JVM 会主动升级为重量级锁,此时线程会被
park,真正阻塞起来,避免在轻量级锁阶段 CAS 空转。
可以看到,现代的 synchronized
早就不是“纯悲观锁”了。 它内部同样使用了
CAS,而且在竞争激烈时,会自动帮你处理“自旋 +
阻塞”的问题,这一点和我前面代码里的手动退避逻辑,本质上是一样的。
同时,synchronized
还能直接锁方法、锁代码块、锁类对象,在使用层面也更加灵活,而 JVM
在这块已经做了非常多的优化,性能并不比 CAS 差。
所以结论其实很简单:还是要看使用场景。
- 并发不高、逻辑简单、单变量更新: CAS 很合适,代码直观,开销也低。
- 并发非常高、竞争激烈、逻辑复杂: 直接用
synchronized反而更稳,JVM 会帮你处理好自旋、阻塞和唤醒,避免 CPU 被白白浪费。
当然,还有一大类是 基于 AQS
的锁(ReentrantLock、CountDownLatch、Semaphore
等),这套机制完全由 Java 自己实现,底层依赖 park/unpark
来做线程调度,有机会的话可以单独展开聊一聊。