『深入学习Java』(六) synchronized 为什么这么厉害?
前言
前几篇我们总结了 java 中线程与锁一些基础知识与常见用法。这一节我们稍微深入一些,学习 synchronized 的底层原理。
synchronized 为什么这么有用?
写代码时,我们只需要在合适的地方加上 synchronized 关键字,就可以避免并发问题。
那么 synchronized 怎么如此神奇的作用呢?这是因为 java 在 JVM 层面对 synchronized 做了一些处理。
Java 编程语言为线程之间的通信提供了多种机制。这些方法中最基本的是同步(synchronization),它是使用监视器(monitors)实现的。
Java 中的每个对象都与一个监视器相关联,线程可以锁定或解锁监视器。一次只有一个线程可以锁定监视器。任何其他试图锁定该监视器的线程都会被阻塞,直到它们能够获得监视器的锁。
一个线程可能会多次锁定一个特定的监视器;每次解锁都会反转一次锁定操作的效果。
synchronized
语句会尝试在该对象的监视器上执行锁定操作。执行完锁定操作后,将执行 synchronized
的代码块。如果代码块执行完成,同一个监视器上自动执行解锁操作。
另外 java 中还其他机制提供了替代的同步方式,例如volatile
变量的读取和写入以及 JUC 包。这个我们后边再继续学习。
每个对象除了有一个关联的监视器之外,还有一个关联的等待集(wait set
)。等待集是一组线程。
创建对象时,其等待集为空。将线程添加到等待集中和从等待集中删除线程是原子操作。等待集仅通过方法 wait/notify/notifyAll
操作。
等待集操作也可能受到线程中断状态和 Thread
处理中断的类方法的影响。
Monitor
Monitor 一般被翻译为监视器或管程。
Java 中的每个对象都可以与一个监视器相关联,如果使用 synchronized 给对象加了重量级锁之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // Field lock:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter
6: getstatic #3 // Field i:I
9: iconst_1
10: iadd
11: putstatic #3 // Field i:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
.....
我们可以看到 monitor 指令,monitorenter/monitorexit,用于控制临界区代码。
Monitor 的大致结构如下图,我们通过5个线程(thread1-5)竞争一个锁,来描述 Monitor 中的各部分。
- Owner Monitor 同一时刻只能有一个 Owner。thread 3 执行 synchronized(lock) 时,会将 Owner 设置为 thread3 的 id。
- EntyList 当 thread3 加锁运行的过程中,thread4、5也运行到 synchronized(lock) 时,Monitor 的 Owner 已被设置为 thread3。此时,thread4、5 就会进入 EntryList 中,处于 BLOCKED 状态。 当 thread3 运行完 synchronized 代码块后,就唤醒 EntryList 中等待的线程来竞争锁。
- Wait Set Wait Set 是获得过锁,然后在运行代码块过程中调用了 wait 方法,线程进入WAITING状态。 只有当调用 lock.notify/notityAll 方法时,才会进入 EntryList 重新竞争锁。
Java 对象头
上面我们提到,如果使用 synchronized 给对象加了重量级锁之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
下面是从 github 借鉴的 java 对象头图,gist.github.com/arturmkrtch…
Klass Word 我们先不关注,只关注 Mark Word 部分。
|------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (128 bits) | State |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (64 bits) | |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 正常 |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 偏向锁 |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | OOP to metadata object | 轻量级锁 |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | 重量级锁 |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | GC 标记 |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
字段名 | 位数 | 含义 | 备注 |
---|---|---|---|
identity_hashcode | 31 | 31位的对象标识hashCode | |
age | 4 | 对象的分代年龄 | 对象在Survivor区复制一次,年龄增加1。 |
biased_lock | 1 | 偏向锁标记。 | 0:启用;1:未启用。 |
lock | 2 | 锁状态标记位。 | biased_lock=0 & lock= 01:无锁; biased_lock=1 & lock= 01:偏向锁; lock = 00:轻量级锁; lock = 10 : 重量级锁; lock = 11:GC标志。 |
thread | 54 | 持有偏向锁的线程ID。 | |
epoch | 2 | 偏向锁的时间戳。 | |
ptr_to_lock_record | 62 | 轻量级锁状态下, 指向栈中锁记录的指针。 | |
ptr_to_heavyweight_monitor | 6 | 重量级锁状态下, 指向对象监视器Monitor的指针。 |
我们可以看到,对象大致可以分有锁无锁状态(biased_lock:1 bits),其中有锁时,又分为偏向锁、轻量级锁、重量级锁。只有重量级锁才关联 Monitor。
通过 jol 工具包,我们可以观测到对象头的具体信息。
无锁
public class JOLSample_01_noLock {
public static void main(String[] args){
Object o = new Object();
out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
我们看到,前两行出来的就是 Mark Word 部分,由于 JVM 采用了小段存储。
我们将 VALUE 重新编排后得到:
Mark Word:
hashcode (31bit): 0000000 00000000 00000000 00000000
age (4bit): 0000
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
由于我们首要关注锁机制,所以我们直接取最后三位001
出来看。
根据我们上面梳理的表格,biased_lock=0 并且 lock= 01 为无锁状态。
偏向锁
偏向锁,顾名思义就是总是偏向某一个线程的
首先,我们需要知道,JVM 有个机制叫做偏向延迟,即即使开启了偏向锁,也需要有个延时过程才能加上锁。
启动偏向锁模式 -XX:+UseBiasedLocking
;设置偏向延迟(JVM 默认4s)-XX:BiasedLockingStartupDelay=4
public class JOLSample_13_BiasedLocking {
public static void main(String[] args) throws Exception {
TimeUnit.SECONDS.sleep(6);
final A a = new A();
ClassLayout layout = ClassLayout.parseInstance(a);
out.println("**** Fresh object");
out.println(layout.toPrintable());
synchronized (a) {
out.println("**** With the lock");
out.println(layout.toPrintable());
}
out.println("**** After the lock");
out.println(layout.toPrintable());
}
public static class A {
// no fields
}
}
- synchronized 前
objlayout.JOLSample_13_BiasedLocking$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000000 00000000 000000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 但是这里还没有线程id,这因为还没进入同步代码块。
- synchronized 中
objlayout.JOLSample_13_BiasedLocking$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e0 29 03 (00000101 11100000 00101001 00000011) (53075973)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000011 00101001 111000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 这里已经有线程id了。
- synchronized 后
objlayout.JOLSample_13_BiasedLocking$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e0 29 03 (00000101 11100000 00101001 00000011) (53075973)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000011 00101001 111000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 持有偏向锁后,若没有其他线程来竞争锁,将一直保持偏向状态。
偏向锁在获取到 Mark Word 中线程id时是自己后,不就会再进行 CAS 操作,减少性能损耗。
如果有其他线程尝试获取锁时,发现 Mark Word 中保存的不是自己的线程Id,JVM 就会将锁升级为轻量级锁。
另外在 JDK 15 版中,偏向锁已经被默认关闭,后续将弃用。相关资料阅读 openjdk.org/jeps/374
偏向锁在同步系统中引入了大量复杂的代码,并且对其他热点组件也具有入侵性。
这种复杂性是理解代码各个部分的障碍,也是在同步系统内进行重大设计更改的障碍。 为此,我们希望禁用、反对并最终取消对偏向锁定的支持。
轻量级锁
轻量级锁是指,线程没有同一时间的竞争(时间是错开的)。
public static void main(String[] args) throws Exception {
final A a = new A();
ClassLayout layout = ClassLayout.parseInstance(a);
Thread thread = new Thread(() -> {
synchronized (a) {
out.println("**** in thread");
out.println(layout.toPrintable());
}
});
thread.start();
thread.join();
synchronized (a) {
out.println("**** With the lock");
out.println(layout.toPrintable());
}
}
objlayout.JOLSample_LightWeightLock$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) e8 f3 97 21 (11101000 11110011 10010111 00100001) (563606504)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
// javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00100001 10010111 11110011 111010
// LockFlag (2bit): 00
objlayout.JOLSample_LightWeightLock$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 08 f1 eb 02 (00001000 11110001 11101011 00000010) (49017096)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
// Mark Word:
//javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00000010 11101011 11110001 000010
//LockFlag (2bit): 00
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word。
然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。
如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。
虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经持有该轻量级锁,再次获取到该锁,也就是锁重入。
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。这里在锁膨胀时,还有一个自旋的操作,我们后续再了解。
轻量级锁在解锁时,也是使用的 CAS 操作,如果 mark word 为null时,会判定为重入直接清除掉。
另外,轻量级锁在 CAS 解锁失败时,也需要膨胀成为重量级锁(Monitor),然后再解锁。
重量级锁(Monitor)
public static void main(String[] args) throws Exception {
final A a = new A();
ClassLayout layout = ClassLayout.parseInstance(a);
Thread thread = new Thread(() -> {
synchronized (a) {
out.println(layout.toPrintable());
PrintObjectHeader.printObjectHeader(a);
}
});
Thread thread1 = new Thread(() -> {
synchronized (a) {
out.println(layout.toPrintable());
PrintObjectHeader.printObjectHeader(a);
}
});
thread.start();
thread1.start();
}
objlayout.JOLSample_HWeightLock$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 1a a8 56 1d (00011010 10101000 01010110 00011101) (492218394)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
//javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00011101 01010110 10101000 000110
//LockFlag (2bit): 10
monitor 的图在第二小节已经有了,不再另外画了。
小结
这篇文章主要讲述 synchronized 的运作原理,了解了 synchronized 如何解决同步问题,其中也涉及到锁升级过程。
我们再来总结一下,每种类型的锁都解决了什么问题。
- 重量级锁 Monitor - 线程是映射到操作系统的原生内核线程之上,需要切换线程用户态/核心态,采用 Monitor 解决同步问题。
- 轻量级锁 - 采用 CAS / 自旋 操作。并发度低的情况下,避免用户态/核心态转换效率过低。
- 偏向锁 - 在无竞争的情况,避免轻量级锁在加解锁时 CAS/自旋 的操作。
参考资料
- tianshuang / jol-samples
- arturmkrtchyan/ObjectHeader32.txt
- docs.oracle.com/javase/spec…
- quarkus.io/blog/biased…
- (六) synchronized的源码分析
- 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》by 周志明