synchronized的实现原理——锁膨胀过程

微信扫一扫,分享到朋友圈

synchronized的实现原理——锁膨胀过程

@

目录

前言

上一篇分析了优化后的synchronized在不同场景下对象头中的表现形式,还记得那个结论吗? 当一个线程第一次获取锁后再去拿锁就是偏向锁,如果有别的线程和当前线程交替执行就膨胀为轻量级锁,如果发生竞争就会膨胀为重量级锁 。这句话看起来很简单,但实际上synhronized的膨胀过程是非常复杂的,有许多场景和细节需要考虑,本篇就对其进行详细分析。

正文

先来看一个案例代码:

public class TestInflate {
static Thread t2;
static Thread t3;
static Thread t1;
static int loopFlag = 19;
public static void main(String[] args) throws InterruptedException {
//a 没有线程偏向---匿名    101偏向锁
List<A> list = new ArrayList<>();
t1 = new Thread() {
@Override
public void run() {
for (int i = 0; i < loopFlag; i++) {
A a = new A();
list.add(a);
synchronized (a) {
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintableTest(a));
}
}
log.debug("========t2=================");
LockSupport.unpark(t2);
}
};
t2 = new Thread() {
@Override
public void run() {
LockSupport.park();
for (int i = 0; i < loopFlag; i++) {
A a = list.get(i);
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
synchronized (a) {
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
}
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
}
log.debug("======t3=====================================");
LockSupport.unpark(t3);
}
};
t3 = new Thread() {
@Override
public void run() {
LockSupport.park();
for (int i = 0; i < loopFlag; i++) {
A a = list.get(i);
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
synchronized (a) {
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
}
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintable(a));
}
}
};
t1.start();
t2.start();
t3.start();
t3.join();
log.debug(ClassLayout.parseInstance(new A()).toPrintable());
}

这里创建了三个线程t1、t2、t3,在t1中创建了loopFlag个对象并依次加锁,然后放入到list中,t2等待t1执行完成后依次读取list中对象进行加锁并打印加锁前、加锁后、解锁后的对象头,t3和t2相同,只不过需要等待t2执行完才开始执行,最后等三个线程执行完成后再新建一个对象并打印对象头(注意运行该代码需要关闭偏向延迟-XX:BiasedLockingStartupDelay=0)。

偏向锁

偏向锁没什么好演示的,但是在源码中获取偏向锁是第一步,且逻辑比较多,有以下几点需要注意:

  • 是否已经超过偏向延迟指定的时间,若没有,则只能获取轻量锁
  • 是否允许偏向
  • 如果只有当前线程且是第一次则直接获取偏向锁(使用class对象中的mark word和线程id做”或”操作,得到一个新的header,并通过CAS替换锁对象头,替换成功则获取到偏向锁,否则进入锁升级的流程)
  • 是否调用了锁对象未重写的hashcode(对应源码中的Object#hash或System.identityHashCode()方法),hashcode会占用对象头的空间,导致无法偏向
  • 线程是否交替执行(即当前线程ID和对象头中的线程ID不一致),若是交替执行可能获取到偏向锁、轻量锁,细节下文详细讲述。

轻量锁

首先注释掉t3,先设置loopFlag=19运行t1和t2,你能猜到打印的对象头是什么样的么?(为节省篇幅,下文对象头都只截取最后8位展示)

15:57:38.579 [Thread-0] DEBUG cn.dark.ex6.TestInflate - 0 00000101
15:57:38.580 [Thread-0] DEBUG cn.dark.ex6.TestInflate - 1 00000101
......
15:57:38.582 [Thread-0] DEBUG cn.dark.ex6.TestInflate - 17 00000101
15:57:38.582 [Thread-0] DEBUG cn.dark.ex6.TestInflate - 18 00000101
15:57:38.582 [Thread-0] DEBUG cn.dark.ex6.TestInflate - ========t2=================
15:57:38.582 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 00000101
15:57:38.583 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 10000000
15:57:38.583 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 00000001
15:57:38.583 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 1 00000101
15:57:38.583 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 1 10000000
15:57:38.583 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 1 00000001
......
15:57:38.589 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 17 00000101
15:57:38.589 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 17 10000000
15:57:38.589 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 17 00000001
15:57:38.589 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 00000101
15:57:38.590 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 10000000
15:57:38.590 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 00000001
15:57:38.590 [Thread-1] DEBUG cn.dark.ex6.TestInflate - ======t3=====================================
15:57:38.590 [main] DEBUG cn.dark.ex6.TestInflate - cn.dark.entity.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)
8     4        (object header)                           2c 6a 01 f8 (00101100 01101010 00000001 11111000) (-134125012)
12     4        (loss due to the next object alignment)

