Java併發編程學習系列二:集合不安全

概念

Java集合類框架的基本接口有哪些?

總共有兩大接口:Collection 和 Map ,一個元素集合,一個是鍵值對集合; 其中 List 和 Set 接口繼承了 Collection 接口,一個是有序元素集合,一個是無序元素集合; 而 ArrayList 和 LinkedList 實現了 List 接口,HashSet 實現了 Set 接口,這幾個都比較經常使用; HashMap 和 HashTable 實現了 Map 接口,而且 HashTable 是線程安全的,可是 HashMap 性能更好;javascript

Collection 和 Collections 有什麼區別?

  • java.util.Collection 是一個集合接口(集合類的一個頂級接口)。它提供了對集合對象進行基本操做的通用接口方法。Collection 接口在 Java 類庫中有不少具體的實現。Collection 接口的意義是爲各類具體的集合提供了最大化的統一操做方式,其直接繼承接口有 List 與 Set。html

  • Collections 則是集合類的一個工具類/幫助類,其中提供了一系列靜態方法,用於對集合中元素進行排序、搜索以及線程安全等各類操做。java

List、Set、Map 之間的區別是什麼?

快速失敗(fail-fast)和安全失敗(fail-safe)的區別是什麼?

快速失敗:當你在迭代一個集合的時候,若是有另外一個線程正在修改你正在訪問的那個集合時,就會拋出一個 ConcurrentModification 異常。
在 java.util 包下的都是快速失敗,不能在多線程下發生併發修改(迭代過程當中被修改)。web

安全失敗:你在迭代的時候會去底層集合作一個拷貝,因此你在修改上層集合的時候是不會受影響的,不會拋出 ConcurrentModification 異常。
在 java.util.concurrent 包下的全是安全失敗的。能夠在多線程下併發使用,併發修改。數組

List

併發不安全

首先咱們查看以下案例:安全

public class ListTest {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();

        for(int i=0;i<10;i++){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            },String.valueOf(i)).start();
        }
    }
}
複製代碼

執行上述代碼,會拋出 java.util.ConcurrentModificationException 併發異常。多線程

解決方案

一、Vector

public class ListTest {

    public static void main(String[] args) {
        List<String> list = new Vector<>();

        for(int i=0;i<10;i++){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            },String.valueOf(i)).start();
        }
    }
}
複製代碼

Vector 類是在 JDK1.0 出現的,比 ArrayList 還早,那爲何不推薦該方法呢?查看源碼得知:併發

    public synchronized boolean add(E var1) {
        ++this.modCount;
        this.ensureCapacityHelper(this.elementCount + 1);
        this.elementData[this.elementCount++] = var1;
        return true;
    }
複製代碼

使用 Synchronized 關鍵字來實現同步操做,效率較低。緣由:在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,由於監視器鎖(monitor)是依賴於底層的操做系統的 Mutex Lock 來實現的,Java 的線程是映射到操做系統的原生線程之上的。若是要掛起或者喚醒一個線程,都須要操做系統幫忙完成,而操做系統實現線程之間的切換時須要從用戶態轉換到內核態,這個狀態之間的轉換須要相對比較長的時間,時間成本相對較高,這也是爲何早期的 synchronized 效率低的緣由。慶幸的是在 Java 6 以後 Java 官方對從 JVM 層面對 synchronized 較大優化,因此如今的 synchronized 鎖效率也優化得很不錯了。JDK1.6 對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減小鎖操做的開銷。app

二、使用 Collections 工具類

List<String> list = Collections.synchronizedList(new ArrayList<>());
複製代碼

一樣咱們來查看源碼,Collections.synchronizedList() 的定義以下:框架

    public static <T> List<T> synchronizedList(List<T> var0) {
        return (List)(var0 instanceof RandomAccess ? new Collections.SynchronizedRandomAccessList(var0) : new Collections.SynchronizedList(var0));
    }
複製代碼
public class ArrayList<Eextends AbstractList<Eimplements List<E>, RandomAccessCloneableSerializable
複製代碼

一路跳轉後,最終定位到 SynchronizedCollection 靜態內部類,List 對象也就變爲了 SynchronizedCollection 類型,查看其 add 方法定義:

        public boolean add(E var1) {
            synchronized(this.mutex) {
                return this.c.add(var1);
            }
        }
複製代碼

同 Vector 同樣,仍是採用的 Synchronized 關鍵字來作同步操做,只是封裝在 Collections 工具類中。

三、使用CopyOnWriteArrayList

咱們來經過看源碼的方式來理解 CopyOnWriteArrayList,實際上 CopyOnWriteArrayList 內部維護的也是一個數組

