跳到正文
Joeplover
学习笔记·2026-07-06·约 6 分钟阅读

从 Mark Word 到手写自旋锁:Java CAS 机制学习笔记

从 Java 对象头、Mark Word、锁升级开始,到 sun.misc.Unsafe 反射获取与 compareAndSwapInt 原子语义,最终手写一个基于 CAS 的自旋锁。记录从理论到实践每个卡过的坑。

Lock mechanism gears symbolizing atomic CAS operations

从 Mark Word 到手写自旋锁:Java CAS 机制学习笔记

为什么学锁要从对象头开始

很多人学 Java 并发直接从 synchronized 用法入手,背出「锁的是对象」就结束了。但这样你永远解释不了三个问题:

  1. synchronized 锁住的是对象,那对象里到底存了什么「锁信息」?
  2. 为什么早期 synchronized 被叫「重量级锁」,后来 JDK 6 做了「锁升级」就不重了?
  3. CAS 作为无锁并发的基石,它操作的「内存值」到底在对象的哪个位置?

答案全在对象头里。所以这条学习路径的第一站不是 CAS,而是 Java 对象的内存布局。

第一站:对象头与 Mark Word

64 位 JVM 的对象内存布局

一个 Java 对象在内存中分三块:

| 对象头 (Header) | 实例数据 (Instance Data) | 对齐填充 (Padding) |

64 位 JVM 下,对象头默认 12 字节(开启指针压缩,-XX:+UseCompressedOops)。其中前 8 字节是 Mark Word,后 4 字节是 Klass Pointer(指向 Class 元数据)。

Mark Word 的位布局

Mark Word 是锁的全部秘密。它在不同锁状态下复用同一块 64 bit 空间:

锁状态25bit31bit1bit4bit1bit(偏向)2bit(锁标志)
无锁unusedhashCodeunused分代年龄001
偏向锁线程ID(54bit) + epoch(2bit)——分代年龄101
轻量级锁指向栈中锁记录的指针(62bit)———00
重量级锁指向 ObjectMonitor 的指针(62bit)———10
GC 标记————11

关键观察:

  • 锁标志位 2 bit,只能存 4 种状态,所以「锁升级」是单向的:无锁 → 偏向 → 轻量 → 重量
  • 偏向锁和轻量级锁的标志位分别是 01 和 00,靠第 1 bit 的偏向位区分
  • hashCode 一旦被调用,偏向锁就被撤销——因为 25bit 存不下 31bit 的 hashCode

锁升级的触发

无锁 ──(线程A首次进入)--> 偏向锁(记录线程A的ID)
       ──(线程B竞争)--> 轻量级锁(Lock Record 在线程B栈帧,CAS 替换 Mark Word)
       ──(自旋失败)--> 重量级锁(ObjectMonitor,操作系统互斥量)

轻量级锁阶段用的是自旋 CAS,失败到一定次数才升级为重量级锁——这就是 synchronized 在 JDK 6 之后「不重了」的原因。

第二站:sun.misc.Unsafe

synchronized 的锁升级底层调用的就是 Unsafe 的 CAS 操作。要真正理解 CAS,不能只看 AtomicInteger,得自己拿 Unsafe 裸写一次。

获取 Unsafe 实例

Unsafe.getUnsafe() 会做调用者类加载器检查(Bootstrap ClassLoader),业务代码直接调会抛 SecurityException。JDK 9+ 之后更严格。绕过的方式是反射拿 theUnsafe 字段:

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class MyCasLock {
    private volatile static int STATE = 0;

    private static final Unsafe UNSAFE;
    private static final long STATE_OFFSET;   // 提升为静态成员,lock() 才能用了
    private static final Object STATE_BASE;

    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);

            Field state = MyCasLock.class.getDeclaredField("STATE");
            STATE_OFFSET = UNSAFE.staticFieldOffset(state);
            STATE_BASE = UNSAFE.staticFieldBase(state);
        } catch (Exception e) {
            throw new ExceptionInInitializerError(e);
        }
    }
}

这里一个容易卡住的点:STATE 是 static 字段,不能像实例字段那样用 objectFieldOffset。静态字段属于 Class 而非实例,所以要用 staticFieldOffset + staticFieldBase 两个方法配合。

