02. 執行緒安全性

搬磚的Wayne發表於2021-05-25

Java併發程式設計實戰筆記 —— 第2章 執行緒安全性

摘要:參考自《Java併發程式設計實戰》,基本上相當於本人的學習筆記或者總結。本章簡要介紹了什麼是執行緒安全性,為什麼需要執行緒安全性,如何通過內建鎖實現執行緒安全性以及判斷是否需要加鎖。

樣式說明:紅色系標記為重點或者關鍵;綠色系標記為自我理解;引用為書本原文。

執行緒安全性簡介

討論一個物件是否需要實現執行緒安全的前提條件是:該物件可以被多個執行緒訪問。

(也就是說如果一個物件僅僅只是被單執行緒訪問,那麼不需要討論其執行緒安全性)

編寫執行緒安全的程式碼,核心在於要對狀態訪問操作進行管理,特別是對於共享的 (shared) 和可變 (mutable) 的狀態的訪問。

  • 共享意味著變數可以由多個執行緒同時訪問;
  • 可變意味著變數的值在其生命週期內可以發生變化;

要使得一個物件是執行緒安全的,就必需要採用同步機制來協同對該物件可變狀態的訪問。

  • 物件的狀態

    從非正式意義上來說,物件的狀態指:儲存在狀態變數中的資料,比如例項或者靜態域中的資料;物件的狀態可能包括其他依賴物件的域;比如HashMap的狀態不僅儲存在其本身,還儲存在多個Map.Entry 物件中。

    物件的狀態中包含了任何可能影響其外部可見行為的資料。

  • 同步機制

    如果某個物件的狀態變數可以被多個執行緒同時訪問,且其中有執行緒對其執行寫入操作,那麼就需要採用同步機制來協同這些執行緒對該變數的訪問。

    JAVA中同步機制的關鍵字是 synchronized,它提供了一種獨佔的加鎖方式,但“同步”還包括volatile型別的變數,顯式鎖 explicit lock 以及原子變數等。

修復沒有使用同步機制的多執行緒訪問:

  • 不線上程之間共享該狀態變數
  • 將狀態變數修改為不可變的變數
  • 在訪問狀態變數時使用同步

那麼,什麼是執行緒安全的類

在多執行緒的情況下,如果這個類的物件在任何時刻只能被一個執行緒訪問,那麼這個類就是執行緒安全的類;或者說,多執行緒同時執行的情況下,這些執行緒同時去訪問這個類的物件例項,同時去執行一些方法,但是每次的執行結果都是確定的,和單執行緒執行的結果是一致的,那麼這個類就是執行緒安全的類。

在任何情況中,只有當類中僅包含自己的狀態時,執行緒安全類才是有意義的。

執行緒安全性是一個在程式碼上使用的術語,但他只是與狀態相關的,因此只能應用於封裝其狀態的整個程式碼,這可能是一個物件,也可能是整個程式。

2.1 執行緒安全性

定義:當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替執行,並且在主調程式碼中不需要任何額外的同步或者協同,這個類都能夠表現出正確的行為,那麼這個類就是執行緒安全的。

良好的規範中通常會定義各種不變性條件 (Invariant) 來約束物件的狀態,以及定義各種後驗條件 (Postcondition) 來描述物件操作的結果。

無狀態物件一定是執行緒安全的。無狀態物件指的是不包含任何域也不包含任何對其它類中域的引用的物件。

當在無狀態的類中新增一個狀態時,如果該狀態完全由執行緒安全的物件來管理,那麼這個類依舊是執行緒安全的。但是有多個狀態變數時,情況會變得更加複雜。

也就是說,當不變性條件中涉及多個變數時,各個變數之間可能並不是彼此獨立的,某個變數的值可能會對別的變數的值產生約束,由此,當更新其中一個變數時,另一個變數同樣也需要在同一個原子操作中同步更新。

比如在無狀態的類中新增兩個狀態,儘管這兩個狀態分別由不同的執行緒安全的物件來管理,但是這兩個狀態之間可能會有依賴或者約束的關係,比如狀態A的值取決於狀態B的值,那麼這個類依舊可能不是執行緒安全的。

2.2 原子性

競態條件 race condition

定義:併發程式設計中,由於不恰當的執行時序而出現不正確的結果的情況。

競態條件發生的情境:當某個計算的正確性取決於多個執行緒的交替執行時序時,就會發生競態條件。

常見的競態條件型別:

  • ”先檢查後執行 check-then-act“操作,即通過一個可能失效的觀測結果來決定下一步的動作。

    先檢查後執行的競態條件的本質:基於一種可能失效的觀察結果來做出判斷或者執行某種計算。

    先檢查後執行的案例:延遲初始化

    public class LazyInitRace {
    	private ExpensiveObject instance = null;
    
    	public ExpensiveObject getInstance() {
    		if (instance == null)
    			instance = new ExpensiveObject();
    		return instance;
    	}
    }
    
    // LazyInitRace類便是一個check-then-act操作的案例
    // 當一個執行緒檢查instance為空,正在初始化新的ExpensiveObject物件例項時,另一個執行緒有可能正在檢查到instance為null並進入下一步
    
  • “讀取-修改-寫入”操作

    int count = 0;
    // some other code...
    count++;
    
    // 自加的操作便是先讀取count的值,再加一,再賦給count,此過程中執行緒不安全
    

原子操作Atomic operations

假定有兩個操作A和B,如果從執行A的執行緒來看,當另一個執行緒執行B時,要麼將B全部執行完,要麼完全不執行B,那麼A和B對彼此來說是原子的 (atomic);

原子操作是指:對於訪問同一個狀態的所有操作(包括該操作本身)來說,這個操作是一個以原子方式執行的操作。

要避免競態條件問題,就必須在某個執行緒修改該變數時,通過某種方式防止其他執行緒使用這個變數,從而確保別的執行緒只能在修改操作完成之前或者之後讀取和修改狀態。

我的理解:操作是由執行緒執行的,這些操作可能是修改物件的某個狀態等,這些操作所讀取的值或者修改的值可能相互依賴或者相互約束,如果不按順序、不按時序進行,可能就會得到不確定的結果,也就是說執行緒不安全;但是如果執行緒執行這些操作時,要麼不執行,要麼直接執行完才釋放給別的執行緒,那麼這些操作之間就是彼此獨立的,原子的;

比如:count++操作,如果能夠保證某個執行緒讀取修改寫入的過程中,別的執行緒只能等待,那麼count++這個操作就可以算作原子操作;要保證這樣的過程,可以通過加鎖機制來完成。

java.util.concurrent.atomic 包提供了一些原子變數類,主要用於數值和物件引用上的原子狀態轉換。

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicIntegerFieldUpdater
  • AtomicLong
  • AtomicLongArray
  • AtomicLongFieldUpdater
  • AtomicMarkableReference
  • AtomicReference
  • AtomicReferenceArray
  • AtomicReferenceFieldUpdater
  • AtomicStampedReference
  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder
  • Striped64

2.3 加鎖機制

對於單個狀態變數可以通過原子變數類來實現其原子操作;

但是當存在多個狀態變數時,要保證多個執行緒之間的操作無論採用何種執行時序或交替方式的不變性條件不被破壞,僅僅使用原子變數類是不夠的。

AtomicLong a1;
AtomicLong a2;

// some code to restraint a1 and a2

a1.incrementAndGet()
a2.incrementAndGet()

// 執行上述兩步操作時,存在競態條件

可以通過Java內建的機制——加鎖機制以確保原子性。

內建鎖:同步程式碼塊 Synchronized Block

同步程式碼塊包括兩部分:一個作為鎖的物件引用,一個作為由這個鎖保護的程式碼塊。

以關鍵字 synchronized 來修飾的方法就是一種橫跨整個方法體的同步程式碼塊,其中該同步程式碼塊的鎖就是方法呼叫所在的物件。

靜態的 synchronized 方法以 Class 物件作為鎖。

每個JAVA物件都可以用作一個實現同步的鎖,這些鎖被稱為內建鎖 intrinsic lock 或監視器鎖 monitor lock.

執行緒在進入同步程式碼塊之前就會自動獲得鎖,並且在退出同步程式碼塊時自動釋放鎖。

獲得內建鎖的唯一途徑就是進入由這個鎖保護的同步程式碼塊或方法。

Java的內建鎖相當於一種互斥鎖,也就是說最多隻有一個執行緒能夠持有這種鎖。

由內建鎖保護的程式碼塊是按原子方式執行的,多個執行緒執行該段程式碼都互不干擾。

但是,由於內建鎖的特性,同一時刻只能有一個執行緒執行該段程式碼,該段程式碼的效能就低下了。

重入

內建鎖是可以重入的,因此如果某個執行緒試圖獲得一個已經有它自己持有的鎖,那麼這個請求就會成功。

重入意味著獲取鎖的操作的粒度是執行緒,而不是呼叫。

應用:物件導向的開發中,子類可能改寫了父類的synchronized方法,然後又呼叫父類中的方法,如果沒有可以重入的鎖,那麼這段程式碼將產生死鎖。

2.4 用鎖來保護狀態

對於可能被多個執行緒同時訪問的可變狀態變數,在訪問它時都需要持有同一個鎖,在這種情況下,我們稱狀態變數是由這個鎖保護的。

需要使用同步的情況不僅僅是在需要修改寫入共享變數時,同樣也包括訪問該變數時。

一種常見的加鎖約定是,將所有可變狀態都封裝在物件內部,並通過物件的內建鎖對所有訪問可變狀態的程式碼的程式碼路徑進行同步,使得在該物件上不會發生併發訪問。許多執行緒安全的類中都使用了這種模式。但是這種模式沒有任何特殊之處,編譯器或者執行時都不會強制實施這種模式。如果在新的方法或者程式碼路徑中忘記了使用同步,那麼這種協議就會被輕易破壞。

並非所有資料都需要鎖的保護,只有被多個執行緒同時訪問的可變資料才需要通過鎖來保護。

2.5 活躍性與效能

同步程式碼塊應當儘可能地精確,直接將整個函式放入同步程式碼塊中可能會導致效能不足。

案例:

// 一個帶計數器hits和快取上次計算結果的求解因子的類,factor方法為求因子的具體實現,未列出

public class CachedFactorizer implements Servlet {
	@GuardedBy("this") private BigInteger lastNumber;
	@GuardedBy("this") private BigInteger[] lastFactors;
	@GuardedBy("this") private long hits;
	@GuardedBy("this") private long cacheHits;

	public synchronized long getHits() { return hits; }

	public void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		BigInteger[] factors = null;
		synchronized (this) {
			++hits;
			if (i.equals(lastNumber)) {
				++cacheHits;
				factors = lastFactors.clone();
			}
		}
		if (factors == null) {
			factors = factor(i);
			synchronized (this) {
				lastNumber = i;
				lastFactors = factors.clone();
			}
		}
		encodeIntoResponse(resq, factors);
	}
}

// 該方法實現相比於直接將 CachedFactorizer.service 函式包裝成synchronized 要合理、平衡很多

當使用鎖時,開發者應當清楚程式碼塊中實現的功能,以及在執行該程式碼塊時是否需要很長的時間。無論是執行計算密集的操作,還是在執行某個可能阻塞的操作,如果持有鎖的時間過長,那麼都會帶來活躍性或效能問題。

當執行時間較長的計算或者可能無法快速完成的操作時(例如,網路I/O或控制檯I/O),一定不要持有鎖。

相關文章