1. 程式人生 > 其它 >寫時複製(Copy-On-Write)思想在Java中的應用

寫時複製(Copy-On-Write)思想在Java中的應用

前言

寫時複製(Copy-on-write,簡稱COW)是一種計算機程式設計領域的優化策略。其核心思想是,如果有多個呼叫者同時請求相同資源(如記憶體或磁碟上的資料儲存),他們會共同獲取相同的指標指向相同的資源,直到某個呼叫者試圖修改資源的內容時,系統才會真正複製一份專用副本(private copy)給該呼叫者,而其他呼叫者所見到的最初的資源仍然保持不變。這個過程對其他的呼叫者是透明的(transparently)。此作法的主要優點是如果呼叫者沒有修改該資源,就不會有副本(private copy)被建立,因此多個呼叫者只是讀取操作時可以共享同一份資源。

COW(奶牛)技術的應用場景很多,Linux通過Copy On Write技術極大地減少了Fork的開銷。檔案系統通過Copy On Write技術一定程度上保證資料的完整性。資料庫伺服器也一般採用了寫時複製策略,為使用者提供一份snapshot。

而JDK的CopyOnWriteArrayList/CopyOnWriteArraySet容器也採用了 COW思想,它是如何工作的是本文討論的重點。

Vector和synchronizedList

我們知道ArrayList是執行緒不安全的,而Vector是執行緒安全的容器。檢視原始碼可以知道,Vector之所以執行緒安全,是因為它幾乎在每個方法宣告處都加了synchronized關鍵字來使整體方法原子化

另外,使用Collections.synchronizedList(new ArrayList())修飾後,新建出來的ArrayList也是安全的,它是如何實現的呢?檢視原始碼發現,它也是幾乎在每個方法都加上synchronized關鍵字使方法原子化,只不過它不是把synchronized加在方法的宣告處,而是加在方法的內部

容器是執行緒安全的,並不意味著就可以在多執行緒環境下放心大膽地隨便用了,來看下面這段使用Vector的程式碼。

@Test
public void testVectorConcurrentReadWrite() {
    Vector<Integer> vector = new Vector<>();
    vector.add(1);
    vector.add(2);
    vector.add(3);
    vector.add(4);
    vector.add(5);
    for (Integer item : vector) {
        new
Thread(vector::clear).start(); System.out.println(item); } }

執行結果如下:

在一個執行緒中使用Iterator迭代器遍歷vector,同時另一個執行緒對vector作修改時,會丟擲java.util.ConcurrentModificationException異常。很多人不理解,因為Vector的所有方法都加了synchronized關鍵字來修飾,包括迭代器方法,理論上應該是執行緒安全的呀。

public synchronized Iterator<E> iterator() {
	//Itr是AbstractList的私有內部類
    return new Itr();
}

看以上錯誤的堆疊指向java.util.Vector$Itr.checkForComodification(Vector.java:1184),原始碼如下:

兩個關鍵變數:

  • expectedModCount:表示對List修改次數的期望值,它的初始值與modCount相等
  • modCount:表示List集合結構被修改次數,是AbstractList類中的一個成員變數,初始值為0

看過ArrayList的原始碼就知道,每次呼叫add()和remove()方法時就會對modCount進行加1操作。而我們上面的測試程式碼中呼叫了Vector類的clear()方法,這個方法中對modCount進行了加1,而迭代器中的expectedModCount依然等於0,兩者不等,因此拋了異常。這就是集合中的fail-fast機制,fail-fast 機制用來防止在對集合進行遍歷過程當中,出現意料之外的修改,會通過Unchecked異常暴力的反應出來。

雖然Vector的方法都採用了synchronized進行了同步,但是實際上通過Iterator訪問的情況下,每個執行緒裡面返回的是不同的iterator,也即是說expectedModCount變數是每個執行緒私有。如果此時有2個執行緒,執行緒1在進行遍歷,執行緒2在進行修改,那麼很有可能導致執行緒2修改後導致Vector中的modCount自增了,執行緒2的expectedModCount也自增了,但是執行緒1的expectedModCount沒有自增,此時執行緒1遍歷時就會出現expectedModCount不等於modCount的情況了

同樣地,SynchronizedList在使用迭代器遍歷的時候同樣會有問題的,原始碼中的註釋已經提醒我們要手動加鎖了。

foreach迴圈裡不能呼叫集合的remove/add/clear方法這一條規約不僅對非執行緒安全的ArrayList/LinkedList適用,對於執行緒安全的Vector以及synchronizedList也同樣適用

