java中synchronized的四種鎖狀態


簡介:

​ 可能在很多人眼里,在java中提到鎖、安全性、同步,首先想到的則是java提供的大佬(synchronized)。那么為什么在多線程下,單單靠一個關鍵字修飾代碼塊就可以實現所謂的安全性呢?可以說是對初學者而言及神奇又強大的存在。也成了大多數初學者百試不爽的良葯。

​ 但是在逐漸對java認知的深入,我們認識到synchronized對於jvm來說是一個重量級的鎖。其笨重無比,在如今人們對速度和性能極致要求的現在,現在此時並不能滿足性能上的要求。

​ 誠然SUN公司也認識到了這一點,在Java SE 1.6對synchronized進行了各種優化后,有些情況下它就並不那么笨重🐖了。在Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的性能開銷而引入偏向鎖和輕量級鎖。


Synchonized實現同步的基礎

​ Java中每一個對象都可以作為鎖。具體有如下三種形式:

  • 對於普通同步方法,鎖是當前實例對象。

  • 對於靜態同步方法,鎖是當前類的Class對象。

  • 對於同步代碼塊,鎖是synchronized括號里配置的對象。


那么線程是怎么獲取上述各種鎖對象的呢?

先看一段簡單的三種同步方式

public class SynchronizedTest { /** * 同步修飾普通方法 */ public synchronized void test01() { // 同步修飾代碼塊 synchronized (this) { System.out.println("hello synchronized"); } } /** * 同步修飾靜態方法 */ public synchronized static void test02() { } } 

使用javap 查看生成的class 文件
javap -verbose ***.class

 
class文件監視器

JVM會在monitorenter監視器入口處獲取鎖,然后執行完對應操作后,在monitorexit監視器出口釋放鎖。在class文件中synchronizedACC_SYNCHRONIZED標記,表明該方法為同步方法。


​ 從JVM規范中可以看到Synchronized在JVM里的實現原理,JVM基於進入和推出monitor對象來實現方法同步和代碼塊同步的,但兩者的實現細節不一樣。

代碼塊同步: 是使用monitorentermonitorexit指令實現的。而方法同步是使用另外一種方式實現的,細節在JVM規范里並沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現。

靜態同步方法: 使用javap 可以看出synchronized被編譯為普通的命令invokevirtualareturn字節碼指令。在JVM層面並沒有任何特別的指令來實現被synchronized修飾的方法,而是在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1,表示該方法是同步方法並使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示Klass作為鎖對象。(引用(詳細介紹了1.6后鎖的各種優化)

 
靜態同步方法

 


monitorenter指令

monitorenter 指令時在編譯后插入到同步代碼塊的開始位置的。而monitorexit是插入到方法的結束處和異常處,JVM要保證每個monitorenter必須都有對應的monitorexit與之對應。任何對象都有一個monitor對象與之關聯,並且一個monitor被持有后,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor所有權,即嘗試獲取對象的鎖。


Java對象頭

synchronized用的鎖是存在Java對象頭里的。所以這里對Java對象頭做詳細介紹。

對象的內存布局

​ 在HotSpot虛擬機中,對象在內存中存儲的布局可以分為3塊區域:

  • 對象頭(Header)

  • 實例數據(Instance Data)

  • 對齊填充(Padding)

    HotSpot虛擬機的對象頭包括兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode,更精確的叫Identity HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機(未開啟壓縮指針)中分別位32bit和64bit,官方稱它位“Mark Word” (標記字段)。對象需要存儲的運行時數據很多,其實已經超出了定義的位數。

    Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲盡量多的信息,它會根據對象的狀態復用自己的存儲空間。例如在32位HotSpot虛擬機中,如果對象處於被鎖定狀態下,那么Mark Word的32bit空間中的25bit用於存儲對象哈希碼,4bit用於存儲對象分代年齡,2bit用於存儲鎖標志位,1bit固定位0,而在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容見表

    存儲內容 標志位 狀態
    對象哈希碼、對象分代年齡 01 未鎖定
    指向鎖記錄的指針 00 輕量級鎖定或者叫自旋鎖
    指向重量級鎖的指針 10 膨脹(重量級鎖定)
    空,不需要記錄信息 11 GC標記,准備垃圾回收,CMS回收器用到
    偏向線程ID、偏向時間戳、對象分代年齡 01 可偏向,第三位數字是偏向鎖標識flag=1

    ​ 對象頭的另外一部分是類型指針(Klass Pointer)。即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身,此外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數據長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的原數組中卻無法確定數組的大小。

    ​ 在運行期間,Mark Word標記字段里存儲的數據會隨着鎖的標志位的變化而變化

 
Mark Word的狀態變化

無鎖狀態下Mark Word: 對象的hashCode+對象分代年齡+(是否位偏向鎖)0+(所標志位)01


Monitor Record

Monitor 從字面意義上理解為監控、監視的意思。在Java中可以把它看作為一個同步工具,相當於操作系統中的互斥量,即值為1的信號量。它內置與每一個對象。在java世界里,每一個對象天生都擁有一把內置鎖(Monitor)。這相當於一個許可證,只有你拿到許可證之后才可以進行操作,沒有拿到則需要進行阻塞等待。

Monitor Record從字面意義上理解為:監視器記錄。Monitor Record是線程私有的數據結構,每一個線程都有一個可用Monitor Record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor record關聯(對象頭的MarkWord中的LockWord指向monitor record的起始地址),同時monitor record中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程占用。如下圖所示為Monitor Record的內部結構

Monitor Record  
Owner 初始時為NULL表示當前沒有任何線程擁有該Monitor Record當線程成功擁有該鎖后,記錄該線程ID作為唯一標識,當鎖被釋放時又設置成NULL
EntryQ 關聯一個系統互斥鎖(semaphore信號量),阻塞所有試圖鎖住Monitor Recoed失敗的線程
RcThis 表示blocked或者waiting在該Monitor Record上所有的線程的個數
Nest 用來實現重入鎖的計數
HashCode 保存從對象頭拷貝過來的HashCode值(可能還包含GC分代年齡)
Candidate 用來避免不必要的阻塞或者等待線程喚醒,因為每一次只有一個線程能夠成功擁有鎖,那么當執行線程結束任務釋放鎖后,如果喚醒所有等待的線程,會造成不必要的上下文切換(影響性能,因為在所有喚醒的線程中,只有一個能夠真正的獲取到鎖,所以其他的線程在從阻塞到就緒到因為競爭鎖失敗又被阻塞,這中間都是一些不必要的資源浪費)。所以Candidate只提供了兩種可能,0表示當前沒有需要喚醒的線程。1表示在阻塞的線程中,喚醒一個繼任線程來競爭鎖

鎖優化

​ 高效並發是從JDK 1.5 到 JDK 1.6的一個重要改進,HotSpot虛擬機在這個版本上花費了大量精力去實現各種鎖優化技術,如:適應性自旋(Adaptive Spinning)、鎖消除(Lock Eliminate)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking等,這些技術都是為了在先咸亨之間更搞笑地共享數據,以及解決競爭問題,從而提高程序的執行效率。

鎖的類型

​ 在Java SE 1.6里Synchronied同步鎖,一共有四種狀態:無鎖、偏向鎖、輕量級所、重量級鎖,它會隨着競爭情況逐漸升級。鎖可以升級但是不可以降級,目的是為了提供獲取鎖和釋放鎖的效率。

鎖膨脹方向: 無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖 (此過程是不可逆的)

自旋鎖與自適應自旋鎖

自旋鎖

​ 引入背景:大家都知道,在沒有加入鎖優化時,大佬Synchronized時一個非常“胖大”的家伙。在多線程競爭鎖時,當一個線程獲取鎖時,它會阻塞所有正在競爭的線程,這樣對性能帶來了極大的影響。在掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作個i系統的並發性能帶來了很大的壓力。同時HotSpot團隊注意到在很多情況下,共享數據的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和回復阻塞線程並不值得。在如今多處理器環境下,完全可以讓另一個沒有獲取到鎖的線程在門外等待一會(自旋),但不放棄CPU的執行時間。等待持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只需要讓線程執行一個忙循環(自旋),這便是自旋鎖由來的原因。

​ 自旋鎖早在JDK1.4 中就引入了,只是當時默認時關閉的。在JDK 1.6后默認為開啟狀態。自旋鎖本質上與阻塞並不相同,先不考慮其對多處理器的要求,如果鎖占用的時間非常的短,那么自旋鎖的新能會非常的好,相反,其會帶來更多的性能開銷(因為在線程自旋時,始終會占用CPU的時間片,如果鎖占用的時間太長,那么自旋的線程會白白消耗掉CPU資源)。因此自旋等待的時間必須要有一定的限度,如果自選超過了限定的次數仍然沒有成功獲取到鎖,就應該使用傳統的方式去掛起線程了,在JDK定義中,自旋鎖默認的自旋次數為10次,用戶可以使用參數-XX:PreBlockSpin來更改。

​ 可是現在又出現了一個問題:如果線程鎖在線程自旋剛結束就釋放掉了鎖,那么是不是有點得不償失。所以這時候我們需要更加聰明的鎖來實現更加靈活的自旋。來提高並發的性能。(這里則需要自適應自旋鎖!)

自適應自旋鎖

​ 在JDK 1.6中引入了自適應自旋鎖。這就意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋 時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲取過鎖,並且持有鎖的線程正在運行中,那么JVM會認為該鎖自旋獲取到鎖的可能性很大,會自動增加等待時間。比如增加到100此循環。相反,如果對於某個鎖,自旋很少成功獲取鎖。那再以后要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,JVM對程序的鎖的狀態預測會越來越准備,JVM也會越來越聰明。

鎖消除

​ 鎖消除時指虛擬機即時編譯器再運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持。意思就是:JVM會判斷再一段程序中的同步明顯不會逃逸出去從而被其他線程訪問到,那JVM就把它們當作棧上數據對待,認為這些數據時線程獨有的,不需要加同步。此時就會進行鎖消除。

​ 當然在實際開發中,我們很清楚的知道那些地方時線程獨有的,不需要加同步鎖,但是在Java API中有很多方法都是加了同步的,那么此時JVM會判斷這段代碼是否需要加鎖。如果數據並不會逃逸,則會進行鎖消除。比如如下操作:在操作String類型數據時,由於String是一個不可變類,對字符串的連接操作總是通過生成的新的String對象來進行的。因此Javac編譯器會對String連接做自動優化。在JDK 1.5之前會使用StringBuffer對象的連續appen()操作,在JDK 1.5及以后的版本中,會轉化為StringBuidler對象的連續append()操作。

    public static String test03(String s1, String s2, String s3) { String s = s1 + s2 + s3; return s; } 
 
上述代碼使用javap 編譯結果

眾所周知,StringBuffer是安全同步的。但是在上述代碼中,JVM判斷該段代碼並不會逃逸,則將該代碼帶默認為線程獨有的資源,則並不需要同步,所以執行了鎖消除操作。(還有Vector中的各種操作也可實現鎖消除。在沒有逃逸出數據安全防衛內)

鎖粗話

​ 原則上,我們都知道在加同步鎖時,盡可能的將同步塊的作用范圍限制到盡量小的范圍(只在共享數據的實際作用域中才進行同步,這樣是為了使得需要同步的操作數量盡可能變小。在存在鎖同步競爭中,也可以使得等待鎖的線程盡早的拿到鎖)。

​ 大部分上述情況是完美正確的,但是如果存在連串的一系列操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作時出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要地性能操作。

​ 這里貼上根據上述Javap 編譯地情況編寫地實例java類

    public static String test04(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } 

​ 在上述地連續append()操作中就屬於這類情況。JVM會檢測到這樣一連串地操作都是對同一個對象加鎖,那么JVM會將加鎖同步地范圍擴展(粗化)到整個一系列操作的 外部,使整個一連串地append()操作只需要加鎖一次就可以了。

輕量級鎖

​ 在JDK 1.6之后引入的輕量級鎖,需要注意的是輕量級鎖並不是替代重量級鎖的,而是對在大多數情況下同步塊並不會有競爭出現提出的一種優化。它可以減少重量級鎖對線程的阻塞帶來地線程開銷。從而提高並發性能。

​ 如果要理解輕量級鎖,那么必須先要了解HotSpot虛擬機中對象頭地內存布局。上面介紹Java對象頭也詳細介紹過。在對象頭中(Object Header)存在兩部分。第一部分用於存儲對象自身的運行時數據,HashCodeGC Age、鎖標記位、是否為偏向鎖。等。一般為32位或者64位(視操作系統位數定)。官方稱之為Mark Word,它是實現輕量級鎖和偏向鎖的關鍵。 另外一部分存儲的是指向方法區對象類型數據的指針(Klass Point),如果對象是數組的話,還會有一個額外的部分用於存儲數據的長度。

輕量級鎖加鎖

​ 在線程執行同步塊之前,JVM會先在當前線程的棧幀中創建一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(JVM會將對象頭中的Mark Word拷貝到鎖記錄中,官方稱為Displaced Mark Ward)這個時候線程堆棧與對象頭的狀態如圖:

 
輕量級鎖CAS操作之前堆棧與對象的狀態

 

​ 如上圖所示:如果當前對象沒有被鎖定,那么鎖標志位位01狀態,JVM在執行當前線程時,首先會在當前線程棧幀中創建鎖記錄Lock Record的空間用於存儲鎖對象目前的Mark Word的拷貝。

​ 然后,虛擬機使用CAS操作將標記字段Mark Word拷貝到鎖記錄中,並且將Mark Word更新位指向Lock Record的指針。如果更新成功了,那么這個線程就有用了該對象的鎖,並且對象Mark Word的鎖標志位更新位(Mark Word中最后的2bit00,即表示此對象處於輕量級鎖定狀態,如圖:

 
輕量級鎖CAS操作之后堆棧與對象的狀態

​ 如果這個更新操作失敗,JVM會檢查當前的Mark Word中是否存在指向當前線程的棧幀的指針,如果有,說明該鎖已經被獲取,可以直接調用。如果沒有,則說明該鎖被其他線程搶占了,如果有兩條以上的線程競爭同一個鎖,那輕量級鎖就不再有效,直接膨脹位重量級鎖,沒有獲得鎖的線程會被阻塞。此時,鎖的標志位為10.Mark Word中存儲的時指向重量級鎖的指針。

​ 輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭中,如果成功,則表示沒有發生競爭關系。如果失敗,表示當前鎖存在競爭關系。鎖就會膨脹成重量級鎖。兩個線程同時爭奪鎖,導致鎖膨脹的流程圖如下:

 
輕量級鎖及膨脹流程圖

偏向鎖

​ 引入背景:在大多實際環境下,鎖不僅不存在多線程競爭,而且總是由同一個線程多次獲取,那么在同一個線程反復獲取所釋放鎖中,其中並還沒有鎖的競爭,那么這樣看上去,多次的獲取鎖和釋放鎖帶來了很多不必要的性能開銷和上下文切換。

​ 為了解決這一問題,HotSpot的作者在Java SE 1.6 中對Synchronized進行了優化,引入了偏向鎖。當一個線程訪問同步快並獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和推出同步塊時不需要進行CAS操作來加鎖和解鎖。只需要簡單地測試一下對象頭的Mark Word里是否存儲着指向當前線程的偏向鎖。如果成功,表示線程已經獲取到了鎖。

 
偏向鎖、輕量級鎖的狀態轉換

偏向鎖的撤銷

​ 偏向鎖使用了一種等待競爭出現才會釋放鎖的機制。所以當其他線程嘗試獲取偏向鎖時,持有偏向鎖的線程才會釋放鎖。但是偏向鎖的撤銷需要等到全局安全點(就是當前線程沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,讓你后檢查持有偏向鎖的線程是否活着。如果線程不處於活動狀態,直接將對象頭設置為無鎖狀態。如果線程活着,JVM會遍歷棧幀中的鎖記錄,棧幀中的鎖記錄和對象頭要么偏向於其他線程,要么恢復到無鎖狀態或者標記對象不適合作為偏向鎖。

 
偏向鎖的獲得和撤銷流程

鎖的優缺點對比

優點 缺點 使用場景
偏向鎖 加鎖和解鎖不需要CAS操作,沒有額外的性能消耗,和執行非同步方法相比僅存在納秒級的差距 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個線程訪問同步快的場景
輕量級鎖 競爭的線程不會阻塞,提高了響應速度 如線程成始終得不到鎖競爭的線程,使用自旋會消耗CPU性能 追求響應時間,同步快執行速度非常快
重量級鎖 線程競爭不適用自旋,不會消耗CPU 線程阻塞,響應時間緩慢,在多線程下,頻繁的獲取釋放鎖,會帶來巨大的性能消耗 追求吞吐量,同步快執行速度較長


鏈接: https://www.jianshu.com/p/dab7745c0954


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM