《深入理解Java虛擬機》5分鐘速成:13章(線程安全與鎖優化)

第13章 線程安全與鎖優化

前言:

1、線程安全的定義?

2、Java語言操作的共享數據,包括哪些?

3、阻塞同步和非阻塞同步的區別?

4、如何實現線程安全?

5、鎖優化包含哪些機制?

6、自旋鎖、鎖消除、鎖粗化、輕量級鎖、偏向鎖的各自機制?


13.1 併發&正確性

併發處理的廣泛應用是使得 Amdahl 定律代替摩爾定律成為計算機性能發展源動力的根本原因,也是人類 “壓榨” 計算機運算能力的最有力武器。

有時候,良好的設計原則不得不向現實做出一些讓步,我們必須讓程序在計算機正確無誤地運行,然後再考慮如何將代碼組織得更好,讓程序運行得更快。對於這部分的主題 “高效併發” 來將,首先需要保證併發的正確性,然後在此基礎上實現高效。本章先從如何保證併發的正確性和如何實現線程安全講起。

13.2 線程安全

當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方法進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那這個對象就是線程安全的。

13.2.1 Java的線程安全

Java語言操作的共享數據,包括哪些?

  • 不可變
  • 絕對線程安全:在 Java API 中標註自己是線程安全的類,大多數都不是絕對的線程安全。
  • 相對線程安全
  • 線程兼容
  • 線程對立

1.) 不可變

  • 在Java語言裡,不可變的對象一定是線程安全的。只要一個不可變的對象被正確構建出來,那其外部的可見狀態永遠也不會改變,永遠也不會在多個線程中處於不一致的狀態。
  • 典型的不可變對象,包括:String類,枚舉類型。

2.) 絕對線程安全

  • 一個類要達到 “不管運行是環境如何,調用者都不需要任何額外的同步措施” 通常需要付出很大的,甚至有時候是不切實際的代價。
  • 在 Java API 中標註自己是線程安全的類,大多數都不是絕對的線程安全。

3.) 相對線程安全

  • 相對的線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作是線程安全,我們在調用的時候不需要做額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。
  • 在 Java 語言中,大部分的線程安全類都屬於這種類型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包裝的集合等。

4.) 線程兼容

  • 線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中可以安全地使用,我們平常說一個類不是線程安全的,絕大多數時候指的是這一種情況。
  • Java API 中大部分的類都是屬於線程兼容的,如與前面的 Vector 和 HashTable 相對應的集合類 ArrayList 和 HashMap 等。

5.) 線程對立

  • 線程對立是指無論調用端是否採取了同步措施,都無法在多線程環境中併發使用的代碼。由於 Java 語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當儘量避免。
  • 一個線程對立的例子是 Thread 類的 suspend() 和 resume() 方法,如果有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另一個嘗試去恢復線程,如果併發進行的話,無論調用時是否進行了同步,目標線程都是存在死鎖風險的,已經被 JDK 聲明廢棄(@Deprecated)了。

13.2.1 線程安全的實現方法

虛擬機提供了同步和鎖機制用於實現線程安全。

  • 阻塞同步(互斥同步):悲觀鎖
  • 非阻塞同步:樂觀鎖

a. 阻塞同步(互斥同步)

  • 互斥同步:(Mutual Exclusion & Synchronization)是常見的一種併發正確性保障手段。
  • 同步:是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一個(或者是一些,使用信號量的時候)線程使用。
  • 互斥:是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現方式。

阻塞同步1:synchronized關鍵字:

Java中最基本的同步手段就是synchronized關鍵字,其編譯後會在同步塊的前後分別形成monitorenter和monitorexit兩個字節碼指令。這兩個字節碼都需要一個Reference類型的參數指明要鎖定和解鎖的對象。如果Java程序中的synchronized明確指定了對象參數,那麼這個對象就是Reference;如果沒有明確指定,那就根據synchronized修飾的是實例方法還是類方法,去獲取對應的對象實例或Class對象作為鎖對象。

在執行monitorenter指令時,首先要嘗試獲取對象的鎖。

  • 如果這個對象沒有鎖定,或者當前線程已經擁有了這個對象的鎖,把鎖的計數器+1;當執行monitorexit指令時將鎖計數器-1。當計數器為0時,鎖就被釋放了。
  • 如果獲取對象失敗了,那當前線程就要阻塞等待,知道對象鎖被另外一個線程釋放為止。

阻塞同步2:重入鎖:

  • 重入鎖(ReentrantLock):除了synchronized之外,還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實現同步。
  • ReentrantLock比synchronized增加了高級功能:等待可中斷、可實現公平鎖、鎖可以綁定多個條件。
  • 等待可中斷:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。
  • 公平鎖:多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。synchronized中的鎖是非公平的。

b. 非阻塞同步

  • 悲觀的併發策略:互斥同步最大的問題,就是進行線程阻塞和喚醒所帶來的性能問題,是一種悲觀的併發策略。總是認為只要不去做正確的同步措施(加鎖),那就肯定會出問題,無論共享數據是否真的會出現競爭,它都要進行加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等操作。
  • 樂觀併發策略:隨著硬件指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略。先進行操作,如果沒有其他線程徵用數據,那操作就成功了;如果共享數據有徵用,產生了衝突,那就再進行其他的補償措施。
  • 非阻塞同步:這種樂觀的併發策略的許多實現不需要線程掛起,所以被稱為非阻塞同步。