因此,要想解決以上問題,只能在遍歷前(無論用不用iterator)加鎖。

synchronized (vector) {
    for (int i = 0; i < vector.size(); i++) {
        System.out.println(vector.get(i));
}
//或者
synchronized (vector) {
    for (Integer item : vector) {
        System.out.println(item);
}

僅僅是遍歷一下容器都要上鎖,效能必然不好。

其實並非只有遍歷前加鎖這一種解決方法,使用併發容器CopyOnWriteArrayList也能避免以上問題。

CopyOnWriteArrayList介紹

一般來說,我們會認為:CopyOnWriteArrayList是同步List的替代品,CopyOnWriteArraySet是同步Set的替代品。

無論是Hashtable–>ConcurrentHashMap,還是說Vector–>CopyOnWriteArrayList。JUC下支援併發的容器與老一代的執行緒安全類相比,總結起來就是加鎖粒度的問題。

  • Hashtable與Vector加鎖的粒度大,直接在方法宣告處使用synchronized
  • ConcurrentHashMap、CopyOnWriteArrayList的加鎖粒度小。用各種方式來實現執行緒安全,比如我們知道的ConcurrentHashMap用了CAS、+ volatile等方式來實現執行緒安全
  • JUC下的執行緒安全容器在遍歷的時候不會丟擲ConcurrentModificationException異常

所以一般來說,我們都會使用JUC包下給我們提供的執行緒安全容器,而不是使用老一代的執行緒安全容器。

下面我們來看看CopyOnWriteArrayList是怎麼實現的,為什麼使用迭代器遍歷的時候就不用額外加鎖,也不會丟擲ConcurrentModificationException異常。

實現原理

Copy-on-write是解決併發的的一種思路,指的是實行讀寫分離,如果執行的是寫操作,則複製一個新集合,在新集合內新增或者刪除元素。待一切修改完成之後,再將原集合的引用指向新的集合。

這樣的好處就是,可以高併發地對COW進行讀和遍歷操作,而不需要加鎖,因為當前集合不會新增任何元素。

寫時複製(copy-on-write)的這種思想,這種機制,並不是始於Java集合之中,在Linux、Redis、檔案系統中都有相應思想的設計,是一種計算機程式設計領域的優化策略。

CopyOnWriteArrayList的核心理念就是讀寫分離,寫操作在一個複製的陣列上進行,讀操作還是在原始陣列上進行,讀寫分離,互不影響。寫操作需要加鎖,防止併發寫入時導致資料丟失。寫操作結束之後需要讓陣列指標指向新的複製陣列

看一下CopyOnWriteArrayList基本的結構。

看一下其讀寫的原始碼,寫操作加鎖,防止併發寫入時導致資料丟失,並複製一個新陣列,增加操作在新陣列上完成,將array指向到新陣列中,最後解鎖。至於讀操作,則是直接讀取array陣列中的元素。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}

public E get(int index) {
    return get(getArray(), index);
}

final Object[] getArray() {
    return array;
}

遍歷 - COWIterator

到現在,還是沒有解釋為什麼CopyOnWriteArrayList在遍歷時,對其進行修改而不丟擲異常。

不管是foreach迴圈還是直接寫Iterator來遍歷,實際上都是使用Iterator遍歷。那麼就直接來看下CopyOnWriteArrayList的iterator()方法。

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}

來看一下迭代器COWIterator的實現原始碼。

可以發現的是,迭代器所有的操作都基於snapshot陣列,而snapshot是傳遞進來的array陣列。

也就是說在使用COWIterator進行遍歷的時候,如果修改了集合,集合內部的array就指向了新的一個數組物件,而COWIterator內部的那個snapshot還是指向初始化時傳進來的舊陣列,所以不會拋異常,因為舊陣列永遠沒變過,舊陣列讀操作永遠可靠且安全。

CopyOnWriteArrayList與synchronizedList效能測試

寫單元測試來對CopyOnWriteArrayList與synchronizedList的併發寫效能作測試,由於CopyOnWriteArrayList寫時直接複製新陣列,可以預想到其寫操作效能不高,會劣於synchronizedList。

