Skip to content

CAS和synchronized介绍,两者存在什么不同

1. CAS(Compare-and-Swap)的实现思路

CAS 是一种非阻塞(non-blocking)的乐观并发策略。它基于硬件层面的原子指令,旨在在不阻塞线程的情况下实现数据更新的原子性。

  1. 核心思想:CAS 操作包含三个基本参数:

    • 内存位置(V):要操作的变量在内存中的地址。
    • 预期原值(A):认为 V 当前应该具有的值。
    • 新值(B):如果 V 等于 A,则要更新为的新值。 CAS 的逻辑是:如果内存位置 V 的值与预期原值 A 相等,那么就将 V 的值更新为新值 B;否则,不进行任何操作。这个“比较并交换”的整个过程是由硬件保证原子性的。
  2. 硬件支持:Java 中的 CAS 操作依赖于现代处理器提供的底层硬件指令。例如,在 Intel x86 架构上,这对应于 CMPXCHG(Compare Exchange)指令;在 ARM 架构上,则是 LDREX/STREX(Load-Exclusive / Store-Exclusive)指令对。这些指令能够确保在多处理器环境下,比较和交换操作的原子性,即不会被其他线程或中断打断。

  3. Java 中的实现

    • sun.misc.Unsafe:Java 通过内部的 sun.misc.Unsafe 类来直接访问和操作内存,并调用底层的 CAS 指令。这个类提供了如 compareAndSwapIntcompareAndSwapLong 等方法。Unsafe 类通常不建议直接在业务代码中使用,因为它可能破坏 JVM 的安全性。
    • java.util.concurrent.atomic:为了方便开发者,Java 在 java.util.concurrent.atomic 包中提供了 AtomicIntegerAtomicLongAtomicReference 等原子类。这些类内部封装了 Unsafe 类的 CAS 操作,提供了一系列线程安全的原子操作方法(如 incrementAndGet()compareAndSet()),使得开发者无需关注底层细节。
    • 自旋(Spin-locking):当 CAS 操作失败时(即 V 不等于 A),意味着变量已被其他线程修改。CAS 并不会阻塞当前线程,而是让线程进行“自旋”,即在一个循环中反复尝试执行 CAS 操作,直到成功为止。这个过程完全在用户态完成,避免了线程上下文切换的开销。

2. synchronized 的实现思路

synchronized 是 Java 语言层面的关键字,它提供了一种阻塞(blocking)的悲观并发策略。synchronized 通过对象监视器(Object Monitor)来实现线程同步,确保同一时间只有一个线程能够执行特定的代码块或方法。

  1. 对象监视器(Monitor):Java 中的每个对象都可以作为锁。当 synchronized 修饰一个方法或代码块时,它会关联到一个对象的监视器。这个监视器在 JVM 的 C++实现中对应于 ObjectMonitor

  2. 锁的获取与释放

    • 当一个线程进入 synchronized 修饰的代码块或方法时,它会尝试获取关联对象的监视器锁。
    • 如果锁可用,线程就获取锁并进入临界区执行代码。其他试图获取同一锁的线程将被阻塞(暂停执行),并被放入等待队列,直到持有锁的线程释放锁。
    • 锁在 synchronized 代码块或方法执行完毕(正常退出或抛出异常)后自动释放。
  3. JVM 锁优化(Lock Escalation):为了提高 synchronized 的性能,JVM 对其进行了大量的优化,根据竞争程度,锁会经历从低到高的升级过程:

    • 偏向锁(Biased Locking):在几乎没有竞争的情况下,锁会偏向于第一个获取它的线程。该线程再次进入同步块时,无需任何同步操作,只需检查 Mark Word 中的偏向 ID 即可,完全在用户态完成。
    • 轻量级锁(Lightweight Locking):当有多个线程交替访问同一把锁,但不存在真正的并发竞争时,偏向锁会升级为轻量级锁。线程会在自己的栈帧中建立锁记录(Lock Record),并通过 CAS 操作尝试将对象的 Mark Word 更新为指向该锁记录的指针。如果 CAS 成功,则获得轻量级锁;如果失败,线程会进行短时间的自旋,尝试再次获取锁。这个阶段也主要在用户态通过 CAS 和自旋完成。
    • 重量级锁(Heavyweight Locking):当竞争激烈,轻量级锁的自旋多次后仍无法获取锁时,锁会升级为重量级锁。此时,synchronized 的实现会依赖于操作系统的互斥量(Mutex)。未获取到锁的线程会被操作系统挂起(阻塞),并从运行状态切换到等待状态。线程的挂起和唤醒涉及到用户态到内核态的切换,这是开销较大的操作。
  4. 内存可见性synchronized 除了保证原子性外,还保证了内存可见性。当一个线程释放 synchronized 锁时,它所做的所有修改都会被刷新到主内存中;当一个线程获取 synchronized 锁时,它会从主内存中读取共享变量的最新值。这符合 Java 内存模型的“happens-before”原则。

