相关问题

  1. synchronized 本质上解决什么问题?
  2. synchronized 除了互斥,还提供哪些内存语义?
  3. synchronized 修饰实例方法时,锁的是谁?
  4. synchronized 修饰静态方法时,锁的是谁?
  5. synchronized(obj) 锁的又是谁?
  6. 两个不同对象分别调用同一个实例 synchronized 方法,会互相阻塞吗?为什么?
  7. static synchronized 方法通过类名调用和通过对象调用,有没有竞争关系?为什么?
  8. 一个 static synchronized 方法和一个实例 synchronized 方法之间是否竞争?
  9. 为什么 synchronized 是可重入锁?
  10. 什么叫“可重入”?
  11. 一个同步方法内部再调用另一个锁住同一对象的同步方法,会不会死锁?
  12. synchronized 代码块在字节码层面和什么指令有关?
  13. synchronized 方法又是如何标识的?
  14. synchronized 和对象头、Mark Word、Monitor 有什么关系?
  15. 传统意义上的锁升级过程是什么?
  16. 为什么现代 JDK 里不能再简单把偏向锁当成重点来讲?
  17. synchronized 在竞争激烈时为什么可能进入重量级 Monitor?

synchronized 的三种使用方式

synchronized 主要有三种使用方式:修饰实例方法、修饰静态方法、修饰代码块。三者最核心的区别就在于:锁对象不同。

修饰实例方法时:

1
2
public synchronized void method() {
}

锁的是当前实例对象,也就是 this。

因此,如果两个线程操作的是同一个实例,它们会互斥;如果操作的是不同实例,因为锁对象不同,通常不会互相阻塞。

修饰静态方法时:

1
2
public static synchronized void method() {
}

锁的是当前类对应的 Class 对象,也就是:

1
ClassName.class

Class 对象对整个类来说只有一个,所以无论创建多少个实例,只要线程执行的是这个静态同步方法,竞争的都是同一把锁,因此需要依次执行。

修饰代码块时:

1
2
3
synchronized (lock) {
    // 临界区
}

锁的是括号中指定的对象 lock。

这种方式最灵活,可以自己决定锁哪个对象,也可以只锁真正需要同步的那一小段代码,而不是整个方法。

所以三种方式可以简单记成:

实例方法锁 this,静态方法锁 Class,代码块锁指定对象。

判断两个 synchronized 是否会互斥,本质上就看一件事:

它们拿的是不是同一个锁对象。

synchronized 不同锁

前面已经知道,synchronized 最终锁住的是某个 Java 对象。

但 JVM 底层并不是始终用同一种方式来实现这把锁,因为不同场景下线程竞争的程度并不一样。

在经典 HotSpot 的实现里,如果一把锁长期都只有线程 A 在使用,那么 JVM 会让这把锁偏向线程 A。对象的锁信息中会记录线程 A 的身份,之后线程 A 再次进入同一个 synchronized 时,只需要确认这把锁仍然偏向自己,不需要每次重新进行完整的锁竞争。

如果后来线程 B 也开始尝试获取这把锁,JVM 会先撤销原来的偏向状态。撤销之后并不一定马上进入轻量级锁,还要看线程 A 当时是否仍然持有这把锁。

如果线程 A 已经退出同步代码,那么此时并不存在真正的并发竞争,对象可以恢复到无锁状态,线程 B 再正常获取锁。

如果线程 A 仍然持有锁,而线程 B 同时来竞争,这时才出现了实际的锁竞争。JVM 会使用轻量级锁,让竞争线程先通过 CAS、自旋等方式尝试获取锁。因为持锁线程可能很快就会释放锁,这种情况下直接让线程阻塞,反而会产生更高的线程切换成本。

如果竞争持续存在,线程经过 CAS、自旋仍然无法获取锁,那么继续占用 CPU 反复尝试就不划算了。此时锁会膨胀为重量级锁,竞争失败的线程进入阻塞等待,后续由 Monitor 负责线程的等待和唤醒。

因此,这几种锁实现分别对应不同的竞争情况:偏向锁主要减少单线程反复加锁的开销,轻量级锁主要处理短暂竞争,重量级锁则用于竞争较激烈、需要线程真正阻塞等待的场景。

synchronized 不同锁的底层实现

前面已经知道,synchronized 最终锁住的是某个 Java 对象,而 JVM 会根据线程竞争程度采用不同的锁实现。

那么 JVM 首先要解决一个问题:怎么知道这个对象现在有没有被加锁,以及当前使用的是哪一种锁?

这个信息就和对象头中的 Mark Word 有关。

Mark Word 属于锁对象本身,它是一块会根据对象当前状态复用的数据。对象没有被加锁时,里面主要保存对象的 hashCode、GC 年龄等运行时信息;对象进入加锁状态以后,其中的锁标志位以及其他部分就会被用来表示当前锁的状态和对应的锁信息:

1
2
3
4
5
6
7
锁对象
   ↓
Mark Word
   ↓
判断当前是什么锁状态
   ↓
找到对应的锁信息

在经典 HotSpot 的实现中,如果锁处于偏向状态,Mark Word 中会记录偏向线程相关的信息。也就是说,对象本身就记录了“这把锁目前偏向哪个线程”。

如果线程 A 再次进入这个 synchronized,发现对象仍然偏向自己,就可以直接进入,不需要每次重新进行完整的 CAS 锁竞争。

如果线程 B 也来获取这把锁,就需要撤销原来的偏向状态。