t1线程不用想,肯定都是101,因为拿到的是偏向锁,但是t2就和我上一篇说的有点不一样了。t2加锁前的状态和t1解锁后是一样的,偏向锁解锁不会改变对象头,接着对其加锁,判断当前线程id和对象头中的线程id是否相同,由于不相同所以会做 偏向撤销 (即将状态修改为001无锁状态)并膨胀为 轻量锁 (实际上对象第一次加锁时,也有这个判断,接着会判断是不是 匿名偏向 ,即是不是可偏向模式且第一次加锁,是则直接获取偏向锁),状态改为00。

需要注意轻量锁加锁前会在当前线程栈帧中创建一个 无锁的Lock Record ,加锁时就会使用CAS操作判断当前对象头中的mark word是否和lr中的displaced word相等,由于都是001所以能加锁成功,之后轻量锁解锁只需要将lr中的dr恢复到当前对象头中(001),这样下一个线程才能对该对象再次加锁。需要注意虽然轻量锁解锁后对象头是001状态,但新建的对象依然是默认的101可偏向无锁状态,正如上面最后一次打印。

批量重偏向

上面创建的19个对象在膨胀为轻量锁的时候都会进行 偏向撤销 ,但是撤销是有性能损耗的,所以JVM设置了一个阈值,当撤销达到20次的时候就会进行 批量重偏向 ,该阈值可通过-XX:BiasedLockingBulkRebiasThreshold=20修改。

将上面代码中的loopFlag改为大于19的数打印结果(后面都不再展示t1线程的打印结果):

16:52:02.005 [Thread-0] DEBUG cn.dark.ex6.TestInflate - ========t2=================
16:52:02.005 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 00000101
16:52:02.005 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 00110000
16:52:02.005 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 0 00000001
......
16:52:02.011 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 00110000
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 18 00000001
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 19 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 19 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 19 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 20 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 20 00000101
16:52:02.012 [Thread-1] DEBUG cn.dark.ex6.TestInflate - 20 00000101
16:54:45.035 [main] DEBUG cn.dark.ex6.TestInflate - cn.dark.entity.A object internals:
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0     4        (object header)                           05 01 00 00 (00000101 00000001 00000000 00000000) (261)
4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8     4        (object header)                           2c 6a 01 f8 (00101100 01101010 00000001 11111000) (-134125012)
12     4        (loss due to the next object alignment)

前面19个对象都需要进行撤销,当达到20时,所有的对象头都变成了101了,并且偏向当前线程t2(这里需要注意, 批量 指的是当前正被加锁的所有对象,还没有加锁的,即从第21个对象开始都是 逐个 重偏向;另外虽重偏向是先将锁对象设置为可偏向无锁模式101,再讲线程id设置进去),如果此时你打印完整的对象头出来还会发现 偏向时间戳标志 设置为了01,即代表过期进行了重偏向。需要注意,这时候新建的对象也是101状态,且是 重偏向

批量撤销

JVM还有一个参数-XX:BiasedLockingBulkRevokeThreshold=40用来控制 批量撤销 ,即默认当一个 累计撤销达到40次,那么新建的对象就直接是 无锁不可偏向 的,因为JVM认为这是代码存在了严重的问题。

将t3注释放开,并将loopFlag设置为50,观察结果:

