併發程式設計之 鎖的優化有哪些

莫那·魯道發表於2018-04-30

併發程式設計之 鎖的優化有哪些

前言

在 JDK 1.6 之前,synchronized 效能令人擔憂,但是 1.6 之後,JVM 團隊針對 synchronized 做了很多的優化,讓 synchroized 在效能層面相比較 ReentrantLock 不相上下。那麼,JVM 團隊做了哪些優化呢?

首先說,怎麼才能優化?我們知道,“鎖” 其實是互斥同步的具體實現,而互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要使用者態轉到核心態來完成。這些操作給系統的併發效能帶來了很大的壓力。

所以,優化的方向就是減少執行緒的阻塞,因為掛起執行緒和恢復執行緒需要切換到作業系統的核心狀態。

Java 1.6 為了減少獲得鎖和釋放鎖帶來的效能損耗,引入了 “偏向鎖“ 和 ”輕量級鎖“ ,在 Java SE 1.6 中,鎖一共有4個狀態,從低到高依次是:無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態。這幾個狀態會隨著競爭情況逐漸升級(即膨脹)。注意:鎖升級之後不能降級(具體原因後面講)。

  1. 偏向鎖
  2. 輕量級鎖
  3. 重量級鎖
  4. 鎖消除
  5. 鎖粗化
  6. 除了虛擬機器,程式設計師自己如何優化鎖

1. 偏向鎖

虛擬機器的團隊根據經驗發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是有同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單的測試一下物件頭的 “Mark Word” 裡是否儲存著指向當前執行緒的偏向鎖。

如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要再測試一下 Mark Word 中偏向鎖的標識是否設定了1(表示當前還是偏向鎖):如果沒有設定,則使用CAS 競爭鎖;如果設定了,則嘗試使用CAS 將物件頭的偏向鎖指向當前執行緒。

可以說,偏向鎖的 “偏”,就是偏心的 “偏”,他的意思就是這個鎖會偏向於第一個獲得他的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要同步

當有另外要給執行緒去嘗試獲取這個鎖時,偏向模式宣告結束,後續的操作將升級為輕量級鎖。

注意:偏向鎖可以提高有同步但無競爭的程式效能,他同樣有缺陷:如果程式中大多數的鎖總是被多個不同的執行緒訪問,那偏向模式就是多餘的。1.6之後的虛擬機器預設啟用偏向鎖,可以使用JVM引數來關閉:-XX:-UseBiasedLocking=false;程式將預設進入輕量級鎖狀態。

可以看到,Mark Word 是實現偏向鎖的關鍵。而後面的輕量級鎖也是通過這個實現的。

2. 輕量級鎖

什麼是輕量級鎖呢? “輕量級” 是相對於使用作業系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制稱為 “重量級” 鎖。 首先需要強調一點,輕量級鎖並不是用來代替重量級鎖的,他的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能損耗。

執行緒在執行同步塊之前,JVM 會先在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,並將物件頭的 Mark Word 複製到鎖記錄中,官方稱為 Displaced Mark Word. 然後執行緒嘗試使用CAS 將物件頭中的 Mark Word 替換為指向鎖記錄的指標。

如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便會嘗試使用自旋來獲取鎖,注意:這裡執行緒並沒有掛起自己,而是通過一定次數的自旋(預設10次,可以使用 -XX:PreBlockSpin 修改),防止切換到核心態導致的開銷。

如果有2個以上的執行緒爭用同一把鎖,那麼輕量級鎖將會失效,升級到重量級鎖。

那麼為什麼升級到重量級鎖之後不能降級呢?假設一下:如果鎖升級到重量級之後,拿到鎖的某個執行緒被阻塞了,等待了很久,那麼輕量級執行緒將會一直自旋等待,消耗CPU效能。所以,在升級到重量級鎖後,就不能降級了,防止輕量級鎖自旋消耗CPU。

可以看到偏向鎖和輕量級鎖的差別,偏向鎖在第一個執行緒拿到鎖之後,將把執行緒ID 儲存在物件頭中,後面的所有操作都不是同步的,相當於無鎖。而輕量級鎖,每次獲取鎖的時候還是需要使用CAS來修改物件頭的記錄,在沒有執行緒競爭的情況下,這個操作是很輕量的,不需要使用作業系統的互斥機制。

3. 重量級鎖

相比較輕量級鎖是通過自旋來獲取鎖的,重量級鎖則是通過作業系統將執行緒切換到核心態並阻塞來實現的。代價十分高昂。

下面看看各個鎖的優缺點對比:

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個執行緒訪問同步塊場景
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 如果始終得不到鎖競爭的執行緒,使用自旋會消耗CPU 追求響應時間,同步塊執行速度非常快
重量級鎖 執行緒競爭不使用自旋,不會消耗CPU 執行緒阻塞,響應時間緩慢 追求吞吐量,同步塊執行時間較長

什麼時候使用什麼鎖,大家可以看看。

4. 鎖消除

什麼是鎖消除呢?指的是 JIT 編譯器在執行時,對一些沒有必要同步的程式碼卻同步了的鎖進行消除。可以說時一種徹底的鎖優化。通過鎖消除,可以節省毫無意義的請求鎖時間。

那麼你們一定會問,誰會這麼傻,不需要同步還去同步啊?

請看下面的程式碼:

  public String[] createStrings(String[] args) {
    Vector<String> v = new Vector<>();
    for (int i = 0; i < 100; i++) {
      v.add(Integer.toString(i));
    }
    return v.toArray(new String[]{});
  }

複製程式碼

注意:v 變數只在這一個方法中使用,只是一個單純的區域性變數,分配在棧中,也就沒有執行緒安全的說法,任何同步都是沒有必要的,而Vector 的add 操作都是同步的。所以虛擬機器檢測到這個情況,會將鎖去除。

鎖消除涉及一個技術:逃逸分析。所謂逃逸分析就是觀察某一個變數十分會逃出某一個作用域。在本例中,變數v沒有逃出函式外,如果函式返回的不是 string 陣列,而是 v 本身,那麼就任務 v 逃逸出了當前函式。也就是說 v 可能被其他執行緒訪問。如果是這樣,虛擬機器就不能消除 v 的鎖操作。

5. 鎖粗化

原則上,我們在編寫程式碼的時候,總是推薦將同步塊儘可能的小。這樣是為了使得需要同步的運算元量小,如果存在鎖競爭,那等待鎖的執行緒也能儘快拿到鎖。

大部分情況下,這個原則是正確的。如果如果一系列連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有執行緒競爭,頻繁的同步操作也會導致不必要的效能損耗。

如果虛擬機器探測到很多零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部。即加大了同步塊。

6. 除了虛擬機器,程式設計師自己如何優化鎖

  1. 減小鎖的持有時間。

  2. 減小鎖的粒度。

  3. 使用讀寫鎖替換獨佔鎖

  4. 鎖分離

  5. 減小鎖的持有時間。

其實這個很簡單,你的鎖持有的時間長,後面的執行緒等待的時間就長,一個執行緒等待1秒,10000個執行緒就多等待了10000秒,因此,只在必要時進行同步,這樣就能明顯減少執行緒持有鎖的時間。提高系統的吞吐量。

  1. 減小鎖的粒度。

這個和我們上面說的虛擬機器幫助我們粗化時反的。但是,我們說,大部分情況下,減小鎖的粒度也削弱多執行緒競爭的有效手段,比如 ConcurrentHashMap,他只鎖住了 Hash 桶中的某一個桶,不像HashTable 一樣鎖住整個物件。

  1. 使用讀寫鎖替換獨佔鎖

我們之前在說 Java 世界的三把鎖的時候說哪三把鎖,內建鎖,重入鎖,讀寫鎖,就是我們現在說的讀寫鎖 ReadWriteLock,使用讀寫鎖來替代獨佔鎖是減小鎖粒度的一種特殊情況,在讀多寫少的場合,讀寫鎖對系統效能是有好處的。可以有效提高系統的併發能力。因為讀操作不會影響資料的完整性和一致性,就像 ConcurrentHashMap 的 get 方法一樣,根本不需要加鎖,這個時候又要說說 HashTable ,該容器連 get 方法都加鎖。你可以想象一下。

  1. 鎖分離

如果將讀寫鎖進一步延伸,就是鎖分離,讀寫鎖根據讀寫操作功能的不同,進行了有效的分離。而 JDK 的 LinkedBlockingQueue 則是鎖分離的最佳實踐。在進行 take 操作和 put 操作使用了兩把不同的鎖。因為他們之間根本沒有競爭關係,或者說,使用佇列的資料結構,將原本耦合的業務分離了。

7. 總結

今天我們總結了一些鎖的優化,有虛擬機器的優化,比如偏向鎖,輕量級鎖,自旋鎖,鎖粗化,鎖消除, 也有我們自己的優化策略,需要平時寫程式碼的時候注意,比如減少鎖的持有時間,減小鎖的粒度,在讀多寫少的場合使用讀寫鎖,儘量通過合理的設計分離鎖。

總之,併發是門藝術。如何提高併發的效能是每個高階程式設計師的追求。

good luck !!!

相關文章