对象头 · 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、⑥ 第三圈
需求地图上这一项的深度是“懂设计”:能用对象头讲清 synchronized 的几种状态和升级的原因,能说出 CAS 在硬件上是什么、ABA 是什么,能按“有没有竞争、临界区多长、要不要等待”在 synchronized、ReentrantLock、原子类、LongAdder 之间做选择。

②没有它会怎样:读、判断、写回被插队

实测 jcstress,JDK 17,2 核 ARM:库存只剩 1 件,两个线程同时扣,统计两个人都抢到的比例(见 JMM 下一圈)。

普通 int14.8% 超卖
volatile int33.6% 超卖
AtomicInteger CAS2.47 亿次里 0 次

不懂这一点时的典型误用:每次调用都 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 和一些锁操作要用它。
mark word(64 位):最低两位说明“剩下的位是什么意思” 无锁 没用到 25 位 · identityHashCode 31 位(算过才有)· 分代年龄 4 位 · 0 01 实测 0x…0001 轻量级锁 指针 → 持锁线程栈上的“锁记录”(原来的对象头搬到那里) 00 实测 thin lock 重量级锁 指针 → ObjectMonitor(C++ 对象:owner、入口队列、WaitSet) 10 实测 fat lock 偏向锁 偏向的线程 54 位 · epoch 2 位 · 年龄 4 位 · 1 01 JDK 17 默认关 两个后果:算过 identityHashCode 的对象就不能再偏向(位置被哈希占了);轻量级锁和重量级锁时,哈希值和年龄被“搬走”了,放在锁记录或 ObjectMonitor 里。 按 64 位 HotSpot、JDK 17、默认的栈上锁记录实现;JDK 23 起默认的新轻量级锁不再往对象头里放栈地址(⑥ 第三圈)。

④-2编译之后:同步块是一对指令,同步方法只是一个标志

javaptakeBlock():synchronized (lock) { … }
 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
