并發與多線程是每個人程序員都頭疼的內容,幸好Java庫所提供了豐富并發基礎模塊,這些多線程安全的模塊作為并發工具類將幫助大家來應對并發開發的各種需求。
擴展閱讀:
1. 同步容器類
在談及同步容器之前,必須要說說他們的老前輩同步容器類。同步容器類的代表就是Vector和HashTable,這是早期JDK中提供的類。此外Collections.synchronizedXXX等工廠方法也可以把普通的容器(如HashMap)封裝成同步容器。這些同步容器類的共同點就是:使用同步(Synchronized)方法來封裝容器的操作方法,以保證容器多線程安全,但這樣也使得容器的每次操作都會對整個容器上鎖,所以同一時刻只能有一個線程訪問容器。
1.1 同步容器的復合操作問題
同步容器類雖然對于單一操作是線程安全的,但是對于復合操作(即由多個操作組合而成,如迭代,跳轉),就不一定能保證線程安全。如下面的代碼:
public class UnsafeVectorHelpers {
//復合操作,先檢查再運行,并不是經常安全的,
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
getLast方法中存在“先檢查再運行”的情況:先去獲得容器大小,再去獲得容器中最后一個元素。雖然這兩個操作單獨都是同步的,但是復合在一起并不能保證整個方法的原子性,所以還需要額外的同步操作。線程安全的代碼如下:
public class SafeVectorHelpers {
public static Object getLast(Vector list) {
//額外的同步操作
synchronized (list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
}
1.2 同步容器類與迭代器
正因為同步容器類沒有解決復合操作的線程安全問題,所以在使用迭代器時,其也不能避免迭代器被修改。甚至同步容器類的迭代器在設計時就沒有考慮并發修改的問題,而是采用快速失敗(fail-fast)的處理方法,即在容器迭代的過程中,發現容器被修改了,就拋出異常ConcurrentModificationException。
雖然也可以通過給容器上鎖解來決迭代器被并發修改的問題,但是這樣做也會帶來性能問題:如果迭代的過程很費事,其他訪問容器的操作都會被擁塞。
除此之外,一些隱式調用迭代器的情況讓同步容器的使用情況更為復雜。
public class HiddenIterator {
//應該使用并發容器
@GuardedBy("this") private final Set<Integer> set = new HashSet<Integer>();
public synchronized void add(Integer i) {
set.add(i);
}
public synchronized void remove(Integer i) {
set.remove(i);
}
public void addTenThings() {
Random r = new Random();
for (int i = 0; i < 10; i++)
add(r.nextInt());
// 隱式地調用了迭代器,
// 連接字符串操作會調用StringBuilder.append(Object),
// 而這個方法又會調用容器Set的toString(),
// 標準容器(不僅是Set)的ToString方法會使用迭代器依次使用容器內元素的toString方法。
System.out.println("DEBUG: added ten elements to " + set);
}
}
注釋中已經解釋了容器的toString()方法是如何迭代調用容器元素的toString方法。同樣的,容器的hashCode和equals方法都是隱式調用迭代器。
2. 并發容器
從Java 5開始,JDK中提供了并發容器類來改進同步容器類的不足。Java 5 中提供了ConcurrentHashMap來代替同步的HashMap,提供了CopyOnWriteArrayList來代替同步都是List。
Java 6 中又繼續引入了ConcurrentSkipListMap和ConcurrentSkipLIstSet來分別代替同步的SortedMap和SortedList
并發容器并不對整個容器上鎖,故而允許多個線程同時訪問容器,改進了同步容器因串行化而效率低的問題。
2.1 ConcurrentHashMap
ConcurrentHashMap也是基于散列的Map,但是并不是在操作的過程中對整個容器上鎖,而是使用一種粒度更細的鎖,即分段鎖。
在ConcurrentHashMap的實現中,其使用了16鎖來分段保護容器,每個鎖保護著散列表的1/16,其第N個散列桶的位置由第(N mod 16)個鎖來保護。如果訪問的元素不是由同一個鎖來保護,則允許并發被訪問。這樣做雖然增加了維護和管理的開銷,但是提高并發性。不過,ConcurrentHashMap中也存在對整個容器加鎖的情況,比如容器要擴容,需要重新計算所有元素的散列值, 就需要獲得全部的分段鎖。
ConcurrentHashMap所提供的迭代器也不會拋出ConcurrentModificationException異常,所以不需要為其加鎖。并發容器的迭代器具有弱一致性(Weakly Consistent),容忍并發的修改,可以(但是不保證)將迭代器上的修改操作反映給容器。
需要注意的是,為了提高對元素訪問的并發性,ConcurrentHashMap中對容器整體操作的語義被消弱,比如size和isEmpty等方法,其返回的結果都是估計值,可能是過期的。
2.2 CopyOnWriteArrayList
CopyOnWriteArrayList用于代替同步的List,其為“寫時復制(Copy-on-Write)”容器,本質為事實不可變對象,一旦需要修改,就會創建一個新的容器副本并發布。容器的迭代器會保留一個指向底層基礎數組的引用,這個數組是不變的,且其當前位置位于迭代器的起始位置。
由于每次修改CopyOnWriteArrayList都會有容器元素復制的開銷,所以其更適合迭代操作遠遠多于修改操作的使用場景中。
2.3 擁塞隊列
Java 5 還新增了兩種容器類型:Queue和BlockingQueue:
- 隊列Queue,其實現有ConcurrentLinkedQueue(并發的先進先出隊列)和PriorityQueue(非并發的優先級隊列);Queue上的操作不會被擁塞,如果隊列為空 ,會立刻返回null,如果隊列已滿,則會立刻返回失敗;
- 擁塞隊列BlockingQueue,是Queue的一種擴展,其上的操作是可擁塞的:如果隊列為空,則獲取元素的操作將被擁塞直到隊列中有可用元素,同理如果隊列已滿,則放入元素的操作也會被用塞到隊列有可用的空間。
隊列的相關內容在前文中已經介紹過了,這里不再展開。
此外Java 6 還提供了雙端隊列 Deque和BlockingDeque,即隊列頭尾都可以都可以插入和移除元素。雙端隊列適用于一種特殊的生產者-消費者模式——密取模式:即每個消費者都有一個雙端隊列,當自己隊列中的元素被消費完之后,就可以秘密地從別的消費者隊列的末端取出元素使用。
3. 同步工具類
Java中還提供了同步工具類,這些同步工具類可以根據自身的狀態來協調線程的控制流,上面提到的擁塞隊列就是一種同步工具類,除此之外還有閉鎖(Latch),信號量(Semaphore)和柵欄(Barrier)等
3.1 閉鎖
閉鎖是一種同步工具類 ,可以延遲線程的進度直到其到達終止狀態。閉鎖的作用就像一扇門:在閉鎖到達結束狀態之前,這扇門處于關閉狀態,所有的線程都不能通過;當閉鎖達到終止狀態后,這扇門打開,所有線程都可以通過。閉鎖一旦到達終止狀態后,其狀態就不會再被改變。
閉鎖可以用來保證一些活動在其所依賴的活動執行完畢之后再繼續執行,如等待資源初始化,等待依賴的服務完畢等等。
CountDownLatch是閉鎖的一種實現,其包括一個計數器,其被初始化為一個正整數,表示要等到事件數量。countDown方法表示一個事件已經放生了,await方法表示等到閉鎖達到終止狀態(擁塞方法,支持中斷和超時)。
下面是一個使用閉鎖的實例,來實現任務計時功能:
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
// 開始鎖
final CountDownLatch startGate = new CountDownLatch(1);
// 結束鎖
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
// 等待主線程初始化完畢
startGate.await();
try {
task.run();
} finally {
// 結束鎖釋放一個
endGate.countDown();
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
// 記錄當前時間為開始時間
long start = System.nanoTime();
// 初始化完畢,開啟開始鎖,子線程可以運行
startGate.countDown();
// 等到個子線程運行完畢
endGate.await();
// 統計執行時間
long end = System.nanoTime();
return end - start;
}
}
3.2 FutureTask
之前討論過的FutureTask其實也可以作為閉門使用,Future.get方法會被擁塞直到對應的任務完成。
下面的例子中使用FutureTask來等到預加載任務的完成。
public class Preloader {
ProductInfo loadProductInfo() throws DataLoadException {
return null;
}
//FutureTask 實現了Runnable和Future
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
public ProductInfo call() throws DataLoadException {
return loadProductInfo();
}
});
private final Thread thread = new Thread(future);
//預先開始加載任務
public void start() { thread.start(); }
public ProductInfo get()
throws DataLoadException, InterruptedException {
try {
//等待任務完成
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
//已知異常
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else //未知異常
throw LaunderThrowable.launderThrowable(cause);
}
}
interface ProductInfo {
}
}
//自定義的異常類型
class DataLoadException extends Exception { }
5.3 信號量
Semaphore是信號量的實現,用來控制的特定資源的操作數,也就是一組虛擬的資源許可:得到資源的同時獲得信號量,使用完資源時釋放信號量,如果當前沒有可用信號量就得等待。如果是二值信號量,也就是一種互斥鎖。
下面的例子使用信號量將普通的容器變為有界阻塞的容器
public class BoundedHashSet <T> {
private final Set<T> set;
// 信號量
private final Semaphore sem;
public BoundedHashSet(int bound) {
// 獲得同步容器
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
// 請求獲得信號量,可能擁塞
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded)
// 無論添加操作是否成功,都釋放信號量
sem.release();
}
}
public boolean remove(T o) {
boolean wasRemoved = set.remove(o);
// 移除成功之后,會釋放一個信號量
if (wasRemoved)
sem.release();
return wasRemoved;
}
}
5.3 柵欄
柵欄(Barrier)和閉鎖是類似的,能擁塞一種線程直到某個事件的發生,只有當所有的線程都達到柵欄的位置,才能繼續執行。柵欄用于等待其他線程,而閉鎖用于等待某個事件。
柵欄的使用場景類似于“明天早上八點,所有人學校操場集合(柵欄),然后再去春游”。
CyclicBarrier是柵欄的一種實現,其可以讓一定數量的參與方反復在柵欄的位置匯聚,其await方法表示某個方法到達柵欄。這個模型在并行迭代算法中很有意思,以下是《java concurrency in practive》中給出的使用范例。
public class CellularAutomata {
private final Board mainBoard;
//柵欄
private final CyclicBarrier barrier;
//子任務
private final Worker[] workers;
public CellularAutomata(Board board) {
this.mainBoard = board;
//環境中CPU的個數
int count = Runtime.getRuntime().availableProcessors();
this.barrier = new CyclicBarrier(count,
new Runnable() {
public void run() {
//當所有子任務完成,更新數值
mainBoard.commitNewValues();
}});
this.workers = new Worker[count];
//劃分子任務;
for (int i = 0; i < count; i++)
workers[i] = new Worker(mainBoard.getSubBoard(count, i));
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) { this.board = board; }
public void run() {
while (!board.hasConverged()) {
for (int x = 0; x < board.getMaxX(); x++)
for (int y = 0; y < board.getMaxY(); y++)
//設置當前子任務的結果
board.setNewValue(x, y, computeValue(x, y));
try {
//完成計算,等待其他任務完成
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex) {
return;
}
}
}
private int computeValue(int x, int y) {
// Compute the new value that goes in (x,y)
return 0;
}
}
public void start() {
for (int i = 0; i < workers.length; i++)
new Thread(workers[i]).start();
mainBoard.waitForConvergence();
}
interface Board {
int getMaxX();
int getMaxY();
int getValue(int x, int y);
int setNewValue(int x, int y, int value);
void commitNewValues();
boolean hasConverged();
void waitForConvergence();
Board getSubBoard(int numPartitions, int index);
}
}
要說明的是,CyclicBarrier的構造器中可以傳進一個Runnable對象,表示當所有線程到達柵欄之后要執行什么任務。
柵欄的一種特殊形式是Exchange,它是一種兩方柵欄(Two-party Barrier) ,雙方會在柵欄處交換數據,這是一種線程間安全交互數據的方法。具體交換數據的時機取決于程序的響應需求,最簡單的方案為:當緩沖區被填滿時,由填充任務進行數據交換;當緩沖區為空時,由讀取任務交換數據。這樣的模型在雙方執行不對等操作時很有用,比如一個任務向緩沖區A寫數據,另一個從緩沖區B讀數據,然后使用Exchange來匯合兩個任務,將被寫滿或是被讀空的緩沖區相互交換。
5.4 實例:高效的結果緩存
最后展示一個并發容器類的使用實例:計算結果緩存,即將已經計算完的結果保存起來,如果調用有緩存的計算結果,則直接返回,如果沒有緩存再進行計算。
以下是同步方法的實現方式:
public class Memoizer1 <A, V> implements Computable<A, V> {
@GuardedBy("this") private final Map<A, V> cache = new HashMap<>();
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;
}
}
由于同步方法是對整個容器上鎖,所以并發的效率不好,因此要使用并發容器作為計算結果的緩存,改進代碼如下:
public class Memoizer2 <A, V> implements Computable<A, V> {
private final Map<A, V> cache = new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memoizer2(Computable<A, V> c) {
this.c = c;
}
// cache是并發容器,支持多線程同時訪問,
// 但是不能表示出某個結果正在被計算
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;
}
}
這樣代碼的并發效率就可以被大大提升了。不過這樣使用并發容器類還有一點小問題:緩存僅僅記錄下那些結果被計算出來,但是不能反映出那些結果正在被計算,如果計算的過程很漫長,也會照成重復計算,而浪費大量時間。這時就可以使用Future來表示任務的生命周期,存進緩存中。完善的代碼如下:
public class Memoizer <A, V> implements Computable<A, V> {
// 記錄那些結果的計算已經開始
private final ConcurrentMap<A, Future<V>> cache
= new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memoizer(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
while (true) {
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<>(eval);
// 如果不存在緩存則提交任務,
// 如果已經緩存則得到緩存值;
f = cache.putIfAbsent(arg, ft);
if (f == null) { 不存在緩存結果
f = ft;
ft.run(); //開始計算
}
}
try {
// 獲得計算結果,如果已經計算完畢,則會立刻返回
// 如果計算還在進行中,就會擁塞
return f.get();
} catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw LaunderThrowable.launderThrowable(e.getCause());
}
}
}
}
擴展閱讀: