1. 程式人生 > 實用技巧 >synchronized 是王的後宮總管,執行緒是王妃

synchronized 是王的後宮總管,執行緒是王妃

關注 「碼哥位元組」每一篇都是硬核,讀者群已開通,後臺回覆 「加群」一起成長。

假如 synchronized 是「王」身邊的「大總管」,那麼 Thread 就像是他後宮的王妃。「王」每日只能選擇一個王妃陪伴,王妃們會想方設法爭寵獲得陪伴權,大總管需要通過一定的手段讓王「翻牌」一個「王妃」與王相伴。

JMM 透析 volatile 與 synchronized 原理一文中講解了記憶體模型與併發實現原理的深層關係,今日聽「碼哥」胡言亂語解開 synchronized 大總管如何排程「王妃」陪伴「王」,王妃不同狀態的變化到底經歷了什麼?且看 synchronized 大總管又採取了哪些手段更加高效「翻牌」一個王妃,宮鬥還有 30 秒到達戰場!!!

目錄

「碼哥位元組」講通過幾個故事,通俗易懂的讓讀者朋友完全掌握 synchronized 的鎖優化(偏向鎖 -> 輕量級鎖 -> 重量級鎖)原理以及執行緒在 6 種狀態之間轉換的奧祕。

抽象出三個概念:Thread 對應後宮佳麗「王妃」,synchronized 是後宮大總管負責安排排程王妃,「王」則是被王妃們想要競爭的資源。

王妃的 6 種狀態

後宮佳麗等級森嚴,王妃們在這場權貴的遊戲中每個人的目的都是為獲取「王」寵愛,在遊戲中自身的狀態也隨著變化。

就像生物從出生到長大、最終死亡的過程一樣,「王妃」也有自己的生命週期,在 王妃的生命週期中一共有 6 種狀態。

  1. New(新入宮):Thread state for a thread which has not yet started.
  2. Runnable 可執行、就緒:(身體舒適,準備好了),Java 中的 Runable 狀態對應作業系統執行緒狀態中的兩種狀態,分別是 Running 和 Ready,也就是說,Java 中處於 Runnable 狀態的執行緒有可能正在執行,也有可能沒有正在執行,正在等待被分配 CPU 資源。
  3. Blocked 阻塞(身體欠佳、被打入冷宮)
  4. WAITING(等待):(等待傳喚)
  5. Timed Waiting(計時等待):在門外計時等待
  6. Terminated(終止):嗝屁

王妃在任意時刻只能是其中的一種狀態,通過 getState() 方法獲取執行緒狀態。

New 新入宮

第一日

「王」微服私訪,駕車遊玩,途徑桃花源。見風景宜人,如人間仙境。停車坐愛楓林晚,霜葉紅於二月花。此時此刻,一女子媚眼含羞合,丹脣逐笑開。風捲葡萄帶,日照石榴裙。從「王」面前走過,遭遇惡人。「王」拿起雙截棍哼哼哈嘿,飛簷走壁制服惡人,美人入懷,「香妃」便初入王宮。

「王」擬寫一份招入宮的詔書,上面寫著new Thread() ,「香妃」的名分便正式成立。New 表示執行緒被建立但是還沒有啟動的狀態,猶如「香妃」剛剛入宮,等待她後面的路程將是驚心動魄,蕩氣迴腸。

此刻 「皇后」(可以理解是 JVM)命令趙公公,為「香妃」分配寢宮(也就是分配記憶體),並初始化其身邊的「丫鬟」(初始化成員變數的值)。

Runnable 可執行、就緒

「香妃」獲得「王」的詔書,安排好衣食住行之後,便準備好陪伴王了。但是後宮佳麗很多,並不是所有人都能獲得陪伴權,「香妃」早已準備好,也在爭取可以獲得與「王」共舞的機會。便主動告知趙公公,自己琴棋書畫樣樣精通。希望得到安排,所以便被趙公公排程。「皇后」安排丫鬟為「香妃」沐浴更衣,抹上胭脂等待召喚(相當於執行緒的 start() 方法被呼叫)。(Java 虛擬機器會為其建立方法呼叫棧可程式計數器,等到排程執行)此刻執行緒就處於可執行狀態。

