synchronized原理

Monitor 概念回顾

Java对象头

以 32 位虚拟机为例

名词解释:

  1. Object Header (64 bits): 它包含了对象的元信息以及用于垃圾回收和同步的数据。
  2. Mark Word (32 bits): 这个部分通常包含用于垃圾回收和同步的标记信息。标记字包含了对象的哈希码锁定状态垃圾回收标记等信息。
  3. Klass Word (32 bits) : 这个部分包含指向对象的类元数据的指针,它描述了对象属于哪个类,包括类的方法、字段等信息。

普通对象

1
2
3
4
5
|--------------------------------------------------------------| 
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象

1
2
3
4
5
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 结构为

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
27
28
29
30
31
32
33
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|

hashcode:这部分通常用于存储对象的哈希码(HashCode),它是一个用于快速查找对象的值。哈希码在对象创建时生成,然后在对象的生命周期中不会更改。
age (4 bits): 这部分用于表示对象的年龄,通常在分代垃圾回收中使用。年龄是一个对象存活的时间的度量。
biased_lock (1 bit): 这个位用于标识对象是否启用了偏向锁。当偏向锁被启用时,该位为1;否则,为0。
01 State: 这个状态表示对象处于正常状态,未被锁定或标记。

thread (23 bits): 这一部分用于存储拥有锁的线程的ID。在偏向锁状态下,它表示偏向锁的线程ID。
epoch (2 bits): 这一部分用于存储偏向时间戳(bias timestamp)。它用于检测是否应取消偏向锁。在不同时间偏向锁的情况下,该值可能不同。
age (4 bits): 这一部分用于表示对象的年龄,通常在分代垃圾回收中使用。年龄是一个对象存活的时间的度量。
biased_lock (1 bit): 这个位用于标识对象是否启用了偏向锁。当偏向锁被启用时,该位为1。
01 State: 这个状态表示对象处于偏向锁状态,已经偏向某个线程。

ptr_to_lock_record (30 bits): 这一部分用于指向偏向锁的记录(bias lock record),该记录包含了关于偏向锁的详细信息。这在取消偏向锁时使用。
00 State: 这个状态表示对象处于轻量级锁状态。

ptr_to_heavyweight_monitor (30 bits): 这一部分用于指向重量级锁的监视器对象。重量级锁通常涉及多个线程之间的同步。
10 State: 这个状态表示对象处于重量级锁状态。

11 State: 这个状态表示对象被标记为垃圾回收(GC Marked),通常在垃圾回收期间使用。

64位虚拟机 Mark Word

1
2
3
4
5
6
7
8
9
10
11
12
13
|--------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal 无锁状态 |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased 偏向锁 |
|--------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | Lightweight Locked 轻量级锁|
|--------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked 重量级锁|
|--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

synchronized加锁过程

  1. 无锁状态(Unlocked):一开始,对象处于无锁状态。这意味着没有线程持有该对象的锁。
  2. 偏向锁检测(Biased Lock Check):在进入同步块之前,JVM 会尝试检测对象是否已经偏向某个线程。偏向锁是一种优化,旨在减少不必要的竞争。如果对象已经被偏向某个线程,且当前线程是偏向线程,那么它可以直接进入同步块,跳过后续步骤。
  3. 自旋锁(Spin Locking):如果对象不是偏向任何线程,或者当前线程不是偏向线程,JVM 会尝试使用自旋锁来避免进入重量级锁。自旋锁是一种快速的锁获取尝试,线程会短暂自旋等待锁的释放,而不进入阻塞状态。
  4. 轻量级锁尝试(Lightweight Lock Attempt):如果自旋锁不成功,当前线程将尝试使用轻量级锁。此时,JVM会尝试在对象头中的 Mark Word 中设置标志来表示当前线程持有该对象的锁。
  5. 竞争(Contention):如果轻量级锁尝试失败,表示可能有其他线程也在竞争同一个锁,进入竞争状态。这时,JVM 将使用适当的机制来处理竞争,通常会升级锁为重量级锁。
  6. 重量级锁(Heavyweight Lock):如果竞争仍然存在,JVM 将升级锁为重量级锁。重量级锁使用操作系统的原生同步机制,例如互斥量,来确保同一时刻只有一个线程可以进入同步块。其他线程将被阻塞,直到持有锁的线程释放它。
  7. 执行同步块(Executing Synchronized Block):一旦线程成功获取锁,它可以进入同步块内执行相应的代码。只有一个线程可以同时执行同步块内的代码。
  8. 释放锁(Release Lock):当线程退出同步块或抛出异常时,它会释放锁,允许其他线程竞争该锁。

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以 使用轻量级锁来优化

