对象头 · mark word · JOL · monitorenter / monitorexit · ACC_SYNCHRONIZED · 轻量级锁 · ObjectMonitor · 膨胀 · 偏向锁 · wait / notify · CAS · LSE · ABA · AtomicInteger · AtomicStampedReference · LongAdder · 锁粗化
synchronized 与 CAS:一个先排队再干活,一个先干活、失败了再来
synchronized 的锁就记在对象头里那 8 个字节上:没人抢时,一次 CAS 把对象头换成“我在用”的标记就算加锁;有人抢了,就给这个对象配一个 ObjectMonitor,抢不到的线程挂起排队,状态变成 BLOCKED。CAS 是另一种思路:不排队,读出旧值、算好新值,用一条 CPU 指令“如果还是旧值就换成新值”,失败了重来。全景第 6 站和第 7 站就是这两条路。本页在 JDK 17 上用 JOL 看对象头怎么变,用五种写法比吞吐,看清各自在什么时候赢。
全景:一个下单请求在线程池里 · 第 6、7 站地图:J02 · synchronized 与锁升级、CAS 与原子类(第 3 个知识点)实验:JDK 17.0.20,JOL 0.17,2 核 ARM 实测
①为什么需要:全景第 6、7 站留下的问题
在全景第 6 站,这一步要解决的是:把“读库存、判断、写回”变成别的线程插不进来的一段。synchronized 做到了,100 件正好卖 100 件,但压测时最多 47 个线程同时 BLOCKED 在这把锁上。第 7 站换成 AtomicInteger 的 CAS,同样卖对了,失败重试 0 次。于是有了这几个问题:
| 问题 | 本页在哪讲 |
|---|---|
| synchronized 到底把锁记在哪?没人抢的时候也很重吗? | ④-1 对象头、④-3 锁升级、⑤ JOL |
| 为什么线程转储里卡在 synchronized 上的是 BLOCKED,卡在 ReentrantLock 上的是 WAITING? | ④-3 ObjectMonitor |
| CAS 凭什么不用锁也不会卖重?什么时候它反而更慢? | ④-4、⑤ 吞吐对比 |
| 面试常问的“偏向锁 → 轻量级锁 → 重量级锁”,JDK 17 上还对吗? | ④-5、⑥ 第三圈 |
②没有它会怎样:读、判断、写回被插队
- “读、判断、写回”是三步,任何两步之间都可能换另一个线程上来(上下文切换),或者两个核真的同时在跑。
- volatile 只保证每次读都是最新的,不保证三步连在一起(JMM 下一圈坑 4)。
- 缺的那一块:让“检查”和“修改”成为一个整体。要么让别的线程在外面等(锁),要么让修改在“检查结果还成立”时才生效、否则重来(CAS)。
不懂这一点时的典型误用:每次调用都 synchronized (new Object()),或者锁一个 Integer 库存值。前者每个线程锁的是自己新建的对象,等于没锁;后者库存一变,锁的就是另一个对象了(⑥ 坑 1)。
③相邻方案:让“检查 + 修改”成为整体的五种做法
| 做法 | 思路 | 抢不到时 | 适合 |
|---|---|---|---|
| synchronized | 悲观:先拿锁再干活 | 先自旋一小会儿,再挂起,BLOCKED | 临界区里有好几步、要改好几个字段;日常首选 |
| ReentrantLock | 悲观,基于 AQS | 排进 AQS 队列 park,WAITING;可超时、可中断、可公平 | 要 tryLock(超时)、多个条件队列、可中断(AQS 页) |
| CAS / 原子类 | 乐观:先算,提交时核对 | 重读、重算、重试,不挂起 | 只改一个变量,临界区极短 |
| LongAdder | 分散:每个线程加到自己那一格 | 换一格再试 | 多线程高频累加、偶尔读总数的计数器 |
| 数据库条件更新 | 把检查和修改放进一条 SQL | 行锁排队 | 多台机器共享的数据(全景第 8 站) |
选择规则:先看数据在哪:多台机器共享就交给数据库或 Redis;只在一个 JVM 里,只改一个数就用原子类,改好几样就用 synchronized,要超时或可中断再换 ReentrantLock。本页学 synchronized 和 CAS 原子类,ReentrantLock 见 AQS 页。
④原理:锁记在对象头里,抢的人多了才去找操作系统
- CAS 指令:CPU 提供的“比较并交换”:给一个地址、一个期望值、一个新值,地址里还是期望值就换成新值并返回成功,否则什么也不做返回失败。整个过程不可分割(JMM 下一圈 ④-2)。
- park / unpark:让线程睡下、叫醒线程,要进内核,几微秒一次(线程页 ④-3)。
- 安全点:JVM 让所有 Java 线程停在一个“可以检查”的位置,GC 和一些锁操作要用它。
④-1对象头:每个对象开头 8 个字节的 mark word
④-2编译之后:同步块是一对指令,同步方法只是一个标志
6: monitorenter // 拿锁
7: … if (left < 1) …
16: aload_1
17: monitorexit // return 0 前放锁
18: ireturn
19: … left--
31: monitorexit // return 1 前放锁
32: ireturn
33: astore_2 // 中间抛了异常:
35: monitorexit // 先放锁
37: athrow // 再接着抛
Exception table:
from to target type
7 18 33 any
19 32 33 any
synchronized int takeMethod();
flags: (0x0020) ACC_SYNCHRONIZED
Code: … 没有 monitorenter / monitorexit
- 1实测 同步块里每个出口都有一条
monitorexit,还多了一张异常表:不管正常返回还是抛异常,锁一定放掉。这是 synchronized 不会“忘了解锁”的原因,也是 ReentrantLock 必须写try / finally的原因。 - 2同步方法由 JVM 在调用和返回时自动加锁、放锁,锁的是
this(静态方法锁的是Class对象)。
④-3锁升级:一步一步看对象头和 ObjectMonitor
一个库存对象 stock,main、exec-17、exec-23 三个线程轮流对它加锁。每一步的对象头取自 JOL 实测。打开右上角的开关,换成 JDK 17 加 -XX:+UseBiasedLocking 的版本。
stock 的 mark word
三个线程
ObjectMonitor
- 1轻量级锁就是一次 CAS。线程在自己的栈帧里放一个“锁记录”,把对象头原样复制进去,再用 CAS 把对象头换成指向这个锁记录的指针。放锁时 CAS 换回来。没人抢的锁,加一次放一次,两次 CAS。
- 2CAS 失败说明有人抢,就膨胀成重量级锁。JVM 分配一个 ObjectMonitor,对象头改成指向它。抢不到的线程先自旋一会儿(适应性自旋:上次自旋成功过,这次就多转几圈),还不行就进入口队列 park,这就是 BLOCKED。
- 3wait / notify 只能用在重量级锁上。持锁线程调
wait():放开 monitor,进 WaitSet,状态 WAITING;别人notify()只是把它从 WaitSet 挪到入口队列,要等通知的那个线程放锁、再抢到锁,wait()才返回。 - 4实测 两个线程都走了,对象头还是 fat lock:ObjectMonitor 不会立刻拆掉,由后台的 MonitorDeflationThread 过一阵子统一回收(JDK 15 起)。
④-4CAS 和原子类:不排队,提交时核对
cas 类指令(本机 UseLSE=true),老 ARM 是“独占读 + 条件写”的小循环,x86 是 lock cmpxchg。原子类的 incrementAndGet 在 JDK 17 源码里是 U.getAndAddInt(this, VALUE, 1) + 1,HotSpot 把它换成一条原子加指令(ARM 上 ldadd 一类、x86 上 lock xadd),根本不会失败重试(按 HotSpot 实现,本页没看汇编)。- 1ABA:CAS 只比较值。订单状态 PAID → REFUNDING → PAID,值回到了原样,CAS 看不出中间变过。实测
AtomicReference照样把它改成了 SHIPPED;AtomicStampedReference带一个版本号,版本从 0 变成了 2,CAS 被拒绝。数值累加不怕 ABA,状态机和无锁链表才怕。 - 2LongAdder:一个
base加一组Cell。没冲突时 CAS 加到 base;冲突了,每个线程按自己的探针值挑一个 Cell 去加,sum()时再把所有格子加起来。写快、读慢、读到的不是某一瞬间的精确快照。
④-5为什么这样设计:大多数锁根本没人抢
分级:先用最便宜的办法HotSpot 的 synchronized
- 没人抢:两次 CAS,不碰操作系统,不分配任何东西
- 真有人抢:才分配 ObjectMonitor,挂起线程,不让抢不到的线程空转浪费 CPU
- 锁就是对象自己,任何对象都能当锁,不用另外建锁对象
- 代价:每个对象头都要留出这些位;升级、撤销、回收 monitor 让 JVM 很复杂
被放弃的:每次都找操作系统要互斥量早期 JDK 1.0~1.5 的做法
- 实现简单,行为好懂
- 没人抢也要进内核,每次加锁几百纳秒到微秒,Vector、Hashtable、StringBuffer 这些到处是 synchronized 的类会很慢
- “synchronized 很重”的印象就是那时候留下的
决定胜负的约束:实测下来,绝大多数被加锁的对象只被一个线程用过(HotSpot 团队的统计,也是偏向锁当年的依据)。所以快路径要尽量便宜,慢路径才付代价。偏向锁把“没人抢”再往下压到连 CAS 都不用,但现代 CPU 的 CAS 已经很便宜,撤销偏向又要停在安全点,JDK 15 起默认关掉(JEP 374),JDK 18 起参数作废。
private final Object stockLock = new Object(); // 私有、final、专门用来当锁
synchronized (stockLock) { if (left < 1) return false; left--; }
synchronized (stockLock) { while (!paid) stockLock.wait(); } // 等条件:一定写在 while 里
synchronized (stockLock) { paid = true; stockLock.notifyAll(); }
AtomicInteger left = new AtomicInteger(100);
left.decrementAndGet(); // 一步完成,不会失败
left.compareAndSet(cur, cur - 1); // 自己写“检查 + 修改”时用
left.updateAndGet(v -> v > 0 ? v - 1 : v); // 带判断的更新,内部就是 CAS 循环
LongAdder sold = new LongAdder(); sold.increment(); sold.sum();
AtomicStampedReference<String> st = new AtomicStampedReference<>("PAID", 0);
锁粗化、锁消除、偏向锁的历史、monitor 回收、JDK 21 以后的新加锁方式,这些放到第⑥步。
⑤亲眼看到:对象头、字节码、ABA 和五种写法的吞吐
四个小程序,--release 8 编译,JDK 17 运行。完整代码:study-labs/java/juc/synchronized-cas-atomic →
$ java -cp … MarkWord (JDK 17 默认) 1. 刚 new 出来 0x…0001 (non-biasable) 2. main 拿着锁 0x0000fe80e872e1e0 (thin lock) 3. 放开锁以后 0x…0001 (non-biasable) 4. exec-17 拿着锁,exec-23 等(BLOCKED) 0x0000fe8098001502 (fat lock) 5. 两个都走了 0x0000fe8098001502 (fat lock) 6. 算过 identityHashCode 0x0000004b4523f801 (hash: 0x4b4523f8) 7. 再对它加锁 0x0000fe80e872e1e0 (thin lock)
$ java -XX:+UseBiasedLocking \ -XX:BiasedLockingStartupDelay=0 -cp … MarkWord 1. 刚 new 出来 0x…0005 (biasable) 2. main 拿着锁 0x0000f98fe4017805 (biased: 0x…3e63f9005e; epoch: 0) 3. 放开锁以后 同上,还偏向 main 4. exec-17 拿着锁,exec-23 等(BLOCKED) 0x0000f98f98001502 (fat lock) 5. 两个都走了 (fat lock) 6. 算过 identityHashCode 0x0000001f28c15201 (hash: …) 7. 再对它加锁 (thin lock,不再偏向)
- 1默认模式:没人抢时是 thin lock,地址
0x0000fe80e872e1e0是 main 线程栈上的锁记录;放锁后对象头原样还原。偏向模式:放锁后对象头不变,还写着 main,下次 main 再来连 CAS 都不用。 - 2第 6、7 步:算过 identityHashCode,哈希值占了对象头,偏向模式下这个对象也不能再偏向,加锁直接走 thin lock。
$ java -cp … Aba AtomicReference: CAS(PAID → SHIPPED) = true,现在 SHIPPED(中间被退款过,它看不出来) AtomicStampedReference:CAS(PAID@0 → SHIPPED) = false,现在 PAID@2(版本号变了,拒绝)
五种写法的吞吐:同一个计数,1 / 2 / 4 个线程
| 写法 | 1 线程 | 2 线程 | 4 线程 | 说明 |
|---|---|---|---|---|
| synchronized | 59.0 | 17.9 | 29.6 | 没人抢时两次 CAS;有人抢就挂起、唤醒 |
| ReentrantLock | 53.4 | 11.2 | 35.2 | 和 synchronized 一个量级 |
| AtomicInteger.incrementAndGet | 148.4 | 123.5 | 75.5 | 一条原子加指令,不会失败 |
| 手写 CAS 循环 | 110.1 | 23.8 | 29.9 | 有竞争时失败重试 4214 万次 |
| LongAdder | 90.7 | 166.4 | 177.3 | 线程越多越快:各加各的格子 |
实测 单位:百万次/秒。2 核 ARM,JDK 17,每格 2000 万次、3 轮取最快,加了 -XX:-EliminateLocks(关掉锁粗化,见 ⑥ 第三圈)。普通 main 程序,不是 JMH;4 个线程有时比 2 个线程快,是因为 2 核上 4 个线程有一半时间在轮流跑、反而撞得少,这类数字只看量级。
- 1没人抢时(1 线程):synchronized 每秒 5900 万次,一次约 17 纳秒;“synchronized 很重”在单线程下不成立。
- 2有人抢时:原子类和 LongAdder 比两种锁快 2.5~15 倍;手写的 CAS 循环却和锁差不多慢,因为大量失败重试,每次失败还要把缓存行从另一个核抢回来。能用
incrementAndGet、addAndGet这类一步完成的方法,就别自己写循环。 - 3全景第 7 站 CAS 重试 0 次,是因为那里的 CAS 夹在 HTTP 请求和数据库调用之间,两个线程几乎碰不上;这里是两个线程不停地对同一个地址做 CAS,冲突密集。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 加了 synchronized 照样超卖。
- 根源
- 锁就是那个对象的对象头。
synchronized (new Object())每次一个新对象;锁Integer left,left--之后变量指向了另一个 Integer;锁字符串常量,整个 JVM 里同值的字符串是同一个对象,可能和别的库抢同一把锁。 - 做法
- 用
private final Object lock = new Object();要锁“这个商品”就从ConcurrentHashMap<Long, Object>里取同一个锁对象。
- 现象
- 接口 RT 飙升,线程转储里几十个线程 BLOCKED 在同一个地址上。全景第 6 站:最多 47 个 BLOCKED
- 根源
- 锁里有一次数据库调用或 HTTP 调用,持锁时间从纳秒变成毫秒,别的线程全在入口队列里等。
- 做法
- 锁里只放必须一起改的那几行;IO 挪到锁外;同一个 JVM 多台部署时,本来就该交给数据库条件更新。
- 现象
- 改成“无锁”以后 CPU 打满、吞吐没涨。⑤:失败重试 4214 万次,吞吐和锁差不多
- 根源
- 冲突越多,失败越多,每次失败都要重读、重算、再抢一次缓存行,CPU 都花在空转上。
- 做法
- 计数用
LongAdder;累加用getAndAdd一步完成;冲突大、临界区长的就老老实实用锁。
- 现象
- 偶发条件没满足就往下走;或者直接抛
IllegalMonitorStateException。 - 根源
- wait 返回时要重新抢锁,抢到之前条件可能又被别人改掉;JVM 也允许“虚假唤醒”。没持有这个对象的锁就调 wait / notify,没有 monitor 可操作。
- 做法
while (!条件) lock.wait();,并且在synchronized (lock)里;一般用notifyAll。新代码更推荐ReentrantLock的Condition或阻塞队列。
- 现象
- JDK 21 开了虚拟线程,吞吐反而掉到接近串行。
- 根源
- JDK 21~23 的实现里,synchronized 的锁和所在的平台线程(载体线程)绑在一起,虚拟线程在锁里阻塞时卸不下来,把载体线程钉住了(pinning)。
- 做法
- JDK 21~23 上锁里有 IO 的地方换成 ReentrantLock;JDK 24 起(JEP 491)synchronized 不再钉住。
使用规范
不锁 this 暴露给外面的对象,不锁字符串、包装类、Class(除非有意)。
坑 1,以及外部代码拿你的对象加锁造成死锁。
能先在锁外算好的先算好,锁里只做“检查 + 写入”。
坑 2。
需要“读到精确值再决定”的(库存)用 AtomicInteger;只累加、偶尔看总数的(销量、QPS)用 LongAdder。
坑 3。
订单状态、无锁结构里的指针用 AtomicStampedReference,或者数据库里用版本号做乐观锁。
ABA。
深处:JIT 和 JVM 还在背后做了什么
锁粗化和锁消除
C2 编译器会把循环里反复对同一个对象加锁、放锁的代码合并成一次大锁(锁粗化),对不会逃出线程的对象的锁直接删掉(锁消除)。实测 默认参数下单线程 synchronized 每秒 2.33 亿次,关掉(-XX:-EliminateLocks)后是 5900 万次,差 4 倍。所以写微基准测锁的开销,要么关掉它,要么用 JMH 并让结果“逃逸”出去。
偏向锁:快在哪,为什么被拿掉
实测 JDK 17 加 -XX:+UseBiasedLocking,单线程 synchronized 每秒 3.2 亿次(默认 5900 万次):偏向以后连 CAS 都省了。但一旦第二个线程来,就要在安全点上撤销偏向;2、4 个线程时偏向锁一点不占便宜(19.6、26.5 百万次/秒)。它当年是为 Vector、Hashtable 这种“到处 synchronized、其实只有一个线程用”的老代码准备的。JEP 374 的理由:现代 CPU 上 CAS 已经很便宜,新代码多用 j.u.c 的类,偏向锁的撤销逻辑又让 JVM 里很多地方变复杂,所以 JDK 15 默认关闭,JDK 18 起参数作废。
版本差异
| JDK 6 | 引入偏向锁、轻量级锁、适应性自旋,“synchronized 很重”从此不成立 |
|---|---|
| JDK 8 | LongAdder、@sun.misc.Contended;偏向锁默认开,启动后延迟 4 秒才生效 |
| JDK 15 | 偏向锁默认关闭(JEP 374);ObjectMonitor 改由 MonitorDeflationThread 异步回收 |
| JDK 17 | 本页实测版本:还能用 -XX:+UseBiasedLocking 打开(会提示已废弃) |
| JDK 18 | UseBiasedLocking 参数作废 |
| JDK 21 | 新增 -XX:LockingMode,可选新的轻量级加锁:不往对象头里放栈地址,改用线程自己的锁栈 |
| JDK 23 | 新的轻量级加锁成为默认 |
| JDK 24 | JEP 491:虚拟线程在 synchronized 里阻塞不再钉住载体线程 |
JDK 21 以后的几条按 JEP 和发布说明,本机没跑。
面试官会怎么问
synchronized 的锁升级过程?
JDK 17 默认:无锁 → 轻量级锁(一次 CAS 把对象头换成指向栈上锁记录的指针)→ 有人抢、CAS 失败,膨胀成重量级锁(对象头指向 ObjectMonitor,抢不到的线程自旋后挂起)。偏向锁在 JDK 15 起默认关闭。锁一般不降级,monitor 由后台线程异步回收(④-3、⑥ 第三圈)。
synchronized 和 ReentrantLock 怎么选?
功能上 ReentrantLock 多了超时、可中断、公平、多个 Condition;性能上同一量级(⑤)。默认用 synchronized,写法简单、不会忘了解锁;需要那些额外功能,或 JDK 21~23 上用虚拟线程时,换 ReentrantLock(③、坑 5)。
CAS 有什么问题?
ABA(用版本号解决);高竞争下自旋浪费 CPU(用 LongAdder 分散或者用锁);只能保证一个变量(多个变量要合成一个对象,用 AtomicReference,或者用锁)(④-4、坑 3)。
LongAdder 为什么比 AtomicLong 快?
冲突时各线程加到不同的 Cell,Cell 带 @Contended 避免伪共享;代价是 sum() 要遍历、而且不是精确快照(④-4、⑤)。
- HotSpot 源码:
ObjectSynchronizer::enter、ObjectMonitor::enter的自旋和入队,cxq 和 EntryList 两条队 - JDK 21 的
-XX:LockingMode=2:线程锁栈怎么取代栈上锁记录,用 JOL 看对象头的差别 - JMH 正式测:锁粗化、偏向锁、LongAdder 在多核机器上的曲线
- VarHandle:比原子类更底层的 CAS 和各种内存序(JMM 页第三圈)
- AQS:ReentrantLock 怎么用 CAS 加一个队列搭出来(J02 下一个知识点,已有页)
↺回到⑤:改造你的案例
1. 按商品分锁
把全景靶场的 sync 模式从一把全局锁改成每个商品一把(ConcurrentHashMap<Long, Object> 的 computeIfAbsent)。压两个不同商品,看 BLOCKED 的线程数降了多少。
用到:坑 1、坑 2
2. 用 updateAndGet 写“有库存才扣”
把 cas 模式的手写循环换成 left.getAndUpdate(v -> v > 0 ? v - 1 : v),返回值大于 0 才算抢到。对比两种写法的代码量和结果。
用到:④-4、规范 3
3. 销量计数换 LongAdder
全景靶场里的 sold 是 AtomicInteger。改成 LongAdder,用 Bench 的写法在 4 个线程下对比,再想想页面上“已售”该怎么显示才不会看到不一致的数。
用到:④-4、⑤ 吞吐
自测
1. 用一句话说出这页的精华。
synchronized 把锁记在对象头里,没人抢时一两次 CAS 就完事,有人抢才膨胀成 ObjectMonitor 让线程挂起排队;CAS 不排队,提交时核对、失败重来,冲突少时最快,冲突多时该换 LongAdder 或锁。
2. 不加任何同步,扣库存疼在哪?
“读、判断、写回”三步之间会被插队;volatile 也挡不住,jcstress 实测 33.6% 超卖(②)。
3. 线程转储里一堆 BLOCKED,根源是 ④ 的哪一部分?
④-3:有人抢时锁膨胀成 ObjectMonitor,抢不到的线程自旋后进入口队列挂起,Java 状态就是 BLOCKED。说明锁被长时间持有,先看持锁的那个线程在干什么(坑 2)。
4. 预测:JDK 17 默认参数,一个对象先 System.identityHashCode(o),再被两个线程激烈争抢,最后两个线程都走了。这时 JOL 打出来的对象头是什么?哈希值去哪了?
fat lock(⑤ 第 5 步:两个线程走了 monitor 也不会马上回收)。哈希值被搬进了 ObjectMonitor 里保存的原始对象头,等 MonitorDeflationThread 回收 monitor 时再写回对象头(④-1、④-3)。
5. 预测:在 2 核机器上,把手写 CAS 循环的线程数从 2 加到 64,失败重试次数和吞吐怎么变?换成 LongAdder 呢?
同一时刻最多 2 个线程在跑,所以冲突不会无限增加,但被切走的线程回来时读到的值更可能已经过期,重试还会增多;吞吐大体和 2 线程同一量级或更差。LongAdder 冲突后各加各的格子,吞吐基本保持(④-4、⑤、线程页 ④-4)。
6. 放回全景:第 6 站换成 synchronized 以后,第 5 站的超卖修好了。如果锁的是 this(OrderService 是 Spring 单例),而有人在第 9 站的 userOrders 写入也用 synchronized (this),会怎样?
两段毫不相干的逻辑抢同一把锁:下单扣库存要等写订单列表,写订单列表也要等扣库存,BLOCKED 的线程更多,第 2 站的线程池更快被占满。各用各的私有锁对象(规范 1),扣库存那段最好再交给第 8 站的数据库条件更新。