17:15:46.640 [Thread-1] DEBUG cn.dark.ex6.TestInflate - ======t3=====================================
17:15:46.640 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 0 00000001
17:15:46.640 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 0 11100000
17:15:46.640 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 0 00000001
......
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 18 00000001
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 18 11100000
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 18 00000001
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 19 00000101
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 19 11100000
17:15:46.644 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 19 00000001
.......
17:15:46.650 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 39 00000101
17:15:46.650 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 39 11100000
17:15:46.651 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 39 00000001
......
17:15:46.652 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 49 00000101
17:15:46.652 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 49 11100000
17:15:46.653 [Thread-2] DEBUG cn.dark.ex6.TestInflate - 49 00000001
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8     4        (object header)                           2c 6a 01 f8 (00101100 01101010 00000001 11111000) (-134125012)
12     4        (loss due to the next object alignment)

t3线程前面20个对象都是从001加锁为轻量锁,所以不用进行撤销,而t2线程从第21个对象开始都是获取的偏向锁,所以,t3线程就需要从第21个对象开始撤销,当和其它所有线程对该类对象累计撤销了40次后新建的对象都不能再获取偏向锁(这里博主是直接设置的50个对象,读者可以设置40个对象来验证),不过在此之前已经获取偏向锁的对象还是要逐个撤销。

但是系统是长期运行的,可能批量重偏向之后很久才会累计撤销达到40次,比如一个月、一年甚至更久,这种情况下就没有必要进行批量撤销了,因此JVM提供了一个参数-XX:BiasedLockingDecayTime=25000,即默认距上一次批量重偏向超过25000ms后,计数器就会重置为0。下面是JVM关于这一点的源码:

// 当前时间
jlong cur_time = os::javaTimeMillis();
// 该类上一次批量撤销的时间
jlong last_bulk_revocation_time = k->last_biased_lock_bulk_revocation_time();
// 该类偏向锁撤销的次数
int revocation_count = k->biased_lock_revocation_count();
// BiasedLockingBulkRebiasThreshold是重偏向阈值(默认20),
// BiasedLockingBulkRevokeThreshold是批量撤销阈值(默认40),
// BiasedLockingDecayTime默认25000。
if ((revocation_count >= BiasedLockingBulkRebiasThreshold) &&
(revocation_count <  BiasedLockingBulkRevokeThreshold) &&
(last_bulk_revocation_time != 0) &&
(cur_time - last_bulk_revocation_time >= BiasedLockingDecayTime)) {
// 重置计数器
k->set_biased_lock_revocation_count(0);
revocation_count = 0;
}

具体案例很简单,读者们可以思考下怎么验证这个结论。

重量锁

由于synchronized是c++语言实现的,实现比较复杂,就不进行详细的源码分析了,下面只是对其实现原理的一个总结。另外重量锁的实现原理和ReentrantLock的思想是一样的,读者们可以对比理解。

当多个线程发生竞争的时候,synchronized就会膨胀为重量锁,这时会创建一个ObjectMoitor对象,这个对象包含了三个由ObjectWaiter对象组成的队列: cxqEntryListWaitSet ,以及两个字段 ownerRead Thread 。cxq和EntryList都是获取锁失败用来存储等待的线程的,WaitSet则是Java中调用wait方法进入阻塞的线程,owner指向当前获取锁的线程,而Read Thread则表示从cxq和EntryList中挑选出来去抢锁的线程,但由于是非公平锁,所以不一定能抢到锁。

在膨胀为重量锁的时候若没有获取到锁,不是立马就阻塞未获取到锁的线程,因其是 非公平锁 ,首先会去尝试加锁,不管前面是否有线程等待(如果是公平锁的话就会判断是否有线程等待,有的话则直接入队睡眠),如果加锁失败,synchronized还会采用自旋的方式去获取锁,JDK1.6之前是默认自旋10次后睡眠,而优化之后引入了 适应性自旋 ,即JVM会根据各种情况动态改变自旋次数:

  • 如果平均负载小于CPU则一直自旋
  • 如果有超过(CPU/2)个线程正在自旋,则后来线程直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节电模式则停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

你可能会比较好奇为什么不一直采用自旋,因为自旋是会消耗CPU的,适合并发数不多或自旋次数少的情形,否则不如直接调用系统函数进入睡眠状态。