轻量级锁对使用者是透明的,即语法仍然是 synchronized

假设有两个方法同步块,利用同一个对象加锁

1
2
3
4
5
6
7
8
9
10
11
12
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
  • 创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

  • 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存 入锁记录

image-20231031115626931

  • 如果 cas 替换成功,对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下

    image-20231031143349476

  • 如果 cas 失败,有两种情况

    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数

image-20231031143607867

  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重 入计数减一

    image-20231031143649632

  • 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象 头

    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image-20231031143825427

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED
  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步 块,释放了锁),这时当前线程就可以避免阻塞。

自旋重试成功的情况

image-20231031144036930

自旋重试失败的情况

image-20231031144119594

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能,由操作系统底层控制

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。

以后只要不发生竞争,这个对象就归该线程所有 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
// 同步块 C
}
}

image-20231031144326659

image-20231031144334098

偏向状态

1
2
3
4
5
6
7
8
9
10
11
12
13
|--------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased 偏向锁 |
|--------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked |
|--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值

测试偏向锁延迟特性

默认synchronized 加锁是先加的偏向锁

首先配置: -XX:BiasedLockingStartupDelay=0 禁用延迟,如果不禁用,请用sleep方法让主线程睡不低于4s,因为这是偏向锁的特性,延迟加载

image-20231101101002777

代码

1
2
3
4
5
6
7
8
9
10
11
public static void test3() {
A a = new A();
ClassLayout classLayout = ClassLayout.parseInstance(a);
new Thread(() -> {
log.debug("加锁前:{}", classLayout.toPrintableSimpleSerMs());
synchronized (a) {
log.debug("加锁之后:{}", classLayout.toPrintableSimpleSerMs());
}
log.debug("解锁后:{}", classLayout.toPrintableSimpleSerMs());
}, "T1").start();
}

打印结果

  • 第一次打印锁状态为 101 为偏向锁状态
  • 第二次对A对象进行加锁操作,最后三位可以看到还是101依然是偏向锁,不同的是后面的54为多了T1线程的ThreadId
  • 第三次解锁之后打印的结果跟第二次一样,这也就是偏向锁的思想,偏向锁的对象解锁后,线程 id 仍存储于对象头中

image-20231101103202099

上述的toPrintableSimpleSerMs()方法是基于Jol-core Jar包进行的扩展方法,详细可以看这篇文章

测试禁用偏向锁

在运行配置中设置VM Options -XX:-UseBiasedLocking

image-20231101103711769

打印结果

  • 因禁用了偏向锁,所以第一次打印的最后三位为001处于无锁的状态
  • 第二次是加锁,最后三位为000偏向锁被禁用了,只能升级为轻量级锁,前面54为依然为ThreadId
  • 第三次解锁之后,回到无锁状态001

image-20231101103746789

测试HashCode

运行配置改成取消延迟加载

1
2
3
4
5
6
7
8
9
10
11
12
public static void test3() {
A a = new A();
ClassLayout classLayout = ClassLayout.parseInstance(a);
new Thread(() -> {
log.debug("加锁前:{}", classLayout.toPrintableSimpleSerMs());
a.hashCode();
synchronized (a) {
log.debug("加锁之后:{}", classLayout.toPrintableSimpleSerMs());
}
log.debug("解锁后:{}", classLayout.toPrintableSimpleSerMs());
}, "T1").start();
}

打印结果

  • 第一次后三位为101,偏向锁状态
  • 第二次加锁后升级为轻量级锁,后面的 62位记录的是偏向锁的详细信息,在取消加锁的时候会用到
  • 第三次取消加锁之后,后三位为001无锁状态,前面的为HashCode值

image-20231101104438972