CopyOnWriteArrayList你都不知道,怎麼拿offer?

Java3y發表於2019-01-19

前言

只有光頭才能變強

cow

前一陣子寫過一篇COW(Copy On Write)文章,結果閱讀量很低啊…COW奶牛!Copy On Write機制瞭解一下

可能大家對這個技術比較陌生吧,但這項技術是挺多應用場景的。除了上文所說的Linux、檔案系統外,其實在Java也有其身影。

大家對執行緒安全容器可能最熟悉的就是ConcurrentHashMap了,因為這個容器經常會在面試的時候考查。

比如說,一個常見的面試場景:

  • 面試官問:“HashMap是執行緒安全的嗎?如果HashMap執行緒不安全的話,那有沒有安全的Map容器”
  • 3y:“執行緒安全的Map有兩個,一個是Hashtable,一個是ConcurrentHashMap”
  • 面試官繼續問:“那Hashtable和ConcurrentHashMap有什麼區別啊?”
  • 3y:“balabalabalabalabalabala”
  • 面試官:”ok,ok,ok,看你Java基礎挺不錯的呀“

那如果有這樣的面試呢?

  • 面試官問:“ArrayList是執行緒安全的嗎?如果ArrayList執行緒不安全的話,那有沒有安全的類似ArrayList的容器”
  • 3y:“執行緒安全的ArrayList我們可以使用Vector,或者說我們可以使用Collections下的方法來包裝一下”
  • 面試官繼續問:“嗯,我相信你也知道Vector是一個比較老的容器了,還有沒有其他的呢?”
  • 3y:“Emmmm,這個…“
  • 面試官提示:“就比如JUC中有ConcurrentHashMap,那JUC中有類似”ArrayList”的執行緒安全容器類嗎?“
  • 3y:“Emmmm,這個…“
  • 面試官:”ok,ok,ok,今天的面試時間也差不多了,你回去等通知吧。“

今天主要講解的是CopyOnWriteArrayList~

本文力求簡單講清每個知識點,希望大家看完能有所收穫

一、Vector和SynchronizedList

1.1回顧執行緒安全的Vector和SynchronizedList

我們知道ArrayList是用於替代Vector的,Vector是執行緒安全的容器。因為它幾乎在每個方法宣告處都加了synchronized關鍵字來使容器安全。

Vector實現

如果使用Collections.synchronizedList(new ArrayList())來使ArrayList變成是執行緒安全的話,也是幾乎都是每個方法都加上synchronized關鍵字的,只不過它不是加在方法的宣告處,而是方法的內部

Collections.synchronizedList()的實現

1.2Vector和SynchronizedList可能會出現的問題

在講解CopyOnWrite容器之前,我們還是先來看一下執行緒安全容器的一些可能沒有注意到的地方~

下面我們直接來看一下這段程式碼:


    // 得到Vector最後一個元素
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    // 刪除Vector最後一個元素
    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }

以我們第一反應來分析一下上面兩個方法:在多執行緒環境下,是否有問題

  • 我們可以知道的是Vector的size()和get()以及remove()都被synchronized修飾的。

答案:從呼叫者的角度是有問題

我們可以寫段程式碼測試一下:


import java.util.Vector;

public class UnsafeVectorHelpers {


    public static void main(String[] args) {

        // 初始化Vector
        Vector<String> vector = new Vector();
        vector.add("關注公眾號");
        vector.add("Java3y");
        vector.add("買Linux可到我下面的連結,享受最低價");
        vector.add("給3y加雞腿");

        new Thread(() -> getLast(vector)).start();
        new Thread(() -> deleteLast(vector)).start();
        new Thread(() -> getLast(vector)).start();
        new Thread(() -> deleteLast(vector)).start();
    }

    // 得到Vector最後一個元素
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    // 刪除Vector最後一個元素
    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

可以發現的是,有可能會丟擲異常的:

程式碼丟擲異常

原因也很簡單,我們照著流程走一下就好了:

  • 執行緒A執行getLast()方法,執行緒B執行deleteLast()方法
  • 執行緒A執行int lastIndex = list.size() - 1;得到lastIndex的值是3。同時,執行緒B執行int lastIndex = list.size() - 1;得到的lastIndex的值是3
  • 此時執行緒B先得到CPU執行權,執行list.remove(lastIndex)將下標為3的元素刪除了
  • 接著執行緒A得到CPU執行權,執行list.get(lastIndex);,發現已經沒有下標為3的元素,丟擲異常了.

交替執行導致異常發生

出現這個問題的原因也很簡單:

