8分鐘搞懂Java中的各種鎖

测试蔡坨坨發表於2024-03-30

轉載請註明出處❤️

作者:測試蔡坨坨

原文連結:caituotuo.top/f9fc66cb.html


前言

你好,我是測試蔡坨坨。

在前幾篇Redis相關文章中都說到了鎖,同時我們在參加設計評審或者codeReview時也會接觸到關於加鎖的問題。因此,作為測試人員,還是很有必要搞懂相關的鎖機制。

你是否背了很多關於鎖的面試題,但還是沒有搞懂鎖到底有哪些東西,學了很多鎖之後,發現越搞越模糊。

不要慌,本篇我們就來聊一聊Java中的各種鎖。

什麼是鎖

說到鎖,我們自然而然會想到Synchronized、Lock、Reentrantlock、分散式鎖等很多鎖的型別。

那麼第一個問題,我們要搞清楚鎖到底解決什麼問題?

很簡單,鎖要解決的一個問題就是執行緒安全問題。

所謂執行緒安全,主要體現在三方面:原子性、可見性和有序性。

  • 原子性:提供互斥訪問,同一時刻只能有一個執行緒對資料進行操作。
  • 可見性:一個執行緒對主記憶體的修改可以及時被其他執行緒看到。
  • 有序性:一個執行緒觀察其他執行緒的指令執行順序,由於在JMM中允許編譯器和處理器對指令重排序,因此該觀察結果一般雜亂無序。

而Synchronized同步關鍵字是可以解決我們在多執行緒開發領域中涉及到的執行緒安全問題。

執行緒安全問題在實際開發中又是如何體現的呢?

舉個簡單的栗子,有一個int型別的i=0存在主記憶體中,有兩個執行緒Thread1和Thread2同時執行一個i++操作,此時這個結果可能等於1,也可能等於2。為什麼呢?

因為i++這個指令是非原子指令,i++在Java中是一條指令,但是最終轉成底層的彙編指令是三條指令:

  1. 先從記憶體載入i的值(get)
  2. 對i進行遞增(modify)
  3. 把i的值寫回到記憶體中(set)

兩個執行緒同時操作這三條指令時,就有可能兩個執行緒同時拿到i,結果就是i=2。

所以,我們在這種場景中需要加排他鎖,也叫同步鎖。

這裡的同步鎖起到什麼作用呢?

在沒有加鎖之前可能出現最終結果等於2的情況,是因為兩個執行緒同時執行,同時拿到i的值,也就是並行操作,而加上同步鎖就是讓並行變成序列。

同步鎖的特點就是多個執行緒訪問共享資源時,在同一時刻只允許一個執行緒訪問這個共享資源,這樣就能夠解決原子性問題。

功能層面

從功能層面來說,鎖在Java併發程式設計中只有兩類:共享鎖排它鎖

共享鎖也叫讀鎖,讀鎖的特點是在同一時刻允許多個執行緒搶佔到鎖。

排它鎖也叫寫鎖,寫鎖的特點是在同一時刻只允許一個執行緒搶佔到鎖。

效能和執行緒安全

我們經常聽到的樂觀/悲觀鎖、自旋鎖、可重入鎖、偏向鎖、輕量/重量級鎖又是什麼呢?

這就需要從第二個維度進行拆分,加鎖必然存在效能問題,因為加鎖使得並行變成序列,並行的效率一定比序列高,加鎖會造成阻塞。在軟體開發中需要考慮兩個點,效能和執行緒安全。例如:庫存扣減,既要保證效能,又要保證執行緒的併發安全,保證原子性的修改。

