12.synchronized的锁重入、锁消除、锁升级原理?无锁、偏向锁、轻量级锁、自旋、重量级锁

简介: 12.synchronized的锁重入、锁消除、锁升级原理?无锁、偏向锁、轻量级锁、自旋、重量级锁

小陈:呼叫老王......

老王:来了来了,小陈你准备好了吗?今天我们来讲synchronized的锁重入、锁优化、和锁升级的原理

小陈:早就准备好了,我现在都等不及了

老王:那就好,那我们废话不多说,直接开始第一个话题:synchronized是怎么实现锁重入的?

synchronized的锁重入

小陈:老王,其实这个问题,之前我看了前几篇讲的,就知道了。

老王:哦,看来你是有准备的,那你来说说......

小陈:所谓锁重入,就是支持正在持有锁的线程支持再次获取锁,不会出现自己锁死自己的问题。

老王:嗯嗯,没错的

小陈:我打个比方,比如以下的代码:


synchronized(this) {
    synchronized(this){
        synchronized(this){
            synchronized(this){
                synchronized(this){         
                    ........                
                }            
            }        
        }    
    }
}

可能对应下面的指令:


monitorenter 
    monitorenter
        monitorenter
            monitorenter
                monitorenter
                ......
                monitorexit  
            monitorexit   
        monitorexit
    monitorexit
monitorexit

回顾之前讲的加锁就是将_count 由 0 设置为1,将_owner指向自己,这里的 _owner就是指向加锁的线程

(1)所以再次重入加锁的时候,发现有人加锁了,同时检查 _owner是不是自己加锁的如果是自己加锁的,只需要将_count 次数加1即可

image.png

(2)同样,在释放锁的时候执行monitorexit指令,首先将 _count进行减1,当 _count 减少到0的时候表示自己释放了锁,然后将_owner 指向null

小陈:所以,根据上诉锁重入的方式,代码进入了5次synchronized 相当于执行了5次monitorenter加锁,最后_count = 5。 当5次monitorexit执行完了之后,_count = 0即释放了锁

老王:很好,说得很详细,鼓掌鼓掌.......

锁消除

老王:小陈啊,锁重入这个你理解得很不错,锁消除这个你再来说说..

小陈:锁消除啊,这个也很简单,就是在不存在锁竞争的地方使用了synchronized,jvm会自动帮你优化掉,比如说下面的这段代码......


public void business() {
    // lock对象方法内部创建,线程私有的,根本不会引起竞争
    Object lock = new Object();
    synchronized(lock) {
         i++;
         j++;
         // 其它业务操作       
    }    
}

上面的这段代码,由于lock对象是线程私有的,多个线程不会共享;像这种情况多线程之间没有竞争,就没必要使用锁了,就有可能被JVM优化成以下的代码:


public void business() {
    i++;
    j++;
    // 其它业务操作
}

小陈:这就是我理解的锁消除,只有一个线程会用到,不会引起多个线程竞争的;相当于就自己用,没必要加锁了。

老王:嗯嗯,这个锁消除也理解的不错......

synchronized锁升级

老王:那synchronized的锁升级原理呢? 你来说说

小陈:额,这个锁升级其实我了解得不深,还比较模糊,还是老王您来讲吧......

老王:哈哈,好。讲解锁升级之前,我先问个问题,synchronized为什么要设计成可升级的锁呢?

小陈:这个,我理解的就是希望能尽量花费最小的代价能达到目的

老王:嗯嗯,是这个理由没错;但是你知道synchronized在什么锁的情况下花费什么代价吗?以及每次升级之后花费了什么代价吗?

小陈:额,这个......,不清楚

老王:在说这个之前,我先给你看一下前两章都讲解过Mark Word的图,我们再来回顾一下:

image.png

之前我们说过,Mark Word是一个32bit位的数据结构最后两位表示的是锁标志位,当Mark Word的锁标志位不同的时候,代表Mark Word 中记录的数据不一样。

(1)比如锁模式标志位是,也就是最后两位01的时候,表示处于无锁模式或者偏向锁模式

无锁:如果此时偏向锁标志,倒数第3位,是0,即最后3位是001,表示当前处于无锁模式,此时Mark Word就常规记录对象hashcode、GC年龄信息。

偏向锁:倒数第3位是1,即Mark word最后3位是101,则表示当前处于偏向锁模式,那么Mark Word就记录获取了偏向锁的线程ID、对象的GC年龄