所以当自旋没有获取到锁,则会将当前线程添加到cxq队列的队首(注意在入队后还会抢一次锁,这就是非公平锁的特点,尽可能的避免调用系统函数进入内核态阻塞)并调用park函数睡眠。

park函数是基于 pthread_mutex_lock 函数实现的,而Java中的LockSupport.park则是基于 pthread_cond_timedwait 函数,这两个都是 系统函数 ,更底层则是通过 futex 实现(注意此处都是基于Linux系统讨论,其它不同的操作系统有不同的实现方式),这里就不展开讨论了。

需要注意线程一旦进入队列后,执行的顺序就是固定了,因为在当前持有锁的线程释放锁后,会从队列中唤醒 最后入队 的线程,即 一朝排队,永远排队 ,所以 公平锁非公平锁 的区别就体现在入队前是否抢锁(排除有新的线程来抢锁的情况)。

所谓唤醒最后入队的线程,其实就类似于栈, 先睡眠的线程后唤醒 ,这点和ReentratLock是相反的,下面给出证明:

public class Demo2 {
private static Demo2 lock = new Demo2();
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
synchronized (lock) {
log.info(Thread.currentThread().getName());
}
});
}
synchronized (lock) {
for (Thread thread : threads) {
thread.start();
// 睡眠一下保证线程的启动顺序
Thread.sleep(100);
}
}
}
}

上面程序创建了10个线程,然后主线程拿到锁后依次启动10个线程,这10个线程内又会分别去获取锁,因为被主线程占有,就会膨胀为重量锁进入阻塞,最终打印结果如下:

16:25:49.877 [Thread-9] INFO  cn.dark.mydemo.sync.Demo2 - Thread-9
16:25:49.879 [Thread-8] INFO  cn.dark.mydemo.sync.Demo2 - Thread-8
16:25:49.879 [Thread-7] INFO  cn.dark.mydemo.sync.Demo2 - Thread-7
16:25:49.879 [Thread-6] INFO  cn.dark.mydemo.sync.Demo2 - Thread-6
16:25:49.879 [Thread-5] INFO  cn.dark.mydemo.sync.Demo2 - Thread-5
16:25:49.879 [Thread-4] INFO  cn.dark.mydemo.sync.Demo2 - Thread-4
16:25:49.879 [Thread-3] INFO  cn.dark.mydemo.sync.Demo2 - Thread-3
16:25:49.879 [Thread-2] INFO  cn.dark.mydemo.sync.Demo2 - Thread-2
16:25:49.879 [Thread-1] INFO  cn.dark.mydemo.sync.Demo2 - Thread-1
16:25:49.879 [Thread-0] INFO  cn.dark.mydemo.sync.Demo2 - Thread-0

可以看到10个线程并不是按照启动顺序执行的,而是以相反的顺序被唤醒并执行。

以上就是Synchronized的膨胀过程以及底层的一些实现原理,最后我画了一张synchronized锁膨胀过程的图帮助理解,有不对的地方欢迎指出:

总结

通过两篇文章分析了synchronized的实现原理,可以看到要实现一把高性能的锁是相当复杂的,这也是为什么JDK1.6才对synchronized进行了优化(大概也是迫于ReentratLock的压力吧),优化过后性能基本上和ReentrantLock差不多,只不过后者使用上更加灵活,支持更多的高级特性,但思想上其实都是一样的(应该都是借鉴了futex的实现原理)。

深刻理解synchronized的膨胀过程,不仅仅用于应付面试,而是能够更好的使用它进行并发编程,比如何时加锁,何时使用无锁的自旋锁。另外在进行业务开发遇到类似场景时也可以借鉴其思想。

本篇文章参考了以下文章,最后在此表示感谢,让我少走了很多弯路,也了解了很多底层知识。

微信扫一扫,分享到朋友圈

synchronized的实现原理——锁膨胀过程

iPhone 12发布前夕 苹果在美手机销量正在放缓

上一篇

微信开发者工具集成GitHub,多人协调开发,上传拉取等

下一篇

你也可能喜欢

synchronized的实现原理——锁膨胀过程

长按储存图像,分享给朋友