在這個層面上來說,我們如何最佳化,如何權衡效能和執行緒安全兩者之間的關係呢?

  • 鎖粒度的最佳化,把鎖的範圍縮小,保證鎖競爭的範圍在目標需求範圍內就好了。

  • 無鎖化程式設計(樂觀鎖)/悲觀鎖

    樂觀鎖是沒有加鎖的,它是透過一個資料的版本來控制多執行緒併發的資料修改安全性。

    既然說到了樂觀鎖,不得不提一嘴悲觀鎖,樂觀鎖和悲觀鎖有什麼區別呢?

    舉個例子,小明同學去上廁所,廁所裡有一萬個坑位,悲觀鎖的場景就是雖然有一萬個坑位,但是小明也擔心有人會來跟他搶一個坑位,於是小明上去二話不說就加了把鎖;樂觀鎖的場景就是一萬個坑位就小明一個人,小明進去之後也不用上鎖,也不擔心有人會來搶同一個坑位。

  • 偏向鎖/輕量級鎖(自旋鎖)/重量級鎖

    加鎖的本質實際上是去競爭一個同步狀態,如上圖所示,有一個檔案,兩個執行緒需要去競爭檔案的訪問資格,如何知道是否有訪問資格呢?可以透過一個同步標識,比如int status=0/1(0表示空閒,1表示繁忙),執行緒1競爭到鎖,進入訪問時將status修改成1,執行緒2再進入到鎖判斷時,只需要去判斷當前的status是否等於1即可。

    Synchronized是透過作業系統層面的Mutex機制(mutually exclusive)實現同步狀態,透過競爭Mutex機制,實現互斥狀態的處理。執行緒1和2去競爭Mutex的時候,會涉及到核心指令的呼叫,因為Mutex是作業系統層面提供的一個互斥機制,所以需要透過核心指令去呼叫這個機制來實現互斥競爭行為。

    這個地方就會涉及到使用者態核心態的切換,這個切換會佔用CPU資源,消耗效能,因為使用者執行緒要進入阻塞等待,然後切換到核心執行緒來執行,需要把當前執行的執行緒執行指令的上下文儲存起來,同時要切換到核心執行緒去執行指令,也就是會涉及到執行緒的阻塞喚醒以及上下文的儲存

    假設執行緒2競爭到了鎖,執行緒1就會進入阻塞等待,所以加鎖會影響效能。

    效能主要體現在三個方面:

    1.競爭同步狀態時涉及到上下文切換,也就是從使用者態到核心態的切換

    2.執行緒阻塞和喚醒的切換

    3.並行到序列的改變

    由於1和3無法改變,所以我們重點關注第二點執行緒的阻塞和喚醒,這個地方的切換是否能夠避免,也就是說執行緒2競爭到鎖之後,執行緒1不去阻塞等待,也就是讓執行緒1在阻塞之前進行重試(重試就是執行緒1第一次嘗試加鎖,發現執行緒2已經獲取到鎖,這時就進入下一次迴圈再進行重試)。這種方式也就是所謂的自旋鎖,在阻塞等待之前透過一定的自旋嘗試去競爭鎖資源,也叫做輕量級鎖

    咱就是說我們加鎖的程式碼有沒有可能壓根就不存在競爭場景?有可能。

    我們加鎖的目的是保證這段程式碼的執行緒安全性,但是有可能在實際開發中這段程式碼壓根就不存在競爭。

    舉個例子,前端頁面做了非空校驗,理論上傳給後端的引數就不會為空,但是也有可能有人直接呼叫介面傳一個空值,所以後端一般都做非空校驗,也叫做防禦性程式設計。

    同理,如果一段程式碼中鎖的競爭必要性不存在,但是我們又想保護這段程式碼,於是就引入了偏向鎖。

    所謂偏向鎖就是當執行緒1進入鎖的時候,如果當前不存在競爭,那麼它就會把這個鎖偏向執行緒1,執行緒1下次再進入的時候,就不再需要競爭鎖。

    簡而言之,偏向鎖可以認為沒有競爭,輕量級鎖存在輕微競爭,而重量級鎖就是整個的實現。

  • 鎖消除/鎖膨脹

    在jdk中還引入了鎖消除和鎖膨脹,這是編譯器層面的最佳化,主要最佳化加鎖的效能。

    鎖消除也就是程式碼本身可能就沒有執行緒安全問題,但是你又加了鎖,然後jvm編譯的時候發現這個地方加了鎖,導致無效競爭,那麼它就會把這個鎖消除掉。鎖膨脹是因為控制的鎖粒度太小,導致頻繁加鎖和釋放鎖,所以它就會把鎖的範圍擴大。

  • 讀寫鎖

    讀寫鎖也是一種最佳化,讀操作不會影響資料的準確性,因為它不會修改資料,也就是說讀操作不需要加鎖,針對讀多寫少的場景,讀寫鎖可以確保讀和讀不會互斥,不需要競爭鎖,而寫和寫實現互斥。

  • 公平鎖/非公平鎖

    公平鎖就是每個執行緒獲取鎖的順序是按照執行緒訪問鎖的先後順序獲取的;非公平鎖就是每個執行緒獲取鎖的順序是隨機的,並不會遵循先來後到的規則,所有執行緒會競爭獲取鎖。

    預設情況下鎖都是非公平的,比如Synchronized(只能為非公平鎖)、Reentrantlock(在建立Reentrantlock時可以手動指定成公平鎖),因為非公平鎖的效能要比公平鎖的效能更好,非公平鎖意味著減少了鎖的等待,減少了執行緒的阻塞和喚醒。

鎖的特性

第三維度從鎖的特性來說,又會有重入鎖和分散式鎖。

  • 重入鎖

    鎖主要用來控制多執行緒訪問問題,對於同一執行緒,如果連續兩次對同一把鎖進行加鎖,那麼這個執行緒就會被卡死,在實際開發中,方法之間的呼叫錯綜複雜,一不小心就可能在多個不同的方法中反覆呼叫lock(),造成死鎖。

    重入鎖就是用來解決這個問題的,使得同一執行緒可以對同一把鎖在不釋放的前提下,反覆加鎖不會導致執行緒卡死,唯一的一點就是需要保證lock()和unlock()的次數相同。

  • 分散式鎖

    分散式鎖是解決分散式架構下粒度的問題,解決的是程序維度的問題,而Synchronized是解決Java併發裡面的執行緒維度。關於分散式鎖更多知識點後面我們單獨來討論。

相關文章