探秘Java并發模塊:容器與工具類

并發與多線程是每個人程序員都頭疼的內容,幸好Java庫所提供了豐富并發基礎模塊,這些多線程安全的模塊作為并發工具類將幫助大家來應對并發開發的各種需求。

擴展閱讀:

  1. 多線程安全性:每個人都在談,但是不是每個人都談地清
  2. 對象共享:Java并發環境中的煩心事
  3. 從Java內存模型角度理解安全初始化
  4. 從任務到線程:Java結構化并發應用程序
  5. 關閉線程的正確方法:“優雅”的中斷
  6. 駕馭Java線程池:定制與擴展

1. 同步容器類

在談及同步容器之前,必須要說說他們的老前輩同步容器類。同步容器類的代表就是VectorHashTable,這是早期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方法。同樣的,容器的hashCodeequals方法都是隱式調用迭代器。

2. 并發容器

從Java 5開始,JDK中提供了并發容器類來改進同步容器類的不足。Java 5 中提供了ConcurrentHashMap來代替同步的HashMap,提供了CopyOnWriteArrayList來代替同步都是List。

Java 6 中又繼續引入了ConcurrentSkipListMapConcurrentSkipLIstSet來分別代替同步的SortedMapSortedList

并發容器并不對整個容器上鎖,故而允許多個線程同時訪問容器,改進了同步容器因串行化而效率低的問題。

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 還新增了兩種容器類型:QueueBlockingQueue

  • 隊列Queue,其實現有ConcurrentLinkedQueue(并發的先進先出隊列)和PriorityQueue(非并發的優先級隊列);Queue上的操作不會被擁塞,如果隊列為空 ,會立刻返回null,如果隊列已滿,則會立刻返回失敗;
  • 擁塞隊列BlockingQueue,是Queue的一種擴展,其上的操作是可擁塞的:如果隊列為空,則獲取元素的操作將被擁塞直到隊列中有可用元素,同理如果隊列已滿,則放入元素的操作也會被用塞到隊列有可用的空間。

隊列的相關內容在前文中已經介紹過了,這里不再展開。

此外Java 6 還提供了雙端隊列 DequeBlockingDeque,即隊列頭尾都可以都可以插入和移除元素。雙端隊列適用于一種特殊的生產者-消費者模式——密取模式:即每個消費者都有一個雙端隊列,當自己隊列中的元素被消費完之后,就可以秘密地從別的消費者隊列的末端取出元素使用。

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());
            }
        }
    }
}

擴展閱讀:

  1. 多線程安全性:每個人都在談,但是不是每個人都談地清
  2. 對象共享:Java并發環境中的煩心事
  3. 從Java內存模型角度理解安全初始化
  4. 從任務到線程:Java結構化并發應用程序
  5. 關閉線程的正確方法:“優雅”的中斷
  6. 駕馭Java線程池:定制與擴展
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,923評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,740評論 3 420
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,856評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,175評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,931評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,321評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,383評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,533評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,082評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,891評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,618評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,319評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,732評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,987評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,794評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,076評論 2 375

推薦閱讀更多精彩內容

  • 一.線程安全性 線程安全是建立在對于對象狀態訪問操作進行管理,特別是對共享的與可變的狀態的訪問 解釋下上面的話: ...
    黃大大吃不胖閱讀 860評論 0 3
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,327評論 11 349
  • 一、設計線程安全的類 在設計線程安全類的過程中,需要包含以下三個基本要素: 找出構成對象狀態的所有變量 找出約束狀...
    端木軒閱讀 592評論 1 3
  • 我一直在試圖尋找一個更加理性的角度,用非常客觀的態度去解讀微商,但很可惜,恐怕很難做得到,因為微商這種生態夾雜著商...
    任凱曄閱讀 311評論 0 1
  • 浪花 我坐在岸邊的礁石上 望著泛白的海岸線 海鷗在海面上低璇 仰頭沖對天空喊話 我猜想 它也許是在對碧天說情話 礁...
    鶴起閱讀 335評論 0 1