从 Mark Word 到手写自旋锁:Java CAS 机制学习笔记
从 Java 对象头、Mark Word、锁升级开始,到 sun.misc.Unsafe 反射获取与 compareAndSwapInt 原子语义,最终手写一个基于 CAS 的自旋锁。记录从理论到实践每个卡过的坑。
从 Mark Word 到手写自旋锁:Java CAS 机制学习笔记
为什么学锁要从对象头开始
很多人学 Java 并发直接从 synchronized 用法入手,背出「锁的是对象」就结束了。但这样你永远解释不了三个问题:
synchronized锁住的是对象,那对象里到底存了什么「锁信息」?- 为什么早期
synchronized被叫「重量级锁」,后来 JDK 6 做了「锁升级」就不重了? - 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 空间:
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit(偏向) | 2bit(锁标志) |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashCode | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID(54bit) + epoch(2bit) | — | — | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针(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 在这里的作用:
- 保证
unlock的写有 happens-before 关系——其他线程读到的一定是释放后的 0 - 禁止
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 的区别——这俩不搞清楚根本编译不过。
第五站:自旋锁的问题
手写的自旋锁解决了互斥,但有几个问题:
- 不可重入:同一线程第二次
lock()会死锁,因为 STATE 已经是 1,自己 CAS 自己永远失败 - 无公平性:多个线程同时自旋,谁先 CAS 成功完全随机,可能有线程长期饥饿
- 高竞争下 CPU 浪费:大量线程自旋空转,比
synchronized直接 park 还差
ReentrantLock 解决了前两个:用 owner 线程记录持锁者 + 计数器实现重入,用 CLH 队列实现公平。但那是另一个话题了。
学习路径总结
对象头 / Mark Word 理解锁信息存在哪、为什么锁升级单向
↓
Unsafe API 拿到底层原子操作的入口
↓
compareAndSwapInt 理解「比较后交换」原子语义
↓
手写自旋锁 把理论拼成可运行的锁
↓
发现问题 不可重入/不公平/自旋浪费 → 理解 ReentrantLock 的设计动机
这条路走下来,再回头看 synchronized 的锁升级和 AtomicInteger 的 incrementAndGet,就不只是背 API 了——每个设计决策都有你可以指认的代码出处。