鎖:鎖優化(synchronized 鎖升級過程、鎖消除、鎖粗化)


1、synchronized 鎖升級過程

  高效並發是從JDK 5到JDK 6的一個重要改進,HotSpot虛擬機開發團隊在這個版本上花費了大量的精力去實現各種鎖優化技術,包括偏向鎖( Biased Locking )、輕量級鎖( Lightweight Locking )和如適應性自旋(Adaptive Spinning)、鎖消除( Lock Elimination)、鎖粗化( Lock Coarsening )等,這些技術都是為了在線程之間更高效地共享數據,以及解決競爭問題,從而提高程序的執行效率。這些鎖的狀態存在於Java對象的布局當中

2、Java對象的布局

(1)在JVM中,對象在內存中的布局分為三塊區域:對象頭、實例數據和對齊填充數據

(2)對象頭(對象頭 = Mark Word + 類型指針(未開啟指針壓縮的情況下),在 64位系統中,Mark Word = 8 bytes,類型指針 = 8bytes,對象頭 = 16 bytes = 128bits;)

  當一個線程嘗試訪問synchronized修飾的代碼塊時,它首先要獲得鎖,這個鎖是存在鎖對象的對象頭中的。

  HotSpot采用instanceOopDesc和arrayOopDesc來描述對象頭,arrayOopDesc對象用來描述數組類型。instanceOopDesc的定義的在Hotspot源碼的 instanceOop.hpp 文件中,另外,arrayOopDesc的定義對應 arrayOop.hpp 。

  Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等等,占用內存大小與虛擬機位長一致。Mark Word對應的類型是 markOop 。源碼位於 markOop.hpp 中。

在 64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下:

  klass pointer這一部分用於存儲對象的類型指針,該指針指向它的類元數據,JVM通過這個指針確定對象是哪個類的實例。該指針的位長度為JVM的一個字大小,即32位的JVM為32位,64位的JVM為64位。 如果應用的對象過多,使用64位的指針將浪費大量內存,統計而言,64位的JVM將會比32位的JVM多耗費50%的內存。為了節約內存可以使用選項 - XX:+UseCompressedOops 開啟指針壓縮,其中,oop即ordinary
  object pointer普通對象指針。開啟該選項后,下列指針將壓縮至32位:

  • 每個Class的屬性指針(即靜態變量)
  • 每個對象的屬性指針(即對象變量)
  • 普通對象數組的每個元素指針

  當然,也不是所有的指針都會壓縮,一些特殊類型的指針JVM不會優化,比如指向PermGen的Class對象指針(JDK8中指向元空間的Class對象指針)、本地變量、堆棧元素、入參、返回值和NULL指針等。

(3)實例數據

  就是類中定義的成員變量。

(4)對齊填充

  對齊填充並不是必然存在的,也沒有什么特別的意義,他僅僅起着占位符的作用,由於HotSpot VM的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說,就是對象的大小必須是8字節的整數倍。而對象頭正好是8字節的倍數,因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

3、偏向鎖

(1)概念

偏向鎖是JDK 6中的重要引進,因為HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低,引進了偏向鎖。
偏向鎖的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是這個鎖會偏向於第一個獲得它的線程,會在對象頭存儲鎖偏向的線程ID,以后該線程進入和退出同步塊時只需要檢查是否為偏向鎖、鎖標志位以及ThreadID即可。

不過一旦出現多個線程競爭時必須撤銷偏向鎖,所以撤銷偏向鎖消耗的性能必須小於之前節省下來的CAS原子操作的性能消耗,不然就得不償失了。

一個線程的情況適合用偏向鎖:

public class Test extends Thread{
    static Object object=new Object();
    public void run(){
        for(int i=0;i<5;i++){
            synchronized (object){
                System.out.println("hello");
            }
        }
    }
    public static void main(String[] args){
       Test test=new Test();
       test.start();
    }
}

