前言
并發編程式Java基礎,同時也是Java最難的一部分,因為與底層作業系統和硬體息息相關,并且程式難以除錯,本系列就從synchronized原理開始,逐步深入,領會并發編程之美,
正文
基礎稍微好點的同學應該都知道,Java中獲取鎖有兩種方式,一種是使用synchronized關鍵字,另外一種就是使用Lock介面的實作類,前者就是Java原生的方式,但在優化以前(JDK1.6)性能都不如Lock,因為在優化之前一旦使用synchronized就會發生系統呼叫進入內核態,所以性能很差,也因此大神Doug Lea自己寫了一套并發類,也就是JUC,并在JDK1.5版本引入進了Java類別庫,那么作為Java的親兒子synchronized自然也不能示弱啊,所以sun公司對其做了大量的優化,引入了偏向鎖、輕量級鎖、重量鎖、鎖消除、鎖粗化,才使得synchronized性能大大提升,
執行緒模型
Java的執行緒本質是什么? 首先我們需要了解執行緒的模型,實作執行緒有以下三種方式:
- 使用內核執行緒,即一對一模型
- 使用用戶執行緒,即一對多模型(一個內核執行緒對應多個用戶執行緒,如現在比較火的Golang)
- 混合實作,即多對多模型,這種比較復雜,不用太過深入,
而Java現在就是采用的一對一模型(JDK1.2以前是使用的用戶執行緒實作),即當呼叫start方法時都是真實地創建一個內核執行緒(KLT),但程式一般不會直接使用內核執行緒,而是使用內核執行緒的一種高級介面——輕量級行程(LWP),輕量級行程和內核執行緒也是一對一的關系,因此使用它可以保證每個執行緒都是一個獨立的調度單元,即當前執行緒阻塞了也不會影響整個行程作業,但帶來的問題就是在執行緒創建、銷毀、同步、切換等場景都會涉及系統呼叫,性能比較低;另外每個輕量級行程都要占據一定的系統資源,因此,能夠創建的執行緒數量是有限的,
鎖優化
因為大部分情況下不會出現執行緒競爭,所以為了避免執行緒每次遇到synchronized都直接進入內核態,sun公司使用大量的優化手段:
- 偏向鎖:當一個執行緒第一次獲得鎖后再次申請獲取就可以直接拿到鎖,相當于無鎖,這種情況下效率最高,
- 輕量級鎖:在沒有多執行緒競爭,但有多個執行緒交替執行情況下,避免呼叫系統函式mutex(特指linux系統)產生的性能消耗,
- 重量級鎖:發生了多執行緒競爭,就會呼叫mutex函式使得未獲取到鎖的執行緒進入睡眠狀態,
- 鎖消除:代碼經過逃逸分析后,判斷沒有資料會逃逸出執行緒,就不會給這段這段代碼加鎖,
- 鎖粗化:如果虛擬機檢測到有一系列零碎的操作都對同一物件加鎖,就會將整個同步操作擴大到這些操作的外部,這樣就只需要加鎖一次即可,
本篇主要討論鎖膨脹的程序對物件的影響,所以總結為一句話就是:當一個執行緒第一次獲取鎖后再去拿鎖就是偏向鎖,如果有別的執行緒和當前執行緒交替執行就膨脹為輕量級鎖,如果發生競爭就會膨脹為重量級鎖,這個就是synchronized鎖膨脹的原理,但并不完全正確,其中還有很多細節,下面就一步步來說明,
物件的記憶體布局
理論
物件在記憶體中是如何分配的呢?學過JVM的人應該都知道,如下圖:
但上圖只是說明了一個物件在記憶體中由哪幾部分組成,但具體每一部分多大,整個物件又有多大呢?比如下面這個類的物件在記憶體中占用多少個位元組:
public class A{}
32位和64位虛擬機表現不同,這里以主流的64位進行說明,一個物件在記憶體中存盤必須是8位元組的整數倍,其中物件頭占了12位元組,這里A物件沒有實體資料,所以還需要4位元組的對其填充,所以占用16位元組(如果該物件中有一個boolean物件的成員變數,這個物件又占用多少位元組呢),另外物件頭中也分為了兩部分,一部分是指向方法區元資料的型別指標(klass point),固定占用4位元組32位;另一部分則是則是用于存盤物件hashcode、分代年齡、鎖標識(偏向、輕量、重量)、執行緒id等資訊的mark word,占用8位元組64位,由于型別指標是固定的,下面主要討論mark word部分的記憶體布局,
我們可以看到在mark word中存盤了很多資訊,這么多資訊64位肯定是不夠存盤的,那怎么辦呢?虛擬機將mark word設計成為了一個非固定的動態資料結構,意思是它會根據當前的物件狀態存盤不同的資訊,達到空間復用的目的,下圖就是一個物件的mark word在不同的狀態下存盤的資訊:
從上圖我們可以發現無鎖、偏向鎖、輕量鎖、重量鎖分別的狀態是:01、01、00、10,偏向鎖同時還需要額外的以為表示是否可偏向,因為當一個物件持有偏向鎖時,需要在物件頭中存盤執行緒id和偏向時間戳,占用56bit,而物件的hashcode需要占用31bit,空間就不夠了,所以一旦物件呼叫了未重寫的hashcode方法就無法獲取偏向鎖,
另外我們可以看到當鎖膨脹為輕量鎖或重量鎖時,物件頭中62bit都用來存盤鎖記錄(Lock record)的地址了,那他們的分代年齡、hashcode這些資訊去哪了呢?其實就存在于鎖記錄空間中,而鎖記錄是存在于當前執行緒的堆疊幀中的,虛擬機會使用CAS操作嘗試把mark word指向當前的Lock record,如果修改成功,則當前執行緒獲取到該鎖,并標記為00輕量鎖,如果修改失敗,虛擬機會檢查物件的mark word是否指向當前執行緒的堆疊幀,如果是,則直接獲取鎖執行即可,否則則說明有其它執行緒和當前執行緒在競爭鎖資源,直接膨脹為重量級鎖,等待的執行緒則進入阻塞狀態,
證明
偏向鎖
上面說的都是理論,怎么證明呢?先引入下面這個依賴:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
然后針對之前創建的A類,執行下面的方法:
public class TestJol {
static A l = new A();
public static void main(String[] args) throws InterruptedException {
log.debug("執行緒還未啟動----無鎖");
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
控制臺就會列印如下資訊:
我們主要看到二進制部分內容前兩行內容(第三行是型別指標),按照之前所說,當前這個物件應該是無鎖可偏向狀態,那么前25個bit應該是未被使用的,后三個bit應該是101,中間部分也應該都是0,但是圖中顯示的和我們理論不符啊,別急,這其實是由于我們現在的家用電腦基本上采用的都是小端存盤導致的,那什么又是小端存盤呢?小端存盤就是高地址存高位元組,低地址存低位元組,
所以小端地址輸出的格式是反著的從右到左(反之大端存盤輸出格式就是符合我們人類閱讀習慣的格式),這里只是幫助理解,不深入探究大小端存盤問題,
因此之前輸出的資訊是符合我們上面所說的理論的,接著我們在輸出物件頭之前獲取下hashcode,看看會發生什么,main方法中增加下面這行代碼,
System.out.println(Integer.toHexString(l.hashCode()));
可以看到物件頭中存盤的hashcode和我們輸出的hashcode是一致的,同時狀態變為了無鎖不可偏向(001),
再來看看加鎖之后會有什么變化:
public static void testLock() {
//偏向鎖 首選判斷是否可偏向 判斷是否偏向了 拿到當前的id 通過cas 設定到物件頭
synchronized (l) {//t1 locked t2 ctlock
log.debug("name:" + Thread.currentThread().getName());
//有鎖 是一把偏向鎖
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
去掉hashcode方法的呼叫并呼叫這個方法,另外還需要關閉偏向延遲-XX:BiasedLockingStartupDelay=0,否則也會直接膨脹為輕量鎖,輸出結果如下:
可以看到在獲取偏向鎖后將執行緒id存入到了物件頭中,
輕量鎖
接下來我們看看膨脹為輕量鎖的程序,導致膨脹輕量鎖的原因主要有以下幾點:
- 呼叫了未重寫的hashcode方法
- 開啟了偏向延遲(因為我們是短時間執行程式,默認延遲時間是4s中)
- 多執行緒交替執行
前兩點讀者可自行列印輸出看看,這里主要來看最后一點,使用如下程式:
public class TestJol {
static A l = new A();
static Thread t1;
static Thread t2;
public static void main(String[] args) throws InterruptedException {
t1 = new Thread() {
@SneakyThrows
@Override
public void run() {
testLock();
Thread.sleep(1000);
testLock();
}
};
t2 = new Thread() {
@SneakyThrows
@Override
public void run() {
testLock();
Thread.sleep(2000);
testLock();
}
};
t1.setName("t1");
t1.start();
t2.setName("t2");
t2.start();
}
public static void testLock() {
//偏向鎖 首選判斷是否可偏向 判斷是否偏向了 拿到當前的id 通過cas 設定到物件頭
synchronized (l) {//t1 locked t2 ctlock
log.debug("name:" + Thread.currentThread().getName());
//有鎖 是一把偏向鎖
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
}
這里創建了兩個執行緒t1、t2,各自先呼叫一次testLock方法,然后使用sleep睡眠讓出cpu后再呼叫一次,形成交替執行testLock方法,最終列印如下:
注意t1和t2首次都是獲取到的偏向鎖,并且執行緒id是相同的,但是按理說執行緒id應該會變才對,這里筆者猜測為JVM優化,使得執行緒可以重用,但暫時還無法驗證,接著看后兩條記錄是睡眠之后列印的,這時t1和t2獲取到的鎖都是輕量級鎖了,物件頭中存盤的Lock record的地址,和我們猜測相符合,
重量鎖
最后去掉上面代碼中的兩個sleep,這樣兩個執行緒就會發生競爭膨脹為重量鎖:
可以看到和我們的理論也是相符合的,
總結
本篇是并發系列的第一篇,也是synchronized原理的第一篇,主要分析了鎖物件在記憶體中的布局情況以及鎖膨脹的程序,并通過代碼驗證了所學理論,但synchronized的實作原理是非常復雜的,尤其是優化過后,更深入的內容將在后面的文章中逐步展開,另外讀者們可以思考一個問題,synchronized有沒有使用自旋鎖來優化?
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/36335.html
標籤:Java
上一篇:java基本資料型別的轉換
下一篇:Java例外處理的兩種方式