(2)轻量级锁:当锁模式标志位是00的时候,表示当前处于轻量级锁模式,此时会生成一个轻量级的锁记录,存放在获取锁的线程栈空间中,Mark Word此时就存储这个锁记录的地址

Mark Word存储的地址在哪个线程的栈空间中,就表示哪个线程获取到了轻量级锁。

(3)重量级锁:当锁模式标志位是10的时候,表示当前处于重量级锁模式,此时加锁就不是Mark Word的责任了,需要找monitor锁监视器,这个上一章我们已经讲解monitor加锁的原理了

此时Mark Word就记录了一下monitor的地址,然后有线程找Mark Word的时候,Mark Word就把monitor地址给它,告诉线程自个根据这个地址找monitor进行加锁

老王:小陈啊,这个是我们前两章讲解过的内容,这些都还记得不?

小陈:嗯嗯,这些我都知道,老王你前面两章已经分析得非常细致了。

老王:那好,我就不继续啰嗦了,首先马上进入synchronized锁升级过程中,偏向锁的讲解。

偏向锁

如果上表格所示,当有线程第一次进入synchronized的同步代码块之内,发现:

image.png

Mark Word的最后三位是001,表示当前无锁状态,说明锁的这时候竞争不激烈啊。

于是选择代价最小的方式加了个偏向锁只在第一次获取偏向锁的时候执行CAS操作将自己的线程Id通过CAS操作设置到Mark Word中),同时将偏向锁标志位改为1

后面如果自己再获取锁的时候,每次检查一下发现自己之前加了偏向锁,就直接执行代码,就不需要再次加锁了......

老王:说到这里,你知道偏向锁的原理了没?

小陈:明白了,感情线程A这个家伙加锁的时候发现之前没人加过锁,所以这家伙很自私加了个偏向锁指向了自己,后面自己再进入synchronized的时候就不需要加锁了,嘿嘿,原来是这样啊

老王:没错,就是这样......

老王:加了偏向锁的人确实是个自私的人,这家伙用完了锁之后,自己加锁时候修改过的Mark Word信息都不会再改回来了也就是它不会主动释放锁

image.png

小陈:啊这...,这个哥们不释放锁,如果它用完了,别人这个时候需要进入synchronized代码块怎么办?

老王:你说的这个问题啊,其实JVM的设计者也考虑到了,这就涉及到一个重偏向的问题。

偏向锁之重偏向

老王:我给你举个例子说明一下重偏向咋回事:

线程B去申请加锁,发现是线程A加了偏向锁;这时候回去判断一下线程A是否存活,如果线程A挂了就可以重新偏向了重偏向也就是将自己的线程ID设置到Mark Word中

如果线程A没挂,但是synchronized代码块执行完了,这个时候也可以重新偏向了,将偏向标识指向自己,轮到我了,哈哈。

image.png

老王:小陈啊,这就回答了你的问题了,线程A用完了这家伙不把Mark Word标识改回来;没关系啊,线程B判断线程A没在synchronized同步代码块了,就执行重新偏向了

小陈:嗯嗯,老王你这么将我就明白了。

小陈:老王啊,我还有个问题,就是如果线程B在申请获取锁的时候,线程A这哥们还没执行完synchronized同步代码块怎么办?

老王:这个时候就有锁的竞争了,这就需要将锁升级一下了,线程B就会把锁升级为轻量级锁?

偏向锁为什么要升级为轻量级锁?

小陈:为啥啊,都使用偏向锁不行吗?不升级有什么坏处?

老王:下面给你讲原因,先给你看下如下代码块:


// 代码块1
synchronized(this){
  // 业务代码1  
}
// 代码块2
synchronized(this){
  // 业务代码2
}
// 代码块3
synchronized(this){
  // 业务代码3
}
// 代码块4
synchronized(this){
  // 业务代码4
}

假如这个时候有线程A、B、C、D四个线程,线程A先加了偏向锁。之前讲过偏向锁只是在第一次获取锁的时候加锁,后面都是直接操作的不需要加锁

这个时候其它几个线程B、C、D想要加锁,如果线程A连续执行上面4个代码块,那么其他线程看到线程A都在执行synchronized同步代码块,没完没了了,想重偏向都不行!! ,这个时候就需要等线程A执行完4个synchronized代码块之后才能获取锁啊,哈哈,别的线程都只能看线程A一个人自己在那表演了,这样代码就变成串行执行了。