javaptakeMethod():synchronized 方法
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

    ④-4CAS 和原子类:不排队,提交时核对

    ① 读cur = left.get() ② 算next = cur - 1 ③ compareAndSet(cur, next)一条原子指令:还是 cur 才写 成功:扣到了 失败:有人先改了 回到 ① 重读:不挂起、不切换,只是再算一遍
    ③ 在 ARM v8.1 以上是一条 cas 类指令(本机 UseLSE=true),老 ARM 是“独占读 + 条件写”的小循环,x86 是 lock cmpxchg。原子类的 incrementAndGet 在 JDK 17 源码里是 U.getAndAddInt(this, VALUE, 1) + 1,HotSpot 把它换成一条原子加指令(ARM 上 ldadd 一类、x86 上 lock xadd),根本不会失败重试(按 HotSpot 实现,本页没看汇编)。

    ④-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 起参数作废。

    API最少要会的几行
    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,不再偏向)
    $ java -cp … Aba
    AtomicReference:       CAS(PAID → SHIPPED) = true,现在 SHIPPED(中间被退款过,它看不出来)
    AtomicStampedReference:CAS(PAID@0 → SHIPPED) = false,现在 PAID@2(版本号变了,拒绝)

    五种写法的吞吐:同一个计数,1 / 2 / 4 个线程

    写法1 线程2 线程4 线程说明
    synchronized59.017.929.6没人抢时两次 CAS;有人抢就挂起、唤醒
    ReentrantLock53.411.235.2和 synchronized 一个量级
    AtomicInteger.incrementAndGet148.4123.575.5一条原子加指令,不会失败
    手写 CAS 循环110.123.829.9有竞争时失败重试 4214 万次
    LongAdder90.7166.4177.3线程越多越快:各加各的格子

    实测 单位:百万次/秒。2 核 ARM,JDK 17,每格 2000 万次、3 轮取最快,加了 -XX:-EliminateLocks(关掉锁粗化,见 ⑥ 第三圈)。普通 main 程序,不是 JMH;4 个线程有时比 2 个线程快,是因为 2 核上 4 个线程有一半时间在轮流跑、反而撞得少,这类数字只看量级。

    到这里已经能交差了:你能看懂对象头的几种状态,知道 synchronized 没人抢时很便宜、有人抢时挂起排队,能写出正确的 CAS、知道 ABA 和 LongAdder 用在哪,能按场景在锁和原子类之间选。
    已知还没处理的细节:锁错对象(⑥ 坑 1)、锁里做慢操作(⑥ 坑 2)、高竞争下的手写 CAS(⑥ 坑 3)、wait 的写法(⑥ 坑 4)、虚拟线程 pinning(⑥ 坑 5)。

    ⑥细节·规范·优化

    第一圈

    注意事项:不知道就会出事故

    坑 1锁错了对象← ④-1 锁记在对象自己的头上
    现象
    加了 synchronized 照样超卖。
    根源
    锁就是那个对象的对象头。synchronized (new Object()) 每次一个新对象;锁 Integer left,left-- 之后变量指向了另一个 Integer;锁字符串常量,整个 JVM 里同值的字符串是同一个对象,可能和别的库抢同一把锁。
    做法
    用 private final Object lock = new Object();要锁“这个商品”就从 ConcurrentHashMap<Long, Object> 里取同一个锁对象。
    坑 2锁里做慢操作← ④-3 抢不到就挂起排队
    现象
    接口 RT 飙升,线程转储里几十个线程 BLOCKED 在同一个地址上。全景第 6 站:最多 47 个 BLOCKED
    根源
    锁里有一次数据库调用或 HTTP 调用,持锁时间从纳秒变成毫秒,别的线程全在入口队列里等。
    做法
    锁里只放必须一起改的那几行;IO 挪到锁外;同一个 JVM 多台部署时,本来就该交给数据库条件更新。
    坑 3高竞争下手写 CAS 循环← ④-4 失败就重来
    现象
    改成“无锁”以后 CPU 打满、吞吐没涨。⑤:失败重试 4214 万次,吞吐和锁差不多
    根源
    冲突越多,失败越多,每次失败都要重读、重算、再抢一次缓存行,CPU 都花在空转上。
    做法
    计数用 LongAdder;累加用 getAndAdd 一步完成;冲突大、临界区长的就老老实实用锁。
    坑 4wait 写在 if 里、不在锁里调用← ④-3 wait 要放锁、重新抢锁
    现象
    偶发条件没满足就往下走;或者直接抛 IllegalMonitorStateException。
    根源
    wait 返回时要重新抢锁,抢到之前条件可能又被别人改掉;JVM 也允许“虚假唤醒”。没持有这个对象的锁就调 wait / notify,没有 monitor 可操作。
    做法
    while (!条件) lock.wait();,并且在 synchronized (lock) 里;一般用 notifyAll。新代码更推荐 ReentrantLock 的 Condition 或阻塞队列。
    坑 5虚拟线程在 synchronized 里阻塞← ④-1 thin lock 指向线程栈
    现象
    JDK 21 开了虚拟线程,吞吐反而掉到接近串行。
    根源
    JDK 21~23 的实现里,synchronized 的锁和所在的平台线程(载体线程)绑在一起,虚拟线程在锁里阻塞时卸不下来,把载体线程钉住了(pinning)。
    做法
    JDK 21~23 上锁里有 IO 的地方换成 ReentrantLock;JDK 24 起(JEP 491)synchronized 不再钉住。
    第二圈

    使用规范

    锁对象私有、final、专用

    不锁 this 暴露给外面的对象,不锁字符串、包装类、Class(除非有意)。

    坑 1,以及外部代码拿你的对象加锁造成死锁。

    临界区只放必须一起改的东西

    能先在锁外算好的先算好,锁里只做“检查 + 写入”。

    坑 2。

    单个变量用原子类,计数器用 LongAdder

    需要“读到精确值再决定”的(库存)用 AtomicInteger;只累加、偶尔看总数的(销量、QPS)用 LongAdder。

    坑 3。

    状态机的 CAS 带版本

    订单状态、无锁结构里的指针用 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 8LongAdder、@sun.misc.Contended;偏向锁默认开,启动后延迟 4 秒才生效
    JDK 15偏向锁默认关闭(JEP 374);ObjectMonitor 改由 MonitorDeflationThread 异步回收
    JDK 17本页实测版本:还能用 -XX:+UseBiasedLocking 打开(会提示已废弃)
    JDK 18UseBiasedLocking 参数作废
    JDK 21新增 -XX:LockingMode,可选新的轻量级加锁:不往对象头里放栈地址,改用线程自己的锁栈
    JDK 23新的轻量级加锁成为默认
    JDK 24JEP 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、⑤)。

    下一圈

    ↺回到⑤:改造你的案例

    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 站的数据库条件更新。