Java 中的 Runable 狀態對應作業系統執行緒狀態中的兩種狀態,分別是 Running 和 Ready,也就是說,Java 中處於 Runnable 狀態的執行緒有可能正在執行,也有可能沒有正在執行,正在等待被分配 CPU 資源。

如果一個正在執行的執行緒是 Runnable 狀態,當它執行到任務的一半時,執行該執行緒的 CPU 被排程去做其他事情,導致該執行緒暫時不執行,它的狀態依然不變,還是 Runnable,因為它有可能隨時被排程回來繼續執行任務。

注意:啟動執行緒使用 start() 方法,而不是 run() 方法。呼叫 start()方法啟動執行緒,系統會把該 run方法當成方法執行體處理。需要切記的是:呼叫了執行緒的 run()方法之後,該執行緒就不在處於新建狀態,不要再次呼叫 start()方法,只能對新建狀態的執行緒呼叫start()方法,否則會引發 IllegaIThreadStateExccption異常。

「香妃」沐浴更衣之後(被呼叫start() )便焚香撫琴,「淑妃」不跟示弱起舞弄影競爭陪伴權,「香妃」畢竟新來的,喜新厭舊的渣男「王」甚是寵愛,「香妃」獲得了今晚的陪伴權,獲得「皇后」給予 CPU 分片後,執行 run()方法,該方法核心功能就是造娃…..

在造娃之前,「香妃」經歷了很多紛爭,狀態也一直在變化。稍有不慎,可能會進入 TERMINATED 狀態,直接玩完。請繼續閱讀……

Waiting 等待、Timed Waiting 計時等待、Blocked 阻塞

原先被得寵的「淑妃」敗給了新人「香妃」。當進入寢宮之時,準備造娃,王有要事需要處理,便使用了 Object.wait() 技能卡,「香妃」只能等待王回來……

王歸來為了解鎖之前對「香妃」釋放的Object.wait()技能便釋放 Object.notify() 解鎖卡通知「香妃」可以一起麼麼噠了,此刻「香妃」竟然大小便失禁觸發了 Thread.join() 只好去上廁所,讓老王稍等片刻。

「淑妃」當晚雖然已經處於Runnable 態,但是被總管施展了 LockSupport.park() 技能卡,導致無法進入寢宮,狀態由 Runnable 變成了 Waiting 態。今夜無緣老王!!!

「咔妃」由於太黑了,直接被 synchronized 大總管拒之門外,從 Runnable 變成 Blocked 。

還有其他「妃」被老王放鴿子了,跟他們說三更之後見,這個時間管理,羅某人表示不服。她們分別被以下技能卡命中進入,直接進入 TIMED_WAITING 狀態 :

  1. Thread.sleep:
  2. Object.wait with timeout
  3. Thread.join with timeout
  4. LockSupport.parkNanos
  5. LockSupport.parkUntil

第二日

言歸正傳, 昨日「香妃」大小便失禁觸發了 Thread.join() 上完廁所後,跟王一番雲雨,終於天亮。

而被 synchronized 大總管拒之門外,從 Runnable 變成 Blocked 的「淑妃」在第二日得到老王的寵信。因為「香妃」竟然關鍵時刻大小便失禁,所以便想著找昨天差點被翻牌的「淑妃」。所以今日「淑妃」獲得了老王留給她的 monitor 鎖,得到了 syncronized 大總管的許可,由昨天的 Blocked 變成了Runnable ……

另外,有的王妃為了獲得陪伴權,或者想掌管後宮。陰謀詭計被識破,被判處 Terminated 刑罰,滅頂之災,強擼灰飛煙滅!

synchronized 總管如何提升效率翻牌

王妃們除了使用 LockSupport.unpark() 等獲取陪伴權,還可以通過由老王欽點大總管synchronized 去翻牌獲得陪伴權。面對三千佳麗,大總管必須要提高效率,不然將會累死而選不出一個王妃去陪伴老王,這可是要殺頭的。

因為在 Java 5 版本之前,synchronized 的篩選方法效率很差,一堆王妃跑進來吵著我行我上,秩序混亂,堪比 OFO 退押金現場,上一任總管就被殺頭了……

