Java6及以上版本對synchronized的優化


1.概述

在多線程並發編程中synchronized一直是元老級角色, 很多人都會稱呼它為重量級鎖. 但是, 隨着Java SE 1.6對synchronized進行了各種優化之后, 有些情況下它就並不那么重了. 本文詳細介紹Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖, 以及鎖的存儲結構和升級過程.

2.實現同步的基礎

Java中的每個對象都可以作為鎖. 具體變現為以下3中形式.

  1. 對於普通同步方法, 鎖是當前實例對象.
  2. 對於靜態同步方法, 鎖是當前類的Class對象.
  3. 對於同步方法塊, 鎖是synchronized括號里配置的對象.

一個線程試圖訪問同步代碼塊時, 必須獲取鎖. 在退出或者拋出異常時, 必須釋放鎖.

3.實現方式

JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步, 但是兩者的實現細節不一樣.

  1. 代碼塊同步: 通過使用monitorenter和monitorexit指令實現的.
  2. 同步方法: ACC_SYNCHRONIZED修飾

monitorenter指令是在編譯后插入到同步代碼塊的開始位置, 而monitorexit指令是在編譯后插入到同步代碼塊的結束處或異常處.

示例代碼

為了證明JVM的實現方式, 下面通過反編譯代碼來證明.

public class Demo {

    public void f1() {
        synchronized (Demo.class) {
            System.out.println("Hello World.");
        }
    }

    public synchronized void f2() {
        System.out.println("Hello World.");
    }

}

編譯之后的字節碼如下(只摘取了方法的字節碼):

public void f1();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=3, args_size=1
       0: ldc           #2                  // class me/snail/base/Demo
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Hello World.
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      14: monitorexit
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
    LineNumberTable:
      line 6: 0
      line 7: 5
      line 8: 13
      line 9: 23
    StackMapTable: number_of_entries = 2
      frame_type = 255 /* full_frame */
        offset_delta = 18
        locals = [ class me/snail/base/Demo, class java/lang/Object ]
        stack = [ class java/lang/Throwable ]
      frame_type = 250 /* chop */
        offset_delta = 4

public synchronized void f2();
  descriptor: ()V
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=2, locals=1, args_size=1
       0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String Hello World.
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
    LineNumberTable:
      line 12: 0
      line 13: 8
}

先說f1()方法, 發現其中一個monitorenter對應了兩個monitorexit, 這是不對的. 但是仔細看#15: goto語句, 直接跳轉到了#23: return處, 再看#22: athrow語句發現, 原來第二個monitorexit是保證同步代碼塊拋出異常時鎖能得到正確的釋放而存在的, 這就理解了.

綜上: 發現同步代碼塊是通過monitorenter和monitorexit來實現的; 同步方法是加了一個ACC_SYNCHRONIZED修飾來實現的.

4.Java對象頭(存儲鎖類型)

在HotSpot虛擬機中, 對象在內存中的布局分為三塊區域: 對象頭, 示例數據和對其填充.

對象頭中包含兩部分: MarkWord 和 類型指針.

如果是數組對象的話, 對象頭還有一部分是存儲數組的長度.

多線程下synchronized的加鎖就是對同一個對象的對象頭中的MarkWord中的變量進行CAS操作.

MarkWord

Mark Word用於存儲對象自身的運行時數據, 如HashCode, GC分代年齡, 鎖狀態標志, 線程持有的鎖, 偏向線程ID等等.
占用內存大小與虛擬機位長一致(32位JVM -> MarkWord是32位, 64位JVM->MarkWord是64位).

類型指針

類型指針指向對象的類元數據, 虛擬機通過這個指針確定該對象是哪個類的實例.

對象頭的長度

長度 內容 說明
32/64bit MarkWord 存儲對象的hashCode或鎖信息等
32/64bit Class Metadada Address 存儲對象類型數據的指針
32/64bit Array Length 數組的長度(如果當前對象是數組)

如果是數組對象的話, 虛擬機用3個字節(32/64bit + 32/64bit + 32/64bit)存儲對象頭; 如果是普通對象的話, 虛擬機用2字節存儲對象頭(32/64bit + 32/64bit).

5.優化后synchronized鎖的分類

級別從低到高依次是:

  1. 無鎖狀態
  2. 偏向鎖狀態
  3. 輕量級鎖狀態
  4. 重量級鎖狀態

鎖可以升級, 但不能降級. 即: 無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖是單向的.

下面看一下每個鎖狀態時, 對象頭中的MarkWord這一個字節中的內容是什么. 以32位為例.

無鎖狀態

25bit 4bit 1bit(是否是偏向鎖) 2bit(鎖標志位)
對象的hashCode 對象分代年齡 0 01

偏向鎖狀態

23bit 2bit 4bit 1bit 2bit
線程ID epoch 對象分代年齡 1 01

輕量級鎖狀態

30bit 2bit
指向棧中鎖記錄的指針 00

重量級鎖狀態

30bit 2bit
指向互斥量(重量級鎖)的指針 10

6.鎖的升級(進化)

6-1.偏向鎖

偏向鎖是針對於一個線程而言的, 線程獲得鎖之后就不會再有解鎖等操作了, 這樣可以省略很多開銷. 假如有兩個線程來競爭該鎖話, 那么偏向鎖就失效了, 進而升級成輕量級鎖了.