小陈:原来是在这样啊...,也就是说如果不进行升级,就会存在这种问题,明白了。

老王:这下子多个线程竞争锁的时候为什么要升级明白了吧?

小陈:懂了懂......

老王:下面我们进入锁升级的第一个级别,轻量级锁,讲之前,先回顾之前将的一个知识点:

轻量级锁

轻量级锁模式下,加锁之前会创建一个锁记录,然后将Mark Word中的数据备份到锁记录中(Mark Word存储hashcode、GC年龄等很重要数据,不能丢失了),以便后续恢复Mark Word使用。

这个锁记录放在加锁线程的虚拟机栈中,加锁的过程就是将Mark Word 前面的30位指向锁记录地址。所以mark word的这个地址指向哪个线程的虚拟机栈中,就说明哪个线程获取了轻量级锁

小陈:嗯嗯,这个我了解,之前我们在前面的文章里面讨论过。

老王:好的,既然你了解我就放心了,记得如果不了解的话需要看一下之前讲过的文章哦......

老王:就好比下面的图,线程A获取了轻量级锁,锁记录存在线程A的虚拟机栈中,然后Mark Word的前面30位存储锁记录的地址。

image.png

老王:了解了轻量级加锁的原理之后,我们继续,来讲讲偏向锁升级为轻量级锁的过程:

(1)首先线程A持有偏向锁,然后正在执行synchronized块中的代码

(2)这个时候线程B来竞争锁,发现有人加了偏向锁并且正在执行synchronized块中的代码,为了避免上述说的线程A一直持有锁不释放的情况,需要对锁进行升级,升级为轻量级锁

(3)先将线程A暂停为线程A创建一个锁记录Lock Record,将Mark Word的数据复制到锁记录中;然后将锁记录放入线程A的虚拟机栈中

(4)然后将Mark Word中的前30位指向线程A中锁记录的地址,将线程A唤醒,线程A就知道自己持有了轻量级锁

image.png

老王:上面就是偏向锁升级为轻量级锁的过程,小陈你看明白了吗?

小陈:等等,我再消化一下,10分钟过后.....(再重新看一遍)

小陈:老王,你说的这个偏向锁升级轻量级锁的过程我看懂了...

小陈:老王啊,上面偏向锁升级为轻量级锁的过程和原理我了解了,那在轻量级锁模式下,多线程是怎么竞争锁和释放锁的?

老王:我再慢慢给你讲解下:

(1)线程A线程B同时竞争锁,在轻量级锁模式下,都会创建Lock Record锁记录放入自己的栈帧中

(2)同时执行CAS操作将Mark Word前30位设置为自己锁记录的地址谁设置成功了,锁就获取到锁

image.png

老王:上面讲了加锁的过程,轻量级锁的释放很简单,就将自己的Lock Record中的Mark Word备份的数据恢复回去即可,恢复的时候执行的是CAS操作将Mark Word数据恢复成加锁前的样子。

老王:这个轻量级锁的加锁和释放锁原理懂了没?

小陈:嗯嗯,清晰明了了,老王真棒......

老王:好了看看时间,差不多九点半了,讲完最后一个轻量级锁升级为重量级锁就差不多下班了

重量级锁的自旋

老王:小陈啊,你想想,在轻量级锁模式下获取锁失败的线程应该会怎么样?

小陈:获取锁失败的线程应该会再去尝试吧?或者直接沉睡等待别人释放锁的时候将它唤醒?

老王:你说的两种其实都有可能,但是你觉得哪种花销会更小一点?

小陈:我觉得线程沉睡花费代价更大吧,这涉及到上下文切换,操作系统层次涉及到用户态转内核态,是一个非常重的操作

老王:你说的没错,既然线程沉睡和唤醒代价这么大,所以肯定是不会让线程轻易就沉睡的;

比如说线程沉睡再唤醒最少需要3000ms的时间,如果某个线程只使用锁150ms的时间就释放了,如果直接采用沉睡方式的话,这个时候synchronized的性能就太差了。

所以啊JVM的设计者,设计了一种方案,获取锁失败之后的线程自己先原地等一段时间,然后再去重试获取锁,这种方式就叫做自旋

小陈:但是JVM怎么知道要等多久呢,加入持有锁的那个人一直不释放锁,其他人要一直自旋等待,然后不断重复尝试吗?这样不是非常消耗CPU的资源的吗?

老王:这里自旋多少次是有一个限制的,之前我们讲解monitor的底层原理的时候就讲解过了,如果忘记的话可以回去重新看一下。

monitor有一个 _spinFreq参数表示最大自旋的次数_spinClock参数表示自旋的间隔时间。所以自旋最多会重试_spinFreq次,每次失败之后等 _spinClock的时间过后再去重试,如果尝试_spinFreq次之后都没有成功,那没辙了,只能沉睡了。

老王:自旋其实是非常消耗CPU资源的,自旋期间相当于CPU啥也不干,就在那等着的。为了避免自旋时间太长,所以JVM就规定了默认最多自旋10次,10次还获取不到锁,那就直接将线程挂起了,线程就会直接阻塞等待了,这个时候性能就差了。

老王:小陈啊,关于这个重量级锁下的自旋过程,你清楚了没?

小陈:嗯嗯,非常了解了,老王牛逼......

总结

总的来说啊,JVM设计的这套synchronized锁升级的原则,主要是为了花费最小的代价能达到加锁的目的

比如在没有竞争的情况下,进入synchronized的使用使用偏向锁就够了,这样只需要第一次执行CAS操作获取锁,获取了偏向锁之后,后面每次进入synchronized同步代码块就不需要再次加锁了。

然后在存在多个线程竞争锁的时候就不能使用偏向锁了,不能只偏心一个人,它优先获取锁,别人都看它表演,这样是不行的。

于是就升级为轻量级锁,在轻量级锁模式在每次加锁和释放是都需要执行CAS操作,对比偏向锁来说性能低一点的,但是总体还是比较轻量级的。

为了尽量提升线程获取锁的机会,避免线程陷入获取锁失败就立即沉睡的局面(线程沉睡再唤醒涉及上下文切换,用户态内核态切换,是一个非常重的操作,很费时间),所以设计自旋等待;线程每次自旋一段时间之后再去重试获取锁。

当竞争非常激烈,并发很高,或者是synchronized代码块执行耗时比较长,就会积压大量的线程都在自旋,由于自旋是空耗费CPU资源的,也就是CPU在那等着,做不了其他事情,所以在尝试了最大的自旋次数之后;及时释放CPU资源,将线程挂起了

老王:总的来说synchronized升级的原理就是这样了?

小陈:嗯嗯,讲解的非常详细了,真棒......

老王:好了,本章的讲解就到这里了,synchronized也讲解的差不多了,下一章最后讲解一下synchronized保证并发安全的可见性、有序性、原子性是怎么做到的?

那么我们这个《练气篇》就差不多了,学完这一篇之后,你会发现对volatile、synchronized的了解更加深入了,在并发底层保证了什么,怎么做到了?相信你学完这一篇之后,功力就会更加不少咯。

小陈:好的,老王,我们下一章见。

相关文章
|
4月前
多线程并发锁的方案—互斥锁
多线程并发锁的方案—互斥锁
|
4月前
多线程并发锁方案—自旋锁
多线程并发锁方案—自旋锁
|
5月前
|
Java 编译器 程序员
synchronized 原理(锁升级、锁消除和锁粗化)
synchronized 原理(锁升级、锁消除和锁粗化)
|
10月前
|
存储 Java
一文打通锁升级(偏向锁,轻量级锁,重量级锁)
一文打通锁升级(偏向锁,轻量级锁,重量级锁)
|
10月前
|
存储 Java
重量级锁,偏向锁和轻量级锁
重量级锁,偏向锁和轻量级锁
76 0
|
12月前
|
存储 对象存储
锁消除、锁粗化、锁升级区别与联系
锁消除、锁粗化、锁升级区别与联系
锁消除、锁粗化、锁升级区别与联系
|
存储 Java
identityHashCode与偏向锁
我们知道在Java中,一切对象都继承自java.lang.Object类。这个类中有一个可继承的方法叫hashCode()。
267 0
|
安全 Java 调度
【Java 并发编程】线程锁机制 ( 锁的四种状态 | 无锁状态 | 偏向锁 | 轻量级锁 | 重量级锁 | 锁竞争 | 锁升级 )
【Java 并发编程】线程锁机制 ( 锁的四种状态 | 无锁状态 | 偏向锁 | 轻量级锁 | 重量级锁 | 锁竞争 | 锁升级 )
222 0
【Java 并发编程】线程锁机制 ( 锁的四种状态 | 无锁状态 | 偏向锁 | 轻量级锁 | 重量级锁 | 锁竞争 | 锁升级 )