前言
並發編程式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中)
- 多線程交替執行
前兩點讀者可自行打印輸出看看,這里主要來看最后一點,使用如下程序(記得關閉偏向延遲):
@Slf4j
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() {
Thread.sleep(3000);
testLock();
}
};
t1.setName("t1");
t1.start();
t2.setName("t2");
t2.start();
}
public static void testLock() {
synchronized (l) {
log.debug("name:" + Thread.currentThread().getName());
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
}
這里創建了兩個線程t1、t2,t1先調用一次testLock方法,然后使用sleep睡眠讓出cpu,這時候讓t2拿到cpu和鎖調用一次,確保t2執行完成后t1再調用一次(注意不要兩個線程產生競爭),形成交替執行testLock方法,最終打印如下:
注意t1首先獲取到的偏向鎖,之后t2打印可以看到升級為輕量級鎖,接着t1醒來打印也是獲取的輕量級鎖(如果發生競爭就會膨脹為重量級鎖10),對象頭中存儲的是Lock Record的地址,和我們猜測相符合。
重量鎖
最后去掉上面代碼中的兩個sleep並刪掉t1中的一個調用,這樣兩個線程就會發生競爭膨脹為重量鎖:
可以看到和我們的理論也是相符合的。
總結
本篇是並發系列的第一篇,也是synchronized原理的第一篇,主要分析了鎖對象在內存中的布局情況以及鎖膨脹的過程,並通過代碼驗證了所學理論,但synchronized的實現原理是非常復雜的,尤其是優化過后。更深入的內容將在后面的文章中逐步展開,另外讀者們可以思考一個問題,synchronized有沒有使用自旋鎖來優化?
我的博客即將同步至騰訊雲+社區,邀請大家一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=37nlwj3o8puso