private transient volatile Object[] array;
複製代碼

只是該數組是被 volatile 修飾,注意這裏僅僅修飾的是數組引用,與被 volatile 修飾的普通變量有所區別,關於這點我在以前的文章中有分析,對 volatile 還不是太瞭解的朋友也能夠去看一下。對 list 來講,咱們天然而然最關心的就是讀寫的時候,分別爲 get 和 add 方法的實現。

如下方式利用 CopyOnWriteArrayList 來保證線程安全。

List<String> list = new CopyOnWriteArrayList<>();
複製代碼

CopyOnWriteArrayList 比 Vector 更高級,由於加鎖的方式不同,前者使用 Lock 鎖。查看其 add 方法定義:

    public boolean add(E var1) {
        ReentrantLock var2 = this.lock;
        var2.lock();

        boolean var6;
        try {
            Object[] var3 = this.getArray();
            int var4 = var3.length;
            Object[] var5 = Arrays.copyOf(var3, var4 + 1);
            var5[var4] = var1;
            this.setArray(var5);
            var6 = true;
        } finally {
            var2.unlock();
        }

        return var6;
    }
複製代碼

除了經過 Lock 來加鎖處理,每次寫數據前,都會另外經過 Arrays.copyOf 方法複製一份數據,在寫入的時候避免覆蓋,形成數據問題。

咱們須要注意 CopyOnWriteArrayList 中的這兩個字段屬性以及相關方法。

    final transient ReentrantLock lock = new ReentrantLock();
    private transient volatile Object[] array;

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

    final void setArray(Object[] var1) {
        this.array = var1;
    }
複製代碼

注意事項:

  1. 採用 ReentrantLock 來加鎖,保證同一時刻只有一個線程在對數據進行讀寫操做;
  2. 數組引用是 volatile 修飾的,所以將舊的數組引用指向新的數組,根據 volatile 的 happens-before 規則,寫線程對數組引用的修改對讀線程是可見的的,可是數組中的元素並不可見。
  3. 因爲在寫數據的時候,是在新的數組中插入數據的,從而保證讀寫是在兩個不一樣的數據容器中進行操做。

CopyOnWriteArrayList 的缺點:

  1. 內存佔用問題:由於 CopyOnWrite 的寫時複製機制,因此在進行寫操做的時候,內存裏會同時駐紮兩個對 象的內存,舊的對象和新寫入的對象(注意:在複製的時候只是複製容器裏的引用,只是在寫的時候會建立新對象添加到新容器裏,而舊容器的對象還在使用,因此有兩份對象內存)。若是這些對象佔用的內存比較大,比 如說 200M 左右,那麼再寫入 100M 數據進去,內存就會佔用 300M,那麼這個時候頗有可能形成頻繁的 minor GC 和 major GC。
  2. 數據一致性問題:CopyOnWrite 容器只能保證數據的最終一致性,不能保證數據的實時一致性。因此若是你但願寫入的的數據,立刻能讀到,請不要使用 CopyOnWrite 容器。

CopyOnWriteArrayList 的優勢:

  1. 線程安全,可以保證數據一致性。
  2. 與 Vector、ArrayList 相比,CopyOnWriteArrayList 在多線程遍歷迭代過程當中不會報錯。

關於優缺點中各自的第二條,經過如下案例向你們說明:

public class CowTest {