(2)原理

  • 虛擬機將會把對象頭中的標志位設為“01”,即偏向模式。
  • 同時使用CAS操作把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中 ,如果CAS操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作,偏向鎖的效率高。
  • 它同樣是一個帶有效益權衡性質的優化,也就是說,它並不一定總是對程序運行有利,如果程序中大多數的鎖總是被多個不同的線程訪問比如線程池,那偏向模式就是多余的。
  • 在JDK5中偏向鎖默認是關閉的,而到了JDK6中偏向鎖已經默認開啟。但在應用程序啟動幾秒鍾之后才激活,可以使用 - XX:BiasedLockingStartupDelay=0 參數關閉延遲,如果確定應用程序中所有鎖通常情況下處於競爭狀態,可以通過 XX: -UseBiasedLocking=false 參數關閉偏向鎖


(3)撤銷

  • 偏向鎖的撤銷動作必須等待全局安全點
  • 暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態
  • 撤銷偏向鎖,恢復到無鎖(標志位為 01)或輕量級鎖(標志位為 00)的狀態

(4)好處

  • 偏向鎖是在只有一個線程執行同步塊時進一步提高性能,適用於一個線程反復獲得同一鎖的情況。偏向鎖可以提高帶有同步但無競爭的程序性能。


4、輕量級鎖

(1)概念

  輕量級鎖是JDK 6之中加入的新型鎖機制,它名字中的“輕量級”是相對於使用monitor的傳統鎖而言的,因此傳統的鎖機制就稱為“重量級”鎖。首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的(不是所有的時候開銷都比較小,只是在一定的情況下能夠減少消耗)。

  引入輕量級鎖的目的:在多線程交替執行同步塊的情況下,盡量避免重量級鎖引起的性能消耗,但是如果多個線程在同一時刻進入臨界區,會導致輕量級鎖膨脹升級重量級鎖,所以輕量級鎖的出現並非是要替代重量級鎖。當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖

(2)輕量級鎖原理

  • 判斷當前對象是否處於無鎖狀態(hashcode、0、01),如果是,則JVM首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word),將對象的Mark Word復制到棧幀中的Lock Record中,將Lock Reocrd中的owner指向當前對象。
  • JVM利用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針,如果成功表示競爭到鎖,則將鎖標志位變成00,執行同步操作。
  • 如果失敗則判斷當前對象的Mark Word是否指向當前線程的棧幀,如果是則表示當前線程已經持有當前對象的鎖,則直接執行同步代碼塊;否則只能說明該鎖對象已經被其他線程搶占了,這時輕量級鎖需要膨脹為重量級鎖,鎖標志位變成10,后面等待的線程將會進入阻塞狀態。

(3)輕量級鎖的釋放

輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:

  • 取出在獲取輕量級鎖保存在Displaced Mark Word中的數據。
  • 用CAS操作將取出的數據替換當前對象的Mark Word中,如果成功,則說明釋放鎖成功。
  • 如果CAS操作替換失敗,說明有其他線程嘗試獲取該鎖,則需要將輕量級鎖需要膨脹升級為重量級鎖

  對於輕量級鎖,其性能提升的依據是“對於絕大部分的鎖,在整個生命周期內都是不會存在競爭的”,如果打破這個依據則除了互斥的開銷外,還有額外的CAS操作,因此在有多線程競爭的情況下,輕量級鎖比重量級鎖更慢。

(4)輕量級鎖好處

  在多線程交替執行同步塊的情況下,可以避免重量級鎖引起的性能消耗。

 

