Java集合概述(上)
前言
先說說,為什么要寫這么一篇博客(我總是喜歡寫原因)。因為最近到年底了,正好又要准備面試,所以在做各方面的技術總結。而Java集合是Java非常重要的一部分,自己前前后后也花了不少時間學習,但是一直比較零散。所以,打算趁着這個機會,來寫一個總結。
由於能力有限,這方面沒有足夠積累,如果有什么問題,還請指出。謝謝。
集合分類,主要分為:
- Collection(繼承Iterable接口):按照單個元素存儲的集合
- List:一種線性數據結構的主要體現。有序,可重復
- Set:一種不允許出現重復元素的集合。無序(插入順序與輸出順序不一致),不可重復
- Queue:一種先進先出(FIFO)的數據結構。有序,可重復,先進先出
- Map(無繼承接口):按照K-V存儲的Map
- keySet:可以查看所有的Key。底層實現各不相同。ConcurrentHashMap則是采用的自定義實現的KeySetView內部靜態類(實現了Set接口),而HashMap這樣的AbstractMap子類,則是是Set接口
- values:同上,ConcurrentHashMap采用ValueSetView,HashMap采用Set接口
- entrySet:同上,ConcurrentHashMap采用EntrySetView,HashMap采用Set接口
原本Map是打算按照 AbstractMap;SortedMap;ConcurrentMap;來分類,但是發現這個分類屬於理論價值,大於使用價值,也可能是我現在層次不夠吧。最后還是學着孤盡大佬在《碼處高效》中那樣,通過三個視圖,來觀察Map。具體后面闡述,我也只是闡述其中部分的Map。
論述方面,我主要會從數據組織方式(底層數據存儲方式),數據處理方式(如HashMap的put操作等),特點小結結三個方面進行闡述。但是由於內容量的問題,這里並不會非常細致地闡述代碼實現。
最后,由於內容量的緣故,這部分內容,我將分為兩個部分。這篇博客主要論述List與Map,而Set與Queue放在另外一篇博客。
一,List
ArrayList
數據組織方式
transient Object[] elementData; // non-private to simplify nested class access
ArrayList的底層是一個Object類型的數組。那么ArrayList就有着和數組一樣的特點:隨機查詢快,但數據的插入,刪除慢(因為很可能需要移動其他元素)。
數據處理方式
add
public void add(int index, E element) {
// 校驗index是否在0-size范圍內,如果不是,拋出異常IndexOutOfBoundsException
rangeCheckForAdd(index);
// 這個操作后面有多個操作,總結一下,就是校驗,判斷是否需要擴容,擴容。
ensureCapacityInternal(size + 1); // Increments modCount!!
// 通過System.arraycopy操作,為新添加的元素element,在elementData數組的對應index位置,騰出空間
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
// 緊跟着上面的操作elementData數組的index位置,賦值為element
elementData[index] = element;
// 數組元素數量+1
size++;
}
grow
// 簡單來說, 就是根據所給的minCapacity,計算對應容量(2的冪次方),然后校驗容量,最后擴容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
小結
根據其數據組織方式,與數據處理方式,可以明確:
- ArrayList隨機查詢快(直接通過index定位數據中具體元素)
- ArrayList插入與刪除操作慢(涉及數組元素移動操作System.arraycopy,還可能涉及擴容操作)
- ArrayList是容量可變的(自帶擴容操作,初始化,默認為DEFAULT_CAPACITY=10)
- ArrayList是非線程安全的(沒有線程安全措施)
補充:
- ArrayList的默認容量為10(即無參構造時)
- 出於性能考慮,避免多次擴容,最好在初始化時設置對應size(即使后面不夠了,它也可以自動擴容)
LinkedList
數據組織方式
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
LinkedList的底層是自定義的Node雙向鏈表。那么LinkedList就有着和鏈表一樣的特點:數據的插入與刪除快,但是隨機訪問慢。
數據處理方式
add
public void add(int index, E element) {
// 數據校驗,index是否超出0-size范圍
checkPositionIndex(index);
if (index == size)
// 如果插入的元素是放在最后一個,那就執行尾插入操作(因為LinkedList是有保存first與last兩個Node的,所以可以直接操作)
linkLast(element);
else
// 首先通過node(index)方法,獲取到當前index位置的Node元素(內部實現,依舊是遍歷。不過會根據index與列表中值的比較結果,判斷是從first開始遍歷,還是從last開始遍歷),再通過linkBefore方法,進行插入操作
linkBefore(element, node(index));
}
peek
// LinkedList實現了Deque接口,所以需要實現其中的peek方法。獲取當前數組的第一個元素,但不進行刪除操作
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
小結
根據其數據組織方式,與數據處理方式,可以明確:
-
LinkedList隨機查詢慢(需要進行遍歷查詢,雖然通過列表中值,降低了一半的遍歷范圍,但其數據組織方式決定了它的速度慢):
測試表明,10W條數據,LinkedList的隨即提取速度與ArrayList相比,存在數百倍的差距(引自《碼出高效》)
-
LinkedList插入與刪除操作快(依舊需要靠遍歷來定位目標元素,但只需要修改鏈表節點的前后節點引用)
-
LinkedList是容量可變的(鏈表可以隨意鏈接)
-
LinkedList是非線程安全的(沒有線程安全措施)
補充:
- 通過鏈表,可以有效地將零散的內存單元通過引用的方式串聯起來,形成按鏈路順序查找的線性結構,內存利用率較高(引用自《碼出高效》)
Vector
Vector本質與ArrayList沒太大區別,底層同樣是Object數組,默認大小依舊為10(不過Vector采用的是不推薦的魔法數字)。
唯一的區別,就是Vector在關鍵方法上添加了Sychronized關鍵字,來確保線程安全。
但是,由於處理得較為粗糙,以及其特點,所以性能很差,基本已經被拋棄。
這里就不再贅述了。
CopyOnWriteArrayList
CopyOnWriteArrayList,作為COW容器的一員,其思想就是空間換時間,主要針對讀多寫少的場景。當有元素寫入時,會新建一個數組,將原有數組的元素復制過來,然后進行寫操作(此時數組的讀操作,還是針對原數組)。在寫操作完成后,會將讀操作針對的數組引用,從原數組指向新數組。這樣就可以在寫操作進行時,不影響讀操作的進行。
數據組織方式
/** The array, accessed only via getArray/setArray. */
// 一方面通過transient避免序列化,另一方面通過volatile確保可見性,從而確保單個屬性(這里是引用變量)的線程安全
private transient volatile Object[] array;
數據處理方式
add
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
// 進行加鎖,同時只能有一個寫操作
// 另外,加鎖操作放在try塊外,一方面是try規范(lock操作並不會發生異常,並且可以減少try塊大小),另一方面是避免加鎖失敗,finally的釋放鎖出現IllegalMonitorStateException異常
lock.lock();
try {
// 獲取原有數組,並賦值給elements(引用變量)
Object[] elements = getArray();
int len = elements.length;
// 數據校驗
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
// 下面的操作,就是對原有數組進行復制,並賦值給newElements(並且留出index位置)
Object[] newElements;
int numMoved = len - index;
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// 設置新數組index位置的值為element,完成賦值操作
newElements[index] = element;
// 將數組引用(讀操作正在讀的數組引用)改為newElements
setArray(newElements);
} finally {
// 無論是否異常,都需要釋放鎖,
lock.unlock();
}
}
最大的特色,就是這部分了。至於remove操作,都是類似的。故不再贅述。
小結
由於CopyOnWriteArrayList的數據組織方式與ArrayList一致,也是采用的數組,故:
- CopyOnWriteArrayList隨機查詢快
- CopyOnWriteArrayList插入與讀寫慢
- CopyOnWriteArrayList是容量可變的(每次進行增刪的寫操作,都會新建一個數組,進而進行替換)
補充:
- CopyOnWriteArrayList是線程安全的(讀寫操作隔離,寫操作通過ReentrantLock確保線程安全)
- CopyOnWriteArrayList的寫操作不直接影響讀操作(兩者在內存上針對的不是同一個數組)
- CopyOnWriteArrayList只適用於讀多寫少場景(畢竟寫操作是需要復制數組)
- CopyOnWriteArrayList占據雙倍內存(因為寫操作的時候需要復制數組)
- CopyOnWriteArrayList的性能會隨着寫入頻次與數組大小上升,而快速下降(寫入頻次m x 數組大小n)
推薦:高並發請求下,可以攢一下要進行的寫操作(如添加,或刪除,可以分開保存),然后進行addAll或removeAll操作。這樣可以有效減低資源消耗。但是這個攢的度需要好好把握,就和請求合並一樣,需要好好權衡。
二,Map
TreeMap
數據組織方式
數據處理方式
小結
HashMap
HashMap一方面是工作中用的非常多的集合,另一方面是面試的高頻(我每次面試幾乎都會被人問這個)。
而HashMap,與ConcurrentHashMap一樣,都存在Jdk8之前與Jdk8之后的區別。不過,我應該會以Jdk8之后為重點,畢竟現在SpringBoot2.x都要求Jdk8了。
數據組織方式
Jdk8之前
// jdk8之前,其底層是數組+鏈表
// 鏈表底層Entry是Map的內部接口
transient Entry<K, V>[] table;
Jdk8之后
transient Node<K, V>[] table;
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
}
數據處理方式
Jdk8之前的put方法(注釋並不多,因為我沒有源碼,我是按照筆記圖片,手擼的這段)
public V put (K key, V value) {
// HashMap采用延遲創建。判斷當前table是否為空。如果為空,就根據默認值15,創建一個數組,並賦值給table
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 數據校驗
if ( key == null)
return putForNullKey(value);
// 根據key,計算哈希值
int hash = hash(key);
// 通過indexFor(內部貌似采用位運算),根據key的哈希值與數組長度,計算該K-V鍵值對在數組中的下標i
int i = indexFor(hash, table.length);
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash = hash && ((k = e.key) || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 記錄修改次數+1,類似版本號
modCount++;
addEntry(hash, key, value, i);
return null;
}
Jdk8之后的put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 計算key的哈希值(數據校驗,key的哈希值,即其hashCode)
static final int hash(Object key) {
int h;
// 通過其hashCode的高16位與其低16位的異或運算,既降低系統性能開銷,又避免高位不參加下標運算造成的碰撞
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 執行主要put操作
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 從下面這個代碼塊,可以看出Java8后的HashMap等,代碼晦澀不少
if ((tab = table) == null || (n = tab.length) == 0)
// 如果table為null,或table.length為0(其中混雜了賦值語句),就進行進行初始化操作(通過resize()操作,這點與Spring的refresh()應用是一致的),並將其長度賦值給n(注意這里,都賦值給了局部變量,而非全局變量)
n = (tab = resize()).length;
// 根據key的hash值,計算其下標,並判斷數組中對應下標位置是否為null
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果對應位置為null,直接通過newNode方法(生成Node),設置數組對應i位置為對應新Node
tab[i] = newNode(hash, key, value, null);
else {
// 如果對應位置不為null,那就需要進行鏈表操作,進而判斷是否樹化(紅黑樹),是否擴容等
Node<K,V> e; K k;
// 通過hash與equals等,判斷新添加值的key與已存在值的key是否真正相等
// 這里擴展兩點:第一,判斷對象是否相等,必須hashcode與equals都判斷相等。前者避免兩個對象只是值,但不是同一個對象(兩位都是p9大佬,不代表兩位就是同一個人)。后者避免哈希碰撞問題(即使是兩個不同的對象的內存地址,也可能哈希值相等)
// 第二,我看到這里的時候,比較擔心,會不會出現value相等,但是hashCode不同,導致這里判斷為false。然后我發現包裝類型,早就重寫了hashCode方法,如Integer的hashCode就直接返回value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果相等,就直接更新對應Node即可
e = p;
// 如果上面判斷失敗,則判斷原有的數組元素,是不是已經樹化(不再是Node類型,而是TreeNode,當然TreeNode依舊是由Node構成的)
else if (p instanceof TreeNode)
// 如果原有數組元素已經樹化,那么就進行調用putTreeVal方法,將當前元素,置入目標紅黑樹中(其中涉及紅黑樹的旋轉等操作)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果不是空,也不是相同元素,更不是紅黑樹,那說明那已經是一個鏈表(已經由多個元素),或即將成為鏈表(已經有一個元素,並即將添加一個新的元素)
else {
// 遍歷對應鏈表元素,並通過binCount記錄鏈表已存在的元素數
for (int binCount = 0; ; ++binCount) {
// 如果e=p.next()為null,說明達到了鏈表的最后(e的前一個值為當前鏈表的最后一個元素)
if ((e = p.next) == null) {
// 通過newNode獲得對應p的Node,並將其設置為鏈表的最后一個元素
p.next = newNode(hash, key, value, null);
// 通過binCount,判斷鏈表的長度是否達到了樹化的閾值
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 達到閾值,則通過當前table數組與hash值,以及treefyBin方法,將當前數組位置的鏈表樹化
treeifyBin(tab, hash);
break;
}
// 在遍歷過程中,找到了相同的元素,即跳過(因為內容相同)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 該賦值操作,屬於鏈表的操作,從而繼續鏈表遍歷
p = e;
}
}
// 下面這段代碼,就涉及到HashMap的putIfAbsent(也是調用putVal,只是第四個參數onlyIfAbsent不同)
// 簡單來說,就是遇到key相同的元素,怎么處理。put操作是直接賦值,而putIfAbsent則是判斷對應key的value是否為null,如果是null,才會賦值。否則就不變(類似Redis)
// 只不過,這個過程通過新增的第四個參數控制,從而確保同一套代碼(putVal方法),實現兩種不同功能(put與putIfAbsent)
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 版本號
++modCount;
// 一方面size前綴自增,另一方面,判斷自增后的size是否超過閾值(默認16*0.75=12,數組容量*負載因子)
if (++size > threshold)
// 擴容(擴容2倍后,重排)
resize();
// 空方法,為子類保留的,如LinkedHashMap
afterNodeInsertion(evict);
return null;
}
這個方法可以算是HashMap的核心,畢竟通過這個方法,也算是摸到了HashMap的運行機制了。
流程簡述:
- 如果HashMap的底層數組沒有初始化,則通過resize()方法進行構建
- 對key計算hash值,然后再計算下標
- 如果數組對應下標位置為null(這里我認為不該用哈希碰撞),則直接放入對應位置
- 如果數組對應下標位置為TreeNode(即對應位置已經樹化),則通過putTreeVal方法,將對應Node置入樹中
- 否則遍歷數組對應下標位置的鏈表,將對應Node置入
- 如果鏈表的長度超過閾值,則進行樹化操作
- 如果節點存在舊值,直接替換
- 如果數組的元素數量超過閾值(數組容量*負載因子),則進行擴容(擴容2倍,重排)
Jdk8之后的get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 這里我覺得沒什么說的。根據不同情況,分別從數組,紅黑樹,數組來獲取目標元素
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
小結
就使用場景而言,《碼出高效》給出這樣一句話:
除局部方法或絕對線程安全的情形外,優先推薦ConcurrentHashMap。兩者雖然性能相差無幾,但后者解決了高並發下的線程安全問題。HashMap的死鏈問題及擴容數據丟失問題是慎用HashMap的兩個主要原因。
這里,我忍不住站在Java工程師的角度,推薦《碼出高效》以及配套的《阿里Java開發手冊》。作為一名也算看過不少技術書籍的開發者,這兩本書在我這兒,也算得上是優秀書籍了。
不過,文中也提到,這種情形,在Jdk8之后有所修復,改善。具體的,可以看看書籍(主要內容有點多)。
ConcurrentHashMap
ConcurrentHashMap部分,我將只描述Jdk8之后的版本。
而Jdk8之前的版本,其實底層就是類似HashTable的Segament組成的數組。通過分段鎖,達成線程安全。算是HashTable與HashMap的折中方案。復雜度並不是很高,不過Jdk8之后的版本,就較為復雜。首先,引入紅黑樹,優化存儲結構。其次,取消原有的分段鎖設計,采用了更高效的線程安全設計方案(利用了無鎖操作CAS與頭節點同步鎖等)。最后,使用了更優化的方式統計集合內的元素數量(引用自《碼出高效》,我還真沒注意到這點)。
數據組織方式
transient volatile Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
// 此處省略其內部方法,感興趣的,可以自行查看
}
從上述來看,ConcurrentHashMap的底層數據組織為數組+鏈表。依據Jdk8后的HashMap,可以推測,在對應條件下,鏈表會轉為紅黑樹結構。事實也是如此,請看下代碼。
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
// 此處省略其內部方法,感興趣的,可以自行查看
}
ConcurrentHashMap,與HashMap一樣,其內部也有專門為紅黑樹服務的TreeNode。
所以,從數據組織方面來看,其實ConcurrentHashMap與同版本的HashMap,可以說就是一個模子刻出來的(畢竟都是Doug Lea帶着擼的)。
兩者的區別,或者說ConcurrentHashMap的精妙之處,就在於ConcurrentHashMap對多線程的考慮與處理。
其中的細節挺多的,我只闡述我對其中一些大頭的理解(因為很多細節,我也不知道,也是看了大佬的總結,才發現)。
數據處理方式
put
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 數據校驗,如果key或value為Null,直接NPE
if (key == null || value == null) throw new NullPointerException();
// 通過spread方法,計算hash值(本質還是與HashMap一樣,針對hashCode進行高低16位異或計算等)
int hash = spread(key.hashCode());
// 記錄鏈表長度
int binCount = 0;
// 這里的循環操作是為了之后的CAS操作(就是CAS的自旋操作)
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 同HashMap一樣,如果數組為空或長度為0,則進行數組初始化操作(循環頭中已經完成賦值操作)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果數組對應位置為null,則通過CAS操作,進行值的插入操作
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果對應節點的Node.hash值為MOVED=-1
else if ((fh = f.hash) == MOVED)
// 進行resize協助操作(具體協助方式,還沒研究)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
// 如果數組對應位置(即首節點)的哈希值大於等於零(樹化后等情況下,對應位置哈希值小於零)
// static final int MOVED = -1; // hash for forwarding nodes
// static final int TREEBIN = -2; // hash for roots of trees
// static final int RESERVED = -3; // hash for transient reservations
if (fh >= 0) {
// 說明此情況下,數組對應位置,存儲的是鏈表。進行鏈表插入,遍歷操作(具體參照HashMap的put操作)
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果數組對應位置的元素,是樹化節點(即為TreeBin實例)
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 調用putTreeVal方法,進行紅黑樹的值插入操作
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
// 判斷onlylfAbsent參數,進行val設置。具體參照HashMap的put方法的對應位置解釋
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 前面的各類操作,都會計算binCount(數組當前位置存儲的節點數)
if (binCount != 0) {
// 如果對應節點數超過了樹化閾值TREEIFY_THRESHOLD=8
if (binCount >= TREEIFY_THRESHOLD)
// 對數組當前位置,進行樹化操作
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 計數
addCount(1L, binCount);
return null;
}
小結
ConcurrentHashMap的魅力在於其線程安全的實現,有機會好好研究研究,專門寫一個相關的博客。
三,總結
其實,Java集合主要從兩個維度分析。一個是底層數據組織方式,如鏈表與數組(基本就這兩種,或者如HashMap那樣組合兩種)。另一個是線程安全方式,就是線程安全與非線程安全。
最后就是由於一些底層數據組織方式的調整,帶來的循環,有序等特性。