上一篇:《Java并發系列(13)——線程池的選擇與參數設定》
文章目錄
-
- 10 synchronized 實作原理
-
- 10.1 研究思路
-
- 10.1.1 輸出 JVM 指令
- 10.1.2 跟蹤 JVM 源碼
- 10.2 預備知識
-
- 10.2.1 對象頭
-
- 10.2.1.1 什麼是對象頭
- 10.2.1.2 列印對象頭
- 10.2.1.3 小端存儲
- 10.2.2 使用者态與核心态
-
- 10.2.2.1 使用者态與核心态
- 10.2.2.2 使用者線程與核心線程
- 10.3 Hashtable 性能測試
-
- 10.3.1 測試一
- 10.3.2 測試二
- 10.3.3 測試三
- 10.3.4 測試四
- 10.3.5 測試結果彙總
- 10.4 偏向鎖
-
- 10.4.1 第一次加鎖
- 10.4.2 釋放鎖
- 10.4.3 mark word
- 10.4.4 hash code
- 10.4.5 再次加鎖
- 10.4.6 重入
- 10.4.7 示意圖
10 synchronized 實作原理
10.1 研究思路
synchronized 實作已經不屬于 Java 層面了,它是 JVM 的範疇,而且不是 JVM 規範而是 JVM 實作的範疇,比如:HotSpot,JRocket,J9 等。
是以要研究 synchronized 實作,有能力最好是研讀 JVM 源碼。
10.1.1 輸出 JVM 指令
- 進入 synchronized 塊:monitorenter;
- 退出 synchronized 塊:monitorexit;
有一個 monitorenter 至少有一個 monitorexit,通常會有多個。
Java 代碼:
package per.lvjc.concurrent.synchro;
public class SynchroTest {
public static void main(String[] args) {
synchronized (SynchroTest.class) {
System.out.println();
synchronized (SynchroTest.class) {
try {
System.out.println();
} catch (Exception e) {
System.out.println();
}
}
}
}
}
javap:
javap -v -l -c -p C:\Users\lvjc\IdeaProjects\concurrent\target\classes\per\lvjc\concurrent\synchro\SynchroTest.class > D:
\data\jvm\SynchroTest_javap.txt
輸出的 SynchroTest_javap.txt 檔案,節選其中 main 方法:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=6, args_size=1
0: ldc #2 // class per/lvjc/concurrent/synchro/SynchroTest
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: invokevirtual #4 // Method java/io/PrintStream.println:()V
11: ldc #2 // class per/lvjc/concurrent/synchro/SynchroTest
13: dup
14: astore_2
15: monitorenter
16: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
19: invokevirtual #4 // Method java/io/PrintStream.println:()V
22: goto 32
25: astore_3
26: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
29: invokevirtual #4 // Method java/io/PrintStream.println:()V
32: aload_2
33: monitorexit
34: goto 44
37: astore 4
39: aload_2
40: monitorexit
41: aload 4
43: athrow
44: aload_1
45: monitorexit
46: goto 56
49: astore 5
51: aload_1
52: monitorexit
53: aload 5
55: athrow
56: return
Exception table:
from to target type
16 22 25 Class java/lang/Exception
16 34 37 any
37 41 37 any
5 46 49 any
49 53 49 any
這裡面就有 2 個 monitorenter 和 4 個 monitorexit。
下面列舉 4 種場景,把這 4 個 monitorexit 都用到:
- 正常運作:偏移 4 位元組處的 monitorenter -> 偏移 15 位元組的 monitorenter -> 偏移 33 位元組的 monitorexit -> 偏移 45 位元組的 monitorexit;
- 裡面的 synchronized 塊抛 Exception:4 monitorenter -> 15 monitorenter -> 16 ~ 22(不含) 之間抛 Exception,根據異常表 goto 25 -> 33 monitorexit -> 45 monitorexit;
- 外面的 synchronized 塊抛 Throwable:4 monitorenter -> 5 ~ 11 之間抛 Throwable,根據異常表 goto 49 -> 52 monitorexit;
- 裡面的 synchronized 塊抛 Error:4 monitorenter -> 15 monitorenter -> 16 ~ 34 之間抛 Error,根據異常表 goto 37 -> 40 monitorexit -> 45 monitorexit。
10.1.2 跟蹤 JVM 源碼
進一步,monitorenter 和 monitorexit 指令是怎麼實作的,那就要深入到 JVM 源碼。
雖然 JVM 是 JDK 的一部分,但 Java 開發所使用的 JDK 是經過編譯的,裡面已經看不到 JVM 源碼了。
OpenJDK 是開源的,可以在官網下載下傳到未編譯的源碼:OpenJDK8 源碼下載下傳。
下載下傳到源碼之後,可以在 bytecodeInterpreter.cpp 檔案裡面看到所有 JVM 指令的實作入口:
圖上左邊可以看到所有的 JVM 指令,每個 JVM 指令用一個位元組表示,是以 JVM 指令最多 256 個。
搜尋 monitorenter 指令,就可以找到它的實作:
此處暫時不深入 JVM 源碼,僅提供一種研究思路,有能力有時間的讀者可自行研究源碼。
10.2 預備知識
synchronized 實作其實很容易猜出來,與 Java 實作的 AQS 原理幾乎是一樣的。
10.2.1 對象頭
根據 AQS 的實作思路,要實作一個鎖,隻需要存儲三個資訊即可:
- 鎖狀态:由目前鎖狀态資訊計算可以知道目前是否允許獲得鎖,在 AQS 體系中是 state 和 exclusiveOwnerThread 變量;
- 競争隊列:存儲在競争同一個鎖對象的所有線程,以便釋放鎖時喚醒,在 AQS 中是 head 和 tail 變量以及 Node 内部類的 pre,next 變量記錄的一個雙向連結清單;
- 休眠隊列:存儲主動讓出 CPU 資源和鎖資源進入休眠暫時不參與 CPU 排程的所有線程,在 AQS 中是 ConditionObject 内部類中 firstWaiter 和 lastWaiter 變量以及 Node 内部類的 nextWaiter 變量記錄的一個單向連結清單。
隊列的實作很簡單,鎖狀态的存儲與計算是一件比較麻煩的事情,而且會随着鎖的特性變多而變得更複雜,比如:
- ReentrantLock 用 exclusiveOwnerThread 變量存儲目前持有鎖的線程,用 state 存儲重入次數;
- ReentrantReadWriteLock 由于區分讀寫鎖,就要把 state 變量一分為二,高 16 位存儲共享鎖,低 16 位存儲獨占鎖,同時還需要一個 ThreadLocal 存儲各個讀線程重入共享鎖的次數。
而 synchronized 是怎麼存儲鎖狀态的呢?答案是對象頭。
後面可以看到 synchronized 加鎖與解鎖其實就是在玩轉對象頭,這也是為什麼說 synchronized 是對象鎖,它鎖的一定是對象,因為沒有對象就沒有對象頭,以目前 synchronized 實作來說是加不了鎖的。
10.2.1.1 什麼是對象頭
一個 Java 對象裡面包含的資料主要有三部分:
- 對象頭;
- 執行個體變量,比如類裡面定義了一個 int,那麼這裡就有 4 個位元組;
- 對齊填充:一個對象占用的總記憶體必須是 8 的整數倍,不足的部分以空位填充整齊。
對象頭又分為兩個部分(也可以說是三部分):
- mark word,32 位系統占用 4 位元組,64 位系統占用 8 位元組;
- class pointer,指向目前對象在方法區中的 class 定義的首位址,32 位系統占用 4 位元組,64 位系統不開啟指針壓縮占用 8 位元組,開啟指針壓縮占用 4 位元組;
- 數組長度,數組對象才會有,因為是 int,是以占用 4 位元組。
mark word 比較複雜,它可能會存儲下面這些資料:
- 偏向鎖目前偏向的線程 id;
- 偏向鎖過期辨別;
- 指向棧幀中 lock record(鎖記錄)對象(C++對象)的指針;
- 指向 ObjectMonitor 對象(C++對象)的指針;
- hash code;
- 分代年齡;
- 是否可偏向辨別;
- 鎖辨別;
- gc 辨別。
為什麼說“可能”呢,因為 mark word 空間有限,這裡面有些資料是互斥的,“有你沒我”。
關于 mark word 暫時隻提這麼多,後面會結合具體的場景來詳細說明。
10.2.1.2 列印對象頭
後面會經常把鎖對象的對象頭列印出來檢視,是以這裡先講一下列印對象頭的方法。
引入 OpenJDK 提供的一個 jol 包:
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>
非數組對象的對象頭:
package per.lvjc.concurrent.synchro;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
@Slf4j(topic = "synchro")
public class CommonObjectHeader {
private static int i = 1;
private boolean bo = false;
private long lo = 6;
private Object o = new Object();
public static void main(String[] args) {
log.debug(ClassLayout.parseInstance(new CommonObjectHeader()).toPrintable());
}
}
運作結果(64 位系統,預設開啟指針壓縮):
22:30:38.243 [main] DEBUG synchro - per.lvjc.concurrent.synchro.CommonObjectHeader object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 1 boolean CommonObjectHeader.bo false
13 3 (alignment/padding gap)
16 8 long CommonObjectHeader.lo 6
24 4 java.lang.Object CommonObjectHeader.o (object)
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
發現一個有意思的是,不僅對象之間要按照 8 位元組的整數倍進行填充,對象内部執行個體變量之間也要對齊填充。
添加 JVM 參數
-XX:-UseCompressedOops
關閉指針壓縮:
22:33:03.735 [main] DEBUG synchro - per.lvjc.concurrent.synchro.CommonObjectHeader object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 30 32 37 f1 (00110000 00110010 00110111 11110001) (-248040912)
12 4 (object header) f0 01 00 00 (11110000 00000001 00000000 00000000) (496)
16 8 long CommonObjectHeader.lo 6
24 1 boolean CommonObjectHeader.bo false
25 7 (alignment/padding gap)
32 8 java.lang.Object CommonObjectHeader.o (object)
Instance size: 40 bytes
Space losses: 7 bytes internal + 0 bytes external = 7 bytes total
數組對象的對象頭:
package per.lvjc.concurrent.synchro;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
@Slf4j(topic = "synchro")
public class ArrayObjectHeader {
private static Object[] objects = new Object[7];
public static void main(String[] args) {
log.debug(ClassLayout.parseInstance(objects).toPrintable());
}
}
打開指針壓縮:
20:15:02.164 [main] DEBUG synchro - [Ljava.lang.Object; object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 4c 23 00 f8 (01001100 00100011 00000000 11111000) (-134208692)
12 4 (object header) 07 00 00 00 (00000111 00000000 00000000 00000000) (7)
16 28 java.lang.Object Object;.<elements> N/A
44 4 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
10.2.1.3 小端存儲
仔細看上面列印的對象頭:
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
00000001 00000000 00000000 00000000
後面小括号标注了一個 1,但這個 32 位的二進制數明明是 2^24,怎麼會是 1 呢?
再看
05 c1 00 f8
,0 開頭不用算,肯定是個正數,怎麼會是負數呢?
這個就涉及到作業系統的存儲模式。
這裡的現象說明作業系統采用的是小端存儲模式,即低位位元組存在低記憶體位址,高位位元組存在高記憶體位址。而 jol 列印的時候是從低到高列印的,是以
05 c1 00 f8
這個數
05
是低位
f8
是高位,要把它倒過來看,即
f8 00 c1 05
。
這一點非常重要,一定要注意,否則後面看鎖對象的對象頭時就坑自己了。
10.2.2 使用者态與核心态
重量鎖為什麼被稱為重量鎖?輕量鎖為什麼被稱為輕量鎖?為什麼偏向鎖效率最高?
如果不了解使用者态與核心态,就算明白了重量鎖、輕量鎖、偏向鎖的實作原理,依然會對以上問題感到困惑。
10.2.2.1 使用者态與核心态
先舉個例子:
- 你要查我的資料庫,我給你開一個隻讀賬号,允許你直接連接配接資料庫做一些簡單的查詢操作;
- 現在你想更新資料庫,我不能讓你随便更新,也不能完全不讓你更新,于是我要求你把更新語句送出給我由我來操作,如果是合理的更新操作我就執行,如果你送出了一個删庫語句給我,我就不執行。
與此類似:
- 應用程式使用計算機資源做一些合理的運算,作業系統不過多幹涉;
- 應用程式如果想做一些作業系統認為可能會造成損害的操作,作業系統不會完全不讓做,因為有可能是合理操作,但也不會完全放開讓應用程式自由操作,于是作業系統開放出一批 API 讓應用程式調用,而在這些 API 中作業系統就可以做一些安全檢查之類的操作。
那麼現在,CPU 至少需要兩種權限:
- 較低的權限,隻能在 CPU 上執行部分指令;
- 較高的權限,可以在 CPU 上執行全部指令。
這樣以後:
- 應用程式平時以較低的權限運作,這種狀态被稱為使用者态;
- 有需要時,調用作業系統提供的 API,作業系統以更高的權限使用 CPU 來完成這些操作,這種狀态被稱為核心态。
應用程式的這種調用系統 API 的行為,被稱為系統調用,系統調用會使應用程式程序從使用者态切換到核心态,也就是切換了一個更高的 CPU 通路權限。
安全起見,作業系統不能信任任何應用程式,是以核心态所使用的計算機資源比如記憶體等,是不與工作在使用者态的應用程式共享的。是以從使用者态切換到核心态就要儲存使用者态的工作環境,切換出核心态的工作環境,也就是一次 CPU 上下文切換。同樣,從核心态切換回使用者态時還要再發生一次 CPU 上下文切換。
10.2.2.2 使用者線程與核心線程
使用者線程:可以了解為應用程式建立的線程,比如 Java 中的 Thread。
核心線程:即作業系統建立的線程。
根據使用者線程與核心線程的對應關系,可以有三種線程模型:
- 一對一模型:應用程式每建立一個線程,作業系統就會建立一個核心線程與之對應;
- 多對一模型:應用程式中的多個線程共享一個核心線程;
- 多對多模型:應用程式中的多個線程映射到作業系統中數量相等或更少的核心線程。
顯然,Java 采用的是一對一模型,至少對于目前的 HotSpot 可以這麼說。
對于一對一模型:
- 實作比較簡單,比如 Java 中建立一個線程,JVM 隻需要調用系統 API 如 pthread_create 建立一個線程與之對應;Java 中加重量鎖,JVM 就直接調用系統 API 如 pthread_mutex_lock 即可;JVM 也不需要實作線程排程;
- 但是,這樣也限制了應用程式不能建立過多的線程,因為作業系統中的線程是與之對應的;
- 由于總是要麻煩作業系統進行一些線程相關的操作,是以會發生比較多的系統調用,相對來說是比較重的操作。
對于多對一模型:
- 其實就是應用程式自己實作了線程,作業系統不認這個線程,但對于應用來說隻要實作了與核心線程相似的行為,就可以當作真正的線程來用,就像 Java 自己實作了顯式鎖一樣;
- 好處是比較輕便,就像 Java 的顯式鎖一樣,很多時候不需要麻煩作業系統;
- 但畢竟是模拟出來的假線程,有些功能不借助真正的核心線程是實作不了的,比如線程排程、線程優先級;
- 多個使用者線程映射到一個核心線程,這些使用者線程就像一條繩上的螞蚱,一個阻塞全都阻塞,一個挂全都挂。
10.3 Hashtable 性能測試
從一道很古老的面試題說起:HashMap 和 Hashtable 的差別是什麼?
常說,HashMap 不是線程安全的,Hashtable 是線程安全的,Hashtable 大部分方法都做了同步是以性能比較差。
那就測試一下,所謂的“Hashtable 性能比較差”到底有多差。
測試結果會非常有意思。
10.3.1 測試一
測試代碼,單線程循環 put,對比 HashMap,Hashtable,ConcurrentHashMap 執行耗時:
package per.lvjc.concurrent.synchro.biase;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class HashtableTest {
private static Map<String, String> map;
private static String mapType;
private static String[] keys;
private static String[] values;
private static int arrayLength;
//循環 put 20 億次
private static long loopSize = 20_0000_0000L;
public static void main(String[] args) {
//資料初始化隻做一次,并且不計時
init();
//測試 5 次
for (int i = 0; i < 5; i++) {
long start = System.currentTimeMillis();
test();
long end = System.currentTimeMillis();
System.out.println(mapType + " cost " + (end - start) + " ms");
}
}
private static void init() {
//依次打開 HashMap,Hashtable,ConcurrentHashMap 進行測試
//map = new HashMap<>();
//map = new Hashtable<>();
map = new ConcurrentHashMap<>();
mapType = map.getClass().getSimpleName();
arrayLength = 26;
keys = new String[arrayLength];
values = new String[arrayLength];
for (int i = 0; i < arrayLength; i++) {
keys[i] = (char) (i + 65) + "";
values[i] = (char) (i + 97) + "";
}
}
private static void test() {
long count = 0;
while (count <loopSize) {
for (int i = 0; i < arrayLength; i++) {
map.put(keys[i], values[i]);
count++;
}
}
}
}
HashMap 執行結果如下,穩定在 11 秒:
HashMap cost 8705 ms
HashMap cost 11382 ms
HashMap cost 11116 ms
HashMap cost 11141 ms
HashMap cost 11117 ms
Hashtable 執行結果如下,穩定在 21 秒:
Hashtable cost 19931 ms
Hashtable cost 18605 ms
Hashtable cost 21021 ms
Hashtable cost 21046 ms
Hashtable cost 21052 ms
ConcurrentHashMap 執行結果如下,穩定在 32 秒:
ConcurrentHashMap cost 31860 ms
ConcurrentHashMap cost 32502 ms
ConcurrentHashMap cost 32924 ms
ConcurrentHashMap cost 32907 ms
ConcurrentHashMap cost 32844 ms
第一輪測試結果:單線程效率,HashMap >> Hashtable >> ConcurrentHashMap。(這裡 >> 意思是明顯大于,下同。)
10.3.2 測試二
現在把代碼稍作改動:
public static void main(String[] args) throws InterruptedException {
//其它代碼都不變,先睡 5 秒再測
TimeUnit.SECONDS.sleep(5);
init();
for (int i = 0; i < 5; i++) {
long start = System.currentTimeMillis();
test();
long end = System.currentTimeMillis();
System.out.println(mapType + " cost " + (end - start) + " ms");
}
}
HashMap 執行結果,仍然穩定在 11 秒,不過看起來第一次測試的時間發生了一些變化:
HashMap cost 11256 ms
HashMap cost 11367 ms
HashMap cost 11113 ms
HashMap cost 11105 ms
HashMap cost 11123 ms
Hashtable 執行結果,發生了巨大的變化,與 HashMap 相當:
Hashtable cost 11848 ms
Hashtable cost 11929 ms
Hashtable cost 11691 ms
Hashtable cost 11682 ms
Hashtable cost 11691 ms
ConcurrentHashMap 執行結果,也有了較大的提升,但與 HashMap,Hashtable 相比仍然有較大差距:
ConcurrentHashMap cost 15195 ms
ConcurrentHashMap cost 16030 ms
ConcurrentHashMap cost 15975 ms
ConcurrentHashMap cost 15971 ms
ConcurrentHashMap cost 15994 ms
第二輪測試結果:單線程效率,HashMap (約)= Hashtable >> ConcurrentHashMap
10.3.3 測試三
睡 5 秒放到 init 之後:
public static void main(String[] args) throws InterruptedException {
init();
TimeUnit.SECONDS.sleep(5);
for (int i = 0; i < 5; i++) {
long start = System.currentTimeMillis();
test();
long end = System.currentTimeMillis();
System.out.println(mapType + " cost " + (end - start) + " ms");
}
}
HashMap 結果,仍然保持不變:
HashMap cost 11316 ms
HashMap cost 11380 ms
HashMap cost 11148 ms
HashMap cost 11209 ms
HashMap cost 11238 ms
Hashtable 結果,又回到了測試一的老樣子:
Hashtable cost 19400 ms
Hashtable cost 19935 ms
Hashtable cost 19116 ms
Hashtable cost 18876 ms
Hashtable cost 18838 ms
ConcurrentHashMap 結果,與在 init 之前 sleep 效果一樣:
ConcurrentHashMap cost 15351 ms
ConcurrentHashMap cost 14723 ms
ConcurrentHashMap cost 15434 ms
ConcurrentHashMap cost 15396 ms
ConcurrentHashMap cost 15271 ms
第三輪測試結果:單線程效率,HashMap >> ConcurrenHashMap >> Hashtable
10.3.4 測試四
init 之後對 map 鎖對象制造一次競争。
public static void main(String[] args) throws InterruptedException {
init();
//init 之後來兩個線程搶一下鎖
Thread thread1 = new Thread(() -> {
synchronized (map) {
System.out.println("thread 1");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (map) {
System.out.println("thread 2");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
for (int i = 0; i < 5; i++) {
long start = System.currentTimeMillis();
test();
long end = System.currentTimeMillis();
System.out.println(mapType + " cost " + (end - start) + " ms");
}
}
HashMap 結果,仍然不受影響:
HashMap cost 8846 ms
HashMap cost 12785 ms
HashMap cost 11983 ms
HashMap cost 11978 ms
HashMap cost 11979 ms
Hashtable 的結果,與測試三一樣:
Hashtable cost 19001 ms
Hashtable cost 19925 ms
Hashtable cost 18993 ms
Hashtable cost 18925 ms
Hashtable cost 18982 ms
ConcurrentHashMap 結果:
ConcurrentHashMap cost 18256 ms
ConcurrentHashMap cost 18483 ms
ConcurrentHashMap cost 18506 ms
ConcurrentHashMap cost 18543 ms
ConcurrentHashMap cost 18533 ms
第四輪測試結果:單線程效率,HashMap >> Hashtable >> ConcurrentHashMap。
10.3.5 測試結果彙總
單線程 20 億次 put 耗時:
無特殊操作 | 初始化之前 sleep 5 s | 初始化之後 sleep 5 s | 初始化之後競争鎖 | |
---|---|---|---|---|
HashMap | 11.1 s | 11.1 s | 11.2 s | 12.0 s |
Hashtable | 21.0 s | 11.7 s | 18.9 s | 19.0 s |
ConcurrentHashMap | 32.9 s | 16.0 s | 15.3 s | 18.5 s |
先簡單解釋一句,Hashtable 和 ConcurrentHashMap 耗時變化很大,是因為它們都用到了 synchronized,但它們使用 synchronized 的方式不一樣,是以變化規律也不同。而 HashMap 與 synchronized 沒有任何關系,是以耗時基本不變。
10.4 偏向鎖
偏向,biased,意思是偏向于某個線程,隻要是這個線程來加鎖,直接把鎖給它就完了,是以它的效率非常高。
10.4.1 第一次加鎖
hotspot 源碼在這裡:
CASE(_monitorenter): {
//...
//拿到了鎖記錄
if (entry != NULL) {
//...
//1.判斷偏向鎖是否開啟
// implies UseBiasedLocking
if (mark->has_bias_pattern()) {
//...
anticipated_bias_locking_value =
(((uintptr_t)lockee->klass()->prototype_header() | thread_ident) ^ (uintptr_t)mark) &
~((uintptr_t) markOopDesc::age_mask_in_place);
//2.判斷是否已經偏向自己
if (anticipated_bias_locking_value == 0) {
// already biased towards this thread, nothing to do
if (PrintBiasedLockingStatistics) {
(* BiasedLocking::biased_lock_entry_count_addr())++;
}
success = true;
}
//3.判斷偏向鎖是否可用
else if ((anticipated_bias_locking_value & markOopDesc::biased_lock_mask_in_place) != 0) {
// try revoke bias
//...
}
//4.判斷偏向是否過期
else if ((anticipated_bias_locking_value & epoch_mask_in_place) !=0) {
// try rebias
//...
}
//5.其它情況,包括匿名偏向(未偏向任何線程)
else {
// try to bias towards thread in case object is anonymously biased
//...
success = true;
}
}
//6.輕量鎖
// traditional lightweight locking
if (!success) {
markOop displaced = lockee->mark()->set_unlocked();
entry->lock()->set_displaced_header(displaced);
bool call_vm = UseHeavyMonitors;
if (call_vm || Atomic::cmpxchg_ptr(entry, lockee->mark_addr(), displaced) != displaced) {
// Is it simple recursive case?
if (!call_vm && THREAD->is_lock_owned((address) displaced->clear_lock_bits())) {
entry->lock()->set_displaced_header(NULL);
} else {
//7.重量鎖
CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
}
}
}
UPDATE_PC_AND_TOS_AND_CONTINUE(1, -1);
} else {
istate->set_msg(more_monitors);
UPDATE_PC_AND_RETURN(0); // Re-execute
}
}
作者原本的英文注釋已經将主流程表達得很清晰了,關于偏向鎖要做幾個判斷:
- 是否已經偏向自己;
- 是否偏向不可用;
- 是否偏向過期;
- 是否匿名偏向。
此處由于是第一次加鎖的場景,是以會走到匿名偏向的代碼塊。
// try to bias towards thread in case object is anonymously biased
//生成一個匿名偏向的 mark word
markOop header = (markOop) ((uintptr_t) mark & ((uintptr_t)markOopDesc::biased_lock_mask_in_place |
(uintptr_t)markOopDesc::age_mask_in_place |
epoch_mask_in_place));
if (hash != markOopDesc::no_hash) {
header = header->copy_set_hash(hash);
}
//或運算,計算出一個攜帶有自己線程 id 的 mark word
markOop new_header = (markOop) ((uintptr_t) header | thread_ident);
// debugging hint
DEBUG_ONLY(entry->lock()->set_displaced_header((markOop) (uintptr_t) 0xdeaddead);)
//cas,lockee 即鎖對象,若 mark word 為 header 則替換為 new_header
if (Atomic::cmpxchg_ptr((void*)new_header, lockee->mark_addr(), header) == header) {
if (PrintBiasedLockingStatistics)
(* BiasedLocking::anonymously_biased_lock_entry_count_addr())++;
}
else {
CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
}
在這裡計算一個匿名偏向的 mark word 和一個帶有自己線程 id 的 mark word,然後 cas 用帶有自己線程 id 的 mark word 替換掉鎖對象的 mark word。
10.4.2 釋放鎖
hotspot 源碼:
CASE(_monitorexit): {
oop lockee = STACK_OBJECT(-1);
CHECK_NULL(lockee);
// derefing's lockee ought to provoke implicit null check
// find our monitor slot
BasicObjectLock* limit = istate->monitor_base();
BasicObjectLock* most_recent = (BasicObjectLock*) istate->stack_base();
while (most_recent != limit ) {
if ((most_recent)->obj() == lockee) {
BasicLock* lock = most_recent->lock();
markOop header = lock->displaced_header();
//1.lock record 指針斷掉,不再指向鎖對象
most_recent->set_obj(NULL);
//2.如果不是偏向鎖還要做點額外的事情
if (!lockee->mark()->has_bias_pattern()) {
//...
}
UPDATE_PC_AND_TOS_AND_CONTINUE(1, -1);
}
most_recent++;
}
// Need to throw illegal monitor state exception
CALL_VM(InterpreterRuntime::throw_illegal_monitor_state_exception(THREAD), handle_exception);
ShouldNotReachHere();
}
是以偏向鎖釋放鎖很簡單,之前加鎖的時候把線程棧中 lock record 的 object 指針指向了鎖對象,現在把它斷掉就可以。
10.4.3 mark word
前面講了,偏向鎖第一次加鎖主要就是一個 CAS 替換 mark word 的操作。
現在把鎖對象的對象頭給列印出來,直覺地看一下 mark word 是怎麼變化的。
Java 代碼如下:
package per.lvjc.concurrent.synchro.header;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockHeader {
private static Object lock = new Object();
public static void main(String[] args) {
//1.加鎖前列印對象頭
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
//2.已發生偏向,列印對象頭
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
//3.偏向鎖釋放,列印對象頭
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
需要添加 JVM 參數:
-XX:BiasedLockingStartupDelay=0
,把偏向延遲時間設為 0。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 28 f3 94 (00000101 00101000 11110011 10010100) (-1796003835)
4 4 (object header) 6f 01 00 00 (01101111 00000001 00000000 00000000) (367)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 28 f3 94 (00000101 00101000 11110011 10010100) (-1796003835)
4 4 (object header) 6f 01 00 00 (01101111 00000001 00000000 00000000) (367)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
前面講過,由于小端存儲,mark word 8 個位元組要倒過來看,是以可以看到:
- 加鎖前:00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000101;
- 加鎖後:00000000_00000000_00000001_01101111_10010100_11110011_00101000_00000101;
- 釋放鎖:00000000_00000000_00000001_01101111_10010100_11110011_00101000_00000101。
解釋一下,這裡 mark word 存儲了什麼資訊:
- 加鎖前:這裡前 7 個位元組都沒用到,最後一個位元組第 1 位沒用到,接着 4 位雖然也是 0 但其實它表示目前對象分代年齡是 0,緊跟着後面的 1 表示可偏向,最後的 01 表示偏向鎖;
- 加鎖後:前 54 位表示已經偏向的線程 id,緊跟着 2 位是 epoch 即偏向過期辨別,最後一個位元組同上;
- 釋放鎖:與釋放偏向鎖之前相同。
此處沒有發生 GC,是以分代年齡是 4 個 0。分代年齡占用 4 位,這也是為什麼分代年齡最大 15。
三種狀态最後 3 位都是 101,是以看到 101:
- 可能是未偏向,也可能是已偏向,主要看前面 54 位有沒有線程 id;
- 可能是偏向鎖被持有,也可能是偏向鎖已釋放,而且這兩種狀态 mark word 完全一樣,這個隻有 JVM 能根據 lock record 區分出來,需要周遊線程棧裡所有的 lock record,如果沒有任何 lock record 指向鎖對象,則鎖已被釋放,如果偏向的線程已經死了,自然偏向鎖也肯定已經釋放了。
下面驗證前 54 位是否真的是某個線程 id,要改一下代碼:
public static void main(String[] args) throws InterruptedException {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
//列印 thread id 沒用,thread id 并不是線程 id
System.out.println(Thread.currentThread().getId());
//睡着,不要讓 JVM 退出
TimeUnit.SECONDS.sleep(3600);
}
列印出來的 thread id 永遠是 1,因為 main 線程的 thread id 就是 1。
此時 mark word 列印出來是:
05 38 d1 3a 61 02 00 00
,未經颠倒。
使用 jps 指令,顯示出 java 程序:
D:\apps\java\jdk_1.8.0_261\bin>jps
14564
20760 Jps
14044 BiasedLockHeader
19532 Launcher
可以看到這裡跑着的 BiasedLockHeader 對應的 pid 是 14044。
執行 jstack 指令:
D:\apps\java\jdk_1.8.0_261\bin>jstack -l 14044 > D:\data\jvm\jstack_14044.log
到輸出的日志裡面找 main 線程 id,因為這裡偏向的肯定是 main 線程,可以找到類似如下資訊:
"main" #1 prio=5 os_prio=0 tid=0x000002613ad13800 nid=0xba4 waiting on condition [0x000000b3859ff000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at per.lvjc.concurrent.synchro.header.BiasedLockHeader.main(BiasedLockHeader.java:20)
這裡顯示的 tid 與上面列印出來的 mark word(颠倒之後)是對應的,最後的 05 不算在内,因為線程 id 隻用了 54 位。
10.4.4 hash code
這裡再提一下 hash code,因為 hash code 對偏向鎖是有影響的。
看下面的代碼:
package per.lvjc.concurrent.synchro.header;
import org.openjdk.jol.info.ClassLayout;
public class HashCodeHeader {
private static Object lock = new Object();
public static void main(String[] args) {
//計算 hash code 并列印出二進制字元串
System.out.println(Integer.toBinaryString(lock.hashCode()));
//列印對象頭
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
輸出結果:
1111011011110100110100110011
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 33 4d 6f (00000001 00110011 01001101 01101111) (1867330305)
4 4 (object header) 0f 00 00 00 (00001111 00000000 00000000 00000000) (15)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可以看到這裡 mark word 最後 3 位 001,偏向标志位 0 顯示目前鎖對象不可偏向。
這時候 mark word = 25 位未使用 + 31 位 hash code + 1 位未使用 + 4 位分代年齡 + 1 位偏向辨別 + 2 位鎖标記。
這裡的鎖标記 01 原本應該表示偏向鎖,但前面 1 位偏向辨別是 0,不可偏向,是以這裡表示的是(輕量鎖)無鎖。
順便一提,101 可能有偏向鎖,也可能已經釋放鎖,001 一定是無鎖。
至于那 31 位是不是 hash code,和上面輸出的 hash code 二進制字元串(隻有 28 位,前面補 3 個 0)對比一下就可以發現是一緻的。
這裡主要是為了說明:計算一個對象的 hash code,會導緻不可對這個對象加偏向鎖。因為計算 hash code 之後會存儲在mark word 裡,占用 31 位(64 位系統),而偏向的線程 id 要用 54 位,兩者無法并存。
10.4.5 再次加鎖
偏向鎖再次加鎖,也就是這種場景:
synchronized (lock) {
//...
}
synchronized (lock) {
//...
}
根據 JVM 源碼,再次加鎖時,它會依次判斷:
- 是否開啟偏向?當然已經開啟;
- 是否偏向自己,肯定是的,鎖對象的 mark word 在經過第一次加鎖之後已經記錄了自己的線程 id。
就這樣,加鎖完成。
10.4.6 重入
鎖重入:
synchronized (lock) {
//...
synchronized (lock) {
//...
}
}
加鎖流程與上面再次加鎖完全一樣。
不過這裡線上程棧裡會有兩個 lock record 指向鎖對象,每次加鎖都會産生一個 lock record。