簡介
多線程一直是面試中的重點和難點,無論你現在處於啥級別段位,對synchronized關鍵字的學習避免不了,這是我的心得體會。下面咱們以面試的思維來對synchronized做一個系統的描述,如果有面試官問你,說說你對synchronized的理解?你可以從synchronized使用層面,synchronized的JVM層面,synchronized的優化層面3個方面做系統回答,說不定面試官會對你刮目相看哦!文章會有大量的代碼是方便理解的,如果你有時間一定要動手敲下加深理解和記憶。如果這篇文章能對您能有所幫助是我創作路上最大欣慰。
synchronized使用層面
大家都知道synchronized是一把鎖,鎖究竟是什么呢?舉個例子,你可以把鎖理解為廁所門上那把鎖的唯一鑰匙,每個人要進去只能拿着這把鑰匙可以去開這個廁所的門,這把鑰匙在一時刻只能有一個人擁有,有鑰匙的人可以反復出入廁所,在程序中我們叫做這種重復出入廁所行為叫鎖的可重入。它可以修飾靜態方法,實例方法和代碼塊 ,那下面我們一起來看看synchronized用於同步代碼鎖表達的意思。
- 對於普通同步方法,鎖的是對象實例。
- 對於靜態同步方法,鎖的是類的Class對象。
- 對於同步代碼塊,鎖的是括號中的對象。
先說下同步和異步的概念。
- 同步:交替執行。
- 異步:同時執行。
舉個例子比如吃飯和看電視兩件事情,先吃完飯后再去看電視,在時間維度上這兩件事是有先后順序的,叫同步。可以一邊吃飯,一邊看刷劇,在時間維度上是不分先后同時進行的,飯吃完了電視也看了,就可以去學習了,這就是異步,異步的好處是可以提高效率,這樣你就可以節省時間去學習了。
下面我們看看代碼,代碼中有做了很詳細的注釋,可以復制到本地進行測試。如果有synchronized基礎的童鞋,可以跳過鎖使用層面的講解。
1 /** 2 * @author :jiaolian 3 * @date :Created in 2020-12-17 14:48 4 * @description:測試靜態方法同步和普通方法同步是不同的鎖,包括synchronized修飾的靜態代碼塊用法; 5 * @modified By: 6 * 公眾號:叫練 7 */ 8 public class SyncTest { 9 10 public static void main(String[] args) { 11 Service service = new Service(); 12 /** 13 * 啟動下面4個線程,分別測試m1-m4方法。 14 */ 15 Thread threadA = new Thread(() -> Service.m1()); 16 Thread threadB = new Thread(() -> Service.m2()); 17 Thread threadC = new Thread(() -> service.m3()); 18 Thread threadD = new Thread(() -> service.m4()); 19 threadA.start(); 20 threadB.start(); 21 threadC.start(); 22 threadD.start(); 23 24 } 25 26 /** 27 * 此案例說明了synchronized修飾的靜態方法和普通方法獲取的不是同一把鎖,因為他們是異步的,相當於是同步執行; 28 */ 29 private static class Service { 30 /** 31 * m1方法synchronized修飾靜態方法,鎖表示鎖定的是Service.class 32 */ 33 public synchronized static void m1() { 34 System.out.println("m1 getlock"); 35 try { 36 Thread.sleep(2000); 37 } catch (InterruptedException e) { 38 e.printStackTrace(); 39 } 40 System.out.println("m1 releaselock"); 41 } 42 43 /** 44 * m2方法synchronized修飾靜態方法,鎖表示鎖定的是Service.class 45 * 當線程AB同時啟動,m1和m2方法是同步的。可以證明m1和m2是同一把鎖。 46 */ 47 public synchronized static void m2() { 48 System.out.println("m2 getlock"); 49 System.out.println("m2 releaselock"); 50 } 51 52 /** 53 * m3方法synchronized修飾的普通方法,鎖表示鎖定的是Service service = new Service();中的service對象; 54 */ 55 public synchronized void m3() { 56 System.out.println("m3 getlock"); 57 try { 58 Thread.sleep(1000); 59 } catch (InterruptedException e) { 60 e.printStackTrace(); 61 } 62 System.out.println("m3 releaselock"); 63 } 64 65 /** 66 * 1.m4方法synchronized修飾的同步代碼塊,鎖表示鎖定的是當前對象實例,也就是Service service = new Service();中的service對象;和m3一樣,是同一把鎖; 67 * 2.當線程CD同時啟動,m3和m4方法是同步的。可以證明m3和m4是同一把鎖。 68 * 3.synchronized也可以修飾其他對象,比如synchronized (Service.class),此時m4,m1,m2方法是同步的,啟動線程ABD可以證明。 69 */ 70 public void m4() { 71 synchronized (this) { 72 System.out.println("m4 getlock"); 73 System.out.println("m4 releaselock"); 74 } 75 } 76 77 } 78 }
經過上面的測試,你可以能會有疑問,鎖既然是存在的,那它存儲在什么地方?答案:對象里面。下面我們用代碼來證明下。
鎖在對象頭里面,一個對象包括對象頭,實例數據和對齊填充。對象頭包括MarkWord和對象指針,對象指針是指向方法區的對象類型的,,實例對象就是屬性數據,一個對象可能有很多屬性,屬性是動態的。對齊填充是為了補齊字節數的,如果對象大小不是8字節的整數倍,需要補齊剩余的字節數,這是方便計算機來計算的。在64位機器里面,一個對象的對象頭一般占12個自己大小,在64位操作系統一般占4個字節,所以MarkWord就是8個字節了。
MarkWord包括對象hashcode,偏向鎖標志位,線程id和鎖的標識。為了方便測試對象頭的內容,需要引入maven openjdk的依賴包。
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
/** * @author :duyang * @date :Created in 2020-05-14 20:21 * @description:對象占用內存 * @modified By: * * Fruit對象頭是12字節(markword+class) * int 占4個字節 * * 32位機器可能占8個字節; * * Object對象頭12 對齊填充4 一共是16 */ public class ObjectMemory { public static void main(String[] args) { //System.out.print(ClassLayout.parseClass(Fruit.class).toPrintable()); System.out.print(ClassLayout.parseInstance(Fruit.class).toPrintable()); } } /** *Fruit 測試類 */ public class Fruit { //占一個字節大小 private boolean flag; }
測試結果:下面畫紅線的3行分別表示對象頭,實例數據和對齊填充。對象頭是12個字節,實例數據Fruit對象的一個boolean字段flag占1個字節大小,其余3個字節是對齊填充的部分,一共是16個字節大小。
咦?你說的鎖呢,怎么沒有看到呢?小伙,別着急,待會我們講到synchronized升級優化層面的時候再來詳細分析一波。下面我們先分析下synchronized在JVM層面的意思。
最后上圖文總結:
synchronized JVM層面
1 /** 2 * @author :jiaolian 3 * @date :Created in 2020-12-20 13:43 4 * @description:鎖的jvm層面使用 5 * @modified By: 6 * 公眾號:叫練 7 */ 8 public class SyncJvmTest { 9 public static void main(String[] args) { 10 synchronized (SyncJvmTest.class) { 11 System.out.println("jvm同步測試"); 12 } 13 } 14 }
上面的案例中,我們同步代碼塊中我們簡單輸出一句話,我們主要看看jvm中它是怎么實現的。我們用Javap -v SyncJvmTest.class反編譯出上面的代碼,如下圖所示。
上圖第一行有一個monitorenter和第六行一個monitorexit,中間的jvm指令(2-5行)對應的Java代碼中的main方法的代碼,synchronized就是依賴於這兩個指令實現。我們來看看JVM規范中monitorenter語義。
- 每個對象都有一把鎖,當一個線程進入同步代碼塊,都會去獲取這個對象所持有monitor對象鎖(C++實現),如果當前線程獲取鎖,會把monitor對象進入數自增1次。
- 如果該線程重復進入,會把monitor對象進入數再次自增1次。
- 當有其他線程進入,會把其他線程放入等待隊列排隊,直到獲取鎖的線程將monitor對象的進入數設置為0釋放鎖,其他線程才有機會獲取鎖。
synchronized的優化層面
synchronized是一個重量級鎖,主要是因為線程競爭鎖會引起操作系統用戶態和內核態切換,浪費資源效率不高,在jdk1.5之前,synchronized沒有做任何優化,但在jdk1.6做了性能優化,它會經歷偏向鎖,輕量級鎖,最后才到重量級鎖這個過程,在性能方面有了很大的提升,在jdk1.7的ConcurrentHashMap是基於ReentrantLock的實現了鎖,但在jdk1.8之后又替換成了synchronized,就從這一點可以看出JVM團隊對synchronized的性能還是挺有信心的。下面我們分別來介紹下無鎖,偏向鎖,輕量級鎖,重量級鎖。下面我們我畫張圖來描述這幾個級別鎖的在對象頭存儲狀態。如圖所示。
- 無鎖。如果不加synchronized關鍵字,表示無鎖,很好理解。
- 偏向鎖。
- 升級過程:當線程進入同步塊時,Markword會存儲偏向線程的id並且cas將Markword鎖狀態標識為01,是否偏向用1表示當前處於偏向鎖(對着上圖來看),如果是偏向線程下次進入同步代碼只要比較Markword的線程id是否和當前線程id相等,如果相等不用做任何操作就可以進入同步代碼執行,如果不比較后不相等說明有其他線程競爭鎖,synchronized會升級成輕量級鎖。這個過程中在操作系統層面不用做內核態和用戶態的切換,減少切換線程帶來的資源消耗。
- 膨脹過程:當有另外線程進入,偏向鎖會升級成輕量級鎖。比如線程A是偏向鎖,這是B線程進入,就會成輕量級鎖,只要有兩個線程就會升級成輕量級鎖。
下面我們代碼來看下偏向鎖的鎖狀態。
1 package com.duyang.base.basic.markword; 2 3 import lombok.SneakyThrows; 4 import org.openjdk.jol.info.ClassLayout; 5 6 /** 7 * @author :jiaolian 8 * @date :Created in 2020-12-19 11:25 9 * @description:markword測試 10 * @modified By: 11 * 公眾號:叫練 12 */ 13 public class MarkWordTest { 14 15 private static Fruit fruit = new Fruit(); 16 17 public static void main(String[] args) throws InterruptedException { 18 Task task = new Task(); 19 Thread threadA = new Thread(task); 20 Thread threadB = new Thread(task); 21 Thread threadC = new Thread(task); 22 threadA.start(); 23 //threadA.join(); 24 //threadB.start(); 25 //threadC.start(); 26 } 27 28 private static class Task extends Thread { 29 30 @SneakyThrows 31 @Override 32 public void run() { 33 synchronized (fruit) { 34 System.out.println("==================="+Thread.currentThread().getId()+" "); 35 try { 36 Thread.sleep(3000); 37 } catch (InterruptedException e) { 38 e.printStackTrace(); 39 } 40 System.out.print(ClassLayout.parseInstance(fruit).toPrintable()); 41 } 42 } 43 } 44 }
上面代碼啟動線程A,控制台輸出如下圖所示,紅色標記3個bit是101分別表示,高位的1表示是偏向鎖,01是偏向鎖標識位。符合偏向鎖標識的情況。
- 輕量級鎖。
- 升級過程:在線程運行獲取鎖后,會在棧幀中創造鎖記錄並將MarkWord復制到鎖記錄,然后將MarkWord指向鎖記錄,如果當前線程持有鎖,其他線程再進入,此時其他線程會cas自旋,直到獲取鎖,輕量級鎖適合多線程交替執行,效率高(cas只消耗cpu,我在cas原理一篇文章中詳細講過。)。
- 膨脹過程:有兩種情況會膨脹成重量級鎖。1種情況是cas自旋10次還沒獲取鎖。第2種情況其他線程正在cas獲取鎖,第三個線程競爭獲取鎖,鎖也會膨脹變成重量級鎖。
下面我們代碼來測試下輕量級鎖的鎖狀態。
打開23行-24行代碼,執行線程A,B,我的目的是順序執行線程A B ,所以我在代碼中先執行threadA.join(),讓A線程先執行完畢,再執行B線程,如下圖所示MarkWord鎖狀態變化,線程A開始是偏向鎖用101表示,執行線程B就變成輕量級鎖了,鎖狀態變成了00,符合輕量級鎖鎖狀態。證明完畢。
- 重量級鎖。重量級鎖升級后是不可逆的,也就是說重量鎖不可以再變為輕量級鎖。
打開25行代碼,執行線程A,B,C,我的目的是先執行線程A,在代碼中先執行threadA.join(),讓A線程先執行完畢,然后再同時執行線程BC ,如下圖所示看看MarkWord鎖狀態變化,線程A開始是偏向鎖,到同時執行線程BC,因為有激烈競爭,屬於輕量級鎖膨脹條件第2種情況,當其他線程正在cas獲取鎖,第三個線程競爭獲取鎖,鎖也會膨脹變成重量級鎖。此時BC線程鎖狀態都變成了10,這種情況符合重量級鎖鎖狀態。膨脹重量級鎖證明完畢。
到此為止,我們已經把synchronized鎖升級過程中的鎖狀態通過代碼的形式都證明了一遍,希望對你有幫助。下圖是自己總結。
總結
多線程synchronized一直是個很重要的話題,也是面試中常見的考點。希望大家都能盡快理解掌握,分享給你們希望你們喜歡!
我是叫練,多叫多練,歡迎大家和我一起討論交流,我會盡快回復大家,喜歡點贊和關注哦!公眾號【叫練】。