HashMap
在平常開發中很是經常使用,它基於哈希表實現,以 key-value
形式存儲。本文經過 JDK1.8
的源碼,分析一下 HashMap
的內部結構和實現原理。html
在 JDK1.7
以前,HashMap
底層由數組 + 鏈表實現,也就是鏈表散列。當向 HashMap
中添加一個鍵值對時,首先計算 key
的 hash
值,以此肯定插入數組中的位置,但可能會碰撞衝突,將其轉換爲鏈表存儲。java
而從 JDK1.8
開始,增長了紅黑樹,由數組 + 鏈表 + 紅黑樹實現,當鏈表長度超過 8
時,鏈表轉換爲紅黑樹以提升性能。它的存儲方式以下:node
HashMap
的幾個靜態常量以下:算法
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 默認初始容量爲 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量爲 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認負載因子爲 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默認鏈表中元素大於 8 時轉爲紅黑樹
static final int TREEIFY_THRESHOLD = 8;
// 擴容時,鏈表中元素小於這個值就會還原爲鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// 數組的容量大於 64 時才容許被樹形化
static final int MIN_TREEIFY_CAPACITY = 64;
···
}
複製代碼
下面是 HashMap
中幾個重要的變量:數組
transient Node<K,V>[] table; // 存儲元素數組
transient Set<Map.Entry<K,V>> entrySet; // 緩存 entry 返回的 Set
transient int size; // 鍵值對個數
transient int modCount; // 內部結構修改次數
int threshold; // 臨界值
final float loadFactor; // 負載因子
複製代碼
Node<K,V>[] table緩存
Node<K,V>[] table
數組用來存儲具體的元素,是 HashMap
底層數組和鏈表的組成元素。在第一次使用時初始化(默認初始化容量爲 16
),並在必要的時候進行擴容。安全
通常來講,因爲素數致使衝突的機率較小,因此哈希表數組大小爲素數。但 Java
的 HashMap
中採用很是規設計,數組的長度老是 2
的 n
次方,這樣作能夠在取模和擴容時作優化,同時也能減小碰撞衝突。app
Node
是 HashMap
的一個內部類,實現了 Map.Entry
接口,本質上就是一個映射(鍵值對)。它的實現以下:性能
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用來定位數組索引位置
final K key; // 鍵
V value; // 值
Node<K,V> next; // 指向鏈表的下一個結點
Node(int hash, K key, V value, Node<K,V> next) { ··· }
public final K getKey() { ··· }
public final V getValue() { ··· }
public final String toString() { ··· }
// 重寫了 hashCode 和 equals 方法
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) { ··· }
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
複製代碼
entrySet優化
entrySet
用於緩存 entrySet()
方法返回的 Set
。後面會詳細分析。
size
size
是 HashMap
中鍵值對的數量。注意,鍵值對的數量 size
和哈希表數組的長度 capacity
不一樣。
modCount
modCount
用於記錄 HashMap
內部結構發生變化的次數,用於使用迭代器遍歷集合時修改內部結構,而快速失敗。須要注意的是,這裏指的是結構發生變化,例如增長或刪除一個鍵值對或者擴容,可是修改鍵值對的值不屬於結構變化。
threshold 和 loadFactor
threshold
是 HashMap
能容納的最大鍵值對個數,loadFactor
是負載因子,默認爲 0.75
。有以下等式(capacity
是數組容量):
threshold = capacity * loadFactor;
複製代碼
能夠得出,在數組長度定義好以後,負載因子越大,所能容納鍵值對越多。若是存儲元素個數大於 threshold
,就要進行擴容,擴容後的容量是以前的兩倍。
TreeNode
當鏈表長度超過 8
(閾值)時,將鏈表轉換爲紅黑樹存儲,以提升查找的效率。下面是 TreeNode
的定義:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父節點
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) {
super(hash, key, val, next);
}
// 返回當前節點的根節點
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
······
}
複製代碼
HashMap
主要提供了四種構造方法:
1). 構造一個默認初始容量 16
和默認加載因子 0.75
的空 HashMap
。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
複製代碼
2). 構造一個指定的初始容量和默認加載因子 0.75
的空 HashMap
。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製代碼
3). 構造一個指定的初始容量和加載因子的空 HashMap
。
public HashMap(int initialCapacity, float loadFactor) {
// check
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
複製代碼
4). 使用給定的 map
構造一個新 HashMap
。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
複製代碼
HashMap
內部功能實現不少,這裏主要從 hash
方法、put
方法、get
方法、resize
方法和 entrySet
方法進行分析。
HashMap
中,增刪改查都須要用 hash
算法來計算元素在數組中的位置,因此 hash
算法是否均勻高效,對性能影響很大。看一下它的實現:
static final int hash(Object key) {
int h;
// 優化了高位運算算法
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// tab[i = (n - 1) & hash] 取模
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
···
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
···
}
複製代碼
hash
算法計算對象的保存位置,分爲三步:取 key
的 hashCode
值、高位運算、取模運算。
因爲取模元素消耗較大,HashMap
中用了一個很巧妙的方法,利用的就是底層數組長度老是 2
的 n
次方。經過 hash & (table.length - 1)
就能夠獲得對象的保存位置,相較於對 length
取模效率更高。
JDK1.8
中優化了高位運算的算法,經過 hashCode
的高 16
位異或低 16
位實現。下面舉例說明,n
爲 table
的長度:
來看一下 HashMap
的 put
方法:
public V put(K key, V value) {
// 調用 hash 計算 key 的哈希值
return putVal(hash(key), key, value, false, true);
}
複製代碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 若是 table 爲空或長度爲 0,則調用 resize 進行擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根據 key 的 hash 計算數組索引值,若是當前位置爲 null,則直接建立新節點插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table[i] 不爲空
Node<K,V> e; K k;
// 若是 table[i] 的首元素和傳入的 key 相等(hashCode 和 equals),則直接覆蓋,這裏允許 key 和 value 爲 null
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判斷 table[i] 是否爲 treeNode,即 table[i] 是否爲紅黑樹,若是是則在樹中插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 不然遍歷鏈表
else {
for (int binCount = 0; ; ++binCount) {
// 若是 key 不存在
if ((e = p.next) == null) {
// 則新建一個結點
p.next = newNode(hash, key, value, null);
// 若是長度大於8,則轉爲紅黑樹處理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 若是 key 已經存在,則直接覆蓋
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 內部結構發生變化
++modCount;
// 若是超過最大容量就擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製代碼
下面是 put
方法的幾個步驟::
table[]
爲空或者長度爲 0
,若是是則調用 resize()
進行擴容;hash & (table.length - 1)
計算插入的數組索引值,若是當前位置爲 null
,則直接建立節點插入table[i]
的首個元素是否和 key
相等(hashCode
和 equals
),若是相等則直接覆蓋 value
;table[i]
是否爲 treeNode
,即 table[i]
是不是紅黑樹,若是是紅黑樹,則直接在樹中插入鍵值對;key
不存在,則直接建立節點插入,並判斷鏈表長度是否大於 8
,若是是紅黑樹則轉爲紅黑樹處理;若是遍歷中發現 key
已經存在,則直接覆蓋便可;HashMap
的 put
方法能夠經過下圖理解:
來看一下 HashMap
的 get
方法:
public V get(Object key) {
Node<K,V> e;
// 調用 getNode 方法,若是經過 key 獲取的 Node 爲 null,則返回 null;不然返回 node.value
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;
// 若是數組不爲空,數組長度大於 0
// 經過 hash & (length - 1) 計算數組的索引值,而且對應的位置不爲 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 若是桶中第一個元素與 key 相等,則直接返回
if (first.hash == hash &&
((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;
}
複製代碼
下面來分析一下 resize
方法的源碼:
final Node<K,V>[] resize() {
// 保存原先的數組、容量、臨界值
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 若是擴容前容量 > 0
if (oldCap > 0) {
// 若是數組大小已經達到最大 2^30,則修改閾值爲最大值 2^31-1,之後也就不會再擴容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 若是沒有超過最大值,就擴充爲原來的 2 倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0) // 若是擴容前容量 <= 0,舊臨界值 > 0
// 將數組的新容量設置爲 舊數組擴容的臨界值
newCap = oldThr;
else { // 容量 <= 0,舊臨界值 <= 0
// 不然設置爲默認值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算新的臨界值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 建立新的 table,容量爲 newCap
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍歷舊哈希表的每一個桶,將舊哈希表中的桶複製到新的哈希表中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 若是舊桶中只有一個 node
if (e.next == null)
// 則將 oldTab[j] 放入新哈希表中 e.hash & (newCap - 1) 的位置
newTab[e.hash & (newCap - 1)] = e;
// 若是舊桶中爲紅黑樹,則轉換處理
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null; // 將下標不變的節點組織成一條鏈表
Node<K,V> hiHead = null, hiTail = null; // 將下標增長 oldCapaciry 的節點組織成另外一條鏈表
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 原索引
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
// 原索引 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到新數組中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引 + oldCap 放到新數組中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製代碼
resize
方法在擴容時,因爲每次數組的長度變爲原先的 2
倍,因此元素要麼在原位置,要麼在「原始位置 + 原數組長度」的位置。經過計算 e.hash & oldCap
來判斷是否須要移動。
看下圖,n
爲 table
的長度,圖 (a)
爲擴容前的 key1
和 key2
肯定索引位置的示例,圖 (b)
爲擴容後的 key1
和 key2
肯定索引位置的示例,其中 key1(hash1)
是 key1
對應的哈希與高位運算的結果:
元素在從新計算 hash
後,由於 n
變爲 2
倍,那麼 n - 1
的 mask
的範圍(紅色)在高位多 1bit
,所以新的 index
就會這樣變化:
所以,在擴容時,只需看看原來的 hash
值新增的 bit
位是 1
仍是 0
,若是是 0
,索引不變,不然變成 "原索引 + oldCapacity
",能夠看看下圖 16
擴充爲 32
的示意圖:
HashMap
的一種遍歷方式就是使用 entrySet
方法返回的迭代器進行遍歷。先來看一下 entrySet
方法:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
複製代碼
能夠看到,若是緩存 map
中鍵值對的 Set
不爲 null
,則直接返回,不然會建立一個 EntrySet
對象。
EntrySet
類的 iterator
方法會返回一個 EntryIterator
迭代器對象,另外還有兩個迭代器 KeyIterator
、ValueIterator
:
final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
final class KeyIterator extends HashIterator implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator implements Iterator<V> {
public final V next() { return nextNode().value; }
}
複製代碼
它們三個都繼承自 HashIterator
,分別用於鍵遍歷、值遍歷、鍵值對遍歷,它們都重寫了 Iterator
的 next
方法,其中調用了 HashIterator
的 nextNode
方法。
而 HashIterator
是一個抽象類,實現了迭代器的大部分方法:
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
public final void remove() { ··· }
}
複製代碼
能夠看出 HashIterator
迭代器的默認構造器中,將 current
設置爲 null
,而後循環在數組中查找不爲 null
的桶, 讓 next
指向第一個桶中的第一個節點 Node
。
在遍歷時,next
方法會調用 nextNode()
方法,這個方法首先把 next
賦給 e
以稍後返回,並把 e
賦給 current
。而後判斷 next
是否爲空,若是不爲空,返回 e
便可。
若是爲空,就在數組中繼續查找不爲空的桶,找到後退出循環,最後返回 e
。這樣就能都遍歷出來了。
HashMap
的特色主要有:
HashMap
根據鍵的 hashCode
值來存儲數據,大多數狀況下能夠直接定位它的值,於是訪問速度很快。HashMap
不保證插入的順序。HashMap
時,最好估算 map
的大小,初始化時給定一個大體的數值,避免進行頻繁的擴容。threshold = capacity * loadFactor;
若是存儲元素個數大於 threshold
,就要進行擴容,擴容後的容量是以前的兩倍。0.75
是時間和空間之間的一個平衡,通常不建議修改。HashMap
中 key
和 value
容許爲 null
,最多容許一條記錄的鍵爲 null
,容許多條記錄的值爲 null
。Collections
的 synchronizedMap
方法使 HashMap
具備線程安全的能力,或使用 ConcurrentHashMap
。