到了第 6 任以後,做了很大改善。運用了自適應自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖,效率大大提升。

自適應自旋

通知王妃們過來排隊,或者重新叫一個王妃來都是需要作業系統切換 CPU來完成,耗費時間。

為了讓當前申請陪伴的咖妃“稍等一下”, synchronized 大總管會讓王妃自旋,因為王與多爾袞處理軍事機密,很快就會回來。咖妃只需要每隔一段時間詢問大總管王是否歸來,一旦王歸來,那麼自己就不需要進入阻塞態,獲得今日與王為伴。避免因為要去通知多個王妃來競爭費時費力。

用一句話總結自旋鎖的好處,那就是自旋鎖用迴圈去不停地嘗試獲取鎖,讓執行緒始終處於 Runnable 狀態,節省了執行緒狀態切換帶來的開銷。

以下是自旋與非自旋獲取鎖的過程:

AtomicInteger

在 Java 1.5 版本及以上的併發包中,也就是 java.util.concurrent 的包中,裡面的原子類基本都是自旋鎖的實現。我們看下 AtomicInteger 類的定義:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
    
    ......
}

各屬性的作用:

  • unsafe: 獲取並操作記憶體的資料。
  • valueOffset: 儲存 value 在 AtomicInteger 中的偏移量。
  • value: 儲存 AtomicInteger 的 int 值,該屬性需要藉助 volatile 關鍵字保證其線上程間是可見的。

檢視 AtomicInteger 的自增函式 incrementAndGet() 的原始碼時,自增函式底層呼叫的是unsafe.getAndAddInt()。

但是由於JDK本身只有Unsafe.class,只通過class檔案中的引數名,並不能很好的瞭解方法的作用,我們通過OpenJDK 8 來檢視Unsafe的原始碼:

// JDK AtomicInteger 自增
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

// OpenJDK 8
// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
       v = getIntVolatile(o, offset);
   } while (!compareAndSwapInt(o, offset, v, v + delta));
   return v;
}

通過 do while 實現了自旋,getAndAddInt() 迴圈獲取給定物件 o 中的偏移量處的值 v,然後判斷記憶體值是否等於 v。如果相等則將記憶體值設定為 v + delta,否則返回false,繼續迴圈進行重試,直到設定成功才能退出迴圈,並且將舊值返回。

整個“比較 + 更新”操作封裝在 compareAndSwapInt() 中,在 JNI 裡是藉助於一個 CPU 指令完成的,屬於原子操作,可以保證多個執行緒都能夠看到同一個變數的修改值。

synchronized 大總管在 1.6 版本想出了自適應自旋鎖來解決長時間自旋的問題,防止一直傻傻等待傻傻的問。會根據最近自旋的成功率、失敗率。

如果最近嘗試自旋獲取某一把鎖成功了,那麼下一次可能還會繼續使用自旋,並且允許自旋更長的時間;但是如果最近自旋獲取某一把鎖失敗了,那麼可能會省略掉自旋的過程,以便減少無用的自旋,提高效率。

鎖消除

淑妃詭詐,在公元 107 年一個夜黑風高的夜晚,串通後廚小哲子放了無色無味的黯然銷魂藥,一個個有氣無力。

所以只剩下自己一個人向 synchronized 大總管申請,便不需要繁瑣流程,直搗黃龍。直接面見老王,無需加鎖。

鎖消除即刪除不必要的加鎖操作。虛擬機器即時編輯器在執行時,對一些“程式碼上要求同步,但是被檢測到不可能存在共享資料競爭”的鎖進行消除。

根據程式碼逃逸技術,如果判斷到一段程式碼中,堆上的資料不會逃逸出當前執行緒,那麼可以認為這段程式碼是執行緒安全的,不必要加鎖。

public class SynchronizedTest {

    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();

