面试官:Synchronized锁升级过程一定会经过偏向锁么?剖析Synchronized内部原理ReentrantLo
背景
使用
synchronized用法
public class SynchronizedTest {
Object o1 = new Object();
static Object o2 = new Object();
@SneakyThrows
public synchronized void test1() {
Thread.sleep(1000);
}
@SneakyThrows
public static synchronized void test2() {
Thread.sleep(1000);
}
@SneakyThrows
public void test3() {
synchronized (o1) {
Thread.sleep(1000);
}
}
@SneakyThrows
public void test4() {
synchronized (o2) {
Thread.sleep(1000);
}
}
@SneakyThrows
public void test5() {
synchronized (this) {
Thread.sleep(1000);
}
}
@SneakyThrows
public void test6() {
synchronized (SynchronizedTest.class) {
Thread.sleep(1000);
}
}
}
这是synchronized的几种常见用法,但你了解其中锁的粒度么?哪些是锁对象、哪些是锁整个类的呢?下面写了一个测试的执行代码,通过代码的执行结果最有说服力。 代码说明:
- 获取开始时间;
- 分别创建两个线程,并且new两个
SynchronizedTest
,使用对象分别调用上述方法; - 开启两个线程;
- 等待两个线程执行完成;
- 打印执行的总时长;
- 如果时长为1s,则说明锁的粒度是当前new出来的对象;
- 如果时长为2s,则说明锁的力度是当前类。
@SneakyThrows
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
Thread t1 = new Thread(() -> new SynchronizedTest().test1());
Thread t2 = new Thread(() -> new SynchronizedTest().test1());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("执行时间: " + (System.currentTimeMillis() - startTime) / 1000);
}
锁粒度
大家可以根据上述代码自己执行一遍,这样印象会更深;下面我直接贴出采用各使用方法时锁的粒度。
test1 : 锁的是当前实体的该方法,执行结果为1s. test2 : 锁的是当前
类
,执行结果为2s. test3 : 锁的是当前实体的o1属性,执行结果为1s. test4 : 锁的是当前类的o2属性
,执行结果为2s. test5 : 锁的是当前对象,执行结果为1s. test6 : 锁的是当前类
,执行结果为2s.
原理
在原理方面,会先看一下加了synchronized后字节码会有什么变化,再介绍一下在jdk1.6做了哪些优化,然后着重介绍一下锁的各个阶段以及重量级锁的实现方法。
字节码
首先我们来分析一下,使用synchronized时字节码层面会如何变化,这里我写了一个类再通过javac、javap来查看一下字节码有什么变化。java代码如下:
public class Test {
Object o = new Object();
public void test1(){
}
public void test2(){
synchronized (o){
}
}
}
在该java类型目录下,执行javac Test.java
,再执行 javap -v Test.class
,执行完后会出现稍微能看明白一点的汇编代码,其中这两个方法汇编代码如下:
public void test1();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 11: 0
public void test2();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
/** * 0: 将对象引用压入操作数栈 */
0: aload_0
/** * 1: 获取指定字段的值并压入操作数栈 */
1: getfield #7 // Field o:Ljava/lang/Object;
/** * 4: 复制操作数栈顶元素 */
4: dup
/** * 5: 将操作数栈顶元素存储到局部变量 1 中 */
5: astore_1
/** * 6: 进入同步块,获取对象的监视器锁 */
6: monitorenter
/** * 7: 将局部变量 1 所引用的对象压入操作数栈 */
7: aload_1
/** * 8: 退出同步块,释放对象的监视器锁 */
8: monitorexit
/** * 9: 跳转到偏移量为 17 的指令 */
9: goto 17
/** * 12: 将异常对象引用存储到局部变量 2 中 */
12: astore_2
/** * 13: 将局部变量 1 所引用的对象压入操作数栈 */
13: aload_1
/** * 14: 退出同步块,释放对象的监视器锁 */
14: monitorexit
/** * 15: 将局部变量 2 所引用的异常对象压入操作数栈 */
15: aload_2
/** * 16: 抛出操作数栈顶的异常 */
16: athrow
/** * 17: 方法返回 */
17: return
这里我给test2
方法增加了注释,可以观察到到加了synchronized后会多出来monitorenter
,monitorexit
两个指令,后面我们分析源码时可以着重看一下monitor
是如何实现的锁。
锁优化
在JDK1.5的时候,Doug Lee推出了JUC包,在这里实现了ReentrantLock,并且它的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。例如:
锁消除:在synchronized修饰的代码中,如果不存在操作共享变量的情况,会触发锁消除,即便写了synchronized,也不会触发。如:
public int test(){
synchronized (o){
int i = 0;
return ++i;
}
}
锁膨胀:如果在一个方法中,频繁的获取和释放锁资源,就会优化为将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。如:
public void test1() {
for (int i = 0; i < 100000; i++) {
synchronized (o) {
}
}
}
锁升级:在synchronized
在1.6版本之前,获取不到锁时,会立即挂起当前线程,后续释放锁时再唤醒竞争线程进行锁的竞争。而到了1.6版本时,对synchronized
做了锁升级的优化;
无锁、匿名偏向:当前对象没有作为锁存在。 偏向锁:如果当前锁资源,只有一个线程在使用,那么这个线程过来,只需要判断,该对象的threadId指向的是否为当前线程。
- 如果是,直接获取锁;
- 如果不是,基于CAS的方式,尝试通过cas将threadId指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)
轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁)
- 如果cas成功则获取锁;
- 如果自旋了一定次数,没获取到锁,锁升级。
重量级锁:就是最传统的synchronized方式,获取不到锁资源,就挂起当前线程,等待其他线程释放锁并唤起该线程进行锁资源竞争。
观察锁升级
为了更直观的观察到锁的升级过程,首先我在下面我贴了一张百度过来的锁资源对象头中markdown在各个状态下各个字节含义的示意图,大家对比着看一下。
其次在代码中引用了
jol-core
,它可以在代码层面打印出对象头信息,接下来我会举一些例子让大家能更直观的看到锁的升级。
implementation 'org.openjdk.jol:jol-core:0.9'
无锁
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
上面代码就是打印对象o的实例信息,打印结果如下:
可以看到我用红色框框圈出来的就是锁的标识位001为无锁状态
偏向锁
public static void main(String[] args) {
Object o = new Object();
new Thread(() -> {
synchronized (o) {
//t1 - 偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
上面代码就是用一个线程获取锁资源,此时该锁资源就会偏向到该线程,再看一下打印结果:
这两张图的结果都是正确的,不同的jdk版本会出现不同的结果,如果你本地执行发现是
000
轻量级锁的标识不用惊讶,这是因为你使用的jdk版本默认开启了锁的偏向延迟。我本地测试使用open jdk8是第一种结果,在代码执行前加上Thread.sleep(5000L);
会变成第二种结果,使用zulu jdk11、jdk17、jdk21都是都是000
,他们默认都不开启偏向锁。
偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才会让偏向锁撤销,在知道有并发时,就可以选择不开启偏向锁,或者是设置偏向锁延迟开启。
因为JVM在启动时,需要加载大量的.class文件到内存中,这个操作会涉及到synchronized的使用,为了避免出现偏向锁撤销操作,JVM启动初期,有一个延迟4s开启偏向锁的操作
命令参数如下:
-XX:+/-UseBiasedLocking 启动/禁用偏向锁,默认虚拟机启动4秒后启动偏向锁 -XX:BiasedLockingStartupDelay 虚拟机启动后,立刻启动偏向锁
轻量级锁
这里不就举例了,上个例子中不开启偏向锁会直接变更为轻量级锁的。
重量级锁
大家还记得上面在看字节码时有一个Monitor
对象,我们就来看一下这个对象中有什么内容。
ObjectMonitor() {
// 头节点,存储着 MarkWord
_header = NULL;
// 竞争锁的线程个数
_count = 0;
// 等待(wait)的线程个数
_waiters = 0;
// 标识当前 synchronized 锁重入的次数
_recursions = 0;
// 关联的对象
_object = NULL;
// 持有锁的线程
_owner = NULL;
// 保存等待(wait)线程的信息,双向链表
_WaitSet = NULL;
// WaitSet 的锁
_WaitSetLock = 0 ;
// 负责的线程(可能用于特定的职责标识)
_Responsible = NULL ;
// 后继节点(可能用于链表结构)
_succ = NULL ;
// 获取锁资源失败后,线程要放到当前的单向链表中
_cxq = NULL ;
// 下一个空闲节点(可能用于链表管理)
FreeNext = NULL ;
// 等待获取锁的线程队列(_cxq 以及被唤醒的 WaitSet 中的线程,在一定机制下,会放到这里)
_EntryList = NULL ;
// 自旋频率
_SpinFreq = 0 ;
// 自旋时钟
_SpinClock = 0 ;
// 是否为线程所有者的标识
OwnerIsThread = 0 ;
// 前一个所有者线程的线程 ID
_previous_owner_tid = 0;
}
总结
在jdk1.6以后在低并发时使用synchronized
的性能是高于lock
的,因为它是在JVM层面实现的,而且不需要显式地获取和释放锁。但是在高并发下synchronized
是不如lock
,例如lock支持读写锁分离、线程挂起时插入到链表中时间复杂度为O(1)等。
除此之外lock
还支持更多使用场景,例如:公平锁和非公平锁、尝试获取锁等。具体选择哪个还是要根据具体的应用场景和需求来选择,大家平时都使用哪个锁呢?
转载自:https://juejin.cn/post/7404778537742336039