首頁>技術>

這道題想考察什麼?

是否真正瞭解synchronized關鍵字

考察的知識點

synchronized關鍵字的使用、原理、最佳化等

考生應該如何回答

1、在Java中,synchronized關鍵字是一個輕量級的同步機制,也是我們在工作中用得最頻繁的,我們可以使用synchronized修飾一個方法,也可以用來修飾一個程式碼塊。那麼,你真的瞭解synchronized嗎?是騾子是馬,咱拿出來溜溜。

2、關於synchronized的使用,我相信只要正常做過Android或Java開發的工作,對此一定不會陌生。那麼請問,在static方法和非static方法前面加synchronized到底有什麼不同呢?這種問題,光文字解釋一點說服力都沒有,直接擼個程式碼驗證一下。

• static鎖

   public class SynchronizedTest {          private static int number = 0;          public static void main(String[] args) {           //建立5個執行緒,製造多執行緒場景           for (int i = 0; i < 5; i++) {               new Thread("Thread-" + i) {                   @Override                   public void run() {                       try {                           //sleep一個隨機時間                           Thread.sleep(new Random().nextInt(5) * 1000);                       } catch (InterruptedException e) {                           e.printStackTrace();                       }                       //呼叫靜態方法                       SynchronizedTest.testStaticSynchronized();                   }               }.start();           }       }          /**        * 靜態方法加鎖        */       public synchronized static void testStaticSynchronized() {           //對number加1操作           number++;           //列印(執行緒名 + number)           System.out.println(Thread.currentThread().getName() + " -> 當前number為" + number);       }   }         // logcat日誌   // Thread-4 -> 當前number為1   // Thread-3 -> 當前number為2   // Thread-1 -> 當前number為3   // Thread-0 -> 當前number為4   // Thread-2 -> 當前number為5

程式碼邏輯很簡單,建立5個執行緒去透過靜態方法操作number變數,因為是靜態方法,所以直接使用類名呼叫即可。根據logcat日誌的輸出,做到了同步,沒有併發異常。

• 非static鎖

   public class SynchronizedTest {          private static int number = 0;          public static void main(String[] args) {           //建立5個執行緒,製造多執行緒場景           for (int i = 0; i < 5; i++) {               new Thread("Thread-" + i) {                   @Override                   public void run() {                       try {                           //sleep一個隨機時間                           Thread.sleep(new Random().nextInt(5) * 1000);                       } catch (InterruptedException e) {                           e.printStackTrace();                       }                       SynchronizedTest test = new SynchronizedTest();                       //透過物件,呼叫非靜態方法                       test.testNonStaticSynchronized();                   }               }.start();           }       }          /**        * 非靜態方法加鎖        */       public synchronized void testNonStaticSynchronized() {           number++;           System.out.println(Thread.currentThread().getName() + " -> " + number);       }   }      // logcat日誌   // Thread-0 -> 當前number為1   // Thread-4 -> 當前number為1   // Thread-2 -> 當前number為3   // Thread-1 -> 當前number為4   // Thread-3 -> 當前number為4

這裡的程式碼與上面的程式碼邏輯一模一樣,唯一改變的就是因為方法沒有使用static修飾,所以使用建立物件並呼叫方法來操作number變數。看logcat日誌,出現了資料異常,很明顯不加static修飾,是沒辦法保證執行緒同步的。

• 另外我們再做一個測試,賣個關子,先來看程式碼。

   public class SynchronizedTest {          public static void main(String[] args) {           //建立5個執行緒呼叫非靜態方法           for (int i = 0; i < 5; i++) {               new Thread("Thread-" + i) {                   @Override                   public void run() {                       try {                           Thread.sleep(new Random().nextInt(5) * 1000);                       } catch (InterruptedException e) {                           e.printStackTrace();                       }                          //透過物件呼叫非靜態方法                       SynchronizedTest test = new SynchronizedTest();                       test.testNonStaticSynchronized();                   }               }.start();           }              //建立5個執行緒呼叫靜態方法           for (int i = 0; i < 5; i++) {               new Thread("Thread-" + i) {                   @Override                   public void run() {                       try {                           Thread.sleep(new Random().nextInt(5) * 1000);                       } catch (InterruptedException e) {                           e.printStackTrace();                       }                          //透過類名呼叫靜態方法                       SynchronizedTest.testStaticSynchronized();                   }               }.start();           }       }          /**        * 靜態方法加鎖        */       public synchronized static void testStaticSynchronized() {           System.out.println("testStaticSynchronized -> running -> " + System.currentTimeMillis());       }          /**        * 非靜態方法加鎖        */       public synchronized void testNonStaticSynchronized() {           System.out.println("testNonStaticSynchronized -> running -> " + System.currentTimeMillis());       }   }      // logcat日誌   // testNonStaticSynchronized -> running -> 1603433921735   // testNonStaticSynchronized -> running -> 1603433921735   // testStaticSynchronized -> running -> 1603433921735   // testNonStaticSynchronized -> running -> 1603433922740  ----- 注意這裡   // testStaticSynchronized -> running -> 1603433922740     ----- 注意這裡   // testStaticSynchronized -> running -> 1603433922740   // testNonStaticSynchronized -> running -> 1603433923735   // testNonStaticSynchronized -> running -> 1603433924740   // testStaticSynchronized -> running -> 1603433925736   // testStaticSynchronized -> running -> 1603433925736

程式碼邏輯是這樣的,各建立5個執行緒分別執行靜態方法與非靜態方法,看輸出日誌,特別關注一下最後的時間戳。從日誌第我們可以發現,從日誌第4行和第5行發現,testNonStaticSynchronized與testStaticSynchronized方法可以同時執行,那就說明static鎖與非statics鎖互不干預。

經過上面3個demo的分析,基本可以得出結論了,這裡總結一下。

• 類鎖:當synchronized修飾一個static方法時,獲取到的是類鎖,作用於這個類的所有物件。

• 物件鎖:當synchronized修飾一個非static方法時,獲取到的是物件鎖,作用於呼叫該方法的當前物件。

• 類鎖和物件鎖不同,他們之間不會產生互斥。

3、當然,關於如何使用的問題,上面就一筆帶過了,面試官一般也不會問很多,畢竟體現不出面試官的逼格。正所謂,知其然也要知其所以然,我們需要要探討的重點是synchronized關鍵字底層是怎麼幫我們實現同步的?沒錯,這也是面試過程中問得最多的。synchronized的原理這塊,我們也分兩種情況去思考。

• 第一,synchronized修飾程式碼塊。

public class SynchronizedTest {    public static void main(String[] args) {        //透過synchronized修飾程式碼塊        synchronized (SynchronizedTest.class) {            System.out.println("this is in synchronized");        }    }}

上面是一段演示程式碼,沒有實際功能,為了能夠看得簡單明瞭,就是透過synchronized對一條輸出語句進行加鎖。因為synchronized僅僅是Java提供的關鍵字,那麼要想知道底層原理,我們需要透過javap命令反編譯class檔案,看看他的位元組碼到底長啥樣。

看反編譯的結果,著重看紅色標註的地方。我們可以清楚地發現,程式碼塊同步是使用monitorenter和monitorexit兩個指令完成的,monitorenter指令插入到同步程式碼塊的開始位置,monitorexit插入到方法結束處和異常處,被同步的程式碼塊由monitorenter指令進入,然後在monitorexit指令處結束。

這裡的重要角色monitor到底是什麼呢?簡單來說,可以直接理解為鎖物件,只不過是虛擬機器實現的,底層是依賴於作業系統的Mutex Lock實現。任何Java物件都有一個monitor與之關聯,或者說所有的Java物件天生就可以成為monitor,這也就可以解釋我們平時在使用synchronized關鍵字時可以將任意物件作為鎖的原因了。

monitorenter

在執行monitorenter時,當前執行緒會嘗試獲取鎖,如果這個monitor沒有被鎖定,或者當前執行緒已經擁有了這個物件的鎖,那麼就把鎖的計數器加1,獲取鎖成功,繼續執行下面的程式碼。如果獲取鎖失敗了,那當前執行緒就要阻塞等待,直到物件鎖被另一個執行緒釋放為止。

monitorexit

與此對應的,當執行monitorexit指令時,鎖的計數器也會減1,當計數器等於0時,當前執行緒就釋放鎖,不再是這個monitor的所有者。這個時候,其他被這個monitor阻塞的執行緒便可以嘗試去獲取這個monitor的所有權了。

到這裡,synchronized修飾程式碼塊實現同步的原理,我相信你已經搞懂了吧,那趁熱打鐵,繼續看看修飾方法又是怎麼處理的。

• 第二,synchronized修飾方法。

public class SynchronizedTest {    public static void main(String[] args) {        doSynchronizedTest();    }    //透過synchronized修飾方法    public static synchronized void doSynchronizedTest(){        System.out.println("this is in synchronized");    }}

按照上面的老規矩,直接javap進行反編譯,看位元組碼的變化。

從反編譯的結果來看,這次方法的同步並沒有直接透過指令monitorenter和monitorexit來實現,但是相對於其他普通的方法,它的方法描述多了一個ACC_SYNCHRONIZED識別符號。想必你都能猜出來,虛擬機器無非就是根據這個識別符號來實現方法同步,其實現原理大致是這樣的:虛擬機器呼叫某個方法時,呼叫指令首先會檢查該方法的ACC_SYNCHRONIZED訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件,這樣也保證了同步。當然這裡monitor其實會有類鎖和物件鎖兩種情況,上面就有說到。

關於synchronized的原理,這邊再簡單總結一下。synchronized關鍵字的實現同步分兩種場景,程式碼塊同步是使用monitorenter和monitorexit指令的形式,而方法同步使用的ACC_SYNCHRONIZED識別符號的形式。但萬變不離其宗,這兩種形式的根本都是基於JVM進入和退出monitor物件鎖來實現操作同步。

4、扛到了這裡,是該小小的開心一下啦,不過並沒有完全結束呢!從上面的原理分析知道,synchronized關鍵字是基於JVM進入和退出monitor物件鎖來實現操作同步,這種搶佔式獲取monitor鎖,效能上鐵定堪憂呀。這時候煩人的面試官又上線了,請問JDK1.6以後對synchronized鎖做了哪些最佳化?這個問題難度較大,我們細細地說。

其實在JDK1.6之前,synchronized內部實現的鎖都是重量級鎖,也就是說沒有搶到CPU使用權的執行緒都得堵塞,然而在程式真正執行過程中,其實很多情況下並不需要這麼重,每次都直接堵塞反而會導致更多的執行緒上下文切換,消耗更多的資源。所以在JDK1.6以後,對synchronized鎖進行了最佳化,引入偏向鎖,輕量級鎖,重量級鎖的概念。

• 鎖資訊

熟悉synchronized原理的同學應該都知道,當一個執行緒訪問synchronized包裹的同步程式碼塊時,必須獲取monitor鎖才能進入程式碼塊,退出或丟擲異常時再去釋放monitor鎖。這裡就有問題了,執行緒需要獲取的synchronized鎖資訊是存在哪裡的呢?所以在介紹各種鎖的概念之前,我們必須先嚐試解答這個疑惑。

在學習JVM時,我們瞭解過一個物件是由三部分組成的,分別是物件頭、例項資料以及對齊填充。其中物件頭裡又儲存了物件本身執行時資料,包括雜湊碼、GC分代年齡,當然還有我們這裡要講的與鎖相關的標識,比如鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等等。

物件頭預設儲存的是無鎖狀態,隨著程式的執行,物件頭裡儲存的資料會隨著鎖標誌位的變化而變化,大致結構如下圖所示。

現在便可以解開上面的疑惑了,原來執行緒是透過獲取物件頭裡的相關鎖標識來獲取鎖資訊的。有了這個基礎,我們現在可以上正菜了,看synchronized鎖是怎麼一步一步升級最佳化的。

• 偏向鎖

鎖是用於併發場景的,然而,在大多數情況下,鎖其實並不存在多執行緒競爭,甚至都是由同一個執行緒多次獲取,所以沒有必要花太多代價去放在鎖的獲取上,這時偏向鎖就應運而生了。

偏向鎖,顧名思義,它會偏向於第一個訪問鎖的執行緒。當一個執行緒第一次訪問同步程式碼塊並嘗試獲取鎖時,會直接給執行緒加一個偏向鎖,並在物件頭的鎖記錄裡儲存鎖偏向的執行緒ID,這樣的話,以後該執行緒再次進入和退出同步程式碼塊時就不需要進行CAS等操作來加鎖和解鎖,只需檢視物件頭裡是否儲存著指向當前執行緒的偏向鎖即可。很明顯,偏向鎖的做法無疑是消除了同一執行緒競爭鎖的開銷,大大提高了程式的執行效能。

當然,如果在執行過程中,突然有其他執行緒搶佔該鎖,如果透過CAS操作獲取鎖成功,直接替換物件頭中的執行緒ID為新的執行緒ID,繼續會保持偏向鎖狀態;反之如果沒有搶成功時,那麼持有偏向鎖的執行緒會被掛起,造成STW現象,JVM會自動消除它身上的偏向鎖,偏向鎖升級為輕量級鎖。

• 輕量級鎖

輕量級鎖位於偏向鎖與重量級鎖之間,其主要目的是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。自旋鎖就是輕量級鎖的一種典型實現。

自旋鎖原理非常簡單,如果持有鎖的執行緒能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做進入阻塞掛起狀態,它們只需要稍微等一等,其實就是進行自旋操作,等持有鎖的執行緒釋放鎖後即可立即獲取鎖,這樣就進一步避免切換執行緒引起的消耗。

當然,自旋鎖也不是最終解決方案,比如遇到鎖的競爭非常激烈,或者持有鎖的執行緒需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是佔用cpu進行自旋檢查,這對於業務來講就是無用功。如果執行緒自旋帶來的消耗大於執行緒阻塞掛起操作的消耗,那麼自旋鎖就弊大於利了,所以這個自旋的次數是個很重要的閾值,JDK1.5預設為10次,在1.6引入了適應性自旋鎖,適應性自旋鎖意味著自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,基本認為一個執行緒上下文切換的時間是最佳的一個時間。

• 重量級鎖

自旋多次還是失敗後,一般就直接升級成重量級鎖了,也就是鎖的最高級別了,在上一篇synchronized的原理裡有講到其底層基於monitor物件實現,而monitor的本質又是依賴於作業系統的Mutex Lock實現。這裡其實又涉及到我們之前有篇文章講過的一個知識,頻繁切換執行緒的危害?因為作業系統實現執行緒之間的切換需要進行使用者態到核心態的切換,不用想就知道,切換成本當然就很高了。

當JVM檢查到重量級鎖之後,會把想要獲得鎖的執行緒進行阻塞,插入到一個阻塞佇列,被阻塞的執行緒不會消耗CPU,但是阻塞或者喚醒一個執行緒,都需要上面所說的從使用者態轉換到核心態,這個成本比較高,有可能比真正需要執行的同步程式碼的消耗還要大。

我們依次理了一遍,由無鎖到偏向鎖,再到輕量級鎖,最後升級為重量級鎖,具體升級流程參考下面這張整體圖。這一切的出發點都是為了最佳化效能,其實也給我們一線開發者一個啟示,並不是功能實現了,編碼也就結束了,後面還有很長的最佳化之路等待著你我,加油!

8
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • python程式設計操作office三劍客之Excel篇