        for (int i = 0; i < 100000000; i++) {
            test.append("碼哥位元組", "def");
        }
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

雖然StringBuffer的append是一個同步方法,但是這段程式中的StringBuffer屬於一個區域性變數,並且不會從該方法中逃逸出去(即StringBuffer sb的引用沒有傳遞到該方法外,不可能被其他執行緒拿到該引用),所以其實這過程是執行緒安全的,可以將鎖消除。

鎖粗化

甄嬛深得老王寵愛,被偏愛的都有恃無恐。每次進出 synchronized 大總管張掛你的大門都需要驗證是否獲得 monitor 鎖,甄嬛進來後還喜歡出去外面走走過一會有進來看幾眼老王又出去,大總管也不用每次都要驗證,將限制的範圍就加大了,防止反覆驗證。

如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有出現執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

如果虛擬機器檢測到有一串零碎的操作都是對同一物件的加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部。

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("關注");
        stringBuffer.append("公眾號");
        stringBuffer.append("碼哥位元組");
    }
}

每次呼叫stringBuffer.append方法都需要加鎖和解鎖,如果虛擬機器檢測到有一系列連串的對同一個物件加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次 append 方法時進行加鎖,最後一次 append 方法結束後進行解鎖。

偏向鎖/輕量級鎖/重量級鎖

關於 synchronized 原理,在 從JMM 透析 volatile 與 synchronized 原理文中已經詳細闡述。本文主要講解針對 synchronized 效能低 JVM 所做的優化手段。

偏向鎖

老王偏愛甄嬛,synchronized 大總管便在一個叫 Mark Word 裡櫃子儲存鎖偏向的執行緒ID,記錄著甄嬛的 ID,不需要執行繁瑣的翻牌流程。只需要判斷下申請的王妃 ID 是否跟 櫃子裡記錄的 ID 一致。

因為王偏愛甄嬛,所以每次也喜歡翻甄嬛的牌。

當一個執行緒訪問同步程式碼塊並獲取鎖時,會在Mark Word裡儲存鎖偏向的執行緒ID。線上程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測 Mark Word 裡是否儲存著指向當前執行緒的偏向鎖。

引入偏向鎖是為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換ThreadID 的時候依賴一次 CAS 原子指令即可。

偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位為“01”)或輕量級鎖(標誌位為“00”)的狀態。

偏向鎖在JDK 6及以後的 JVM 裡是預設啟用的。可以通過 JVM 引數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程式預設會進入輕量級鎖狀態。

輕量級鎖

是指當鎖是偏向鎖的時候,被另外的執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高效能。

  1. 在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。這時候執行緒堆疊與物件頭的狀態如下圖所示。

  1. 拷貝Object 物件頭中的 Mark Word複製到 LockRecord 中。
  2. 拷貝成功後,虛擬機器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標,並將Lock record 裡的 owne r指標指向 object mark word。如果更新成功,則執行步驟 4。
  3. 如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如下圖所示。
  4. 如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,若當前只有一個等待執行緒,則可通過自旋稍微等待一下,可能另一個執行緒很快就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。

重量級鎖

如上輕量級鎖的加鎖過程步驟(5),輕量級鎖所適應的場景是執行緒近乎交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。Mark Word的鎖標記位更新為10,Mark Word指向互斥量(重量級鎖)

Synchronized 的重量級鎖是通過物件內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的作業系統的 Mutex Lock(互斥鎖)來實現的。而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼 Synchronized 效率低的原因。

鎖升級路徑

從無鎖到偏向鎖,再到輕量級鎖,最後到重量級鎖。結合前面我們講過的知識,偏向鎖效能最好,避免了 CAS 操作。而輕量級鎖利用自旋和 CAS 避免了重量級鎖帶來的執行緒阻塞和喚醒,效能中等。重量級鎖則會把獲取不到鎖的執行緒阻塞,效能最差。

綜上,偏向鎖通過對比 Mark Word 解決加鎖問題,避免執行CAS操作。而輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免執行緒阻塞和喚醒而影響效能。重量級鎖是將除了擁有鎖的執行緒以外的執行緒都阻塞。

讀者群已開通,加我微信備註加群,加入專屬技術群,獲取更多成長!

鳴謝

Java synchronized原理總結: https://zhuanlan.zhihu.com/p/29866981

不可不說的Java“鎖”事: https://tech.meituan.com/2018/11/15/java-lock.html