  • getLast()deleteLast()這兩個方法並不是原子性的,即使他們內部的每一步操作是原子性的(被Synchronize修飾就可以實現原子性),但是內部之間還是可以交替執行。

    • 這裡的意思就是:size()和get()以及remove()都是原子性的,但是如果併發執行getLast()deleteLast(),方法裡面的size()和get()以及remove()是可以交替執行的。

要解決上面這種情況也很簡單,因為我們都是對Vector進行操作的,只要操作Vector前把它鎖住就沒毛病了

所以我們可以改成這樣子:


    // 得到Vector最後一個元素
    public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }
    // 刪除Vector最後一個元素
    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }

ps:如果有人去測試一下,發現會丟擲異常java.lang.ArrayIndexOutOfBoundsException: -1,這是沒有檢查角標的異常,不是併發導致的問題。

經過上面的例子我們可以看看下面的程式碼:


    public static void main(String[] args) {

        // 初始化Vector
        Vector<String> vector = new Vector();
        vector.add("關注公眾號");
        vector.add("Java3y");
        vector.add("買Linux可到我下面的連結,享受最低價");
        vector.add("給3y加雞腿");

        // 遍歷Vector
        for (int i = 0; i < vector.size(); i++) {

            // 比如在這執行vector.clear();
            //new Thread(() -> vector.clear()).start();

            System.out.println(vector.get(i));
        }
    }

同樣地:如果在遍歷Vector的時候,有別的執行緒修改了Vector的長度,那還是會有問題

  • 執行緒A遍歷Vector,執行vector.size()時,發現Vector的長度為5
  • 此時很有可能存在執行緒B對Vector進行clear()操作
  • 隨後執行緒A執行vector.get(i)時,丟擲異常

Vector遍歷丟擲異常

在JDK5以後,Java推薦使用for-each(迭代器)來遍歷我們的集合,好處就是簡潔、陣列索引的邊界值只計算一次

如果使用for-each(迭代器)來做上面的操作,會丟擲ConcurrentModificationException異常

迭代器遍歷會丟擲ConcurrentModificationException

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

SynchronizedList在遍歷的時候同樣會有問題的

如果想要完美解決上面所講的問題,我們可以在遍歷前加鎖


        // 遍歷Vector
         synchronized (vector) {
            for (int i = 0; i < vector.size(); i++) {
                vector.get(i);
            }
        }

有經驗的同學就可以知道:哇,遍歷一下容器都要我加上鎖,這這這不是要慢死了嗎.的確是挺慢的..

所以我們的CopyOnWriteArrayList就登場了!

二、CopyOnWriteArrayList(Set)介紹

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

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

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

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

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

2.1CopyOnWriteArrayList實現原理

我們還是先來回顧一下COW:

如果有多個呼叫者(callers)同時請求相同資源(如記憶體或磁碟上的資料儲存),他們會共同獲取相同的指標指向相同的資源,直到某個呼叫者試圖修改資源的內容時,系統才會真正複製一份專用副本(private copy)給該呼叫者,而其他呼叫者所見到的最初的資源仍然保持不變。優點是如果呼叫者沒有修改該資源,就不會有副本(private copy)被建立,因此多個呼叫者只是讀取操作時可以共享同一份資源

參考自維基百科:https://zh.wikipedia.org/wiki/%E5%AF%AB%E5%85%A5%E6%99%82%E8%A4%87%E8%A3%BD

之前寫部落格的時候,如果是要看原始碼,一般會翻譯一下原始碼的註釋並用圖貼在文章上的。Emmm,發現閱讀體驗並不是很好,所以我這裡就直接概括一下原始碼註釋說了什麼吧。另外,如果使用IDEA的話,可以下一個外掛Translation(免費好用).

Translation外掛

Translation外掛


概括一下CopyOnWriteArrayList原始碼註釋介紹了什麼:

  • CopyOnWriteArrayList是執行緒安全容器(相對於ArrayList),底層通過複製陣列的方式來實現。
  • CopyOnWriteArrayList在遍歷的使用不會丟擲ConcurrentModificationException異常,並且遍歷的時候就不用額外加鎖
  • 元素可以為null

