云计算百科
云计算领域专业知识百科平台

【JVM原理详解】46-锁的内存语义

46-锁的内存语义

引言

前几篇我们分别讲了volatile和final的内存语义。volatile用内存屏障保证可见性和有序性,final用StoreStore/LoadLoad屏障保证初始化安全。但Java并发中还有一类最常用的同步手段——锁。

锁不只是"互斥"工具,它同样承担内存同步职责。无论是内置的synchronized,还是java.util.concurrent.locks.ReentrantLock,甚至是基于CAS的原子类,它们都有明确的内存语义:加锁时刷新工作内存,解锁时写回主内存。理解锁的内存语义,才能明白为什么"synchronized块内的写对下一个获取锁的线程可见",以及为什么ReentrantLock能替代synchronized。

本篇从synchronized的内存语义切入,扩展到ReentrantLock(AQS的volatile state)、CAS的内存语义,最后用happens-before关系对比三种同步方式。

synchronized的内存语义

synchronized是Java最基础的锁机制,底层由JVM的monitorenter/monitorexit指令和对象头的Mark Word实现。但它除了互斥,还有明确的内存同步语义。

加锁与解锁的语义

JMM对synchronized的内存语义定义如下:

  • 加锁(monitorenter / 进入synchronized块):清空当前线程的工作内存(本地缓存副本),相当于强制后续读取从主内存重新加载
  • 解锁(monitorexit / 退出synchronized块):把工作内存中所有修改过的值写回主内存,相当于执行StoreStore + StoreLoad屏障

更直观的理解:

线程A(持锁并修改数据):
┌──────────────────────────┐
│ synchronized(lock) { │ ← 加锁:清空工作内存,后续读从主内存取
│ read sharedData │ (获取锁时的最新值)
│ sharedData = newValue │ (在工作内存修改)
│ } │ ← 解锁:写回主内存(StoreStore + StoreLoad)
└──────────────────────────┘

线程B(随后获取同一把锁):
┌──────────────────────────┐
│ synchronized(lock) { │ ← 加锁:清空工作内存
│ read sharedData │ (从主内存读到线程A写入的newValue)
│ } │
└──────────────────────────┘

这和volatile的"写刷新主内存、读强制重载"机制本质相同,只是作用范围更大——volatile作用于单个字段,synchronized作用于整个临界区内的所有共享变量。

为什么是"清空工作内存"

synchronized加锁时"清空工作内存"的设计,目的是保证临界区内的读取拿到最新值。如果不清空,线程可能用工作内存中的旧副本,导致读到过期数据。

