Java 集合底層原理剖析(List、Set、Map、Queue)
溫馨提示:下面是以 Java 8 版本進行講解,除非有特定說明。
一、Java 集合介紹
Java 集合是一個存儲相同類型數據的容器,類似數組,集合可以不指定長度,但是數組必須指定長度。集合類主要從 Collection 和 Map 兩個根接口派生出來,比如常用的 ArrayList、LinkedList、HashMap、HashSet、ConcurrentHashMap 等等。
二、List
2.1 ArrayList
ArrayList 是基於動態數組實現,容量能自動增長的集合。隨機訪問效率高,隨機插入、隨機刪除效率低。線程不安全,多線程環境下可以使用 Collections.synchronizedList(list) 函數返回一個線程安全的 ArrayList 類,也可以使用 concurrent 並發包下的 CopyOnWriteArrayList 類。
2.1.1先說說synchronizedList(list) 底層源碼如下:
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> { private static final long serialVersionUID = -7754090372962971524L; final List<E> list; SynchronizedList(List<E> list) { super(list); this.list = list; } SynchronizedList(List<E> list, Object mutex) { super(list, mutex); this.list = list; } public boolean equals(Object o) { if (this == o) return true; synchronized (mutex) {return list.equals(o);} } public int hashCode() { synchronized (mutex) {return list.hashCode();} } public E get(int index) { synchronized (mutex) {return list.get(index);} } public E set(int index, E element) { synchronized (mutex) {return list.set(index, element);} } public void add(int index, E element) { synchronized (mutex) {list.add(index, element);} } public E remove(int index) { synchronized (mutex) {return list.remove(index);} } public int indexOf(Object o) { synchronized (mutex) {return list.indexOf(o);} } public int lastIndexOf(Object o) { synchronized (mutex) {return list.lastIndexOf(o);} } public boolean addAll(int index, Collection<? extends E> c) { synchronized (mutex) {return list.addAll(index, c);} } public ListIterator<E> listIterator() { return list.listIterator(); // Must be manually synched by user } public ListIterator<E> listIterator(int index) { return list.listIterator(index); // Must be manually synched by user } public List<E> subList(int fromIndex, int toIndex) { synchronized (mutex) { return new SynchronizedList<>(list.subList(fromIndex, toIndex), mutex); } } @Override public void replaceAll(UnaryOperator<E> operator) { synchronized (mutex) {list.replaceAll(operator);} } @Override public void sort(Comparator<? super E> c) { synchronized (mutex) {list.sort(c);} } private Object readResolve() { return (list instanceof RandomAccess ? new SynchronizedRandomAccessList<>(list) : this); } }
使用方式如下,官方文檔就是下面的使用方式
List list = Collections.synchronizedList(new ArrayList()); ... synchronized (list) { Iterator i = list.iterator(); // Must be in synchronized block while (i.hasNext()) foo(i.next()); }
既然封裝類內部已經加了對象鎖,為什么外部還要加一層對象鎖?
看源碼可知,Collections.synchronizedList中很多方法,比如equals,hasCode,get,set,add,remove,indexOf,lastIndexOf......
都添加了鎖,但是List中
Iterator<E> iterator();
這個方法沒有加鎖,不是線程安全的,所以如果要遍歷,還是必須要在外面加一層鎖。
使用Iterator迭代器的話,似乎也沒必要用Collections.synchronizedList的方法來包裝了——反正都是必須要使用Synchronized代碼塊包起來的。
所以總的來說,Collections.synchronizedList這種做法,適合不需要使用Iterator、對性能要求也不高的情況。SynchronizedList和Vector最主要的區別:
- Vector擴容為原來的2倍長度,ArrayList擴容為原來1.5倍
- SynchronizedList有很好的擴展和兼容功能。他可以將所有的List的子類轉成線程安全的類。
- 使用SynchronizedList的時候,進行遍歷時要手動進行同步處理 。
- SynchronizedList可以指定鎖定的對象。
2.1.2再說說CopyOnWriteArrayList,同樣我們進源碼了解:
CopyOnWriteArrayList是Java並發包中提供的一個並發容器,它是個線程安全且讀操作無鎖的ArrayList,寫操作則通過創建底層數組的新副本來實現,是一種讀寫分離的並發策略,我們也可以稱這種容器為"寫時復制器",Java並發包中類似的容器還有CopyOnWriteSet。
我們都知道,集合框架中的ArrayList是非線程安全的,Vector雖是線程安全的,但由於簡單粗暴的鎖同步機制,性能較差。而CopyOnWriteArrayList則提供了另一種不同的並發處理策略(當然是針對特定的並發場景)。
很多時候,我們的系統應對的都是讀多寫少的並發場景。CopyOnWriteArrayList容器允許並發讀,讀操作是無鎖的,性能較高。至於寫操作,比如向容器中添加一個元素,則首先將當前容器復制一份,然后在新副本上執行寫操作,結束之后再將原容器的引用指向新容器。
優缺點分析
了解了CopyOnWriteArrayList的實現原理,分析它的優缺點及使用場景就很容易了。
優點:
讀操作性能很高,因為無需任何同步措施,比較適用於讀多寫少的並發場景。Java的list在遍歷時,若中途有別的線程對list容器進行修改,則會拋出ConcurrentModificationException異常。而CopyOnWriteArrayList由於其"讀寫分離"的思想,遍歷和修改操作分別作用在不同的list容器,所以在使用迭代器進行遍歷時候,也就不會拋出ConcurrentModificationException異常了
缺點:
缺點也很明顯,一是內存占用問題,畢竟每次執行寫操作都要將原容器拷貝一份,數據量大時,對內存壓力較大,可能會引起頻繁GC;二是無法保證實時性,Vector對於讀寫操作均加鎖同步,可以保證讀和寫的強一致性。而CopyOnWriteArrayList由於其實現策略的原因,寫和讀分別作用在新老不同容器上,在寫操作執行過程中,讀不會阻塞但讀取到的卻是老容器的數據。
源碼分析
基本原理了解了,CopyOnWriteArrayList的代碼實現看起來就很容易理解了。
添加操作:
public boolean add(E e) { //ReentrantLock加鎖,保證線程安全 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(); } }
添加的邏輯很簡單,先將原容器copy一份,然后在新副本上執行寫操作,之后再切換引用。當然此過程是要加鎖的。
刪除操作
public E remove(int index) { //加鎖 final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; E oldValue = get(elements, index); int numMoved = len - index - 1; if (numMoved == 0) //如果要刪除的是列表末端數據,拷貝前len-1個數據到新副本上,再切換引用 setArray(Arrays.copyOf(elements, len - 1)); else { //否則,將除要刪除元素之外的其他元素拷貝到新副本中,並切換引用 Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); setArray(newElements); } return oldValue; } finally { //解鎖 lock.unlock(); } }
刪除操作同理,將除要刪除元素之外的其他元素拷貝到新副本中,然后切換引用,將原容器引用指向新副本。同屬寫操作,需要加鎖。
我們再來看看讀操作,CopyOnWriteArrayList的讀操作是不用加鎖的,性能很高。
public E get(int index) { return get(getArray(), index); }
直接讀取即可,無需加鎖
動態數組,是指當數組容量不足以存放新的元素時,會創建新的數組,然后把原數組中的內容復制到新數組。
主要屬性:
//存儲實際數據,使用transient修飾,序列化的時不會被保存 transient Object[] elementData; //元素的數量,即容量。 private int size;
特征:
- 允許元素為 null;
- 查詢效率高、插入、刪除效率低,因為大量 copy 原來元素;
- 線程不安全。
使用場景:
- 需要快速隨機訪問元素
- 單線程環境
add(element) 流程:
- 判斷當前數組是否為空,如果是則創建長度為 10(默認)的數組,因為 new ArrayList 的時是沒有初始化;
- 判斷是否需要擴容,如果當前數組的長度加 1(即 size+1)后是否大於當前數組長度,則進行擴容 grow();
- 最后在數組末尾添加元素,並 size+1。
grow() 流程:
- 創建新數組,長度擴大為原數組的 1.5 倍;
- 如果擴大 1.5 倍還是不夠,則根據實際長度來擴容,比如 addAll() 場景;
- 將原數組的數據使用 System.arraycopy(native 方法)復制到新數組中。
add(index,element) 流程:
1.檢查 index 是否在數組范圍內,假如數組長度是 2,則 index 必須 >=0 並且 <=2,否則報 IndexOutOfBoundsException 異常;
2.擴容檢查;
3.通過拷貝方式,把數組位置為 index 至 size-1 的元素都往后移動一位,騰出位置之后放入元素,並 size+1。
set(index,element) 流程:
1.檢查 index 是否在數組范圍內,假如數組長度是 2,則 index 必須 >=0 並且 <2;
2.保留被覆蓋的值,因為最后需要返回舊的值;
3.新元素覆蓋位置為 index 的舊元素,返回舊值。
get(index) 流程:
1.判斷下標有沒有越界;
2.通過數組下標來獲取元素,get 的時間復雜度是 O(1)。
remove(index) 流程:
1.檢查指定位置是否在數組范圍內,假如數組長度是 2,則 index 必須 >=0 並且 < 2;
2.保留要刪除的值,因為最后需要返回舊的值;
3.計算出需要移動元素個數,再通過拷貝使數組內位置為 index+1 到 size-1 的元素往前移動一位,把數組最后一個元素設置為 null(精辟小技巧),返回舊值。
注意事項:
1.new ArrayList 創建對象時,如果沒有指定集合容量則初始化為 0;如果有指定,則按照指定的大小初始化;
2.擴容時,先將集合擴大 1.5 倍,如果還是不夠,則根據實際長度來擴容,保證都能存儲所有數據,比如 addAll() 場景。
3.如果新擴容后數組長度大於(Integer.MAX_VALUE-8),則拋出 OutOfMemoryError。
2.2 LinkedList
LinkedList 是可以在任何位置進行插入和移除操作的有序集合,它是基於雙向鏈表實現的,線程不安全。LinkedList 功能比較強大,可以實現棧、隊列或雙向隊列。
主要屬性:
//鏈表長度 transient int size = 0; //頭部節點 transient Node<E> first; //尾部節點 transient Node<E> last; /\*\* \* 靜態內部類,存儲數據的節點 \*/ private static class Node\<E\> { //自身結點 E item; //下一個節點 Node<E> next; //上一個節點 Node<E> prev; }
特征:
允許元素為 null;
插入和刪除效率高,查詢效率低;
順序訪問會非常高效,而隨機訪問效率(比如 get 方法)比較低;
既能實現棧 Stack(后進先出),也能實現隊列(先進先出), 也能實現雙向隊列,因為提供了 xxxFirst()、xxxLast() 等方法;
線程不安全。
使用場景:
需要快速插入,刪除元素
按照順序訪問其中的元素
單線程環境
add() 流程:
創建一個新結點,結點元素 item 為傳入參數,前繼節點 prev 為“當前鏈表 last 結點”,后繼節點 next 為 null;
判斷當前鏈表 last 結點是否為空,如果是則把新建結點作為頭結點,如果不是則把新結點作為 last 結點。
最后返回 true。
get(index,element) 流程:
檢查 index 是否在數組范圍內,假如數組長度是 2,則 index 必須 >=0 並且 < 2;
index 小於“雙向鏈表長度的 1/2”則從頭開始往后遍歷查找,否則從鏈表末尾開始向前遍歷查找。
remove() 流程:
判斷 first 結點是否為空,如果是則報 NoSuchElementException 異常;
如果不為空,則把待刪除結點的 next 結點的 prev 屬性賦值為 null,達到刪除頭結點的效果。
返回刪除值。
2.3 Vector
Vector 是矢量隊列,也是基於動態數組實現,容量可以自動擴容。跟 ArrayList 實現原理一樣,但是 Vector 是線程安全,使用 Synchronized 實現線程安全,性能非常差,已被淘汰,使用 CopyOnWriteArrayList 替代 Vector。
主要屬性:
//存儲實際數據 protected Object[] elementData; //動態數組的實際大小 protected int elementCount; //動態數組的擴容系數 protected int capacityIncrement;
特征:
允許元素為 null;
查詢效率高、插入、刪除效率低,因為需要移動元素;
默認的初始化大小為 10,沒有指定增長系數則每次都是擴容一倍,如果擴容后還不夠,則直接根據參數長度來擴容;
線程安全,性能差(Synchronized),使用 CopyOnWriteArrayList 替代 Vector。
**使用場景:**多線程環境
2.4 Stack
Stack 是棧,先進后出原則,Stack 繼承 Vector,也是通過數組實現,線程安全。因為效率比較低,不推薦使用,可以使用 LinkedList(線程不安全)或者 ConcurrentLinkedDeque(線程安全)來實現先進先出的效果。
**數據結構:**動態數組
**構造函數:**只有一個默認 Stack()
**特征:**先進后出
實現原理:
Stack 執行 push() 時,將數據推進棧,即把數據追加到數組的末尾。
Stack 執行 peek 時,取出棧頂數據,不刪除此數據,即獲取數組首個元素。
Stack 執行 pop 時,取出棧頂數據,在棧頂刪除數據,即刪除數組首個元素。
Stack 繼承於 Vector,所以 Vector 擁有的屬性和功能,Stack 都擁有,比如 add()、set() 等等。
2.5 CopyOnWriteArrayList
CopyOnWriteArrayList 是線程安全的 ArrayList,寫操作(add、set、remove 等等)時,把原數組拷貝一份出來,然后在新數組進行寫操作,操作完后,再將原數組引用指向到新數組。CopyOnWriteArrayList 可以替代 Collections.synchronizedList(List list)。
**數據結構:**動態數組
特征:
線程安全;
讀多寫少,比如緩存;
不能保證實時一致性,只能保證最終一致性。
缺點:
寫操作,需要拷貝數組,比較消耗內存,如果原數組容量大的情況下,可能觸發頻繁的 Young GC 或者 Full GC;
不能用於實時讀的場景,因為讀取到數據可能是舊的,可以保證最終一致性。
實現原理:
CopyOnWriteArrayList 寫操作加了鎖,不然多線程進行寫操作時會復制多個副本;讀操作沒有加鎖,所以可以實現並發讀,但是可能讀到舊的數據,比如正在執行讀操作時,同時有多個寫操作在進行,遇到這種場景時,就會都到舊數據。
2.6 CopyOnWriteArraySet
CopyOnWriteArraySet 是線程安全的無序並且不能重復的集合,可以認為是線程安全的 HashSet,底層是通過 CopyOnWriteArrayList 機制實現。
**數據結構:**動態數組(CopyOnWriteArrayList),並不是散列表。
特征:
線程安全
讀多寫少,比如緩存
不能存儲重復元素
2.7 ArrayList 和 Vector 區別
Vector 線程安全,ArrayList 線程不安全;
ArrayList 在擴容時默認是擴展 1.5 倍,Vector 是默認擴展 1 倍;
ArrayList 支持序列化,Vector 不支持;
Vector 提供 indexOf(obj, start) 接口,ArrayList 沒有;
Vector 構造函數可以指定擴容增加系數,ArrayList 不可以。
2.8 ArrayList 與 LinkedList 的區別
ArrayList 的數據結構是動態數組,LinkedList 的數據結構是鏈表;
ArrayList 不支持高效的插入和刪除元素,LinkedList 不支持高效的隨機訪問元素;
ArrayList 的空間浪費在數組末尾預留一定的容量空間,LinkedList 的空間浪費在每一個結點都要消耗空間來存儲 prev、next 等信息。
三、Map
3.1 HashMap
HashMap 是以key-value 鍵值對形式存儲數據,允許 key 為 null(多個則覆蓋),也允許 value 為 null。底層結構是數組 + 鏈表 + 紅黑樹。
主要屬性:
initialCapacity:初始容量,默認 16,2 的 N 次方。
loadFactor:負載因子,默認 0.75,用於擴容。
threshold:閾值,等於 initialCapacity * loadFactor,比如:16 * 0.75 = 12。
size:存放元素的個數,非 Node 數組長度。
Node
//存儲元素的數組 transient Node<K,V>[] table; //存放元素的個數,非Node數組長度 transient int size; //記錄結構性修改次數,用於快速失敗 transient int modCount; //閾值 int threshold; //負載因子,默認0.75,用於擴容 final float loadFactor; /\*\* \* 靜態內部類,存儲數據的節點 \*/ static class Node\<K,V\> implements Map.Entry\<K,V\> { //節點的hash值 final int hash; //節點的key值 final K key; //節點的value值 V value; //下一個節點的引用 Node<K,V> next; }
**數據結構:**數組 + 單鏈表,Node 結構:hash|key|value|next
**只允許一個 key 為 Null(多個則覆蓋),但允許多個 value 為 Null **
查詢、插入、刪除效率都高(集成了數組和單鏈表的特性)
** * 默認的初始化大小為 16,之后每次擴充為原來的 2 倍
線程不安全
使用場景:
快速增刪改查
隨機存取
緩存
哈希沖突的解決方案:
開放定址法
再散列函數法
鏈地址法(拉鏈法,常用)
put() 存儲的流程(Java 8):
(1)計算待新增數據 key 的 hash 值;
(2)判斷 Node[] 數組是否為空或者數據長度為 0 的情況,則需要進行初始化;
(3)根據 hash 值通過位運算定計算出 Node 數組的下標,判斷該數組第一個 Node 節點是否有數據,如果沒有數據,則插入新值;
(4)如果有數據,則根據具體情況進行操作,如下:
1.如果該 Node 結點的 key(即鏈表頭結點)與待新增的 key 相等(== 或者 equals),則直接覆蓋值,最后返回舊值;
2.如果該結構是樹形,則按照樹的方式插入新值;
3.如果是鏈表結構,則判斷鏈表長度是否大於閾值 8,如果 >=8 並且數組長度 >=64 才轉為紅黑樹,如果 >=8 並且數組長度 < 64 則進行擴容;
4.如果不需要轉為紅黑樹,則遍歷鏈表,如果找到 key 和 hash 值同時相等,則進行覆蓋返回舊值,如果沒有找到,則將新值插入到鏈表的最后面(尾插法);
5.判斷數組長度是否大於閾值,如果是則進入擴容階段。
resize() 擴容的流程(Java 8):
擴容過程比較復雜, 遷移算法與 Java 7 不一樣,Java 8 不需要每個元素都重新計算 hash,遷移過程中元素的位置要么是在原位置,要么是在原位置再移動 2 次冪的位置。
get() 查詢的流程(Java 8):
根據 put() 方法的方式計算出數組的下標;
遍歷數組下標對應的鏈表,如果找到 key 和 hash 值同時相等就返回對應的值,否則返回 null。
get() 注意事項:Java 8 沒有把 key 為 null 放到數組 table[0] 中。
remove() 刪除的流程(Java 8):
根據 get() 方法的方式計算出數組的下標,即定位到存儲刪除元素的 Node 結點;
如果待刪結點是頭節點,則用它的 next 結點頂替它作為頭節點;
如果待刪結點是紅黑樹結點,則直接調用紅黑樹的刪除方法進行刪除;
如果待刪結點是鏈表中的一個節點,則用待刪除結點的前一個節點的 next 屬性指向它的 next 結點;
如果刪除成功則返回被刪結點的 value,否則返回 null。
remove() 注意事項:刪除單個 key,注意返回是的鍵值對中的 value。
為什么使用位運算(&)來代替取模運算(%):
效率高,位運算直接對內存數據進行操作,不需轉成十進制,因此處理速度非常快;
可以解決負數問題,比如:-17 % 10 = -7。
HashMap 在 Java 7 和 Java 8 中的區別:
(1)存放數據的結點名稱不同,作用都一樣,存的都是 hashcode、key、value、next 等數據:
Java 7:使用 Entry 存放數據
Java 8:改名為 Node
(2)定位數組下標位置方法不同:
Java 7:計算 key 的 hash,將 hash 值進行了四次擾動,再進行取模得出;
Java 8:計算 key 的 hash,將 hash 值進行高 16 位異或低 16 位,再進行與運算得出。
(3)擴容算法不同:
Java 7:擴容要重新計算 hash
Java 8:不用重新計算
(4)put 方法插入鏈表位置不同:
Java 7:頭插法
Java 8:尾插法
(5)Java 8 引入了紅黑樹,當鏈表長度 >=8 時,並且同時數組的長度 >=64 時,鏈表就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高 HashMap 的性能。
3.2 HashTable
和 HashMap 一樣,Hashtable 也是一個哈希散列表,Hashtable 繼承於 Dictionary,使用重入鎖 Synchronized 實現線程安全,key 和 value 都不允許為 Null。HashTable 已被高性能的 ConcurrentHashMap 代替。
主要屬性:
initialCapacity:初始容量,默認 11。
loadFactor:負載因子,默認 0.75。
threshold:閾值。
modCount:記錄結構性修改次數,用於快速失敗。
//真正存儲數據的數組 private transient Entry<?,?>[] table; //存放元素的個數,非Entry數組長度 private transient int count; //閾值 private int threshold; //負載因子,默認0.75 private float loadFactor; //記錄結構性修改次數,用於快速失敗 private transient int modCount = 0; /\*\* \* 靜態內部類,存儲數據的節點 \*/ private static class Entry\<K,V\> implements Map.Entry\<K,V\> { //節點的hash值 final int hash; //節點的key值 final K key; //節點的value值 V value; //下一個節點的引用 Entry<K,V> next; }
快速失敗原理是在並發場景下進行遍歷操作時,如果有另外一個線程對它執行了寫操作,此時迭代器可以發現並拋出 ConcurrentModificationException,而不需等到遍歷完后才報異常。
**數據結構:**鏈表的數組,數組 + 鏈表,Entry 結構:hash|key|value|next
特征:
key 和 value 都不允許為 Null;
HashTable 默認的初始大小為 11,之后每次擴充為原來的 2 倍;
線程安全。
原理:
與 HashMap 不一樣的流程是定位數組下標邏輯,HashTable 是在 key.hashcode() 后使用取模,HashMap 是位運算。HashTable 是 put() 之前進行判斷是否擴容 resize(),而 HashMap 是 put() 之后擴容。
更多參考(總結的不錯):(26條消息) Java 集合底層原理剖析(List、Set、Map、Queue)_快樂的工程師的博客-CSDN博客