关于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如下图,
我们看下图
线程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中的【轻量级锁定】位置的值
这样,Thread0记录了原来【轻量级锁定】位置的值,
而markword记录了Thread0中lock record的值,这个值就是 00
如果的自己线程进行重入操作,就会往Thread0里面压入新的锁记录,退出重入,就弹出一次
此时如果其他线程进来想用cas做交换,发现markword这里的值变成了00,他就知道他被人捷足先登,所以他会等待
此时这一等待,就表明有竞争,就需要锁升级,所以其实synchronized默认是执行上面的轻量锁,只有出现这种情况才会进行锁升级
锁升级后,Thread0再回来准备解锁,他会发现他放在【轻量级锁定】位置的00被修改了,证明进行了锁升级,他会开始进行重量级解锁流程
好,我们现在来看看如何锁升级
轻量级锁升级为重量级锁
加轻量锁时,可能CAS无法成功,有可能是因为其他线程已经加了轻量锁,而我们知道,轻量锁存在的意义是多线程且冲突少的情况下,现在冲突了,证明轻量锁可能无法胜任并发
此时需要进行锁升级,也可以叫做锁膨胀,变成重量级锁
步骤:
- Thread-A先对Object加轻量锁,然后Thread-B进入,发现已经加轻量锁,随即申请Monitor,修改markword为Monitor的地址,让Object指向重量锁地址,然后Thread-B进入阻塞队列
- Thread-A执行完任务,打算使用cas恢复markword中的值,由于markword已经被修改,无法恢复,判定进入重量级锁流程,通过地址找到Monitor,然后设置owner对象,唤醒Thread-B
阻塞是一种比较耗时的操作,经常会出现一个线程刚阻塞,然后另一个线程释放锁的情况,
java中设置了自旋操作,算是一种轻量的“阻塞”,即在本地进行循环
但是自旋毕竟算执行程序,所以会消耗cpu,在多核下进行自旋对cpu影响小
自旋的次数不定,假设上次自旋了几次成功执行,这次也可以自旋类似次数,假设上次自旋了很多次也没有能够执行,那么这次可能就不自旋
自旋需要设置一个次数阈值,超过了就设置进入阻塞队列
偏向锁
如果线程A已经加了轻量锁,此时没有其他线程竞争,但是他每次重入都需要CAS操作,会比较繁琐
这就好比,你的手机要打开,重量锁类似于密码,很安全,但是比较麻烦,轻量锁类似于指纹,安全性不高,但是快捷
但是如果每次用完都黑屏,重新打开需要按指纹,多多少少也有点麻烦
所以偏向锁类似于【屏幕常亮】,在第一次解锁成功后,即使你没有使用屏幕,我也一直保持亮着,线程ID会被设置到对象的MarkWord头,如果每次都是这个线程来获取,那么就不用CAS,但是如果有一次不是你,就要重新CAS,
所以markword非常重要,几乎所有与锁相关的东西都在其中了!还有GC年龄,分代用的,大家再看一遍,加深记忆
偏向锁的核心就在上图中的线程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位中,前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如果默认重量锁的话,会很影响性能,所以默认轻量锁,然后进行锁升级才是最好的选择
参考资料