如果此时线程 A 已经离开同步代码,那么实际上不存在并发竞争,对象可以重新恢复到无锁状态,线程 B 再正常获取。

如果线程 A 仍然持有锁,而线程 B 同时尝试获取,那么才真正出现了竞争,这时就会进入轻量级锁的处理。

轻量级锁中,除了对象自己的 Mark Word,还会出现一个 Lock Record。

Lock Record 是线程在自己的栈帧中为这次加锁创建的一条锁记录。

假设线程 A 准备锁住 lock 对象,最开始:

1
2
3
4
5
6
lock 对象:

Mark Word
┌─────────────────┐
│ 原来的对象头信息 │
└─────────────────┘

线程 A 会先在自己的栈中创建 Lock Record,并把 lock 原来的 Mark Word 保存进去:

1
2
3
4
5
6
线程 A

Lock Record
┌─────────────────┐
│ 原来的 Mark Word │
└─────────────────┘

然后线程 A 通过 CAS 尝试修改 lock 对象自己的 Mark Word:

1
2
3
4
5
6
7
CAS:

如果 lock.MarkWord
仍然等于刚才看到的原始 Mark Word

那么把它修改成
指向线程 A 的 Lock Record

如果 CAS 成功,结构就变成:

1
2
3
4
5
6
线程 A                         lock 对象

Lock Record                   Mark Word
┌─────────────────┐           ┌─────────────────┐
│ 原来的 Mark Word │ <──────── │ 指向 Lock Record │
└─────────────────┘           └─────────────────┘

这就表示线程 A 成功获得了轻量级锁。

所以轻量级锁真正竞争的是锁对象自己的 Mark Word。多个线程都想通过 CAS 修改同一个 Mark Word,谁成功建立起自己和这个对象之间的锁记录关系,谁就成功获取锁。

如果线程 A 执行结束,就可以利用自己 Lock Record 中保存的原始 Mark Word,尝试通过 CAS 把对象头恢复回去,从而完成解锁。

如果线程 B 在 A 持锁期间也来获取这把锁,它同样会尝试加锁,但此时对象的 Mark Word 已经发生变化,所以不能再按照无锁状态成功完成 CAS。

这时 JVM 不会因为一次 CAS 失败就马上把线程阻塞,而是可以先让竞争线程进行短暂的自旋,也就是让线程暂时留在 CPU 上继续尝试获取锁。

这样做的原因是,很多 synchronized 临界区都比较短。线程 A 可能马上就执行结束,如果线程 B 稍微等待一下就能拿到锁,那么没有必要为了这几十微秒甚至更短的竞争去执行线程阻塞和唤醒。

但如果竞争一直持续,自旋多次仍然拿不到锁,那么继续占着 CPU 空转就开始得不偿失。

这时候锁就会发生膨胀,进入重量级锁。

这里没有一个 Java 规范规定的“CAS 失败 10 次就升级”之类的固定数字。是否继续自旋、什么时候进入膨胀路径属于 JVM 的运行时实现策略,而不是记录在 Lock Record 中的一个简单失败次数。现代 HotSpot 的具体快速锁实现也一直在演进。

重量级锁出现以后,JVM 会为这个对象关联一个 ObjectMonitor。

这时候对象的 Mark Word 不再通过轻量级 Lock Record 来表示锁,而是进入 Monitor 相关的状态:

1
2
3
4
5
lock 对象

Mark Word
    ↓
ObjectMonitor

ObjectMonitor 内部会维护当前锁的拥有者以及竞争、等待这把锁的线程。

所以到了重量级锁阶段,获取不到锁的线程不需要一直占着 CPU 自旋,而是可以进入 Monitor 管理的等待流程,最终被挂起;等持锁线程释放锁以后,再唤醒相应线程重新参与竞争。当前 HotSpot 的 ObjectMonitor 实现中也维护了 owner、竞争状态和等待线程等信息。

整个过程串起来就是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
对象没有竞争
    ↓
偏向当前线程
    ↓
其他线程出现
    ↓
撤销偏向
    ↓
如果原线程已经退出
    → 恢复无锁

如果原线程仍然持锁
    ↓
出现真实竞争
    ↓
轻量级锁
    ↓
CAS + 自旋尝试解决短暂竞争
    ↓
竞争持续,自旋已经不划算
    ↓
锁膨胀
    ↓
ObjectMonitor
    ↓
竞争线程可以进入阻塞等待

因此,Mark Word 并不是单纯记录一个“锁住 / 没锁住”的布尔值。

它真正承担的是锁状态入口的作用:JVM 通过对象的 Mark Word 判断当前处于什么锁状态,并找到这一状态对应的锁信息。

经典模型中可以理解为:

1
2
3
4
5
6
7
8
偏向锁
→ Mark Word 中记录偏向线程相关信息

轻量级锁
→ Mark Word 与线程的 Lock Record 建立关系

重量级锁
→ Mark Word 与 ObjectMonitor 建立关系

需要注意的是,偏向锁属于经典 HotSpot 的历史优化,现代 JDK 已经移除了这条实现路径;现代 HotSpot 对轻量级快速锁的具体实现也发生过变化。现在学习这套模型,主要是为了理解 synchronized 为什么能够从低成本的用户态竞争逐渐过渡到 Monitor 管理的重量级竞争。当前 OpenJDK 的 Mark Word 仍然使用锁状态位区分 lock-neutral、fast-locked 和 inflated 等状态。