staticFieldBase 返回什么

staticFieldBase(state) 返回的是持有这个静态字段的对象——对于类静态变量,它返回的是 Class 对象本身(或者说承载这些字段的对象),不是 null。

写实例字段时 compareAndSwapInt(this, offset, 0, 1) 的第一个参数是对象实例;写静态字段时,第一个参数就是 staticFieldBase 的返回值。

第三站:compareAndSwapInt 的语义

CAS 的语义说起来一句话,但很容易说反:

如果内存中 STATE 的值 == 期望值(0),就把它改成新值(1),返回 true;否则不改,返回 false。

关键字是「比较后交换」——不是「交换后比较」。

UNSAFE.compareAndSwapInt(STATE_BASE, STATE_OFFSET, 0, 1)
//                                ↑对象/类  ↑偏移量  ↑期望 ↑新值

整个调用是原子的,由 CPU 的 LOCK CMPXCHG 指令保证(x86)。不会有中间态——要么成功改了,要么没改。

第四站:手写自旋锁

有了上面的拼图,自旋锁就呼之欲出了:

public class MyCasLock {
    private volatile static int STATE = 0;
    // ... Unsafe / STATE_OFFSET / STATE_BASE 初始化略 ...

    public void lock() {
        while (!UNSAFE.compareAndSwapInt(STATE_BASE, STATE_OFFSET, 0, 1)) {
            Thread.yield();   // 自旋失败时让出 CPU,避免活锁
        }
    }

    public void unlock() {
        STATE = 0;   // volatile 写,保证其他线程立即可见
    }
}

为什么 STATE 要 volatile

compareAndSwapInt 本身有内存屏障——它对 STATE 的读写是 volatile 语义的。但 unlock 里只是普通的 STATE = 0 赋值。volatile 保证 unlock 的写对其他线程立即可见,否则下一个线程的 CAS 永远读到旧值 1,自旋到死。

volatile 在这里的作用:

  1. 保证 unlock 的写有 happens-before 关系——其他线程读到的一定是释放后的 0
  2. 禁止 STATE = 0 和前后语句重排序

设计讨论:static 还是实例字段

这个实现里 STATE 是 static,意味着所有 MyCasLock 实例共用一把锁——全局锁。

如果是实例字段:

private volatile int state = 0;  // 非静态
// offset 获取改为:
STATE_OFFSET = UNSAFE.objectFieldOffset(MyCasLock.class.getDeclaredField("state"));
// CAS 改为:
UNSAFE.compareAndSwapInt(this, STATE_OFFSET, 0, 1);

这样每个 MyCasLock 实例就是独立的锁——更符合 "new 一个锁对象" 的直觉,和 ReentrantLock 行为一致。

两种写法都对,取决于你是想要全局单锁还是多个独立锁。学习时写下 static 版本最大的收获是搞清楚了 staticFieldOffset 和 objectFieldOffset 的区别——这俩不搞清楚根本编译不过。

第五站:自旋锁的问题

手写的自旋锁解决了互斥,但有几个问题:

  1. 不可重入:同一线程第二次 lock() 会死锁,因为 STATE 已经是 1,自己 CAS 自己永远失败
  2. 无公平性:多个线程同时自旋,谁先 CAS 成功完全随机,可能有线程长期饥饿
  3. 高竞争下 CPU 浪费:大量线程自旋空转,比 synchronized 直接 park 还差

ReentrantLock 解决了前两个:用 owner 线程记录持锁者 + 计数器实现重入,用 CLH 队列实现公平。但那是另一个话题了。

学习路径总结

对象头 / Mark Word        理解锁信息存在哪、为什么锁升级单向
      ↓
Unsafe API              拿到底层原子操作的入口
      ↓
compareAndSwapInt       理解「比较后交换」原子语义
      ↓
手写自旋锁               把理论拼成可运行的锁
      ↓
发现问题                 不可重入/不公平/自旋浪费 → 理解 ReentrantLock 的设计动机

这条路走下来,再回头看 synchronized 的锁升级和 AtomicInteger 的 incrementAndGet,就不只是背 API 了——每个设计决策都有你可以指认的代码出处。