《Java併發編程實踐》二(5):線程安全組件

這是第二部分最後一章,介紹java提供的線程安全組件;需要注意的是,由於本書比較老,只涵蓋Java 1.6的併發組件,內容並不過時,但完整性有所欠缺。

第4章介紹了編寫線程安全類的幾種途徑,其中“委託線程安全”策略基於現有的線程安全類型來構建自定義的線程安全類,是最可靠的、最常用的策略。因此本章全面地介紹一下JDK爲我們提供的線程安全的組件。

同步集合(Synchronized Collections)

同步集合是基於“java監視器模式”實現的線程安全集合類型,它主要包括Vector,Hashtable,以及Collections.synchronizedXxx方法創建的集合。

同步集合的組合操作

同步集合的每個public方法都是線程安全的,但是包含多個方法的組合操作則不一定,比如經典的"put-if-absent"操作。

//map is Hashtable
table = Collections.synchronizedMap(map)
if (!table.containsKey(key)) {
	table.put(key,value);
}

上面的代碼是存在競爭條件的,當前線程檢查發現table裏沒有key,準備執行table.put時,可能有另外一個線程已經插入了同樣key。之所以說該代碼不一定線程安全,是因爲線程安全性關乎對狀態正確性的定義,上述代碼的行爲是否正確取決於業務場景。

再看另個一個case:

public static Object getLast(Vector list) {
	int lastIndex = list.size() - 1;
	return list.get(lastIndex);
}

getLast從Vector獲取最後一個元素,這個操作在多線程下可能會拋出異常,我想這幾乎不會是你期望的行爲,因此該代碼可判定爲非線程安全。

編寫線程安全的組合操作

由於同步集合明確的宣稱了自己的線程安全策略:java監視器模式,因此我們是可以基於這一點來實現線程安全的組合操作的:

public static Object getLast(Vector list) {
	synchronized(list) {
		int lastIndex = list.size() - 1;
		return list.get(lastIndex);
	}
}

迭代器和ConcurrentModificationException

對集合進行遍歷是最常見的組合操作,同步集合(還有其他非線程安全集合類型)的迭代器不是線程安全的。迭代器的使用是如此經典,以至於java專門設計了一種快速失敗(fail-fast)策略來強化它的非線程安全性。

該策略就是,在迭代過程中,如果發現集合被篡改,就會拋出ConcurrentModificationException異常。不過該機制的實現並不十分可靠,”盡力而爲“而已,因此併發的迭代有可能並不會被異常阻止。

仍然需要通過java監視器鎖來保證迭代操作的線程安全性:

synchronized(list) {
	for (Object element : list) {
	 	operation(element);
	}
}

如果元素的處理操作很耗時,那麼集合會被鎖定很長時間,此時可以先製作集合的一份copy(copy過程仍需要加鎖),再對copy進行遍歷即可。

隱式的迭代

java某些常見的寫法可能隱含了對集合的迭代,容易被開發者忽視:

System.out.println("DEBUG: added ten elements to " + set)

上面的代碼會遍歷set,還有Collection的containsAll,removeAll和retainAll操作都會對輸入集合進行遍歷。

併發集合類型

同步集合類型通過將所有操作變成互斥的來獲得線程安全性,大家是降低程序的併發性能。java 1.5引入併發集合類型,允許多線程同時修改集合對象,極大地提升了程序的併發性能。因此,開發者應該儘量使用併發集合類型。

ConcurrentHashMap

ConcurrentHashMap是HashMap的併發版本,支持多個線程同時讀寫,迭代,不會有ConcurrentModificationException。

ConcurrentHashMap弱點(如果認爲是弱點的話)是並不支持將map完全鎖住,換句話說,沒辦法阻止其他線程修改這個map。因爲ConcurrentHashMap實現的就是一個不斷變化map類型,從這個角度說,它並不是HashMap、HashTable的一個升級版本。

因而,像size(),isEmpty()這樣的方法,只是大致反映map一個瞬時狀態,並不十分準確。

附加的線程安全操作

ConcurrentHashMap直接支持了put-if-absent, replace, 條件刪除等常用的組合操作。由於我們沒法鎖住ConcurrentHashMap對象,所以想要添加額外的原子操作是不可能的。

CopyOnWriteArrayList

