# 全網最細 | 21張圖帶你領略集合的線程不安全


全網最細 | 21張圖帶你領略集合的線程不安全

封面圖

本篇主要內容如下:

本篇主要內容

本篇所有示例代碼已更新到 我的Github

本篇文章已收納到我的Java在線文檔

《Java並發必知必會》系列:

1.反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

2.程序員深夜慘遭老婆鄙視,原因竟是CAS原理太簡單?

3.用積木講解ABA原理 | 老婆居然又聽懂了!

4.全網最細 | 21張圖帶你領略集合的線程不安全

集合,准備團戰

一、線程不安全之ArrayList

集合框架有Map和Collection兩大類,Collection下面有List、Set、Queue。List下面有ArrayList、Vector、LinkedList。如下圖所示:

集合框架思維導圖

JUC並發包下的集合類Collections有Queue、CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentMap

JUC包下的Collections

我們先來看看ArrayList。

1.1、ArrayList的底層初始化操作

首先我們來復習下ArrayList的使用,下面是初始化一個ArrayList,數組存放的是Integer類型的值。

new ArrayList<Integer>();

那么底層做了什么操作呢?

1.2、ArrayList的底層原理

1.2.1 初始化數組

/**
 * Constructs an empty list with an initial capacity of ten.
 */
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

創建了一個空數組,容量為0,根據官方的英文注釋,這里容量應該為10,但其實是0,后續會講到為什么不是10。

1.2.1 ArrayList的add操作

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

重點是這一步:elementData[size++] = e; size++和elementData[xx]=e,這兩個操作都不是原子操作(不可分割的一個或一系列操作,要么都成功執行,要么都不執行)。

1.2.2 ArrayList擴容源碼解析

(1)執行add操作時,會先確認是否超過數組大小

ensureCapacityInternal(size + 1);

ensureCapacityInternal方法

(2)計算數組的當前容量calculateCapacity

private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

minCapacity : 值為1

elementData:代表當前數組

我們先看ensureCapacityInternal調用的ensureCapacityInternal方法

calculateCapacity(elementData, minCapacity)

calculateCapacity方法如下:

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

elementData:代表當前數組,添加第一個元素時,elementData等於DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空數組)

minCapacity:等於1

DEFAULT_CAPACITY: 等於10

返回 Math.max(DEFAULT_CAPACITY, minCapacity) = 10

小結:所以第一次添加元素時,計算數組的大小為10

(3)確定當前容量ensureExplicitCapacity

ensureExplicitCapacity方法

minCapacity = 10

elementData.length=0

小結:因minCapacity > elementData.length,所以進行第一次擴容,調用grow()方法從0擴大到10

(4)調用grow方法

grow方法

oldCapacity=0,newCapacity=10。

然后執行 elementData = Arrays.copyOf(elementData, newCapacity);

將當前數組和容量大小進行數組拷貝操作,賦值給elementData。數組的容量設置為10

elementData的值和DEFAULTCAPACITY_EMPTY_ELEMENTDATA的值將會不一樣。

(5)然后將元素賦值給數組第一個元素,且size自增1

elementData[size++] = e;

(6)添加第二個元素時,傳給ensureCapacityInternal的是2

ensureCapacityInternal(size + 1)

size=1,size+1=2

(7)第二次添加元素時,執行calculateCapacity

mark

elementData的值和DEFAULTCAPACITY_EMPTY_ELEMENTDATA的值不相等,所以直接返回2

(8)第二次添加元素時,執行ensureExplicitCapacity

因minCapacity等於2,小於當前數組的長度10,所以不進行擴容,不執行grow方法。

mark

(9)將第二個元素添加到數組中,size自增1

elementData[size++] = e

(10)當添加第11個元素時調用grow方法進行擴容

mark

minCapacity=11, elementData.length=10,調用grow方法。

(11)擴容1.5倍

int newCapacity = oldCapacity + (oldCapacity >> 1);

oldCapacity=10,先換算成二級制1010,然后右移一位,變成0101,對應十進制5,所以newCapacity=10+5=15,擴容1.5倍后是15。

擴容1.5倍

(12)小結

  • 1.ArrayList初始化為一個空數組
  • 2.ArrayList的Add操作不是線程安全的
  • 3.ArrayList添加第一個元素時,數組的容量設置為10
  • 4.當ArrayList數組超過當前容量時,擴容至1.5倍(遇到計算結果為小數的,向下取整),第一次擴容后,容量為15,第二次擴容至22...
  • 5.ArrayList在第一次和擴容后都會對數組進行拷貝,調用Arrays.copyOf方法。

安全出行

1.3、ArrayList單線程環境是否安全?

場景:

我們通過一個添加積木的例子來說明單線程下ArrayList是線程安全的。

將 積木 三角形A四邊形B五邊形C六邊形D五角星E依次添加到一個盒子中,盒子中共有5個方格,每一個方格可以放一個積木。

ArrayList單線程下添加元素

代碼實現:

(1)這次我們用新的積木類BuildingBlockWithName

這個積木類可以傳形狀shape和名字name

/**
 * 積木類
 * @author: 悟空聊架構
 * @create: 2020-08-27
 */
class BuildingBlockWithName {
    String shape;
    String name;
    public BuildingBlockWithName(String shape, String name) {
        this.shape = shape;
        this.name = name;
    }
    @Override
    public String toString() {
        return "BuildingBlockWithName{" + "shape='" + shape + ",name=" + name +'}';
    }
}

(2)初始化一個ArrayList

ArrayList<BuildingBlock> arrayList = new ArrayList<>();

(3)依次添加三角形A、四邊形B、五邊形C、六邊形D、五角星E

arrayList.add(new BuildingBlockWithName("三角形", "A"));
arrayList.add(new BuildingBlockWithName("四邊形", "B"));
arrayList.add(new BuildingBlockWithName("五邊形", "C"));
arrayList.add(new BuildingBlockWithName("六邊形", "D"));
arrayList.add(new BuildingBlockWithName("五角星", "E"));

(4)驗證arrayList中元素的內容和順序是否和添加的一致