5、自旋鎖(https://www.cnblogs.com/zhai1997/p/13467606.html

  monitor會阻塞和喚醒線程,線程的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,這些操作給系統的並發性能帶來了很大的壓力。同時,虛擬機的開發團隊也注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,為了這段時間阻塞和喚醒線程並不值得(采用多次嘗試的方式而不是進入阻塞,可以避免用戶態與內核態的切換)。如果物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,我們就可以讓后面請求鎖的那個線程“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只需讓線程執行一個忙循環(自旋) , 這項技術就是所謂的自旋鎖。

  自旋鎖在JDK1.4.2中就已經引入,只不過默認是關閉的,可以使用-XX:+UseSpinning參數來開啟,在JDK6中就已經改為默認開啟了。

  自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了線程切換的開銷,但它是要占用處理器時間的,因此,如果鎖被占用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被占用的時間很長。那么自旋的線程只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來性能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程了。自旋次數的默認值是10次,用戶可以使用參數-XX : PreBlockSpin來更改。

  適應性自旋鎖

  在JDK 6中引入了自適應的自旋鎖。自適應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100次循環。另外,如果對於某個鎖,自旋很少成功獲得過,那在以后要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越准確,虛擬機就會變得越來越“聰明”了。

6、鎖消除

  鎖消除是指虛擬機即時編譯器(JIT)在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持,如果判斷在一段代碼中,堆上的所有數據都不會逃逸出去從而被其他線程訪問到,那就可以把它們當做棧上數據對待,認為它們是線程私有的,同步加鎖自然就無須進行。變量是否逃逸,對於虛擬機來說需要使用數據流分析來確定,但是程序員自己應該是很清楚的,怎么會在明知道不存在數據爭用的情況下要求同步呢?實際上有許多同步措施並不是程序員自己加入的,同步的代碼在Java程序中的普遍程度也許超過了大部分讀者的想象。下面這段非常簡單的代碼僅僅是輸出3個字符串相加的結果,無論是源碼字面上還是程序語義上都沒有同步。

public class Test extends Thread{
    public static void main(String[] args) {
        contactString("aa", "bb", "cc");
    }
    public static String contactString(String s1, String s2, String s3) {
        return new StringBuffer().append(s1).append(s2).append(s3).toString();
    }
}

  StringBuffer的append ( ) 是一個同步方法,鎖就是this也就是(new StringBuilder())。虛擬機發現它的動態作用域被限制在concatString( )方法內部。也就是說, new StringBuilder()對象的引用永遠不會“逃逸”到concatString ( )方法之外,其他線程無法訪問到它,因此,雖然這里有鎖,但是可以被安全地消除掉,在即時編譯之后,這段代碼就會忽略掉所有的同步而直接執行了。

 

   append方法是同步的,進行了三次的字符串的拼接,方法的調用者是StringBuffer對象,它是一個局部變量沒有逃逸出這個方法內部,即使是多個線程來執行對象也沒有逃逸出這個方法,此時append方法的鎖就可以消除掉

 

7、鎖粗化

  原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用范圍限制得盡量小,只在共享數據的實際作用域中才進行同步,這樣是為了使得需要同步的操作數量盡可能變小,如果存在鎖競爭,那等待鎖的線程也能盡快拿到鎖。大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。

public class Test extends Thread{
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append("aa");
        }
        System.out.println(sb.toString());
    }
}

  JVM會探測到一連串細小的操作都使用同一個對象加鎖,將同步代碼塊的范圍放大,放到這串操作的外面,這樣只需要加一次鎖即可。

 

 

8、對 synchronized的優化

(1)減少synchronized的范圍

同步代碼塊中盡量短,減少同步代碼塊中代碼的執行時間,減少鎖的競爭。單位時間內執行的線程就會變多,等待的線程變少

(2)降低 synchronized鎖的粒度

將一個鎖拆分為多個鎖提高並發度,盡量不要用類名點class來創建鎖

(3)HashTable中的鎖

 

 查看相關方法的源碼:

 這些方法都使用了synchronized關鍵字,也就說在對哈希表進行插入數據的時候就不能獲取或移除數據

(4)ConcurrentHashMap

 添加元素的時候只會鎖第一個桶,那么其他的桶就可以添加元素,其他操作是沒有鎖的,因此效率更高

采用的是CAS算法,省略了長度為16的數組(太大會浪費空間,太小又不行),依舊采取數組、鏈表加紅黑樹的存儲結構(這是java8的新特性)

(5)讀寫分離

讀取時不加鎖,寫入和刪除時加鎖
ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet

 

總結:

JVM內置鎖的膨脹升級

 

 

 這個過程不能逆向,可以釋放鎖,然后從偏向鎖狀態開始升級

 


免責聲明!

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



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