@Test
public void testThreadSafeListWrite() {
    List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
    List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
    StopWatch stopWatch = new StopWatch();
    int loopCount = 10000;
    stopWatch.start();
    /**
     * ThreadLocalRandom:是JDK 7之後提供併發產生隨機數,能夠解決多個執行緒發生的競爭爭奪。
     * ThreadLocalRandom不是直接用new例項化,而是第一次使用其靜態方法current()。
     * 從Math.random()改變到ThreadLocalRandom有如下好處:我們不再有從多個執行緒訪問同一個隨機數生成器例項的爭奪。
     */
    IntStream.rangeClosed(1, loopCount).parallel().forEach(
            item -> copyOnWriteArrayList.add(ThreadLocalRandom.current().nextInt(loopCount)));
    stopWatch.stop();
    System.out.println(
            "Write:copyOnWriteList: " + stopWatch.getTime() + ",copyOnWriteList.size()=" + copyOnWriteArrayList
                    .size());

    stopWatch.reset();
    stopWatch.start();
    /**
     * parallelStream特點:基於伺服器核心的限制,如果你是八核
     * 每次執行緒只能起八個,不能自定義執行緒池
     */
    IntStream.rangeClosed(1, loopCount).parallel().forEach(
            item -> synchronizedList.add(ThreadLocalRandom.current().nextInt(loopCount)));
    stopWatch.stop();
    System.out.println(
            "Write:synchronizedList: " + stopWatch.getTime() + ",synchronizedList.size()=" + synchronizedList
                    .size());
}

執行結果如下,可以看到同樣條件下的寫耗時,CopyOnWriteArrayList是synchronizedList的30多倍。

同樣地,寫單元測試來對CopyOnWriteArrayList與synchronizedList的併發讀效能作測試,由於CopyOnWriteArrayList讀操作不加鎖,可以預想到其讀操作效能明顯會優於synchronizedList。

@Test
public void testThreadSafeListRead() {
    List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
    List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
    copyOnWriteArrayList.addAll(IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList()));
    synchronizedList.addAll(IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList()));

    int copyOnWriteArrayListSize = copyOnWriteArrayList.size();
    StopWatch stopWatch = new StopWatch();
    int loopCount = 1000000;
    stopWatch.start();
    /**
     * ThreadLocalRandom:是JDK 7之後提供併發產生隨機數,能夠解決多個執行緒發生的競爭爭奪。
     * ThreadLocalRandom不是直接用new例項化,而是第一次使用其靜態方法current()。
     * 從Math.random()改變到ThreadLocalRandom有如下好處:我們不再有從多個執行緒訪問同一個隨機數生成器例項的爭奪。
     */
    IntStream.rangeClosed(1, loopCount).parallel().forEach(
            item -> copyOnWriteArrayList.get(ThreadLocalRandom.current().nextInt(copyOnWriteArrayListSize)));
    stopWatch.stop();
    System.out.println("Read:copyOnWriteList: " + stopWatch.getTime());

    stopWatch.reset();
    stopWatch.start();
    int synchronizedListSize = synchronizedList.size();
    /**
     * parallelStream特點:基於伺服器核心的限制,如果你是八核
     * 每次執行緒只能起八個,不能自定義執行緒池
     */
    IntStream.rangeClosed(1, loopCount).parallel().forEach(
            item -> synchronizedList.get(ThreadLocalRandom.current().nextInt(synchronizedListSize)));
    stopWatch.stop();
    System.out.println("Read:synchronizedList: " + stopWatch.getTime());
}

執行結果如下,同等條件下的讀耗時,CopyOnWriteArrayList只有synchronizedList的一半。

CopyOnWriteArrayList優缺點總結

優點:

  • 對於一些讀多寫少的資料,寫入時複製的做法就很不錯,例如配置、黑名單、物流地址等變化非常少的資料,這是一種無鎖的實現。可以幫我們實現程式更高的併發。
  • CopyOnWriteArrayList併發安全且效能比Vector好。Vector是增刪改查方法都加了synchronized 來保證同步,但是每個方法執行的時候都要去獲得鎖,效能就會大大下降,而CopyOnWriteArrayList只是在增刪改上加鎖,但是讀不加鎖,在讀方面的效能就好於Vector。

缺點:

  • 資料一致性問題。CopyOnWrite容器只能保證資料的最終一致性,不能保證資料的實時一致性。比如執行緒A在迭代CopyOnWriteArrayList容器的資料。執行緒B線上程A迭代的間隙中將CopyOnWriteArrayList部分的資料修改了,但是執行緒A迭代出來的是舊資料。
  • 記憶體佔用問題。如果CopyOnWriteArrayList經常要增刪改裡面的資料,並且物件比較大,頻繁地寫會消耗記憶體,從而引發Java的GC問題,這個時候,我們應該考慮其他的容器,例如ConcurrentHashMap。