CopyOnWriteArrayList是一個支持併發訪問的ArrayList,顧名思義,它是通過”寫時拷貝“來獲得線程安全性的。可以認爲CopyOnWriteArrayList內部有一個數組,一旦有寫入(或修改)發生,內部製作併發布數組一份copy;如果有併發的迭代操作,那麼迭代仍然發生在老的數組上。

CopyOnWriteArrayList特別適合用作很少修改,且元素不多的集合,比如一個事件源的監聽器列表,一般在完成系統初始化之後就不怎麼修改。

BlockingQueue

BlockingQueue提供了可阻塞的put和take方法,以及他們的限時版本offer和poll。如果隊列滿了,那麼put方法將會阻塞一直到有空間被釋放;take方法如果發現隊列空,那麼被阻塞一直到有元素入隊列。

BlockingQueue天然支持”生產者-消費者“模型,生產者不斷地將數據put到一個BlockingQueue,消費者從BlockingQueue獲取下一個數據進行消費;生產者和消費之間完全解耦和,生產這和消費者的數量也沒有任何限制,且可動態變化。

打個比方,清洗盤子的工作分爲兩道工序,第一道是洗滌,第二道是擦乾;負責洗滌的人將洗好的盤子放入一個框子;負責擦乾的人從框子裏取出盤子進行擦乾。這是一個個典型的產生者-消費者場景,第一道工序的執行者是產生者,第二道工序的執行者是消費者,暫存盤子的框對應着BlockingQueue。

正如裝盤子框子大小有限,BlockingQueue也可以是有界的,如果洗盤子的人發現框子滿了,就必須暫停工作,此時要想加快工作進度,必須增加擦盤子的人數。反過來一樣,如果擦盤子的人總是發現框子是空的,那麼需要增加洗盤子的人數。

我們要儘量使用有界的BlockingQueue,因爲我們不能假定程序實際運行環境下,總是有足夠多消費者或者消費者的處理速度足夠快;如果隊列無界,就存在內存耗光的風險。

BlockingQueue實現

BlockingQueue有以下幾個實現:

  • LinkedBlockingQueue,類似LinkedList的併發隊列實現;
  • ArrayBlockingQueue, 類似ArrayList的併發隊列實現;
  • PriorityBlockingQueue,可阻塞的優先級隊列,不能設置元素數量限制
  • SynchronousQueue,一個沒有存儲空間特殊阻塞隊列;

SynchronousQueue比較特殊一點,由於它沒有存儲空間,所以put方法會一直阻塞,直到其他線程調用take方法;以上面洗盤子場景作比喻的話,此時沒有框子可用,負責第一道工序的人只能手遞手把盤子交給第二道工序的人。它的效果是,任務在生產者和消費者之間直接傳遞,沒有額外的延遲,如果沒有足夠多的消費者,生產者會被阻塞。

串行化的線程封閉

BlockingQueue的實現可以保證一個對象從產生這被安全地發佈到消費者,這個保證使得我們可以對可變對象指定一種”串行化線程封閉“的線程安全策略。

一個對象雖然在它的聲明週期內可能被多個線程訪問,但是它的聲明週期呈現明顯的階段性,每個階段只有一個線程訪問它;就好像它的所有權在多個線程之間傳遞一樣。所以,只要保證這種傳遞是安全的,而BlockingQueue恰好保證了這一點。

在基於BlockingQueue的生產者-消費者模型中,如果生產這在將數據put進隊列之後就不再訪問它,消費者也不會繞過BlockingQueue去獲取數據,那麼並不需要數據類型是線程安全的。

通過第二章介紹的安全發佈手段,也能建立起這種串行化的線程封閉特性,只不過BlockingQueue更簡單,而且邊界明顯,不易出錯。

雙端隊列和任務竊取

java 6增加了雙端隊列集合類型:Deque和BlockingDeque,雙端隊列允許在頭和尾進行插入和移除操作,具體實現有ArrayDeque和LinkedBlockingDeque。

就像BlockingDeque適用於生產者-消費者模型,BlockingDeque適用於”任務竊取“模型;與生產者-消費者共享一個任務隊列不同,任務竊取模型下,每個worker都有一個私有任務隊列,如果一個worker完全了全部工作,那麼它能從其他worker的隊列末尾竊取一個任務。

”任務竊取“模型比生產者-消費者模型更具可伸縮性,因爲正常情況下worker只會訪問自己的隊列,避免了競爭,在竊取任務時,從其他worker隊列的末尾入手,也儘可能地減少了競爭。它特別適合那種生產者即消費者的的場景,worker完成一個任務,可能導致更多的任務被產生並添加到自己的隊列裏(比如網絡爬蟲),這樣確保每個worker都不會空閒。

