1、鎖優化
在JDK6之前,通過synchronized來實現同步效率是很低的,被synchronized包裹的代碼塊經過javac編譯後,會在代碼塊前後加上monitorenter
和monitorexit
字節碼指令,被synchronized修飾的方法則會被加上ACC_SYNCHRONIZED
標識,不論是在字節碼中如何表示,作用和功能都是一樣的,線程要想執行同步代碼塊或同步方法,首先需要競爭鎖。
synchronized保證了任意時刻最多隻有一個線程可以競爭到鎖,那麼競爭不到鎖的的線程該如何處理呢?
在JDK6之前,Java直接通過OS級別的互斥量(Mutex)來實現同步,獲取不到鎖的線程被阻塞掛起,直到持有鎖的線程釋放鎖後再將其喚醒,這需要OS頻繁的將線程從用戶態切換到核心態,這個切換過程開銷是很大的,OS需要暫停原線程並保存數據,喚醒新線程並恢復數據,因此synchronized也被稱爲“重量級鎖”。
也正是由於性能原因,開發者慢慢擯棄了synchronized,投入ReentrantLock
的懷抱。
官方意識到這個問題以後,便將“高效併發”作爲JDK6的一個重要改進項目,經過開發團隊的重重優化,如今synchronized的性能已經和ReentrantLock保持在一個數量級了,雖然還是慢一丟丟,但是官方表示未來synchronized仍然有優化的餘地。
1.1、鎖消除
設計一個類時,考慮到存在併發安全問題,往往會對代碼塊上鎖。
但是有時候這個被設計爲“線程安全”的類在使用時壓根就不存在多線程競爭,那麼還有什麼理由加鎖呢?
鎖消除優化得益於逃逸分析技術的成熟,即時編譯器在運行時會對代碼進行掃描,會對不存在共享數據競爭的鎖消除。
例如:在方法中(棧內存線程私有)實例化一個線程安全的類,該實例既沒有傳遞給其他方法,又沒有作爲對象返回出去(沒有發生逃逸),那麼JVM就會對進行鎖消除。
如下代碼,儘管StringBuffer的append()是被synchronized修飾的,但是不存在線程競爭,鎖會消除。
public String method(){
StringBuffer sb = new StringBuffer();
sb.append("1");//append()是被synchronized修飾的
sb.append("2");
return sb.toString();
}
1.2、鎖粗化
由於鎖的競爭和釋放開銷比較大,如果代碼中對鎖進行了頻繁的競爭和釋放,那麼JVM會進行優化,將鎖的範圍適當擴大。
如下代碼,在循環內使用synchronized,JVM鎖粗化後,會將鎖範圍擴大到循環外。
public void method(){
for (int i= 0; i < 100; i++) {
synchronized (this){
...
}
}
}
1.3、自旋鎖
當有多個線程在競爭同一把鎖時,競爭失敗的線程如何處理?
兩種情況:
- 將線程掛起,鎖釋放後再將其喚醒。
- 線程不掛起,進行自旋,直到競爭成功。
如果鎖競爭非常激烈,且短時間得不到釋放,那麼將線程掛起效率會更高,因爲競爭失敗的線程不斷自旋會造成CPU空轉,浪費性能。
如果鎖競爭並不激烈,且鎖會很快得到釋放,那麼自旋效率會更高。因爲將線程掛起和喚醒是一個開銷很大的操作。
自旋鎖的優化是針對“鎖競爭不激烈,且會很快釋放”的場景,避免了OS頻繁掛起和喚醒線程。
1.4、自適應自旋鎖
當線程競爭鎖失敗時,自旋和掛起哪一種更高效?
當線程競爭鎖失敗時,會自旋10次,如果仍然競爭不到鎖,說明鎖競爭比較激烈,繼續自旋會浪費性能,JVM就會將線程掛起。
在JDK6之前,自旋的次數通過JVM參數-XX:PreBlockSpin
設置,但是開發者往往不知道該設置多少比較合適,於是在JDK6中,對其進行了優化,加入了“自適應自旋鎖”。
自適應自旋鎖的大致原理:線程如果自旋成功了,那麼下次自旋的最大次數會增加,因爲JVM認爲既然上次成功了,那麼這一次也很大概率會成功。
反之,如果很少會自旋成功,那麼下次會減少自旋的次數甚至不自旋,避免CPU空轉。
1.5、鎖膨脹
除了上述幾種優化外,JDK6加入了新型的鎖機制,不直接採用OS級的“重量級鎖”,鎖類型分爲:偏向鎖、輕量級鎖、重量級鎖。隨着鎖競爭的激烈程度不斷膨脹,大大提升了競爭不太激烈的同步性能。
“synchronized鎖的是對象,而非代碼!”
每一個Java對象,在JVM中是存在對象頭(Object Header)的,對象頭中又分Mark Word和Klass Pointer,其中Mark Word就保存了對象的鎖狀態信息,其結構如下圖所示:
無鎖:初始狀態
一個對象被實例化後,如果還沒有被任何線程競爭鎖,那麼它就爲無鎖狀態(01)。
偏向鎖:單線程競爭
當線程A第一次競爭到鎖時,通過CAS操作修改Mark Word中的偏向線程ID、偏向模式。如果不存在其他線程競爭,那麼持有偏向鎖的線程將永遠不需要進行同步。
輕量級鎖:多線程競爭,但是任意時刻最多隻有一個線程競爭
如果線程B再去競爭鎖,發現偏向線程ID不是自己,那麼偏向模式就會立刻不可用。即使兩個線程不存在競爭關係(線程A已經釋放,線程B再去獲取),也會升級爲輕量級鎖(00)。
重量級鎖:同一時刻多線程競爭
一旦輕量級鎖CAS修改失敗,說明存在多線程同時競爭鎖,輕量級鎖就不適用了,必須膨脹爲重量級鎖(10)。此時Mark Word存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程必須進入阻塞狀態。
2、鎖膨脹實戰
說了這麼多,理論終歸是理論,不如實戰一把來的直接。
通過編寫一些多線程競爭代碼,以及打印對象的頭信息,來分析哪些情況下鎖會膨脹,以及膨脹成哪種類型的鎖。
2.1、jol工具
openjdk提供了jol工具,可以打印對象的內存佈局信息,依賴如下:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
2.2、鎖膨脹測試代碼
程序啓動時先sleep5秒是爲了等待偏向鎖系統啓動。
編寫一段鎖逐步膨脹的測試代碼,如下所示:
public class LockTest {
static class Lock{}
public static void main(String[] args) {
sleep(5000);
Lock lock = new Lock();
System.err.println("無鎖");
print(lock);
synchronized (lock) {
//main線程首次競爭鎖,可偏向
System.err.println("偏向鎖");
print(lock);
}
new Thread(()->{
synchronized (lock){
//線程A來競爭,偏向線程ID不是自己,升級爲:輕量級鎖
System.err.println("輕量級鎖");
print(lock);
}
},"Thread-A").start();
sleep(2000);
new Thread(()->{
synchronized (lock){
sleep(1000);
}
},"Thread-B").start();
//確保線程B啓動並獲得鎖,sleep 100毫秒
sleep(100);
synchronized (lock){
//main線程競爭時,線程B還未釋放,多線程同時競爭,升級爲:重量級鎖
System.err.println("重量級鎖");
print(lock);
}
}
static void print(Object o){
System.err.println("==========對象信息開始...==========");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
//jol異步輸出,防止打印重疊,sleep1秒
sleep(1000);
System.err.println("==========對象信息結束...==========");
}
static void sleep(long l){
try {
Thread.sleep(l);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.3、輸出分析
運行後分析一下控制檯輸出信息,這裏貼上截圖並寫上註釋:
無鎖
偏向鎖
輕量級鎖
重量級鎖
以上,就是JVM中鎖逐步膨脹的過程,另外:鎖不支持回退撤銷。
2.4、鎖釋放
偏向鎖是不會主動釋放的,只要沒有其他線程競爭,會永遠偏向持有鎖的線程,這樣在以後的執行中,都不用再進行同步處理了,節省了同步開銷。
public static void main(String[] args) {
sleep(5000);
Lock lock = new Lock();
synchronized (lock){
System.err.println("Main線程首次競爭鎖");
print(lock);
}
System.out.println();
sleep(1000);
System.err.println("同步代碼塊退出以後");
print(lock);
}
輕量級和重量級鎖均會主動釋放,這裏只貼出輕量級鎖。
public static void main(String[] args) {
sleep(5000);
Lock lock = new Lock();
synchronized (lock){
//偏向鎖
}
new Thread(()->{
synchronized (lock){
System.err.println("輕量級鎖");
print(lock);
}
},"Thread-A").start();
sleep(5000);
System.err.println("\n線程A釋放鎖後");
print(lock);
}
重量級鎖類似,這裏就不貼測試結果了。
3、一致性哈希對鎖膨脹的影響
一個對象如果計算過哈希碼,就應該一直保持該值不變(強烈推薦但不強制,因爲用戶可以重載hashCode()方法按自己的意願返回哈希碼)。
在Java中,如果類沒有重寫hashCode(),那麼會自動繼承自Object::hashCode(),Object::hashCode()就是一致性哈希,只要計算過一次,就會將哈希碼寫入到對象頭中,且永遠不會改變。
和具體的哈希算法有關,JVM裏有五種哈希算法,通過參數
-XX:hashCode=[0|1|2|3|4]
指定。
只要對象計算過一致性哈希,偏向模式就置爲0了,也就意味着該對象鎖不能再偏向了,最低也會膨脹會輕量級鎖。
如果對象鎖處於偏向模式時遇到計算一致性哈希請求,那麼會跳過輕量級鎖模式,直接膨脹爲重量級鎖。
鎖膨脹爲輕量級或重量級鎖後,Mark Word中保存的分別是線程棧幀裏的鎖記錄指針和重量級鎖指針,已經沒有位置再保存哈希碼,GC年齡了,那麼這些信息被移動到哪裏去了呢?
升級爲輕量級鎖時,JVM會在當前線程的棧幀中創建一個鎖記錄(Lock Record)空間,用於存儲鎖對象的Mark Word拷貝,哈希碼和GC年齡自然保存在此,釋放鎖後會將這些信息寫回到對象頭。
升級爲重量級鎖後,Mark Word保存的重量級鎖指針,代表重量級鎖的ObjectMonitor類裏有字段記錄無鎖狀態下的Mark Word,鎖釋放後也會將信息寫回到對象頭。
代碼實戰,跳過偏向鎖,直接膨脹輕量級鎖
public static void main(String[] args) {
sleep(5000);
Lock lock = new Lock();
//沒有重寫,一致性哈希,重寫後無效
lock.hashCode();
synchronized (lock){
System.err.println("本應是偏向鎖,但是由於計算過一致性哈希,會直接膨脹爲輕量級鎖");
print(lock);
}
}
偏向鎖過程中遇到一致性哈希計算請求,立馬撤銷偏向模式,膨脹爲重量級鎖
public static void main(String[] args) {
sleep(5000);
Lock lock = new Lock();
synchronized (lock){
//沒有重寫,一致性哈希,重寫後無效
lock.hashCode();
System.err.println("偏向鎖過程中遇到一致性哈希計算請求,立馬撤銷偏向模式,膨脹爲重量級鎖");
print(lock);
}
}
4、鎖性能測試
這裏只做了一個簡單的測試,實際應用環境比測試環境要複雜的多。
單線程下,各類型鎖性能測試:
public class PerformanceTest {
final static int TEST_COUNT = 100000000;
static class Lock{}
public static void main(String[] args) {
sleep(5000);
System.err.println("各類型鎖性能測試");
Lock lock = new Lock();
long start;
long end;
start = System.currentTimeMillis();
for (int i = 0; i < TEST_COUNT; i++) {
}
end = System.currentTimeMillis();
System.out.println("無鎖:" + (end - start));
//偏向鎖
biasedLock(lock);
start = System.currentTimeMillis();
for (int i = 0; i < TEST_COUNT; i++) {
synchronized (lock) {}
}
end = System.currentTimeMillis();
System.out.println("偏向鎖耗時:" + (end - start));
//輕量級鎖
lightweightLock(lock);
start = System.currentTimeMillis();
for (int i = 0; i < TEST_COUNT; i++) {
synchronized (lock) {}
}
end = System.currentTimeMillis();
System.out.println("輕量級鎖耗時:" + (end - start));
//重量級鎖
weightLock(lock);
start = System.currentTimeMillis();
for (int i = 0; i < TEST_COUNT; i++) {
synchronized (lock) {}
}
end = System.currentTimeMillis();
System.out.println("重量級鎖耗時:" + (end - start));
}
static void biasedLock(Object o){
synchronized (o){}
}
//將鎖升級爲輕量級
static void lightweightLock(Object o){
biasedLock(o);
Thread thread = new Thread(() -> {
synchronized (o) {}
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//將鎖升級爲重量級
static void weightLock(Object o){
lightweightLock(o);
Thread t1 = new Thread(() -> {
synchronized (o){
sleep(1000);
}
});
Thread t2 = new Thread(() -> {
synchronized (o){
sleep(1000);
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static void sleep(long l){
try {
Thread.sleep(l);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
各類型鎖性能測試
無鎖:6
偏向鎖耗時:252
輕量級鎖耗時:2698
重量級鎖耗時:1471
由於是單線程,不涉及鎖競爭,重量級鎖反而比輕量級鎖更快,因爲不需要OS對線程進行額外的調度,線程無需掛起和喚醒,而且不用拷貝Mark Word。
在多線程競爭環境下,重量級鎖性能下降是毋庸置疑的,如下測試:
public static void main(String[] args) throws InterruptedException {
System.err.println("多線程測試");
Lock lock = new Lock();
long start;
long end;
//輕量級鎖
lightweightLock(lock);
start = System.currentTimeMillis();
for (int i = 0; i < TEST_COUNT; i++) {
synchronized (lock) {}
}
end = System.currentTimeMillis();
System.out.println("輕量級鎖耗時:" + (end - start));
//重量級鎖
weightLock(lock);
Thread t1 = new Thread(() -> {
for (int i = 0; i < TEST_COUNT / 2; i++) {
synchronized (lock) {}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < TEST_COUNT / 2; i++) {
synchronized (lock) {}
}
});
t1.start();
t2.start();
start = System.currentTimeMillis();
t1.join();
t2.join();
end = System.currentTimeMillis();
System.out.println("重量級鎖耗時:" + (end - start));
}
多線程測試
輕量級鎖耗時:2581
重量級鎖耗時:4460
實際的應用環境遠比測試環境複雜的多,鎖性能和線程競爭的激烈程度、鎖佔用的時間也有很大關係,測試結果僅供參考。