    public static void main(String[] args) {
        // 初始化一個list,放入5個元素
        final List<Integer> list = new CopyOnWriteArrayList<>();
//        final List<Integer> list = new Vector<>();
//        final List<Integer> list = Collections.synchronizedList(new ArrayList<>());
        for(int i = 0; i < 5; i++) {
            list.add(i);
        }

        // 線程一:經過Iterator遍歷List
        Thread t1 = new Thread(()-> {
            for(int item : list) {
                System.out.println("遍歷元素:" + item);
                // 因爲程序跑的太快,這裏sleep了1秒來調慢程序的運行速度
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 線程二:add一個元素
        Thread t2 = new Thread(() ->{
            // 因爲程序跑的太快,這裏sleep了1秒來調慢程序的運行速度
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            list.add(5);
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
            System.out.println(list);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製代碼

執行結果爲:

遍歷元素:0
遍歷元素:1
遍歷元素:2
遍歷元素:3
遍歷元素:4
[012345]
複製代碼

當線程1在遍歷集合,線程2往集合中新增一個數據,若是使用前兩種線程安全的替換方案,有可能產生 ConcurrentModificationException 異常。爲何 CopyOnWriteArrayList 就沒問題呢?緣由在於它保證線程安全採用的是寫時複製並加鎖,因此線程1在遍歷時拿到的集合是舊的,這也就是結果中爲啥沒有輸出5的緣由,雖然拿到的是舊集合,可是至少不會報錯。

CopyOnWriteArrayList 只能保證數據最終一致性,這點從結果中能夠看出來,當在線程2中給集合新增了元素,可是沒法當即通知到線程1,因此沒法保證數據實時性。可是當其餘線程再次讀取集合時,才能讀取到完整的新數據。

最後總結一下,CopyOnWriteArrayList 適合在讀多寫少的場景使用,實時性要求不高,否則讀取到的多是舊數據。添加數據能夠採用批量添加 addAll 方法,減小內存佔用。

Set

併發不安全

首先咱們查看以下案例:

public class SetTest {

    public static void main(String[] args) {
        Set<String> set= new HashSet<>();

        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            },String.valueOf(i)).start();
        }
    }
}
複製代碼

執行上述代碼,會拋出 java.util.ConcurrentModificationException 併發異常。

解決方案

一、使用 Collections 工具類

Set<String> set = Collections.synchronizedSet(new HashSet<>());
複製代碼

同 List 使用 Collections.synchronizedList()同樣,經過 synchronized 來實現線程安全。

二、使用CopyOnWriteArraySet

Set<String> set = new CopyOnWriteArraySet<>();
複製代碼

咱們點擊查看 CopyOnWriteArraySet 類,發現其背後經過 CopyOnWriteArrayList 來存儲數據。

    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        this.al = new CopyOnWriteArrayList();
    }
複製代碼

咱們知道 Set 集合中的元素是不可重複,那麼這是怎麼作到的呢?首先來查看 add 方法。

    public boolean add(E var1) {
        return this.al.addIfAbsent(var1);
    }
複製代碼

其實是執行 CopyOnWriteArrayList 中的 addIfAbsent 方法。

public boolean addIfAbsent(E var1) {
    Object[] var2 = this.getArray();    //獲取當前數組信息
    //indexOf方法用於比對新增值在原數組中是否存在,若存在,則返回值不小於0,不然返回-1,即要執行addIfAbsent方法
    return indexOf(var1, var2, 0, var2.length) >= 0 ? false : this.addIfAbsent(var1, var2);
}

private boolean addIfAbsent(E var1, Object[] var2) {
    ReentrantLock var3 = this.lock;
    var3.lock();

    try {
        Object[] var4 = this.getArray();
        int var5 = var4.length;
        boolean var13;
        if (var2 != var4) {
            int var6 = Math.min(var2.length, var5);

            for(int var7 = 0; var7 < var6; ++var7) {
                if (var4[var7] != var2[var7] && eq(var1, var4[var7])) {
                    boolean var8 = false;
                    return var8;
                }
            }

            if (indexOf(var1, var4, var6, var5) >= 0) {
                var13 = false;
                return var13;
            }
        }

        Object[] var12 = Arrays.copyOf(var4, var5 + 1);
        var12[var5] = var1;
        this.setArray(var12);
        var13 = true;
        return var13;
    } finally {
        var3.unlock();
    }
}
複製代碼

同 CopyOnWriteArrayList 中的 add 方法相似,addIfAbsent 方法也是先加鎖,而後寫前複製來保證線程安全。

Map

併發不安全

首先咱們查看以下案例:

public class MapTest {

    public static void main(String[] args) {

//        Map<String,Object> map = new HashMap<>();
        //加載因子和初始容量
        //等價於:new HashMap(16,0.75)

        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0,5));
                System.out.println(map);
            },String.valueOf(i)).start();

        }
    }
}
複製代碼

執行上述代碼,會拋出 java.util.ConcurrentModificationException 併發異常。

解決方案

一、使用 Collections 工具類

Map<String,Object> map = Collections.synchronizedMap(new HashMap<>());
複製代碼

二、使用Hashtable

Map<String,Object> map = new Hashtable<>();
複製代碼

該類經過對讀寫進行加鎖(synchronized)操做,一個線程在讀寫元素,其他線程必須等待,性能較低。

三、使用 ConcurrentHashMap

Map<String,Object> map = new ConcurrentHashMap<>();
複製代碼

關於 ConcurrentHashMap 的詳細學習,能夠參考這篇文章

參考文獻

併發容器之CopyOnWriteArrayList

先簡單說一說Java中的CopyOnWriteArrayList

如何線程安全地遍歷List:Vector、CopyOnWriteArrayList

相關文章
相關標籤/搜索