非阻塞併發Queue

JDK裏面只有一個非阻的併發安全Queue,就是ConcurrentLinkedQueue。這是一個無鎖化的隊列,入隊列和出隊列的操作性能都非常好。

BlockingDeque和ConcurrentLinkedQueue之間的選擇,完全取決於是否期望線程在該隊列上阻塞;由於”生產者-消費者“模型下,生產線程和消費線程往往都期望這種阻塞,所以BlockingDeque在實際項目中更常見。

阻塞和中斷

線程在幾種情況下會阻塞或暫停,比如等待IO完成、等待獲取鎖,或者等待從Thread.sleep中恢復等。當一個線程阻塞,它通常進入某個阻塞狀態(BLOCKED,WAITING,TIMED_WAITING)。線程陷入阻塞狀態和正在執行耗時操作的區別是,前者是線程自身無法控制的,它需要等待外部條件滿足。

BlockingQueue的put、take方法可拋出InterruptedException,Thread.sleep方法也如此,如果一個方法可拋出InterruptedException異常,意味這是一個阻塞方法,一旦線程被interrupt,該方法會通過拋出異常的形式中斷阻塞。

Thread的interrupt機制是線程間互相協調的一種通訊機制,它本質上就是一個標記位,當線程A希望中斷線程B時,線程A設置線程B的interrupt標識,僅此而已;然後就期望,線程B會在適當的時候檢查interrupt標識,並結束運行。

順着這個設計哲學,當線程阻塞在某個方法內同時被interrupt,阻塞需要提前結束,否則就無法響應interrupt;另一方面該方法也不能正常返回,否則調用者無法區分正常返回和中斷,這樣一來,拋出一個特定異常(InterruptedException)就是必然選擇。

處理InterruptedException

發生InterruptedException異常,意味着當前線程被其他線程interrupt(記住:InterruptedException的發生一定是因爲Thread.interrupt被調用);一般來說,線程是不能忽略捕獲的InterruptedException異常的,我們有兩種常見的應對手段:

  • 傳播這個異常:當前上下文不知道如何處理,直接把異常往上繼續傳播;
  • 恢復異常狀態:一旦InterruptedException被捕獲,線程的interrupt標記會被清除,catch邏輯裏可恢復該標記,讓後面的代碼來處理該標記。
public class TaskRunnable implements Runnable {
	BlockingQueue<Task> queue;
	public void run() {
		try {
			processTask(queue.take());
		} catch (InterruptedException e) {
			// restore interrupted status
			Thread.currentThread().interrupt();
		}
	}
}

同步器(Synchronizer)

在前面介紹的集合類型中,BlockingQueue是一個比較特殊的存在,它不僅提供一種可在併發環境下安全訪問的容器,還可以協調線程之間的執行流。第二個角色有一個專有名字叫同步器,BlockingQueue是一個簡單的同步器,如果它不能滿足需求,還可以使用 semaphore,barrier,latche等更底層的同步器,你甚至還可以創建自定義的同步器。

所有的同步器有着一些相通的結構和屬性:同步器封裝了某個狀態,該狀態決定了當線程到達該同步器時,被允許通過或阻塞,同步器還必須提供一些操作、檢查該狀態的方法。

Latch(門栓)

Latch相當於一個大門,當大門關閉時,所有經過該大門的人都不允許通過,一旦大門打開,所有人都可以通過;而且大門一旦打開,就不能再關閉。

因此Latch適用與等待某個條件達成的同步場景,比如,計算流程等待某個資源ready再繼續;或某些服務等所依賴的服務完成啓動後再啓動;

CountDownLatch是一個更加靈活的Latch版本,就好比一道需要轉動多次才能打開的大門,每次達成一個條件就執行一下CountDownLatch.countDown(),當countDown次數到達預定值時大門打開。