為什么要這樣做呢? 因為經驗表明, 其實大部分情況下, 都會是同一個線程進入同一塊同步代碼塊的. 這也是為什么會有偏向鎖出現的原因.

在Jdk1.6中, 偏向鎖的開關是默認開啟的, 適用於只有一個線程訪問同步塊的場景.

偏向鎖的加鎖

當一個線程訪問同步塊並獲取鎖時, 會在鎖對象的對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID, 以后該線程進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖, 只需要簡單的測試一下鎖對象的對象頭的MarkWord里是否存儲着指向當前線程的偏向鎖(線程ID是當前線程), 如果測試成功, 表示線程已經獲得了鎖; 如果測試失敗, 則需要再測試一下MarkWord中偏向鎖的標識是否設置成1(表示當前是偏向鎖), 如果沒有設置, 則使用CAS競爭鎖, 如果設置了, 則嘗試使用CAS將鎖對象的對象頭的偏向鎖指向當前線程.

偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制, 所以當其他線程嘗試競爭偏向鎖時, 持有偏向鎖的線程才會釋放鎖. 偏向鎖的撤銷需要等到全局安全點(在這個時間點上沒有正在執行的字節碼). 首先會暫停持有偏向鎖的線程, 然后檢查持有偏向鎖的線程是否存活, 如果線程不處於活動狀態, 則將鎖對象的對象頭設置為無鎖狀態; 如果線程仍然活着, 則鎖對象的對象頭中的MarkWord和棧中的鎖記錄要么重新偏向於其它線程要么恢復到無鎖狀態, 最后喚醒暫停的線程(釋放偏向鎖的線程).

總結

偏向鎖在Java6及更高版本中是默認啟用的, 但是它在程序啟動幾秒鍾后才激活. 可以使用-XX:BiasedLockingStartupDelay=0來關閉偏向鎖的啟動延遲, 也可以使用-XX:-UseBiasedLocking=false來關閉偏向鎖, 那么程序會直接進入輕量級鎖狀態.

6-2.輕量級鎖

當出現有兩個線程來競爭鎖的話, 那么偏向鎖就失效了, 此時鎖就會膨脹, 升級為輕量級鎖.

輕量級鎖加鎖

線程在執行同步塊之前, JVM會先在當前線程的棧幀中創建用戶存儲鎖記錄的空間, 並將對象頭中的MarkWord復制到鎖記錄中. 然后線程嘗試使用CAS將對象頭中的MarkWord替換為指向鎖記錄的指針. 如果成功, 當前線程獲得鎖; 如果失敗, 表示其它線程競爭鎖, 當前線程便嘗試使用自旋來獲取鎖, 之后再來的線程, 發現是輕量級鎖, 就開始進行自旋.

輕量級鎖解鎖

輕量級鎖解鎖時, 會使用原子的CAS操作將當前線程的鎖記錄替換回到對象頭, 如果成功, 表示沒有競爭發生; 如果失敗, 表示當前鎖存在競爭, 鎖就會膨脹成重量級鎖.

總結

總結一下加鎖解鎖過程, 有線程A和線程B來競爭對象c的鎖(如: synchronized(c){} ), 這時線程A和線程B同時將對象c的MarkWord復制到自己的鎖記錄中, 兩者競爭去獲取鎖, 假設線程A成功獲取鎖, 並將對象c的對象頭中的線程ID(MarkWord中)修改為指向自己的鎖記錄的指針, 這時線程B仍舊通過CAS去獲取對象c的鎖, 因為對象c的MarkWord中的內容已經被線程A改了, 所以獲取失敗. 此時為了提高獲取鎖的效率, 線程B會循環去獲取鎖, 這個循環是有次數限制的, 如果在循環結束之前CAS操作成功, 那么線程B就獲取到鎖, 如果循環結束依然獲取不到鎖, 則獲取鎖失敗, 對象c的MarkWord中的記錄會被修改為重量級鎖, 然后線程B就會被掛起, 之后有線程C來獲取鎖時, 看到對象c的MarkWord中的是重量級鎖的指針, 說明競爭激烈, 直接掛起.

解鎖時, 線程A嘗試使用CAS將對象c的MarkWord改回自己棧中復制的那個MarkWord, 因為對象c中的MarkWord已經被指向為重量級鎖了, 所以CAS失敗. 線程A會釋放鎖並喚起等待的線程, 進行新一輪的競爭.

6-3.鎖的比較

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗, 和執行非同步代碼方法的性能相差無幾. 如果線程間存在鎖競爭, 會帶來額外的鎖撤銷的消耗. 適用於只有一個線程訪問的同步場景
輕量級鎖 競爭的線程不會阻塞, 提高了程序的響應速度 如果始終得不到鎖競爭的線程, 使用自旋會消耗CPU 追求響應時間, 同步快執行速度非常快
重量級鎖 線程競爭不適用自旋, 不會消耗CPU 線程堵塞, 響應時間緩慢 追求吞吐量, 同步快執行時間速度較長

7.總結

首先要明確一點是引入這些鎖是為了提高獲取鎖的效率, 要明白每種鎖的使用場景, 比如偏向鎖適合一個線程對一個鎖的多次獲取的情況; 輕量級鎖適合鎖執行體比較簡單(即減少鎖粒度或時間), 自旋一會兒就可以成功獲取鎖的情況.

要明白MarkWord中的內容表示的含義.


免責聲明!

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



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