《Java併發編程實踐》二(4):組合對象的線程安全性

第2,3章,介紹了線程安全的最基礎的技術原理,在實際項目中,我們肯定不希望在那個層面來分析每一個對象操作的線程安全性,而是期望使用現有的線程安全組件來構建線程安全的程序。

如何設計一個線程安全的類

如果將一個類的狀態存儲在public static的字段內,那麼保證線程安全是很難的,因爲程序的任何地方都可以自由地修改狀態。良好的封裝限制了狀態的訪問路徑,更容保證線程安全性。

設計一個線程安全類需要考慮一下幾個方面:

  • 收集同步需求:識別對象的狀態字段
  • 識別對象狀態的正確性定義(還記得狀態不變式操作後置條件嗎?)
  • 設計對象的同步策略

對象的狀態首先是它的字段,如果對象全部由基本字段構成,那麼對象狀態是一個n元組(n是字段個數)。如果對象還包含其他對象的引用,它的狀態還包含被引用對象的字段,以此類推。一個LinkedList的狀態包含它所有節點的狀態。

同步策略定義了對象如何來協調對它的併發訪問,以保證狀態的正確性,狀態的正確性包括不變式操作後置條件。同步策略指明對象通過一種或多種手段(不可變性,線程封閉,鎖)來保證線程安全,以及哪個鎖保護哪些狀態。

收集同步需求

使一個對象線程安全,就是要確保它的狀態在訪問過程中,保持正確性。對象生命週期內,所有可能的狀態取值,是對象的狀態空間;很顯然,這個空間越小,越容易分析它的線程安全需求。因此儘可能將對象的部分字段設計爲final,能簡化線程安全分析。

對象的不變式能判定對象狀態有效或無效,比如一個計數器Counter,它的狀態空間是從Long.MIN_VALUE到Long.MAX_VALUE,但負值是無效的。對象操作的後置條件,確定了對象狀態有效的狀態遷移,比如Counter目前的狀態是17,那麼increment操作後正確的狀態只有18,而執行K此increment操作的正確後置狀態是(17+k)。

如果對象狀態包含多個字段,比如一個表示範圍的NumberRange類,包含上界和下界兩個變量;不變式要求上界不小於下界,操作後置條件要求上界和下界保持一致性(被同時修改)。

狀態所有權分析

前面實際已經暗示了,對象的狀態實際上是對象所有字段(這裏包括它引用的對象的字段)的一個子集,一些不歸屬它的字段不是對象狀態的一部分。

最典型的是java的集合類,集合類雖然存儲了元素,但元素並不屬於這個集合,而是歸屬於客戶代碼;因而集合類的狀態不包括元素狀態,因而集合的線程安全性也與元素是否線程安全無關。

歸屬與封裝一般是相伴的,一個對象將它的狀態封裝起來,然後擁有它所封裝的狀態。狀態的所有者決定了狀態的同步策略。不過一旦對象將一個可變狀態發佈出去,那麼將失去對它的完全控制,此時只能假定和其他代碼共享狀態所有權。以此推理,對象通過方法參數得到的對象狀態,無法獨佔所有權

java的垃圾收集,讓我們免於對象歸屬問題所導致的內存錯誤,但並不代表我們不需要考慮這一點。

實例限制

順着狀態所有權的概念,將一個非線程安全對象限制在一個對象實例內部,更容易獲得線程安全性。

@ThreadSafe
public class PersonSet {
	@GuardedBy("this")
	private final Set<Person> mySet = new HashSet<Person>();
	
	public synchronized void addPerson(Person p) {
		mySet.add(p);
	}
	public synchronized boolean containsPerson(Person p) {
		return mySet.contains(p);
	}
}

上面的示例代碼中,mySet字段指向HashSet對象,它是非線程安全的,但是PersonSet將它完全限制在內部,外部只能通過PersonSet的接口來間接訪問它;只要在這些操作上加鎖,就可以爲mySet提供線程安全性。

JDK的Collections.synchronizedList(及類似工廠方法)也是基於這個原理,將非線程安全集合類型提升爲線程安全類型。它使用裝飾者模式,通過一個同步的包裝器來包裝非線程安全集合。