具体来说,JMM规定:

  • 线程进入synchronized块前,必须把所有使用的变量从工作内存中"丢弃"(标记为无效)
  • 临界区内每次读取变量,都必须重新从主内存load(因为工作内存副本已失效)
  • 退出synchronized块时,把工作内存中所有修改过的值同步回主内存
  • 这样,synchronized块就成了一个**“内存屏障块”**——进入时强制与主内存同步,退出时强制把修改同步回去。

    代码示例

    // 适用 JDK 8/11/17
    public class SynchronizedMemoryDemo {
    private int data = 0;
    private boolean flag = false;
    private final Object lock = new Object();

    // 线程A:加锁修改
    public void writer() {
    synchronized (lock) { // 加锁:清空工作内存
    data = 42; // 修改1
    flag = true; // 修改2
    } // 解锁:写回主内存(data=42, flag=true)
    }

    // 线程B:加锁读取
    public void reader() {
    synchronized (lock) { // 加锁:清空工作内存,重新从主内存读
    // 因为A的解锁 hb B的加锁(监视器锁规则)
    // 且A解锁时把data和flag写回了主内存
    // 所以这里必然读到 data=42, flag=true
    System.out.println("data=" + data + ", flag=" + flag);
    }
    }
    }

    注意:即使data和flag不是volatile,只要它们在synchronized块内被读写,且所有访问都通过同一把锁,可见性就有保证。但如果某线程在不加锁的情况下读data,就不保证可见性——synchronized的内存语义只覆盖"同一把锁"的加锁-解锁序列。

    synchronized的happens-before关系

    结合上一篇的happens-before规则,synchronized的内存语义可以用一条规则概括:监视器锁规则——对同一把锁的unlock操作happens-before后续的lock操作。

    推演:

    线程A: [临界区内写] → unlock

    │ happens-before(监视器锁规则)

    线程B: lock → [临界区内读]

    传递性扩展:线程A临界区内的所有写操作(程序顺序规则:写 hb unlock),通过监视器锁规则(unlock hb lock),传递到线程B临界区内的所有读操作(程序顺序规则:lock hb 读)。所以线程A临界区内的所有写,对线程B临界区内的所有读可见。

    ReentrantLock的内存语义

    ReentrantLock是java.util.concurrent.locks包提供的显式锁,功能比synchronized更灵活(可中断、可超时、公平/非公平)。它在内存语义上与synchronized等价——加锁和 unlocks具有相同的可见性保证。实现机制则完全不同。

    AQS与volatile state

    ReentrantLock基于**AQS(AbstractQueuedSynchronizer)**实现。AQS的核心是一个volatile int state字段,表示同步状态:

    // AQS核心字段(简化展示)
    public abstract class AbstractQueuedSynchronizer {
    private volatile int state; // volatile!

    protected final int getState() { return state; }
    protected final void setState(int newState) { state = newState; }
    protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    }

    state是volatile,这是ReentrantLock内存语义的锚点:

    • 加锁:通过CAS把state从0改为1(非公平)或排队后CAS(公平)。CAS成功即获取锁
    • 解锁:把state从1改为0(或递减重入计数)

    因为state是volatile,加锁时的CAS(volatile写)和解锁时的setState(volatile写)都遵循volatile的内存语义。配合happens-before规则,这就建立了与synchronized等价的可见性保证。

    lock/unlock的happens-before链

    ReentrantLock的可见性保证,可以通过happens-before规则推导:

    // 适用 JDK 8/11/17
    import java.util.concurrent.locks.ReentrantLock;

    public class ReentrantLockMemoryDemo {
    private int data = 0;
    private final ReentrantLock lock = new ReentrantLock();

    // 线程A:加锁修改
    public void writer() {
    lock.lock(); // 操作1:CAS state 0→1(volatile写)
    try {
    data = 42; // 操作2:普通写
    } finally {
    lock.unlock(); // 操作3:setState(0)(volatile写)
    }
    }

    // 线程B:加锁读取
    public void reader() {
    lock.lock(); // 操作4:CAS state(volatile读+写)
    try {
    // 推理:
    // 程序顺序:操作2 hb 操作3(同一线程内)
    // volatile规则:操作3(写state) hb 操作4(读state)
    // —— 因为unlock写state,lock读state,state是volatile
    // 程序顺序:操作4 hb 读data
    // 传递性:操作2 hb 读data → data=42必然可见
    System.out.println(data);
    } finally {
    lock.unlock();
    }
    }
    }

    关键链路:

  • 线程A的data=42(普通写)通过程序顺序规则,happens-before unlock()(volatile写state)
  • 线程A的unlock()(写volatile state)通过volatile规则,happens-before 线程B的lock()(读volatile state)
  • 线程B的lock()通过程序顺序规则,happens-before 读data
  • 传递性:data=42 happens-before 读data
  • 所以线程B必然读到data=42。这条推理链和synchronized完全对称——只是synchronized靠监视器锁规则,ReentrantLock靠volatile规则。

    AQS的内存语义设计

    AQS巧妙地用volatile state作为"内存同步锚点",这是一个经典设计:

    • state的volatile写(unlock时)把当前线程在临界区内的所有修改刷新到主内存
    • state的volatile读(lock时)强制后续读取看到最新值

    这等价于:unlock具有"StoreStore + StoreLoad"的语义(volatile写后屏障),lock具有"LoadLoad + LoadStore"的语义(volatile读后屏障)。与synchronized的"解锁写回、加锁清空"语义一致。

    AQS的其他同步器(Semaphore、CountDownLatch、CyclicBarrier)也都基于volatile state,因此具有相同的内存语义——释放/获取操作之间有happens-before关系。

    CAS的内存语义

    CAS(Compare-And-Swap)是java.util.concurrent.atomic包原子类的基础操作。它不仅保证原子性,还有明确的内存语义。

    CAS的volatile读写语义

    JMM规定:CAS操作同时具有volatile读和volatile写的内存语义。

    具体来说,AtomicInteger.compareAndSet(int expect, int update):

    • 读当前值(类似volatile读)
    • 比较是否等于expect
    • 如果相等,写入update(类似volatile写)
    • 整个过程原子

    这个"volatile读写"语义意味着:

    • CAS之前的普通读写,不会重排到CAS之后(volatile写的StoreStore效果)
    • CAS之后的普通读写,不会重排到CAS之前(volatile读的LoadLoad/LoadStore效果)
    • CAS的结果对后续操作立即可见(volatile写的StoreLoad效果)

    底层实现:Unsafe + lock cmpxchg

    CAS在x86上通过lock cmpxchg指令实现。lock前缀的作用上一篇讲过——触发缓存一致性协议,保证原子性和可见性。cmpxchg是"比较并交换"指令,配合lock前缀成为原子操作。

    // AtomicInteger.compareAndSet 的底层(简化)
    public final boolean compareAndSet(int expect, int update) {
    // 调用 Unsafe.compareAndSwapInt
    // 最终生成 lock cmpxchg 指令
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

    lock cmpxchg同时完成"读-比较-写"三步,且是原子的,同时带有lock前缀的内存屏障效果。所以CAS的内存语义是volatile读写的超集——既原子又可见。

    代码示例

    // 适用 JDK 8/11/17
    import java.util.concurrent.atomic.AtomicInteger;

    public class CASMemoryDemo {
    private AtomicInteger atomicValue = new AtomicInteger(0);
    private int normalData = 0;

    // 线程A:先写普通变量,再CAS
    public void writer() {
    normalData = 42; // 操作1:普通写
    boolean success = atomicValue.compareAndSet(0, 1); // 操作2:CAS
    // CAS具有volatile写的语义
    // 程序顺序:操作1 hb 操作2(CAS前的写不能重排到CAS后)
    }

    // 线程B:先CAS(看到A的CAS结果),再读普通变量
    public void reader() {
    int val = atomicValue.get(); // 操作3:volatile读
    if (val == 1) {
    // CAS(volatile写) hb volatile读 → 操作2 hb 操作3
    // 传递性:操作1 hb 操作3 hb 操作4
    System.out.println(normalData); // 操作4:读到42
    }
    }
    }

    推理链:

  • 程序顺序:normalData=42 hb CAS(0→1)
  • volatile规则:CAS(0→1)(volatile写语义)hb atomicValue.get()(volatile读)
  • 程序顺序:get() hb 读normalData
  • 传递性:normalData=42 hb 读normalData
  • 所以线程B读到val==1时,normalData必然是42。这就是CAS的内存语义——它和volatile一样,可以作为"发布锚点"。

    AtomicInteger.get()的语义

    注意上面用了atomicValue.get()而非另一个CAS。AtomicInteger.get()就是读取volatile字段——AtomicInteger内部value是volatile:

    public class AtomicInteger {
    private volatile int value; // volatile!

    public final int get() {
    return value; // volatile读
    }
    }

    所以AtomicInteger的所有操作(get、set、compareAndSet、getAndIncrement)都基于volatile value,具有volatile的内存语义。这让原子类既能保证原子性,又能保证可见性。

    三种同步方式的内存语义对比

    synchronized、ReentrantLock、CAS(原子类)是Java并发的三大同步手段。它们的内存语义对比如下:

    对比表

    维度synchronizedReentrantLockCAS(AtomicInteger等)
    互斥性 互斥(同一时刻一个线程) 互斥 无互斥(乐观策略)
    原子性 临界区整体原子 临界区整体原子 单个CAS操作原子
    可见性 加锁清空、解锁写回 volatile state(等价) volatile读写语义
    有序性 临界区整体有序 volatile屏障(等价) CAS前后屏障
    happens-before 监视器锁规则(unlock hb lock) volatile规则(写state hb 读state) volatile规则(CAS hb 后续读)
    底层指令 monitorenter/monitorexit CAS + volatile state lock cmpxchg
    阻塞 竞争时阻塞(锁升级) 竞争时阻塞(park/unpark) 不阻塞(自旋重试)
    适用场景 临界区保护、复合操作 可中断/超时/公平锁 单变量原子操作
    性能(低竞争) 偏向锁几乎免费 略慢于偏向锁 接近volatile读写
    性能(高竞争) 重量级锁开销大 与synchronized相当 自旋消耗CPU

    内存语义的等价性

    三种方式的可见性保证是等价的——都建立了"前一个操作的结果对后续操作可见"的happens-before关系。区别在于:

    • synchronized:通过JVM内置的monitorenter/monitorexit实现,加锁时清空工作内存、解锁时写回。语义最直观
    • ReentrantLock:通过AQS的volatile state实现,lock/unlock操作state,借用volatile的内存语义。语义等价但实现不同
    • CAS:通过lock cmpxchg指令,单次操作同时完成"读-比较-写"并保证可见性。粒度最细

    选择策略

    // 适用 JDK 8/11/17
    // 场景1:单变量原子操作 → 用原子类
    AtomicInteger counter = new AtomicInteger();
    counter.incrementAndGet(); // CAS,无锁,高并发友好

    // 场景2:多步复合操作 → 用synchronized或ReentrantLock
    synchronized (lock) {
    if (balance >= amount) {
    balance -= amount; // 多步操作需要整体原子
    transfer(amount);
    }
    }

    // 场景3:需要可中断/超时/公平 → 用ReentrantLock
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
    // 临界区
    } finally {
    lock.unlock();
    }
    }

    // 场景4:简单互斥,不需要高级功能 → 优先synchronized
    // 代码简洁,JVM优化充分(偏向锁/轻量锁/重量锁自动升级)

    选择原则:

  • 单变量原子操作(计数、标志位)→ 原子类(CAS),性能最优
  • 简单复合操作(检查-执行-修改)→ synchronized,代码简洁
  • 需要高级特性(可中断、超时、公平、多Condition)→ ReentrantLock
  • 不确定时优先synchronized——JDK 6后锁优化充分,性能通常足够,且不易出错(自动释放锁)
  • 锁释放-获取的happens-before关系

    无论哪种锁,"释放-获取"的happens-before关系都是内存语义的核心:

    线程A(持锁修改):
    [临界区内的所有写] → 释放锁

    │ happens-before(锁释放-获取)

    线程B(获取锁读取):
    获取锁 → [临界区内的所有读能看到A的写]

    • synchronized:unlock hb lock(监视器锁规则)
    • ReentrantLock:unlock(写volatile state)hb lock(读volatile state)(volatile规则)
    • AQS同步器(Semaphore/CountDownLatch):release(写volatile state)hb acquire(读volatile state)

    这条"锁释放-获取"的happens-before关系,是所有锁机制共享的内存语义基石。理解了它,就能推导出任何锁的可见性保证。

    实践要点

  • synchronized块内的修改对下一个获取锁的线程可见。不需要额外用volatile修饰临界区内的变量——锁的内存语义已覆盖。但如果某变量在不加锁时也被访问,那它需要自己的可见性保证(volatile或锁)。

  • ReentrantLock的lock/unlock必须配对。unlock一定要在finally块中,否则异常时锁不释放,且内存同步(写回主内存)也不会执行——后续获取锁的线程可能看不到正确状态。

  • 原子类不是锁的替代品。CAS适合单变量的原子操作,但不能保护多步复合操作。AtomicInteger能保证incrementAndGet原子,但"检查余额+扣款+转账"这种多步操作仍需锁。

  • 公平锁和非公平锁的内存语义相同。ReentrantLock的公平/非公平只影响获取锁的顺序(是否允许插队),不影响内存语义——两者都基于volatile state,可见性保证一致。

  • 锁的粒度影响内存同步开销。synchronized块越大,清空/写回的工作内存范围越大。但这通常不是瓶颈——锁竞争本身的开销远大于内存同步。优化应聚焦减少锁竞争(减小粒度、分段锁、无锁化)。

  • synchronized和ReentrantLock不要混用保护同一资源。两者是不同的锁对象(synchronized用对象头,ReentrantLock用AQS),混用不构成"同一把锁",监视器锁规则不成立,可见性无保证。选择一种并统一使用。

  • CAS自旋在高竞争下消耗CPU。原子类在低竞争下性能优异,但高竞争时CAS频繁失败重试,浪费CPU周期。这种场景用LongAdder(JDK 8+,分散热点)或改用锁更优。

  • AQS同步器(Semaphore/CountDownLatch)也有内存语义。它们的release/acquire基于volatile state,具有与锁相同的happens-before关系。CountDownLatch.countDown() hb await()返回,Semaphore.release() hb acquire()。善用这些"免费"的可见性保证。

  • 小结

    • synchronized的内存语义:加锁时清空工作内存(强制从主内存读),解锁时写回主内存(StoreStore + StoreLoad)。等价于在临界区边界插了全功能内存屏障
    • ReentrantLock的内存语义:基于AQS的volatile state实现。lock是CAS(volatile读+写),unlock是setState(volatile写)。通过volatile规则建立与synchronized等价的happens-before关系
    • CAS的内存语义:同时具有volatile读和volatile写的语义。lock cmpxchg指令保证原子性+可见性,可作为"发布锚点"建立happens-before链
    • 三种方式等价性:synchronized(监视器锁规则)、ReentrantLock(volatile规则)、CAS(volatile规则)的可见性保证等价,都是"释放-获取"的happens-before关系。区别在于互斥性、阻塞行为、功能特性
    • 选择策略:单变量原子用CAS原子类,简单互斥用synchronized,需要高级特性用ReentrantLock。不确定时优先synchronized——简洁且优化充分
    • 锁释放-获取是happens-before核心:所有锁机制(包括AQS同步器)的内存语义,都归结为"释放操作happens-before后续获取操作"。理解这一条,就能推导任何锁的可见性保证
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【JVM原理详解】46-锁的内存语义
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!