1. 程式人生 > >Java6中對synchronized的優化

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修飾來實現的.

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).

優化後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

鎖的升級(進化)

偏向鎖

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

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

在Jdk1.6中, 偏向鎖的開關是預設開啟的, 適用於只有一個執行緒訪問同步塊的場景.

偏向鎖的加鎖

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

偏向鎖的撤銷

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

總結

偏向鎖在Java6及更高版本中是預設啟用的, 但是它在程式啟動幾秒鐘後才啟用. 可以使用-XX:BiasedLockingStartupDelay=0來關閉偏向鎖的啟動延遲, 也可以使用-XX:-UseBiasedLocking=false來關閉偏向鎖, 那麼程式會直接進入輕量級鎖狀態.

輕量級鎖

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

輕量級鎖加鎖

執行緒在執行同步塊之前, 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會釋放鎖並喚起等待的執行緒, 進行新一輪的競爭.

鎖的比較

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

總結

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

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