面试官:说一下Synchronized底层实现,锁升级的具体过程?
介紹
這是我去年7,8月份面試的時候被問的一個面試題,說實話被問到這個問題還是很意外的,感覺這個東西沒啥用啊,直到后面被問了一波new Object,Integer對象等作為加鎖對象行嗎?會出現哪些問題?為啥java6后synchronized性能大幅上升?我徹底蒙蔽了。下面詳細總結一下
synchronized使用方式
我們知道并發編程會產生各種問題的源頭是可見性,原子性,有序性。
而synchronized能同時保證可見性,原子性,有序性。所以我們在解決并發問題的時候經常用synchronized,當然還有很多其他工具,如volatile。但是volatile只能保證可見性,有序性,不能保證原子性,參見我之前的文章
面試官:volatile關鍵字用過吧?說一下作用和實現吧
synchronized可以用在如下地方
修飾實例方法
public class SynchronizedDemo {public synchronized void methodOne() {} }修飾靜態方法
public class SynchronizedDemo {public static synchronized void methodTwo() {} }修飾代碼塊
public class SynchronizedDemo {public void methodThree() {// 對當前實例對象this加鎖synchronized (this) {}}public void methodFour() {// 對class對象加鎖synchronized (SynchronizedDemo.class) {}} }synchronized實現原理
Java對象組成
我們都知道對象是放在堆內存中的,對象大致可以分為三個部分,分別是對象頭,實例變量和填充字節
- 對象頭,主要包括兩部分1. Mark Word (標記字段),2.Klass Pointer(類型指針)。Klass Point 是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例(即指向方法區類的模版信息)。Mark Word用于存儲對象自身的運行時數據
- 實例變量,存放類的屬性數據信息,包括父類的屬性信息,這部分內存按4字節對齊
- 填充數據,由于虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是為了字節對齊
假如有如下的類,a=100這個信息就存儲在實例變量中
public class Test {int a = 100; }填充數據主要是為了方便內存管理,如你想要10字節的內存,但是會給你分配16字節的內存,多出來的字節就是填充數據
synchronized不論是修飾方法還是代碼塊,都是通過持有修飾對象的鎖來實現同步,那么synchronized鎖對象是存在哪里的呢?答案是存在鎖對象的對象頭Mark Word,來看一下Mark Word存儲了哪些內容?
由于對象頭的信息是與對象自身定義的數據沒有關系的額外存儲成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態復用自己的存儲空間,也就是說,Mark Word會隨著程序的運行發生變化,變化狀態如下 (32位虛擬機):
其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化后新增加的,稍后我們會簡要分析。這里我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖標識位為10,其中指針指向的是monitor對象(也稱為管程或監視器鎖)的起始地址。每個對象都存在著一個 monitor 與之關聯。在Java虛擬機(HotSpot)中,monitor是由ObjectMonitor實現的,其主要數據結構如下(位于HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的),省略部分屬性
結合線程狀態解釋一下執行過程。(狀態裝換參考自《深入理解Java虛擬機》)
對于一個synchronized修飾的方法(代碼塊)來說:
由此看來,monitor對象存在于每個Java對象的對象頭中(存儲的是指針),synchronized鎖便是通過這種方式獲取鎖的,也是為什么Java中任意對象可以作為鎖的原因,同時也是notify/notifyAll/wait等方法存在于頂級對象Object中的原因
synchronized如何獲取monitor對象?
那么synchronized是通過什么方式來獲取monitor對象的?
synchronized修飾代碼塊
public class SyncCodeBlock {public int count = 0;public void addOne() {synchronized (this) {count++;}} } javac SyncCodeBlock.java javap -v SyncCodeBlock.class反編譯的字節碼如下
public void addOne();descriptor: ()Vflags: ACC_PUBLICCode:stack=3, locals=3, args_size=10: aload_01: dup2: astore_13: monitorenter // 進入同步方法4: aload_05: dup6: getfield #2 // Field count:I9: iconst_110: iadd11: putfield #2 // Field count:I14: aload_115: monitorexit // 退出同步方法16: goto 2419: astore_220: aload_121: monitorexit // 退出同步方法22: aload_223: athrow24: returnException table:可以看到進入同步代碼塊,執行monitorenter指令,退出同步代碼塊,執行monitorexit指令,可以看到有2個monitorexit指令,第一個是正常退出執行的,第二個是當異常發生時執行的
synchronized修飾方法
public class SyncMethod {public int count = 0;public synchronized void addOne() {count++;} }反編譯的字節碼如下
public synchronized void addOne();descriptor: ()V// 方法標識ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法flags: ACC_PUBLIC, ACC_SYNCHRONIZEDCode:stack=3, locals=1, args_size=10: aload_01: dup2: getfield #2 // Field count:I5: iconst_16: iadd7: putfield #2 // Field count:I10: returnLineNumberTable:我們并沒有看到monitorenter和monitorexit指令,那是怎么來實現同步的呢?
可以看到方法被標識為ACC_SYNCHRONIZED,表明這是一個同步方法
鎖的升級
在Java早期版本中,synchronized屬于重量級鎖,效率低下,因為操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。
慶幸的是在jdk1.6之后Java官方對從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,Jdk1.6之后,為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了偏向鎖和輕量級鎖,簡單介紹一下
jdk1.6之前的synchronized到底有多慢?
我們假設執行下面的代碼用的是jdk1.5,來看看會發生什么。
假設doSomeThing執行1000次,才有可能發生一次并發執行。但是每次都需要讓操作系統從用戶態轉換到核心態,太耗時了。
然后Doug Lea看不下去了(你用的并發包就是他寫的),寫了ReentrantLock類,效率比synchronized快多了,為了理解讓大家理解ReentrantLock到底快在哪?我仿造ReentrantLock寫一個實現
public class MyLock {private volatile int state;// 這里應該用并發安全的容器,這里只是舉例private List<Thread> threadList = new ArrayList<>();private static final Unsafe unsafe;private static final long stateOffset;static {try {Field field = Unsafe.class.getDeclaredField("theUnsafe");field.setAccessible(true);unsafe = (Unsafe) field.get(null);stateOffset = unsafe.objectFieldOffset(MyLock.class.getDeclaredField("state"));} catch (Exception ex) { throw new Error(ex); }}public void lock() {while (!compareAndSetState(0, 1)) {park();}}public void unLock() {while (compareAndSetState(1, 0)) {unPark();}}private void park() {threadList.add(Thread.currentThread());LockSupport.park(Thread.currentThread());}private void unPark() {if (!threadList.isEmpty()) {Thread thread = threadList.get(0);System.out.println(thread.getName());LockSupport.unpark(thread);}}private boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);} }可以看到在api層面就已經解決并發問題,加鎖沒有競爭的時候一個cas就搞定了,節省了大量時間
Doug Lea一個類的效率都比synchronized的效率高,估計synchronized的開發人員看了都不好意思了,于是對synchronized進行了一系列改造,即我們常說的鎖升級過程。
synchronized鎖有四種狀態,無鎖,偏向鎖,輕量級鎖,重量級鎖,這幾個狀態會隨著競爭狀態逐漸升級,鎖可以升級但不能降級,但是偏向鎖狀態可以被重置為無鎖狀態
偏向鎖
為什么要引入偏向鎖?
因為經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競爭的,常常是一個線程多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,為了降低獲取鎖的代價,才引入的偏向鎖。
偏向鎖原理和升級過程
當線程1訪問代碼塊并獲取鎖對象時,會在java對象頭和棧幀中記錄偏向的鎖的threadID,因為偏向鎖不會主動釋放鎖,因此以后線程1再次獲取鎖的時候,需要比較當前線程的threadID和Java對象頭中的threadID是否一致,如果一致(還是線程1獲取鎖對象),則無需使用CAS來加鎖、解鎖;如果不一致(其他線程,如線程2要競爭鎖對象,而偏向鎖不會主動釋放因此還是存儲的線程1的threadID),那么需要查看Java對象頭中記錄的線程1是否存活,如果沒有存活,那么鎖對象被重置為無鎖狀態,其它線程(線程2)可以競爭將其設置為偏向鎖;如果存活,那么立刻查找該線程(線程1)的棧幀信息,如果還是需要繼續持有這個鎖對象,那么暫停當前線程1,撤銷偏向鎖,升級為輕量級鎖,如果線程1 不再使用該鎖對象,那么將鎖對象狀態設為無鎖狀態,重新偏向新的線程。
輕量級鎖
為什么要引入輕量級鎖?
輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因為阻塞線程需要CPU從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就干脆不阻塞這個線程,讓它自旋這等待鎖釋放。
輕量級鎖原理和升級過程
線程1獲取輕量級鎖時會先把鎖對象的對象頭MarkWord復制一份到線程1的棧幀中創建的用于存儲鎖記錄的空間(稱為DisplacedMarkWord),然后使用CAS把對象頭中的內容替換為線程1存儲的鎖記錄(DisplacedMarkWord)的地址;
如果在線程1復制對象頭的同時(在線程1CAS之前),線程2也準備獲取鎖,復制了對象頭到線程2的鎖記錄空間中,但是在線程2CAS的時候,發現線程1已經把對象頭換了,線程2的CAS失敗,那么線程2就嘗試使用自旋鎖來等待線程1釋放鎖。 自旋鎖簡單來說就是讓線程2在循環中不斷CAS
但是如果自旋的時間太長也不行,因為自旋是要消耗CPU的,因此自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了線程1還沒有釋放鎖,或者線程1還在執行,線程2還在自旋等待,這時又有一個線程3過來競爭這個鎖對象,那么這個時候輕量級鎖就會膨脹為重量級鎖。重量級鎖把除了擁有鎖的線程都阻塞,防止CPU空轉。
幾種鎖的優缺點
用鎖的最佳實踐
錯誤的加鎖姿勢1
synchronized (new Object())每次調用創建的是不同的鎖,相當于無鎖
錯誤的加鎖姿勢2
private Integer count; synchronized (count)String,Boolean在實現了都用了享元模式,即值在一定范圍內,對象是同一個。所以看似是用了不同的對象,其實用的是同一個對象。會導致一個鎖被多個地方使用
Java常量池詳解,秒懂各種對象相等操作
正確的加鎖姿勢
// 普通對象鎖 private final Object lock = new Object(); // 靜態對象鎖 private static final Object lock = new Object();題外話
ConcurrentHashMap在jdk1.7的時候,實現用的是分段鎖,用ReentrantLock來保證并發安全。
而在jdk1.8的時候,拋棄了原有的分段鎖,而采用了 CAS + synchronized 來保證并發安全性,也可以說明synchronized的的效率現在確實很高了。
歡迎關注
參考博客
好文
[0]https://blog.csdn.net/tongdanping/article/details/79647337
[1]https://blog.csdn.net/javazejian/article/details/72828483
[2]https://www.cnblogs.com/grow001/p/12232708.html?utm_source=gold_browser_extension
[3]https://blog.csdn.net/chenssy/article/details/54883355
lock接口方法簡介
[4]https://zhuanlan.zhihu.com/p/38264728
condition的使用
[5]https://www.cnblogs.com/xrq730/p/4855155.html
線程狀態
[6]https://blog.csdn.net/xiaosheng900523/article/details/82964768
總結
以上是生活随笔為你收集整理的面试官:说一下Synchronized底层实现,锁升级的具体过程?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【蛮力算法】数据结构与算法
- 下一篇: 数字后端面试100问(校招版)