02. 線程安全性

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),一定不要持有鎖。