java監視器模式

PersonSet實現線程安全的模式,稱之爲Java監視器模式;它通過java對象內置的監視器鎖來保護內部狀態,這是java特有的一種既簡單又可靠,可以實現實例限制策略的技術方案。

該模式的另一種實現方式是用私有的鎖來代替this鎖:

public class PrivateLock {
	private final Object myLock = new Object();
	
	@GuardedBy("myLock") Widget widget;
	void someMethod() {
		synchronized(myLock) {
		// Access or modify the state of widget
		}
	}
}

上面的實現方式有一個好處,對象用來保護狀態的鎖是私有的,客戶代碼無法使用它,也就無法破壞對象的鎖策略。

委託線程安全

如果對象是由線程安全的組件構成的,那麼該對象是否就是線程安全的呢?換句話說,對象能否將線程安全委託給已經具備線程安全性的組件?這有時候成立,有時候僅僅是實現線程安全的一個良好起點。

案例1. 單狀態字段-成功

看看計數器類Counter的實現,它成功地將線程安全需求委託給了AtomicLong:

public class Counter {
	private AtomicLong value = new AtomicLong(0);
	public long inrement() {
		return value.incrementAndGet();
	}
	public long get() {
		return value.get();
	}
}

案例2. 多狀態字段-成功

另個一示例類VisualComponent,它可能發出Key或Mouse相關事件,客戶端可以添加listener來監聽事件:

public class VisualComponent {
	private final List<KeyListener> keyListeners
		= new CopyOnWriteArrayList<KeyListener>();
	private final List<MouseListener> mouseListeners
		= new CopyOnWriteArrayList<MouseListener>();
	public void addKeyListener(KeyListener listener) {
		keyListeners.add(listener);
	}
	public void addMouseListener(MouseListener listener) {
		mouseListeners.add(listener);
	}
}

VisualComponent有keyListeners和mouseListeners兩個狀態成員,都是線程安全的類型;VisualComponent成功地將線程安全委託給了CopyOnWriteArrayList,它沒有額外的狀態約束條件,因爲keyListeners和mouseListeners互相獨立。

案例3. 多狀態字段-失敗

接下來是一個線程安全委託失敗的示例:

public class NumberRange {
	// INVARIANT: lower <= upper
	private final AtomicInteger lower = new AtomicInteger(0);
	private final AtomicInteger upper = new AtomicInteger(0);
	public void setLower(int i) {
		// Warning -- unsafe check-then-act
		if (i > upper.get())
			throw new IllegalArgumentException("can’t set lower to " + i + " > upper");
		lower.set(i);
	}
	public void setUpper(int i) {
		// Warning -- unsafe check-then-act
		if (i < lower.get())
			throw new IllegalArgumentException("can’t set upper to " + i + " < lower");
		upper.set(i);
	}
	public boolean isInRange(int i) {
		return (i >= lower.get() && i <= upper.get());
	}
}

NumberRange是一個表示範圍的類,有下界(lower)和上界(upper)兩個狀態字段,都是類型安全的AtomicInteger對象。爲了滿足(lower<upper)的不變式約束,setLower和setUpper內部都做了檢查。

NumberRange不是線程安全的,因爲setLower和setUpper可能導致無效狀態,非原子化的check-then-act操作序列存在競爭條件。

實現委託線程安全的條件

一個有多個狀態字段的類,要成功將線程安全委託給它的成員字段,有以下三個條件:

  • 每個狀態字段都是線程安全的
  • 這些狀態字段相互獨立
  • 對象沒有什麼操作過程可能使對象處於無效狀態

能否發佈狀態安全的字段

最後一個小問題,對於已經成功實現線程安全委託的類,我們能否將它的狀態字段發佈出去。

Counter能否將value成員發佈出去?不能,Counter的約束條件有兩個,計數值大於0,且每次操作增加1;如果發佈出去,是不能保證這兩點的;

