一、前言
筆者之前看過一篇關於jdk1.8的HashMap源碼分析,作者對里面的解讀很到位,將代碼里關鍵的地方都說了一遍,值得推薦。筆者也會順着他的順序來閱讀一遍,除了基礎的方法外,還添加了很多其他補充內容。
二、HashMap結構概覽
以下是HashMap的數據結構:
不同於之前的jdk的實現,1.8采用的是數組+鏈表+紅黑樹,在鏈表過長的時候可以通過轉換成紅黑樹提升訪問性能。大多數情況下,結構都以鏈表的形式存在,所以檢查是否存在樹節點會增加訪問方法的時間,但是相較於其優點來說還是可以接受的。特別說明:樹結構里還有很多指針引用,這里沒畫出來。將在后續的LinkedHashMap和TreeMap中講解
三、HashMap源碼閱讀
3.1 類的繼承關系
可以看到HashMap繼承自AbstractMap,實現了Serializable和Cloneable。這里筆者不打算介紹AbstractMap的源碼,因為閱讀之后發現比較簡單,有興趣的園友們可以自行去看看,其中的keyset()
和values()
方法與HashMap中的類似。Serializable接口表示HashMap實現了的序列化,Cloneable接口表示可以合法的調用clone()
,如果不實現該接口而調用clone,會報CloneNotSupportedException。關於Map接口的解析,可以看我之前的文章
3.2 HashMap的成員變量
下面我們先來看一下HashMap里面的成員變量:
//默認初始化map的容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//map的最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認的填充因子:0.75,能較好的平衡時間與空間的消耗
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//將鏈表(桶)轉化成紅黑樹的臨界值
static final int TREEIFY_THRESHOLD = 8;
//將紅黑樹轉成鏈表(桶)的臨界值
static final int UNTREEIFY_THRESHOLD = 6;
//轉變成樹的table的最小容量,小於該值則不會進行樹化
static final int MIN_TREEIFY_CAPACITY = 64;
//上圖所示的數組,長度總是2的冪次
transient Node<K,V>[] table;
//map中的鍵值對集合
transient Set<Map.Entry<K,V>> entrySet;
//map中鍵值對的數量
transient int size;
//用於統計map修改次數的計數器,用於fail-fast拋出ConcurrentModificationException
transient int modCount;
//大於該閾值,則重新進行擴容,threshold = capacity(table.length) * load factor
int threshold;
//填充因子
final float loadFactor;
可以看到,HashMap里是以Node節點數組的形式存放數據的,Node數據結構比較簡單,這里我們也來看一下:
//Entry接口在筆者的總章里有介紹。
static class Node<K,V> implements Map.Entry<K,V> {
// key & value 的 hash值
final int hash;
final K key;
V value;
//指向下一個節點
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
由於比較簡單,這里就不詳細介紹了哈。
3.3 HashMap的構造函數
3.3.1 無參數構造函數
public HashMap() {
//其他成員變量也都是默認的
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
3.3.2 傳初始化容量(建議如果知道要使用的map容量,都使用這種)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3.3.3 傳初始化容量以及填充因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//tableSizeFor()是用來將初始化容量轉化大於輸入參數且最近的2的整數次冪的數,比如initialCapacity = 7,那么轉化后就是8。
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor(),將初始化容量轉化大於或等於最接近輸入參數的2的整數次冪的數:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
|
是或運算符,比如說0100 | 0011 = 0111
,>>>
是無符號右移,忽略符號位,空位都以0補齊,比如說0100 >>> 2 = 0001
,現在來說一下這么做的目的:
首先>>>
和|
的操作的目的就是把n從最高位的1以下都填充為1,以010011為例,010011 >>> 1 = 001001
,然后001001 | 010011 = 011011
,然后再把011011無符號右移兩位:011011 >>> 2 = 000110
,然后000110 | 011011 = 011111
,后面的4、8、16計算過程就都省去了,int類型為32位,所以計算到16就全部結束了,最終得到的就是最高位及其以下的都為1,這樣就能保證得到的結果肯定大於或等於原來的n且為奇數,最后再加上1,那么肯定是:大於且最接近輸入值的2的整數次冪的數。
那么為什么要先cap - 1
呢,我們可以先思考以下,如果傳進來的本身就是2的整數冪次,比如說01000
,10進制是8,那么如果不減,得到的結果就是16,顯然不對。所以先減1的目的是cap如果恰好是2的整數次冪,那么返回的也是本身。
合起來得到這個tableSizeFor()方法的目的:返回大於或等於最接近輸入參數的2的整數次冪的數。另外,筆者特意回去看了JDK1.7的源碼,發現1.7用的是roundUpToPowerOf2()
方法,里面用到里了>>
以及減操作,性能上來說肯定還1.8的高。
3.3.4 傳map轉化為HashMap的構造函數
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
putMapEntries():
//evict表示是不是初始化map,false表示是初始化map
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//獲取m中鍵值對的數量
int s = m.size();
if (s > 0) {
if (table == null) {
//計算map的容量,鍵值對的數量 = 容量 * 填充因子
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//如果容量大於了閾值,則重新計算閾值。
if (t > threshold)
threshold = tableSizeFor(t);
}
//如果table已經有,且鍵值對數量大於了閾值,進行擴容
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
3.4 HashMap中重要的方法解析
3.4.1 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;
//先是判斷一通table是否為空以及根據hash找到存放的table數組的下標,並賦值給臨時變量
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;
//如果第一個與key不相等,則循環查看桶
if ((e = first.next) != null) {
//檢查是否為樹節點,是的話采用樹節點的方法來獲取對應的key的值
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//do-while循環判斷,直到找到為止
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
可以發現源碼作者很喜歡在判斷的時候賦值,不知道這個是不是個編程的好習慣。!?(・_・;?
3.4.2 put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
* @param hash key的hash值
* @param key
* @param value
* @param onlyIfAbsent 如果為true,則在有值的時候不會更新
* @param evict false表示在創建map
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果為空,則擴容。注意這里的賦值操作,關系到下面
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果tab對應的數組位置為空,則創建新的node,並指向它
if ((p = tab[i = (n - 1) & hash]) == null)
// newNode方法就是返回Node:return new Node<>(hash, key, value, next);
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果比較hash值和key的值都相等,說明要put的鍵值對已經在里面,賦值給e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果p節點是樹節點,則執行插入樹的操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是樹節點且數組中第一個也不是,則在桶中查找
else {
for (int binCount = 0; ; ++binCount) {
//找到了最后一個都不滿足的話,則在最后插入節點。注意這里的e = p.next,賦值兼具判斷都在if里了
if ((e = p.next) == null)
p.next = newNode(hash, key, value, null);
//之前field說明中的,如果桶中的數量大於樹化閾值,則轉化成樹,第一個是-1
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//在桶中找到了對應的key,賦值給e,退出循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//沒有找到,則繼續向下一個節點尋找
p = e;
}
}
//上面循環中找到了e,則根據onlyIfAbsent是否為true來決定是否替換舊值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//鈎子函數,用於給LinkedHashMap繼承后使用,在HashMap里是空的
afterNodeAccess(e);
return oldValue;
}
}
//修改計數器+1
++modCount;
//實際大小+1, 如果大於閾值,重新計算並擴容
if (++size > threshold)
resize();
//鈎子函數,用於給LinkedHashMap繼承后使用,在HashMap里是空的
afterNodeInsertion(evict);
return null;
}
可以看到真正執行put的是里面的putVal()
方法。里面的插入邏輯一步步下來還是很清晰的。
3.4.3 resize()
通過調用resize()
對map進行擴容操作。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//擴容/縮容前的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//舊的閾值
int oldThr = threshold;
int newCap, newThr = 0;
//說明之前已經初始化過map
if (oldCap > 0) {
//達到了最大的容量,則將閾值設為最大,並且返回舊的table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果兩倍的舊容量小於最大的容量且舊容量大於等於默認初始化容量,則舊的閾值也擴大兩倍。
//oldCap << 1,其實就是*2的意思。
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//舊容量為0且舊閾值大於0,則賦值給新的容量(應該是針對初始化的時候指定了其容量的構造函數出現的這種情況)
else if (oldThr > 0)
newCap = oldThr;
//這種情況就是調用無參數的構造函數
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新閾值為0,則通過:新容量*填充因子 來計算
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//根據新的容量來初始化table,並賦值給table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果舊的table里面有存放節點,則初始化給新的table
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//將下標為j的數組賦給臨時節點e
if ((e = oldTab[j]) != null) {
//清空
oldTab[j] = null;
//如果該節點沒有指向下一個節點,則直接通過計算hash和新的容量來確定新的下標,並指向e
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果為樹節點,按照樹節點的來拆分
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//e還有其他的節點,將該桶拆分成兩份(不一定均分)
else {
//loHead是拆分后的,鏈表的頭部,tail為尾部
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//根據e的hash值和舊的容量做位與運算是否為0來拆分,注意之前是 e.hash & (oldCap - 1)
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
可以看到,resize()
方法對整個數組以及桶進行了遍歷,極其耗費性能,所以再次強調在我們明確知道map要用的容量的時候,使用指定初始化容量的構造函數。
在resize前和resize后的元素布局如下:
再次強調一下,拆分后的結果不一定是均分,要看你存的值
3.4.4 remove()
public V remove(Object key) {
Node<K,V> e;
//與之前的put、get一樣,remove也是調用其他的方法
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* Implements Map.remove and related methods
*
* @param hash key的hash值
* @param key
* @param value 與下面的matchValue結合,如果matchValue為false,則忽略value
* @param matchValue 為true,則判斷是否與value相等
* @param movable 主要跟樹節點的remove有關,為false,則不移動其他的樹節點
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//老規矩,還是先判斷table是否為空之類的邏輯,注意賦值操作
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//對下標節點進行判斷,如果相同,則賦給臨時節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//為樹節點,則按照樹節點的操作來進行查找並返回
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//do-while循環查找
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//如果找到了key對應的node,則進行刪除操作
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//為樹節點,則進行樹節點的刪除操作
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果p == node,說明該key所在的位置為數組的下標位置,所以下標位置指向下一個節點即可
else if (node == p)
tab[index] = node.next;
//否則的話,key在桶中,p為node的上一個節點,p.next指向node.next即可
else
p.next = node.next;
//修改計數器
++modCount;
--size;
//鈎子函數,與上同
afterNodeRemoval(node);
return node;
}
}
return null;
}
這里提到里的remove的話,肯定與之聯想到的就是其拋出ConcurrentModificationException
。舉個栗子:
Map<String, Integer> map = new HashMap<>();
map.put("GoddessY", 1);
map.put("Joemsu", 2);
for (String a : map.keySet()) {
if ("GoddessY".equals(a)) {
map.remove(a);
}
}
這里我們再來看一下其在循環過程中拋出該異常的源碼(以keySet()
為例):
public Set<K> keySet() {
Set<K> ks;
return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}
final class KeySet extends AbstractSet<K> {
public final Iterator<K> iterator() { return new KeyIterator(); }
}
final class KeyIterator extends HashIterator implements Iterator<K> {
public final K next() { return nextNode().key; }
}
abstract class HashIterator {
//指向下一個節點
Node<K,V> next;
//指向當前節點
Node<K,V> current;
//迭代前的修改次數
int expectedModCount;
//當前下標
int index;
HashIterator() {
//注意這里:將修改計數器值賦給expectedModCount
expectedModCount = modCount;
//下面一頓初始化。。。
Node<K,V>[] t = table;
current = next = null;
index = 0;
//在table數組中找到第一個下標不為空的節點。
if (t != null && size > 0) {
do {} while (index < t.length && (next = t[index++]) == null);
}
}
//通過判斷next是否為空,來決定是否hasNext()
public final boolean hasNext() {
return next != null;
}
//這里就是拋出ConcurrentModificationException的地方
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
//如果modCount與初始化傳進去的modCount不同,則拋出並發修改的異常
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
//如果一個下標對應的桶空了,則接着在數組里找其他下標不為空的桶,同時賦值給next
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//使用迭代器的remove不會拋出ConcurrentModificationException異常,原因如下:
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
//注意這里:對expectedModCount重新進行了賦值。所以下次比較的時候還是相同的
expectedModCount = modCount;
}
}
那么我們再回到上面的測試代碼,我們再來看一個有趣的問題,如果我把"GoddessY".equals(a)
換成"Joemsu".equals(a)
還會拋出異常嗎?有興趣的園友們可以試一試,找出原因能夠加深對源碼的理解!(づ。◕‿‿◕。)づ
3.4.5 treeifyBin()
最后我們再來看一下將桶變成紅黑樹的代碼吧,具體的樹結構之類的大概會放在TreeMap里講解,這里不仔細介紹。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//這里MIN_TREEIFY_CAPACITY派上了用場,及時單個桶數量達到了樹化的閾值,總的容量沒到,也不會進行樹化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 返回樹節點 return new TreeNode<>(p.hash, p.key, p.value, next);
TreeNode<K,V> p = replacementTreeNode(e, null);
//為空說明是第一個節點,作為樹的根節點
if (tl == null)
hd = p;
//設置樹的前后節點
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//對整棵樹進行處理,形成紅黑樹
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
四、總結
下面是一些關於HashMap的特征:
-
允許key和value為null
-
基本上和Hashtable(已棄用)相似,除了非同步以及鍵值可以為null
-
不能保證順序
-
訪問集合的時間與map的容量和鍵值對的大小成比例
-
影響HashMap性能的兩個變量:填充因子和初始化容量
-
通常來說,默認的填充因為0.75是一個時間和空間消耗的良好平衡。較高的填充因為減少了空間的消耗,但是增加了查找的時間
-
最好能夠在創建HashMap的時候指定其容量,這樣能存儲效率比使其存儲空間不夠后自動增長更高。畢竟重新調整耗費性能
-
使用大量具有相同hashcode值的key,將降低hash表的表現,最好能實現key的comparable
-
注意hashmap是不同步的。如果要同步請使用
Map m = Collections.synchronizedMap(new HashMap(...));
-
除了使用迭代器的remove方法外其的其他方式刪除,都會拋出ConcurrentModificationException.
-
map通常情況下都是hash桶結構,但是當桶太大的時候,會轉換成紅黑樹,可以增加在桶太大情況下訪問效率,但是大多數情況下,結構都以桶的形式存在,所以檢查是否存在樹節點會增加訪問方法的時間
最后謝謝各位園友觀看,如果有描述不對的地方歡迎指正,與大家共同進步!