BuildingBlockWithName{shape='三角形,name=A}
BuildingBlockWithName{shape='四邊形,name=B}
BuildingBlockWithName{shape='五邊形,name=C}
BuildingBlockWithName{shape='六邊形,name=D}
BuildingBlockWithName{shape='五角星,name=E}

我們看到結果確實是一致的。

小結: 單線程環境中,ArrayList是線程安全的。

1.4、多線程下ArrayList是不安全的

場景如下: 20個線程隨機往ArrayList添加一個任意形狀的積木。

多線程場景往數組存放元素

(1)代碼實現:20個線程往數組中隨機存放一個積木。

多線程下ArrayList是不安全的

(2)打印結果:程序開始運行后,每個線程只存放一個隨機的積木。

打印結果

數組中會不斷存放積木,多個線程會爭搶數組的存放資格,在存放過程中,會拋出一個異常: ConcurrentModificationException(並行修改異常)

Exception in thread "10" Exception in thread "13" java.util.ConcurrentModificationException

mark

這個就是常見的並發異常:java.util.ConcurrentModificationException

1.5 那如何解決ArrayList線程不安全問題呢?

有如下方案:

  • 1.用Vector代替ArrayList
  • 2.用Collections.synchronized(new ArrayList<>())
  • 3.CopyOnWriteArrayList

1.6 Vector是保證線程安全的?

下面就來分析vector的源碼。

1.6.1 初始化Vector

初始化容量為10

public Vector() {
    this(10);
}

1.6.2 Add操作是線程安全的

Add方法加了synchronized,來保證add操作是線程安全的(保證可見性、原子性、有序性),對這幾個概念有不懂的可以看下之前的寫的文章-》 反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

Add方法加了synchronized

1.6.3 Vector擴容至2倍

int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);

容量擴容至2倍

注意: capacityIncrement 在初始化的時候可以傳值,不傳則默認為0。如果傳了,則第一次擴容時為設置的oldCapacity+capacityIncrement,第二次擴容時擴大1倍。

缺點: 雖然保證了線程安全,但因為加了排斥鎖synchronized,會造成阻塞,所以性能降低

阻塞

1.6.4 用積木模擬Vector的add操作

vector的add操作

當往vector存放元素時,給盒子加了一個鎖,只有一個人可以存放積木,放完后,釋放鎖,放第二元素時,再進行加鎖,依次往復進行。

1.7 使用Collections.synchronizedList保證線程安全

我們可以使用Collections.synchronizedList方法來封裝一個ArrayList。

List<Object> arrayList = Collections.synchronizedList(new ArrayList<>());

為什么這樣封裝后,就是線程安全的?

源碼解析: 因為Collections.synchronizedList封裝后的list,list的所有操作方法都是帶synchronized關鍵字的(除iterator()之外),相當於所有操作都會進行加鎖,所以使用它是線程安全的(除迭代數組之外)。

加鎖

mark

注意: 當迭代數組時,需要手動做同步。官方示例如下:

synchronized (list) {
     Iterator i = list.iterator(); // Must be in synchronized block
     while (i.hasNext())
         foo(i.next());
}

1.8 使用CopyOnWriteArrayList保證線程安全

復制

1.8.1 CopyOnWriteArrayList思想

  • Copy on write:寫時復制,一種讀寫分離的思想。
  • 寫操作:添加元素時,不直接往當前容器添加,而是先拷貝一份數組,在新的數組中添加元素后,在將原容器的引用指向新的容器。因為數組時用volatile關鍵字修飾的,所以當array重新賦值后,其他線程可以立即知道(volatile的可見性)
  • 讀操作:讀取數組時,讀老的數組,不需要加鎖。
  • 讀寫分離:寫操作是copy了一份新的數組進行寫,讀操作是讀老的數組,所以是讀寫分離。

1.8.2 使用方式

CopyOnWriteArrayList<BuildingBlockWithName> arrayList = new CopyOnWriteArrayList<>();

1.8.3 底層源碼分析

CopyOnWriteArrayList的add方法分析

add的流程:

  • 先定義了一個可重入鎖 ReentrantLock
  • 添加元素前,先獲取鎖lock.lock()
  • 添加元素時,先拷貝當前數組 Arrays.copyOf
  • 添加元素時,擴容+1(len + 1
  • 添加元素后,將數組引用指向新加了元素后的數組setArray(newElements)

為什么數組重新賦值后,其他線程可以立即知道?

因為這里的數組是用volatile修飾的,哇,又是volatile,這個關鍵字真妙_

 private transient volatile Object[] array;

妙啊

1.8.4 ReentrantLock 和synchronized的區別

划重點

相同點:

  • 1.都是用來協調多線程對共享對象、變量的訪問
  • 2.都是可重入鎖,同一線程可以多次獲得同一個鎖
  • 3.都保證了可見性和互斥性

不同點:

樂觀

  • 1.ReentrantLock 顯示的獲得、釋放鎖, synchronized 隱式獲得釋放鎖
  • 2.ReentrantLock 可響應中斷, synchronized 是不可以響應中斷的,為處理鎖的不可用性提供了更高的靈活性
  • 3.ReentrantLock 是 API 級別的, synchronized 是 JVM 級別的
  • 4.ReentrantLock 可以實現公平鎖、非公平鎖
  • 5.ReentrantLock 通過 Condition 可以綁定多個條件
  • 6.底層實現不一樣, synchronized 是同步阻塞,使用的是悲觀並發策略, lock 是同步非阻塞,采用的是樂觀並發策略

1.8.5 Lock和synchronized的區別

自動擋和手動擋的區別

  • 1.Lock需要手動獲取鎖和釋放鎖。就好比自動擋和手動擋的區別
  • 1.Lock 是一個接口,而 synchronized 是 Java 中的關鍵字, synchronized 是內置的語言實現。
  • 2.synchronized 在發生異常時,會自動釋放線程占有的鎖,因此不會導致死鎖現象發生;而 Lock 在發生異常時,如果沒有主動通過 unLock()去釋放鎖,則很可能造成死鎖現象,因此使用 Lock 時需要在 finally 塊中釋放鎖。
  • 3.Lock 可以讓等待鎖的線程響應中斷,而 synchronized 卻不行,使用 synchronized 時,等待的線程會一直等待下去,不能夠響應中斷。
  • 4.通過 Lock 可以知道有沒有成功獲取鎖,而 synchronized 卻無法辦到。
  • 5.Lock 可以通過實現讀寫鎖提高多個線程進行讀操作的效率。

二、線程不安全之HashSet

有了前面大篇幅的講解ArrayList的線程不安全,以及如何使用其他方式來保證線程安全,現在講HashSet應該更容易理解一些。

2.1 HashSet的用法

用法如下:

Set<BuildingBlockWithName> Set = new HashSet<>();
set.add("a");

初始容量=10,負載因子=0.75(當元素個數達到容量的75%,啟動擴容)

2.2 HashSet的底層原理

public HashSet() {
    map = new HashMap<>();
}

底層用的還是HashMap()。

考點: 為什么HashSet的add操作只用傳一個參數(value),而HashMap需要傳兩個參數(key和value)

2.3 HashSet的add操作

private static final Object PRESENT = new Object();

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

考點回答: 因為HashSet的add操作中,key等於傳的value值,而value是PRESENT,PRESENT是new Object();,所以傳給map的是 key=e, value=new Object。Hash只關心key,不考慮value。

為什么HashSet不安全: 底層add操作不保證可見性、原子性。所以不是線程安全的。

2.4 如何保證線程安全

  • 1.使用Collections.synchronizedSet

    Set<BuildingBlockWithName> set = Collections.synchronizedSet(new HashSet<>());
    
  • 2.使用CopyOnWriteArraySet

    CopyOnWriteArraySet<BuildingBlockWithName> set = new CopyOnWriteArraySet<>();
    

2.5 CopyOnWriteArraySet的底層還是使用的是CopyOnWriteArrayList

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

三、線程不安全之HashMap

3.1 HashMap的使用

同理,HashMap和HashSet一樣,在多線程環境下也是線程不安全的。

Map<String, BuildingBlockWithName> map = new HashMap<>();
map.put("A", new BuildingBlockWithName("三角形", "A"));

3.2 HashMap線程不安全解決方案:

  • 1.Collections.synchronizedMap
Map<String, BuildingBlockWithName> map2 = Collections.synchronizedMap(new HashMap<>());
  • 2.ConcurrentHashMap
ConcurrentHashMap<String, BuildingBlockWithName> set3 = new ConcurrentHashMap<>();

3.3 ConcurrentHashMap原理

ConcurrentHashMap,它內部細分了若干個小的 HashMap,稱之為段(Segment)。 默認情況下一個 ConcurrentHashMap 被進一步細分為 16 個段,既就是鎖的並發度。如果需要在 ConcurrentHashMap 中添加一個新的表項,並不是將整個 HashMap 加鎖,而是首先根據 hashcode 得到該表項應該存放在哪個段中,然后對該段加鎖,並完成 put 操作。在多線程環境中,如果多個線程同時進行put操作,只要被加入的表項不存放在同一個段中,則線程間可以做到真正的並行。

四、其他的集合類

LinkedList: 線程不安全,同ArrayList
TreeSet: 線程不安全,同HashSet
LinkedHashSet: 線程不安全,同HashSet
TreeMap: 同HashMap,線程不安全
HashTable: 線程安全

總結

本篇第一個部分詳細講述了ArrayList集合的底層擴容原理,演示了ArrayList的線程不安全會導致拋出並發修改異常。然后通過源碼解析的方式講解了三種方式來保證線程安全:

  • Vector是通過在add等方法前加synchronized來保證線程安全
  • Collections.synchronized()是通過包裝數組,在數組的操作方法前加synchronized來保證線程安全
  • CopyOnWriteArrayList通過寫時復制來保證線程安全的。

第二部分講解了HashSet的線程不安全性,通過兩種方式保證線程安全:

  • Collections.synchronizedSet
  • CopyOnWriteArraySet

第三部分講解了HashMap的線程不安全性,通過兩種方式保證線程安全:

  • Collections.synchronizedMap
  • ConcurrentHashMap

另外在講解的過程中,也詳細對比了ReentrantLock和synchronized及Lock和synchronized的區別。

彩蛋: 聰明的你,一定發現集合里面還漏掉了一個重要的東西:那就是Queue。期待后續么?

白嫖么?轉發->在看->點贊-收藏!!!

我是悟空,一只努力變強的碼農!我要變身超級賽亞人啦!

悟空

另外可以搜索「悟空聊架構」或者PassJava666,一起進步!
我的GitHub主頁,關注我的Spring Cloud 實戰項目《佳必過》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM