synchonized只能回答加锁?深入解析,关于锁升级流程的各项细节

本文详细解析了synchronized的锁升级过程,包括轻量级锁、重量级锁、偏向锁策略,通过实例说明了锁升级的原因和流程,以及JDK优化背后的设计考量。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

关于synchronized,我相信你一定不会陌生,但是在java 6之后,jdk大幅修改了锁的量级,从原来的重量级锁变成可升级的轻量级锁,今天,我们就来看看吧

首先我们需要介绍一下synchronized的获取锁的本质是什么,看如下代码和字节码

public class Demo {
    private static int count;
    public static void main(String[] args) {
        Object object = new Object();
        synchronized (object) {
            count ++;
        }
    }
} 

字节码如下

 0 new #2 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init> : ()V>
 7 astore_1
 8 aload_1
 9 dup
10 astore_2
   //获取监视器,进入synchronized
11 monitorenter
12 getstatic #7 <Demo.count : I>
15 iconst_1
16 iadd
17 putstatic #7 <Demo.count : I>
20 aload_2
21 monitorexit
  // 释放监视器,释放锁
  
  //一旦抛出异常,执行下面的命令,避免死锁
22 goto 30 (+8)
25 astore_3
26 aload_2
27 monitorexit
28 aload_3
29 athrow
30 return

synchronized获取锁的本质,就是获取monitor,monitor并不存在于锁中,而是向jvm申请而来,这个monitor的地址放在obj的markword中,也就是说,每一个作为锁的object都对应一个Monitor。

obj的markword如下图, img

我们看下图

多个线程试图获取监视器

线程1使用synchronized拿到obj的锁,此时obj 的markWord指向Monitor地址,然后,如果thread2,thread3进入发现obj的markWord已经有地址,,就会跟沿着地址找到Monitor,

发现owner指向Thread1,证明线程1持有了这个锁

他们进入Monitor的等待队列中进行阻塞,当Thread1释放锁后,通知到他们,他们开始抢锁,抢到的线程就就修改Owner指向自己即可

锁升级

线程获取锁流程

上面的描述是一种简单的说法,我们有一个问题没有回答,那就是线程是如何抢锁的?要抢锁,就是要在object中留下自己的痕迹

上面的图中 Thread和obj并没有直接联系,但是实际上,Thread1也保留了object的信息

看下图(图来自黑马教程)

线程获取锁

我们说:Thread0获取到object的的锁,这句话的本质是什么?

就是Thread0中保存一个栈帧,名为Lock Record,其内部有一个lock record 地址记录object地址,也就是指向他

然后尝试用cas交换(注意,是交换)object中的mark word中的【轻量级锁定】位置的值

markword存储

这样,Thread0记录了原来【轻量级锁定】位置的值,

而markword记录了Thread0中lock record的值,这个值就是 00

如果的自己线程进行重入操作,就会往Thread0里面压入新的锁记录,退出重入,就弹出一次

重入获取锁

此时如果其他线程进来想用cas做交换,发现markword这里的值变成了00,他就知道他被人捷足先登,所以他会等待

此时这一等待,就表明有竞争,就需要锁升级,所以其实synchronized默认是执行上面的轻量锁,只有出现这种情况才会进行锁升级

锁升级后,Thread0再回来准备解锁,他会发现他放在【轻量级锁定】位置的00被修改了,证明进行了锁升级,他会开始进行重量级解锁流程

好,我们现在来看看如何锁升级

轻量级锁升级为重量级锁

加轻量锁时,可能CAS无法成功,有可能是因为其他线程已经加了轻量锁,而我们知道,轻量锁存在的意义是多线程且冲突少的情况下,现在冲突了,证明轻量锁可能无法胜任并发

此时需要进行锁升级,也可以叫做锁膨胀,变成重量级锁

步骤:

  1. Thread-A先对Object加轻量锁,然后Thread-B进入,发现已经加轻量锁,随即申请Monitor,修改markword为Monitor的地址,让Object指向重量锁地址,然后Thread-B进入阻塞队列
  2. Thread-A执行完任务,打算使用cas恢复markword中的值,由于markword已经被修改,无法恢复,判定进入重量级锁流程,通过地址找到Monitor,然后设置owner对象,唤醒Thread-B

阻塞是一种比较耗时的操作,经常会出现一个线程刚阻塞,然后另一个线程释放锁的情况,

java中设置了自旋操作,算是一种轻量的“阻塞”,即在本地进行循环

在线程1解锁前,线程2一直自旋

但是自旋毕竟算执行程序,所以会消耗cpu,在多核下进行自旋对cpu影响小

自旋的次数不定,假设上次自旋了几次成功执行,这次也可以自旋类似次数,假设上次自旋了很多次也没有能够执行,那么这次可能就不自旋

自旋需要设置一个次数阈值,超过了就设置进入阻塞队列

偏向锁

如果线程A已经加了轻量锁,此时没有其他线程竞争,但是他每次重入都需要CAS操作,会比较繁琐

这就好比,你的手机要打开,重量锁类似于密码,很安全,但是比较麻烦,轻量锁类似于指纹,安全性不高,但是快捷

但是如果每次用完都黑屏,重新打开需要按指纹,多多少少也有点麻烦

所以偏向锁类似于【屏幕常亮】,在第一次解锁成功后,即使你没有使用屏幕,我也一直保持亮着,线程ID会被设置到对象的MarkWord头,如果每次都是这个线程来获取,那么就不用CAS,但是如果有一次不是你,就要重新CAS,

所以markword非常重要,几乎所有与锁相关的东西都在其中了!还有GC年龄,分代用的,大家再看一遍,加深记忆

再看一遍markword

偏向锁的核心就在上图中的线程ID中,占位23bit,线程ID是谁,锁就偏向锁,当然前提是偏向模式设置为1了,最后一位是标致所以,当mark word的最后三位是101时,表示偏向状态,001表示非偏向状态

策略

锁偏向线程A后,A还在用,如果此时有线程B过来获取锁,证明还是有轻微线程竞争的,所以此时升级为轻量锁

如果A已经释放锁了,线程B来获取锁,此时没有线程竞争,jvm只是将偏向锁的线程ID修改为线程B,避免轻易升级

但是,

如果A,B线程交替使用这个锁,导致每次线程ID都要改,反而效率下降了,而如果一堆线程交替使用,浪费更大,这是一个很严重的问题

**当修改超过20次后,此时我们就需要撤销这一偏向,如果是多个线程,就批量重新偏向,**这里理解难度比较大,我用一个例子来表达

案例

假设有两个线程,线程1先创建30个对象,然后start,打印当前markword,

然后线程2拿到这30个对象,打印当前markword,

为了保证串行,我们需要用synchronized来保证线程1先执行完,才到线程2

首先看线程1的打印

[t1] - 0 00000000 ... 00011111 11110011 11100000 00000101 
[t1] - 1 00000000 ... 00011111 11110011 11100000 00000101 
...
[t1] - 28 00000000...  00011111 11110011 11100000 00000101 
[t1] - 29 00000000...  00011111 11110011 11100000 00000101

我们来解读下每一行,如图

32位含义

最后32位中,前23bit是线程id,最后3位表示偏向模式与标志位

我们来看看线程2的打印,这里我们打印三个数,就是线程2拿到锁前,拿到锁时,和释放锁后的打印,显然,拿到锁前肯定是101,就是线程1所加的偏向锁

																							//注意最后3位
[t2] - 0 00000000  ... 00011111 11110011 11100000 00000101 
[t2] - 0 00000000  ... 00100000 01011000 11110111 00000000 
[t2] - 0 00000000  ... 00000000 00000000 00000000 00000001 
 
... 
[t2] - 18 00000000 ...  00011111 11110011 11100000 00000101 
[t2] - 18 00000000 ...  00100000 01011000 11110111 00000000 
[t2] - 18 00000000 ...  00000000 00000000 00000000 00000001 
// 0~18号的最后三位分别都是 101, 000,001
// 到了19号以后都变成了101,						注意此时的线程id已经变了!
[t2] - 19 00000000 ... 00011111 11110011 11100000 00000101 
[t2] - 19 00000000 ... 00011111 11110011 11110001 00000101 
[t2] - 19 00000000 ... 00011111 11110011 11110001 00000101 
  
[t2] - 20 00000000 ... 00011111 11110011 11100000 00000101 
[t2] - 20 00000000 ... 00011111 11110011 11110001 00000101 
[t2] - 20 00000000 ... 00011111 11110011 11110001 00000101
 ...

可以看到,0~18号时,最后三位,与线程id

  • 拿到锁前是101,00011111 11110011 1110000表示偏向线程1
  • 拿到锁时是000,表示已经升级为轻量级锁
  • 释放锁后是001,表示撤销偏向,当前所不可偏向

到了19号,

  • 线程id变为00011111 11110011 1110000 ,这是线程2的id
  • 最后三位变为101

表示偏向线程2,同时后面所有的锁都偏向了线程2,这叫批量重偏向

撤销了偏向锁,那么后面的就是升级重量锁啦,直接串行

最后

本文我们通过深入线程栈帧与markword结构,了解了synchronized在线程竞争从弱到强时,进行锁升级的整个流程。因为jdk项目组发现,大部分情况下,线程竞争很少,使用的synchronized如果默认重量锁的话,会很影响性能,所以默认轻量锁,然后进行锁升级才是最好的选择

参考资料

黑马程序员全面深入学习Java并发编程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值