2.1.1看一下CopyOnWriteArrayList基本的結構


    /** 可重入鎖物件 */
    final transient ReentrantLock lock = new ReentrantLock();

    /** CopyOnWriteArrayList底層由陣列實現,volatile修飾 */
    private transient volatile Object[] array;

    /**
     * 得到陣列
     */
    final Object[] getArray() {
        return array;
    }

    /**
     * 設定陣列
     */
    final void setArray(Object[] a) {
        array = a;
    }

    /**
     * 初始化CopyOnWriteArrayList相當於初始化陣列
     */
    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }

看起來挺簡單的,CopyOnWriteArrayList底層就是陣列,加鎖就交由ReentrantLock來完成。

2.1.2常見方法的實現

根據上面的分析我們知道如果遍歷Vector/SynchronizedList是需要自己手動加鎖的。

CopyOnWriteArrayList使用迭代器遍歷時不需要顯示加鎖,看看add()、clear()、remove()get()方法的實現可能就有點眉目了。

首先我們可以看看add()方法


    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;
            
            // 將volatile Object[] array 的指向替換成新陣列
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

通過程式碼我們可以知道:在新增的時候就上鎖,並複製一個新陣列,增加操作在新陣列上完成,將array指向到新陣列中,最後解鎖。

再來看看size()方法:


    public int size() {

        // 直接得到array陣列的長度
        return getArray().length;
    }

再來看看get()方法:



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

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

那再來看看set()方法


public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        
        // 得到原陣列的舊值
        Object[] elements = getArray();
        E oldValue = get(elements, index);

        // 判斷新值和舊值是否相等
        if (oldValue != element) {
            
            // 複製新陣列,新值在新陣列中完成
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            
            // 將array引用指向新陣列
            setArray(newElements);
        } else {
            // Not quite a no-op; enssures volatile write semantics
            setArray(elements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

對於remove()、clear()set()和add()是類似的,這裡我就不再貼出程式碼了。

總結:

  • 在修改時,複製出一個新陣列,修改的操作在新陣列中完成,最後將新陣列交由array變數指向
  • 寫加鎖,讀不加鎖

2.1.3剖析為什麼遍歷時不用呼叫者顯式加鎖

常用的方法實現我們已經基本瞭解了,但還是不知道為啥能夠在容器遍歷的時候對其進行修改而不丟擲異常。所以,來看一下他的迭代器吧:



    // 1. 返回的迭代器是COWIterator
    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }


    // 2. 迭代器的成員屬性
    private final Object[] snapshot;
    private int cursor;

    // 3. 迭代器的構造方法
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    // 4. 迭代器的方法...
    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

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

到這裡,我們應該就可以想明白了!CopyOnWriteArrayList在使用迭代器遍歷的時候,操作的都是原陣列

一張圖來解析COW容器

2.1.4CopyOnWriteArrayList缺點

看了上面的實現原始碼,我們應該也大概能分析出CopyOnWriteArrayList的缺點了。

  • 記憶體佔用:如果CopyOnWriteArrayList經常要增刪改裡面的資料,經常要執行add()、set()、remove()的話,那是比較耗費記憶體的。

    • 因為我們知道每次add()、set()、remove()這些增刪改操作都要複製一個陣列出來。
  • 資料一致性:CopyOnWrite容器只能保證資料的最終一致性,不能保證資料的實時一致性

    • 從上面的例子也可以看出來,比如執行緒A在迭代CopyOnWriteArrayList容器的資料。執行緒B線上程A迭代的間隙中將CopyOnWriteArrayList部分的資料修改了(已經呼叫setArray()了)。但是執行緒A迭代出來的是原有的資料。

2.1.5CopyOnWriteSet

CopyOnWriteArraySet的原理就是CopyOnWriteArrayList。


    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }

三、最後

現在臨近雙十一買阿里雲伺服器就特別省錢!之前我買學生機也要9.8塊錢一個月,現在最低價只需要8.3一個月!

如果有要買伺服器的同學可通過我的連結直接享受最低價https://m.aliyun.com/act/team1111/#/share?params=N.FF7yxCciiM.pfn5xpli


閱讀這篇文章可能需要對Java容器和多執行緒有一定的瞭解。如果對這些知識還不太瞭解的同學們可看我之前寫過的文章哦~

如果大家有更好的理解方式或者文章有錯誤的地方還請大家不吝在評論區留言,大家互相學習交流~~~

參考資料:

擴充套件閱讀:

一個堅持原創的Java技術公眾號:Java3y,歡迎大家關注

3y所有的原創文章:

相關文章