樂觀鎖的前置條件:

為什麼筆者說使用樂觀併發策略需要 “硬件指令集的發展” 才能進行呢?因為我們需要操作和衝突檢測這兩個步驟具備原子性,靠什麼來保證呢?如果這裡再使用互斥同步來保證就失去意義了,所以我們只能靠硬件來完成這件事情,硬件保證一個從語法上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類指令常用的有:

  • 測試並設置(Test-and-Set)。
  • 獲取並增加(Fetch-and-Increment)。
  • 交換(Swap)。
  • 比較並交換(Compare-and Swap,下文成 CAS)。
  • 加載連接 / 條件存儲(Load-Linked / Store-Conditional,下文稱 LL/SC)。


13.3 無同步方案

  • 線程安全&同步的關係:要保證線程安全,並不是一定就要進行同步,兩者沒有因果關係。
  • 天然的線程安全代碼:同步只是保證共享數據爭用時的正確性的手段,如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性,因此會有一些代碼天生就是線程安全的,筆者簡單地介紹其中的兩類。

13.3.1 可重入代碼

  • 可重入代碼(Reentrant Code):這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤。
  • 相對線程安全來說,可重入性是更基本的特性,它可以保證線程安全,即所有的可重入的代碼都是線程安全的,但是並非所有的線程安全的代碼都是可重入的。
  • 可重入代碼有一些共同的特徵,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。我們可以通過一個簡單的原則來判斷代碼是否具備可重入性:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。

13.3.2 線程本地存儲

  • 線程本地存儲(Thread Local Storage):如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
  • 符合這種特點的應用並不少見,大部分使用消費隊列的架構模式(如 “生產者 - 消費者” 模式)都會將產品的消費過程儘量在一個線程中消費完,其中最重要的一個應用實例就是經典 Web 交互模型中的 “一個請求對應一個服務器線程”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用線程本地存儲來解決線程安全問題。
  • ThreadLocal本地線程類:Java 語言中,如果一個變量要被多線程訪問,可以使用 volatile 關鍵字聲明它為 “易變的”;如果一個變量要被某個線程獨享,Java 可以通過 java.lang.ThreadLocal 類來實現線程本地存儲的功能。

13.4 鎖優化

JDK1.6的一個重要主題,就是高效併發。HotSpot虛擬機開發團隊在這個版本上,實現了各種鎖優化:

  • 適應性自旋
  • 鎖消除
  • 鎖粗化
  • 輕量級鎖
  • 偏向鎖

13.4.1 自旋鎖(JDK1.4.2引入)

  • 自旋鎖的引入:互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作給系統的併發性帶來很大壓力。同時很多應用共享數據的鎖定狀態,只會持續很短的一段時間,為了這段時間去掛起和恢復線程並不值得。先不掛起線程,等一會兒。
  • 自旋鎖的原理:如果物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,讓後面請求鎖的線程稍等一會,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放。為了讓線程等待,我們只需讓線程執行一個忙循環(自旋)。
  • 自旋的缺點:自旋等待本身雖然避免了線程切換的開銷,但它要佔用處理器時間。所以如果鎖被佔用的時間很短,自旋等待的效果就非常好;如果時間很長,那麼自旋的線程只會白白消耗處理器的資源。所以自旋等待的時間要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,那就應該使用傳統的方式掛起線程了。

自適應自旋(JDK1.6引入):

自旋的時間不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

  • 如果一個鎖對象,自旋等待剛剛成功獲得鎖,並且持有鎖的線程正在運行,那麼虛擬機認為這次自旋仍然可能成功,進而運行自旋等待更長的時間。
  • 如果對於某個鎖,自旋很少成功,那在以後要獲取這個鎖,可能省略掉自旋過程,以免浪費處理器資源。

有了自適應自旋,隨著程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越準確,虛擬機也會越來越聰明。

13.4.2 鎖消除

  • 鎖消除:是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但被檢測到不可能存在共享數據競爭的鎖進行消除。主要根據逃逸分析來實現消除。
  • 多餘鎖同步的消除:a. 編譯器&jvm自行添加的;b. 程序員添加的多餘鎖同步。

程序員怎麼會在明知道不存在數據競爭的情況下使用同步呢?因為很多不是程序員自己加入的。

13.4.3 鎖粗化

  • 原則上,同步塊的作用範圍要儘量小(編碼時)。
  • 但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作在循環體內,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
  • 鎖粗化:就是增大鎖的作用域。

13.4.4 輕量級鎖

  • 重量級鎖:傳統的互斥同步鎖,即重量級鎖。
  • 輕量級鎖:在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。

13.4.5 偏向鎖(樂觀鎖)

  • 偏向鎖:消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。即在無競爭的情況下,把整個同步都消除掉。這個鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要同步。
  • 樂觀鎖:偏向鎖預設場景為:大多數場景下,鎖不會被多個線程訪問。

輕量級鎖&偏向鎖的區別:

  • 輕量級鎖:在無競爭時,使用CAS操作等替換同步使用的互斥量機制;
  • 偏向鎖:在無競爭時,將整個同步都消除掉;
《深入理解Java虛擬機》5分鐘速成:13章(線程安全與鎖優化)


分享到:


相關文章: