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

2023年 9月 28日 27.0k 0

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

老王:来了来了,小陈你准备好了吗?今天我们来讲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的了解更加深入了,在并发底层保证了什么,怎么做到了?相信你学完这一篇之后,功力就会更加不少咯。

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

目录

JAVA并发专题 《筑基篇》

1.什么是CPU多级缓存模型?

2.什么是JAVA内存模型?

3.线程安全之可见性、有序性、原子性是什么?

4.什么是MESI缓存一致性协议?怎么解决并发的可见性问题?

JAVA并发专题《练气篇》

5.volatile怎么保证可见性?

6.什么是内存屏障?具有什么作用?

7.volatile怎么通过内存屏障保证可见性和有序性?

8.volatile为啥不能保证原子性?

9.synchronized是个啥东西?应该怎么使用?

10.synchronized底层之monitor、对象头、Mark Word?

11.synchronized底层是怎么通过monitor进行加锁的?

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

13.synchronized怎么保证可见性、有序性、原子性?

JAVA并发专题《结丹篇》

  • JDK底层Unsafe类是个啥东西?
  • 15.unsafe类的CAS是怎么保证原子性的?

    16.Atomic原子类体系讲解

    17.AtomicInteger、AtomicBoolean的底层原理

    18.AtomicReference、AtomicStampReference底层原理

    19.Atomic中的LongAdder底层原理之分段锁机制

    20.Atmoic系列Strimped64分段锁底层实现源码剖析

    JAVA并发专题《金丹篇》

    21.AQS是个啥?为啥说它是JAVA并发工具基础框架?

    22.基于AQS的互斥锁底层源码深度剖析

    23.基于AQS的共享锁底层源码深度剖析

    24.ReentrantLock是怎么基于AQS实现独占锁的?

    25.ReentrantLock的Condition机制底层源码剖析

    26.CountDownLatch 门栓底层源码和实现机制深度剖析

    27.CyclicBarrier 栅栏底层源码和实现机制深度剖析

    28.Semaphore 信号量底层源码和实现机深度剖析

    29.ReentrantReadWriteLock 读写锁怎么表示?

  • ReentrantReadWriteLock 读写锁底层源码和机制深度剖析
  • JAVA并发专题《元神篇》并发数据结构篇

    31.CopyOnAarrayList 底层分析,怎么通过写时复制副本,提升并发性能?

    32.ConcurrentLinkedQueue 底层分析,CAS 无锁化操作提升并发性能?

    33.ConcurrentHashMap详解,底层怎么通过分段锁提升并发性能?

    34.LinkedBlockedQueue 阻塞队列怎么通过ReentrantLock和Condition实现?

    35.ArrayBlockedQueued 阻塞队列实现思路竟然和LinkedBlockedQueue一样?

    36.DelayQueue 底层源码剖析,延时队列怎么实现?

    37.SynchronousQueue底层原理解析

    JAVA并发专题《飞升篇》线程池底层深度剖析

  • 什么是线程池?看看JDK提供了哪些默认的线程池?底层竟然都是基于ThreadPoolExecutor的?
  • 39.ThreadPoolExecutor 构造函数有哪些参数?这些参数分别表示什么意思?

    40.内部有哪些变量,怎么表示线程池状态和线程数,看看道格.李大神是怎么设计的?

  • ThreadPoolExecutor execute执行流程?怎么进行任务提交的?addWorker方法干了啥?什么是workder?

  • ThreadPoolExecutor execute执行流程?何时将任务提交到阻塞队列? 阻塞队列满会发生什么?

  • ThreadPoolExecutor 中的Worker是如何执行提交到线程池的任务的?多余Worker怎么在超出空闲时间后被干掉的?

  • ThreadPoolExecutor shutdown、shutdownNow内部核心流程

  • 再回头看看为啥不推荐Executors提供几种线程池?

  • ThreadPoolExecutor线程池篇总结

  • 相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论