特性 CAS (Compare-and-Swap) synchronized
并发策略 乐观并发:假设不会发生冲突,尝试更新,失败则重试(非阻塞)。 悲观并发:假设会发生冲突,先获取锁,独占访问(阻塞)。
实现机制 基于硬件原子指令,通过 Unsafe 类和 Atomic 类实现。 基于对象监视器(Monitor),JVM 层面实现,依赖操作系统互斥量(重量级锁)。
阻塞性质 非阻塞:操作失败时线程自旋重试,不会被挂起。 阻塞:竞争失败的线程会被挂起(重量级锁),进入等待状态。
用户态/内核态 主要在用户态完成,极少涉及内核态切换。 低竞争时(偏向/轻量级锁)在用户态完成;高竞争时(重量级锁)涉及频繁的用户态与内核态切换
锁粒度 通常用于对单个共享变量的原子操作,粒度较细。 可以保护一个代码块、方法或整个对象,粒度较粗。
适用场景 适用于对单个共享变量进行高效原子更新,是实现无锁数据结构的基础。 适用于保护更大范围的临界区,或涉及复杂逻辑的同步。
“死循环”风险 高竞争时可能导致长时间自旋(CPU 空转),浪费 CPU 资源。 竞争失败的线程会被挂起,不会空转,但存在线程上下文切换开销。
公平性 通常是非公平的,自旋的线程可能立即成功。 默认是非公平的,但可以通过 ReentrantLock 实现公平锁。
可见性 CAS 指令通常包含内存屏障,保证内存可见性。 锁的获取和释放隐式地保证内存可见性。

3. 性能上的差别

CAS 和 synchronized 的性能差异是一个关键点,它高度依赖于具体的应用场景、竞争程度以及 JVM 的优化水平。理解其性能差异,特别是与用户态和内核态的关联,至关重要。

  1. 用户态与内核态的开销

    • 用户态:应用程序运行的环境,权限受限。
    • 内核态:操作系统内核运行的环境,拥有最高权限,可以访问所有硬件资源。
    • 上下文切换(Context Switch):当操作系统将 CPU 从一个进程/线程切换到另一个进程/线程时,需要保存当前进程/线程的状态,然后加载下一个进程/线程的状态。这个过程是昂贵的,尤其是用户态与内核态之间的切换,因为这涉及到特权指令的执行、寄存器保存/恢复、地址空间切换等。
  2. 低竞争(Low Contention)情况下的性能

    • CAS 更优:在低竞争甚至无竞争的场景下,CAS 的性能通常优于 synchronized。CAS 操作仅仅是执行一个简单的硬件原子指令,它完全在用户态完成,不会导致线程阻塞和上下文切换的开销。
    • synchronized 的优化效果:JVM 对 synchronized 的优化(偏向锁和轻量级锁)使得其在低竞争时开销也变得非常小。
      • 偏向锁:无需任何 CAS,仅检查对象头,开销几乎为零,完全在用户态。
      • 轻量级锁:虽然使用了 CAS 操作,但同样在用户态进行,并且在成功获取锁后,后续操作也避免了更昂贵的同步原语。线程可能进行短时间自旋,但如果很快获取到锁,则开销很小。
    • 总结:在低竞争时,CAS 通常表现出更好的性能,因为它完全避免了传统锁机制的开销。而 synchronized 经过 JVM 优化后,在低竞争时性能也相当接近,甚至可以忽略不计差异。两者都在用户态高效完成。
  3. 高竞争(High Contention)情况下的性能

    • CAS 的劣势(自旋开销与 CPU 空转):在高竞争环境下,CAS 操作可能会反复失败并进行自旋重试。
      • 每次 CAS 失败,线程都会再次尝试,这个“忙等”过程会持续消耗 CPU 资源,但线程却没有实际进展,导致CPU 空转
      • 如果临界区代码执行时间较长,或者竞争非常激烈,CAS 的自旋开销可能远大于线程阻塞和唤醒的开销,从而导致整体性能下降,甚至比 synchronized 更差。CPU 资源的浪费会拖慢整个系统的吞吐量。
      • 这个自旋过程虽然在用户态完成,避免了上下文切换,但无效的 CPU 时间消耗反而成了瓶颈。
    • synchronized 的优势(线程挂起与资源让渡):在高竞争环境下,synchronized 的锁会升级为重量级锁。
      • 此时,未获取到锁的线程会被操作系统挂起(阻塞),不再消耗 CPU 资源进行自旋。CPU 会从用户态切换到内核态,由操作系统负责将线程放入等待队列。
      • 当持有锁的线程释放锁时,操作系统会再次从内核态唤醒等待队列中的一个或多个线程,这同样涉及到用户态和内核态的切换。
      • 虽然用户态到内核态的切换以及线程的挂起和唤醒是昂贵的操作(上下文切换),但它会将 CPU 时间片让给其他有用的任务,而不是进行无谓的自旋。因此,在高竞争且临界区较长的情况下,synchronized 通过阻塞线程来避免 CPU 空转,可能表现出更好的吞吐量和资源利用率
  4. 其他性能考量

    • 缓存一致性(Cache Coherency):无论是 CAS 还是 synchronized,都需要保证多核 CPU 缓存之间的数据一致性。频繁的跨核数据共享和修改会导致缓存线(cache line)失效,引发缓存一致性协议(MESI 等)的开销,这也会影响性能。
    • 实现复杂度:从工程实践角度,对于复杂的并发逻辑,使用 synchronizedjava.util.concurrent.locks.Lock 等显式锁通常更容易理解和证明其正确性。无锁算法(lock-free algorithms)虽然在理论上性能潜力大,但基于 CAS 实现起来非常复杂,容易出错,且调试困难。