VisualComponent能否將keyListeners和mouseListeners發佈出去?可以,客戶端代碼直接操作keyListeners並不會使對象狀態處於無效狀態;

所以一個線程安全的對象要安全地將它的狀態成員發佈出去,需要滿足以下條件:

  • 狀態字段是線程安全的;
  • 對象對該字段的值沒有任何額外約束;
  • 該字段也沒有任何操作可以使對象處於無效狀態。

給線程安全類添加方法

假設現在手頭有一個線程安全的List類,我們想要添加一個putIfAbsent方法,仍然保持線程安全特性,可以考慮以下手段。

手段1:修改類源代碼

如果修改源代碼可行,那肯定可以達到目的,只要我們清楚該類的同步策略,並以一種一致的方式擴展它。

手段2:繼承

@ThreadSafe
public class BetterList <E> extends SafeList<E> {
	public synchronized boolean putIfAbsent(E x) {
		boolean absent = !contains(x);
		if (absent)
		add(x);
		return absent;
	}
}

如果SafeList採用的是java監視器模式,那麼上面的方案是可行的,但是它是脆弱的,因爲它耦合於SafeList的同步策略。一旦SafeList的未來版本修改了鎖機制,該實現將失效。

手段3:Helper類

@NotThreadSafe
public class ListHelper<E> {
	public List<E> list = new SafeList<>();
	...
	public synchronized boolean putIfAbsent(E x) {
		boolean absent = !list.contains(x);
		if (absent)
			list.add(x);
		return absent;
	}
}

上線這個實現將putIfAbsent放在一個helper類裏,並加以同步。但是上面的類不是線程安全的,因爲ListHelper和原List使用的是不同的鎖。

手段4:組合

@ThreadSafe
public class ImprovedList<T> implements List<T> {

	private final List<T> list;
	
	public ImprovedList(List<T> list) { 
		this.list = list; 
	}
	public synchronized boolean putIfAbsent(T x) {
		boolean contains = list.contains(x);
		if (contains)
		list.add(x);
		return !contains;
	}
	public synchronized void clear() { list.clear(); }
	// ... similarly delegate other List methods
}

這是類似Collections.synchronizedList的實現方式,將原list完全封裝,並提供安全同步的接口。這是一種安全的實現方式,只不過可增加一層同步方法對性能稍稍有一些影響。

同步策略文檔

文檔是管理類線程安全性的重要工具,代碼的編寫者應當說明類是否線程安全,以及安全策略,包括每一個lock、每個synchronized關鍵字、volatile關鍵字的意圖;對象如果在運行過程中,執行了某個回調接口,需要說明是否有一把鎖正被持有。

不過很多情況下,類的線程安全文檔缺失的,JDK裏的類以及很多著名第三方庫代碼都不例外。用戶只能通過猜測(或者合理推測)它們的線程安全性。SimpleDateFormat是一個典型的反例,從直覺上看,這個類應該是線程安全的,但是它不是,直到java1.4文檔才說明了這一點。

而JDBC的DatasSource則是一個符合直覺的案例,程序內通常只有一個DatasSource實例,那麼多個線程調用DatasSource.getConnection()是必然會發生的場景,那麼該操作理所當然應該是線程安全的。而Connection(連接對象)的典型使用方式是:需要時獲取,使用完釋放,那麼它可以是非線程安全的。

總結

如果決定了要設計一個線程安全的可變對象,大體步驟如下:

  • 界定對象的狀態範圍,尤其要辨別是否包括依賴的其他對象;
  • 識別對象的正確性定義,包括狀態不變式操作後置條件
  • 然後纔是採用合適的線程安全技術:
    • 如果狀態字段不可變,無需施加任何線程同步機制;
    • 如果狀態字段獨立,那麼可委託給一個java線程安全組件即可;
    • 如果操作過程中,會將對象置於無效狀態,那麼需要加鎖。
  • 最後,能否能夠將狀態字段發佈出去,按以下規則判定:
    • 字段本身是線程安全的;
    • 對象對該字段的值沒有任何額外約束;
    • 該字段類型沒有任何操作可以使對象處於無效狀態。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章