public class TestCountDownLatch {
	public void timeTasks(int nThreads, final Runnable task) throws InterruptedException 	{
		final CountDownLatch taskComplete = new CountDownLatch(nThreads);
		for (int i = 0; i < nThreads; i++) {
			Thread t = new Thread() {
				public void run() {
					try {
						task.run();
					} finally {
						taskComplete.countDown();
					}
				};
			t.start();
		}
		taskComplete.await();
	}
}

上面的代碼展示了CountDownLatch的用法,主線程通過多個子線程來執行任務,子線程通過CountDownLatch來通知任務完成,主線程在該CountDownLatch上等待全部任務完成。

FutureTask

FutureTask的行爲也類似latch,它實現Future接口,代表一個可執行、能返回執行結果的Runnable。FutureTask可能處於三種狀態之一:等待執行,執行中,執行完成;完成可能是正常完成,也可能是被取消,還有可能異常結束;一旦FutureTask完成,就永遠處於該狀態。

Future.get方法的行取決於任務的狀態,如果任務已經完成,get方法立即返回,否則阻塞直到任務完成。

下面看一個示例:

public class Preloader {
	private final FutureTask<ProductInfo> future =
			new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
				public ProductInfo call() throws DataLoadException {
					return loadProductInfo();
				}
		});
	
	public staic void main(String[] args) {
		executor.submmit(future);
		ProductInfo info = future.get();
		...
	}
}

上面的FutureTask實例包裹一個Callable對象,後者的實現代碼執行了一個耗時的加載操作loadProductInfo。主線程用一個executor來執行FutureTask,執行future.get()來等待加載結果。值得注意的是,FutureTask保證了加載結果從任務線程發佈到主線程。

上面的代碼忽略了異常處理,FutureTask.get()方法可拋出InterruptedException和ExecutionException,前者不再贅述,後者包裹了Callable執行過程中拋出的異常。

更常見的情景是主線程創建一個FutureTask列表,然後等待所有任務完成。

信號量(Semaphore)

信號量用於控制併發訪問某個資源的數量,它就像管理一組許可證一樣,初始化一定的數量,活動代碼在訪問相關資源之前,先獲取許可證,使用完了在釋放許可證。如果當前的許可證已經耗盡,那麼獲取許可證的操作將會被阻塞。

信號量特別適合用來實現類似資源池這樣的設計模式,Semaphore的初始值設置爲資源數量:

public class ResourcePool
{
	private Semaphore semaphore = new Semaphore(x);
	
	public Resource get() throws InterruptedException {
		semaphore.acquire();
		return fetchResource();
	}
	
	public void return(Resource res) throws InterruptedException {
		returnResource(res);
		semaphore.release();
	}
}

Barrier(柵欄)

Barrier用來協調一組線程的執行步調,當某個線程調用Barrier.wait(),它被阻塞在這裏;直到所有線程(預定義的線程個數)都執行到這個語句,所有線程一起恢復繼續往前執行。

CyclicBarrier是JDK併發庫提供的實現,它可以重複使用,一個CyclicBarrier被通過的同時被重置,於是這組線程可重用cyclicBarrier在下個約定執行點再次匯合。

Barrier可以用於實現多個線程需要不斷互相等待的場景,就好像”你和朋友約定在麥當勞門口碰面,然後再決定接下來去哪?"。

另外一個Barrier實現是Exchanger,它是一個二元的Barrier,專門用於兩個線程在某個執行點交換數據。他可用來實現類似OpenGL雙緩衝區渲染機制:生產者線程準備好的緩衝數據對象後,將其放入Exchanger,消費者線程,處理完另一緩衝數據後,也將其放入Exchanger;在這個交匯點Exchanger交換這兩個緩衝數據對象,然後生產者和消費者可繼續執行。

《Java Concurrency in Practice》一書的鎖相關內容單獨成章,高級主題部分第十三章。

一個高效、可伸縮的緩存系統

幾乎所有的應用系統都會用到某種形式的緩存,複用之前的計算結果能夠節約CPU,提高系統的響應性,代價是一些額外的內存消耗。但是一個弱雞的緩存系統實現,可能會把計算性能瓶頸點轉換爲可伸縮性瓶頸;換句話說,這個緩存在單線程下確實能改善性能,但是在多核機器上無法通過提升線程數來提升它的性能。

可伸縮性是一種對軟件系統計算處理能力的設計指標,高可伸縮性代表一種設計彈性,通過很少的改動甚至只是硬件設備的添置,就能實現整個系統處理能力的線性增長,實現高吞吐量和低延遲。

這一節設計一個完整的緩存系統,並逐步通過本章學習的技術來改善它的併發性能。

問題描述

我們正在開發一個多線程的軟件系統,該系統內有一個需要頻繁執行且非常耗時的計算任務:通過字符串計算一個整型值。

這個計算任務的接口定義Computablen如下:

public interface Computable<A, V> {
	V compute(A arg) throws InterruptedException;
}

Memoizer1

緩存系統的第一個實現版本Memoizer1:

public class Memoizer1<A, V> implements Computable<A, V> {

	@GuardedBy("this")
	private final Map<A, V> cache = new HashMap<A, V>();
	private final Computable<A, V> c;
	
	public Memoizer1(Computable<A, V> c) {
		this.c = c;
	}
	
	public synchronized V compute(A arg) throws InterruptedException {
		V result = cache.get(arg);
		if (result == null) {
			result = c.compute(arg);
			cache.put(arg, result);
		}
		return result;
	}
}

Memoizer1內部使用HashMap來緩存計算結果,HashMap是非線程安全的,於是Memoizer1使用了java監視器模式。

Memoizer1可可伸縮性很差,因爲compute方法是原子的,只能有一個線程能進入,因此無論機器有多少個處理器,都無法實現並行計算。更加糟糕的是,即使對某個輸入參數數值,Memoizer1已經緩存了計算結果,也有可能被阻塞而不能立即返回。

Memoizer2

Memoizer2嘗試通過ConcurrentHashMap來解決Memoizer1的缺點。

public class Memoizer2<A, V> implements Computable<A, V> {

	private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
	private final Computable<A, V> c;
	
	public Memoizer2(Computable<A, V> c) { 
		this.c = c; 
	}
	
	public V compute(A arg) throws InterruptedException {
		V result = cache.get(arg);
		if (result == null) {
			result = c.compute(arg);
			cache.put(arg, result);
		}
		return result;
	}
}

Memoizer2看起來很好,compute方法不再有阻塞的可能,唯一的缺點是compute方法內,有一個”check-and-act"組合操作,因此存在競爭條件,一個線程如果正在對某個參數執行計算,另一個線程並不知道,於是可能進行一個重複的計算。

Memoizer3

Memoizer3通過Future來改善Memoizer2的問題:

public class Memoizer3<A, V> implements Computable<A, V> {

	private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
	private final Computable<A, V> c;
	public Memoizer3(Computable<A, V> c) { 
		this.c = c; 
	}
	public V compute(final A arg) throws InterruptedException {
		Future<V> f = cache.get(arg);
		if (f == null) {
			Callable<V> eval = new Callable<V>() {
				public V call() throws InterruptedException {
					return c.compute(arg);
				}
			};
			FutureTask<V> ft = new FutureTask<V>(eval);
			f = ft;
			cache.put(arg, ft);
			ft.run(); // call to c.compute happens here
		}
		try {
			return f.get();
		} catch (ExecutionException e) {
			throw launderThrowable(e.getCause());
		}
	}
}

Memoizer3在ConcurrentHashMap存儲的是Future對象,而不是計算結果;特別需要注意的是:compute方法在緩存沒有命中的時候,首先創建FutureTask放入cache,然後再執行計算任務(ft.run());如果此刻,其他線程使用相同參數執行compute方法,會調用同一個FutureTask對象的get方法,等待該計算任務的完成。

經過上面的分析,大家可能已經發現,Memoizer3並沒有完全消除重複計算的風險,只是相比Memoizer2,大大縮小了風險窗口。如果重複計算本身沒有壞的副作用,Memoizer3完全可以在實際項目中運用。

Memoizer4

ConcurrentHashMap提供了原子方法:putIfAbsent,使我們可以進一步改善Memoizer3以臻完美。

public class Memoizer3<A, V> implements Computable<A, V> {

	private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
	private final Computable<A, V> c;
	public Memoizer3(Computable<A, V> c) { 
		this.c = c; 
	}
	public V compute(final A arg) throws InterruptedException {
		Future<V> f = cache.get(arg);
		if (f == null) {
			Callable<V> eval = new Callable<V>() {
				public V call() throws InterruptedException {
					return c.compute(arg);
				}
			};
			FutureTask<V> ft = new FutureTask<V>(eval);
			f = cache.putIfAbsent(arg, ft);
			if (f==null) {
				f=ft;
				ft.run(); // call to c.compute happens here
			}
		}
		try {
			return f.get();
		} catch (ExecutionException e) {
			throw launderThrowable(e.getCause());
		}
	}
}

通過putIfAbsent我們消除了”check-and-act“的副作用了,只有當FutureTask成功進入ConcurrentHashMap之後,我們才執行計算。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章