本文章已授權微信公眾號郭霖(guolin_blog)轉載。
本文章講解的內容是Android中的HashMap源碼分析。
本文章分析的HashMap源碼是基于Android SDK(版本為28)。
要注意的是,Android SDK 28和JDK 1.8對HashMap的底層實現進行了優化,例如:引入了紅黑樹的數據結構和擴容的優化等。
概述
HashMap的UML類圖如下所示:
HashMap是基于哈希表實現的Map接口。此實現提供了所有可選的映射操作,并且允許空鍵和空值,要注意的是,最多允許一條記錄的鍵是空,允許多條記錄的值是空。它不保證映射的順序隨著時間保持不變。
HashMap為基本操作(get和put)提供了恒定時間的性能,假設哈希函數將元素正確地分散在桶(bucket)中。對集合視圖的迭代需要的時間與HashMap實例的容量(桶的數量)加上它的大小(鍵值映射的數量)成正比。如果對迭代的性能有要求的話,就不要將初始容量(initial capacity)設置得太高(或者負荷系數(load factor)太低。
HashMap實例有兩個影響其性能的參數:初始容量(initial capacity)和負荷系數(load factor)。容量是哈希表中的桶數,初始容量就是創建哈希表時的容量。負荷系數是一種度量方法,用來衡量在自動增加哈希表的容量之前,哈希表允許達到的滿度。當哈希表中的條目數量超過當前容量和負荷系數的乘積時,哈希表將被重新散列(即重新構建內部數據結構),也就是擴容,這樣哈希表的桶數大約是原來的兩倍。
通常,默認的負荷系數(0.75)在時間和空間成本之間提供了一個很好的權衡,在大部分下情況下,不建議修改該值。如果設為較高的值,可以減少空間開銷,但是會增加查找成本(反映在HashMap類的大多數操作中,包括get方法和put方法)。threshold是HashMap所能容納的最大數據量的節點(Node)個數,在設置初始容量時,應該考慮映射中的最大條目數及其負荷系數,以便減少擴容的次數。如果初始容量大于threshold除以負荷系數,則不會發生重新散列操作,也就是不會發生擴容。
如果要在一個HashMap實例中存儲許多映射,那么以足夠大的容量創建它將比根據需要讓映射執行自動重新散列以增長表更有效地存儲映射。要注意的是,在同一個哈希碼(HashCode)使用多個鍵肯定會降低任何散列表的性能。為了改善影響,當鍵是可比較時,這個類可以使用鍵之間的比較順序。
要注意的是,HashMap是線程不安全的。如果多個線程并發地訪問一個HashMap,并且至少一個線程對它做了結構修改(結構修改是指添加或者刪除一個或者多個映射,僅僅改變與實例已經包含的鍵相關聯的值不是結構修改),那么它必須在外部同步,可以使用Collections的synchronizedMap來使HashMap具備線程安全的能力,或者使用ConcurrentHashMap。
HashMap這個類的所有集合視圖方法返回的迭代器都是快速失敗的:如果在迭代器創建后的任何時候映射結構被修改,除了通過迭代器自己的remove方法外,通過任何方式,迭代器都會拋出ConcurrentModificationException。因此,在面對并發修改時,迭代器會快速而干凈地失敗,而不是在未來一個不確定的時間冒任意的、不確定的行為。
要注意的是,迭代器的快速失敗行為不能得到保證,因為一般來說,在存在非同步并發修改時不可能做出任何硬性保證。快速失敗迭代器盡最大努力拋出ConcurrentModificationException。因此,如果要編寫一個依賴于這個異常來保證其正確性的程序,那將是錯誤的,迭代器的快速失敗行為應該僅用于檢測錯誤。
源碼分析
下面對HashMap進行源碼分析:
字段和節點(Node)
HashMap的字段,源碼如下所示:
// HashMap.java
// 序列化版本號
private static final long serialVersionUID = 362498820763181265L;
// 默認初始容量(必須是2的冪),它的值是1左移4位,也就是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量,如果任何一個帶參數的構造函數隱式指定了較大的值,就會使用它來比較,而且值必須是2的冪,并且小于1左移30位,也就是1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;
// 在構造函數中未指定時使用的負荷系數,它的值是0.75f
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 為容器使用樹而不是列表時的容器計數閾值,當向至少有這么多節點的bin中添加一個元素時,bin被轉換成樹,該值必須大于2,并且至少應該是8,以符合在樹移除時關于在收縮時轉換成普通箱的假設
static final int TREEIFY_THRESHOLD = 8;
// 在調整大小操作期間取消查詢(拆分)存儲箱的箱計數閾值,應小于TREEIFY_THRESHOLD,且最多為6,以便在去除時檢測到收縮
static final int UNTREEIFY_THRESHOLD = 6;
// 容器變成樹的最小表容量(否則,如果容器中有太多節點,就會調整表的大小),它的值是64,應該至少是4乘以TREEIFY_THRESHOLD,以避免調整大小和樹調整閾值之間的沖突
static final int MIN_TREEIFY_CAPACITY = 64;
// 表,在第一次使用時初始化,并根據需要調整大小,分配時,長度總是2的冪(在某些操作中,我們也允許長度為零,以允許當前不需要的引導機制)
transient Node<K,V>[] table;
// 保存entrySet方法的緩存,要注意的是,AbstractMap的字段用于keySet方法和values方法
transient Set<Map.Entry<K,V>> entrySet;
// 此映射中包含的鍵值映射的數目
transient int size;
// 此字段用于使HashMap的集合視圖上的迭代器快速失敗,結構修改是指那些改變HashMap中映射數量或者修改其內部結構的修改(例如:重新散列)
transient int modCount;
// HashMap所能容納的最大數據量的節點個數,調整大小的下一個大小值(容量乘以裝載系數),也就是所能容納的節點極限,Java文檔描述在序列化時是正確的,另外,如果沒有分配表數組,則該字段保存初始數組容量,或者表示DEFAULT_INITIAL_CAPACITY的值為零
int threshold;
// 哈希表的負荷系數
final float loadFactor;
這里有個很重要的字段table數組,類型是Node<K,V>[],也就是哈希桶數組,table數組的初始長度是16,負荷系數(load factor)默認值是0.75f,threshold是HashMap所能容納的最大數據量的節點(Node)個數,有如下公式:threshold = loadFactor * length,loadFactor是負荷系數,length是數組長度,也就是數組在定義好長度后,負荷系數越大,所能容量的節點就越多,前面也提到了,當節點個數超過這個數值時,HashMap就會擴容,擴容后的容量是原來的兩倍
table數組的長度(length)必須是2的冪,也就是說一定是個合數,這是一種非常規的設計,常規的設計是把桶的大小設計成質數(素數),相對來說質數導致沖突的概率是小于合數,舉個例子:
設有一個哈希函數為H(x) = x % n;,也就是做取模運算,當n取一個合數時,例如取2的冪,譬如取2的3次方,也就是8,例子如下所示,基本數據類型是int,也就是位數是32位:
4(十進制) = 00000000 00000000 00000000 00000100(二進制)
12(十進制) = 00000000 0000000 0000000 00001100(二進制)
20(十進制) = 00000000 00000000 00000000 00010100(二進制)
28(十進制) = 00000000 00000000 00000000 00011100(二進制)
調用哈希函數H(x):
H(4) = 4 % 8 = 4
H(12) = 12 % 8 = 4
H(20) = 20 % 8 = 4
H(28) = 28 % 8 = 4
我們可以發現無論第四位(從右向左數)取什么值,哈希函數H(x)的值都一樣,也就是從第四位左方向的位數都不參與哈希函數H(x)的運算,這就無法反應x的特性,從而增大沖突的幾率,也就是說取合數會增大沖突的幾率。
我們可以試下取質數,譬如取3,分別用前面提到的4、12、20和28去調用哈希函數H(x):
H(4) = 4 % 3 = 1
H(12) = 12 % 3 = 0
H(20) = 20 % 3 = 2
H(28) = 28 % 3 = 4
我們可以發現哈希函數H(x)的值都不一樣,也就是說取質數可以減少沖突的幾率。
桶的大小設計為質數的例子就是Hashtable,它的初始桶大小是11,不過擴容后就不能保證還是素數了。HashMap采用這種這種非常規的設計主要目的是為了優化取模和擴容,同時為了減少沖突,HashMap在確定哈希桶索引的位置時,加入了高位參與運算。
我們看下靜態內部類Node的源碼,源碼如下所示:
// HashMap.java
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) {
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;
// 判斷key和value是否都相等
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
// 如果key和value都相等,就返回true
return true;
}
// 如果key或者value不相等,就返回false
return false;
}
}
Node是HashMap的靜態內部類,實現了Map.Entry<K,V>接口,本質上就是一個映射(鍵值對)。
HashMap使用哈希表存儲數據。哈希表可以使用四種方式來解決哈希沖突,后面的題外話會有詳細講解,HashMap是使用鏈地址法來解決哈希沖突的,簡單來說,就是數組和鏈表的結合,每個數組元素都是一個鏈表結構,首先調用key的hashCode方法得到哈希值(該方法適用于每個Java對象),然后再通過哈希算法的后兩步運算(高位運算、取模運算)來定位該鍵值對對應的存儲位置,如果兩個key定位到相同的存儲位置,表示發生了哈希碰撞,哈希算法的計算結果越分散均勻,哈希碰撞的幾率就越低,Map的存取效率就越高。
如果哈希桶數組很大,即使較差的哈希算法計算結果相對來說比較分散均勻,出現哈希碰撞的幾率也相對來說比較低;如果哈希桶數組很小,即使較好的哈希算法計算結果相對來說不夠分散均勻,出現哈希碰撞的幾率也相對來說比較高,所以這需要在時間成本和空間成本之間權衡,可以通過好的哈希算法和擴容機制來達到哈希桶數組占用空間少,同時出現哈希碰撞的幾率也低。
構造方法
HashMap的構造方法,源碼如下所示:
// HashMap.java
// 構造一個具有指定初始容量和負荷系數的空HashMap
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
// 如果指定初始容量小于0,就拋出IllegalArgumentException異常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
// 如果指定初始容量大于最大容量,就取最大容量
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
// 如果負荷系數小于等于0或者不是單精度浮點數(float)就拋出IllegalArgumentException異常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 將指定負荷系數賦值給成員變量loadFactor
this.loadFactor = loadFactor;
// 調用tableSizeFor方法,并且傳入指定初始容量,把得到的值賦值給成員變量threshold
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
// 調用前面的方法,并且傳入指定初始容量和默認的負荷系數
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
// 將默認的負荷系數賦值給成員變量loadFactor,所有其他字段都是默認值
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(Map<? extends K, ? extends V> m) {
// 將默認的負荷系數賦值給成員變量loadFactor
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 調用putMapEntries方法,這個方法在調用putAll方法時也會調用
putMapEntries(m, false);
}
我們看下tableSizeFor方法,這個方法是返回給定目標容量的2的冪,源碼如下所示:
// HashMap.java
// 返回給定目標容量的2的冪
static final int tableSizeFor(int cap) {
int n = cap - 1; // 第一步:首先把傳入的給定目標容量減1,然后賦值給n
n |= n >>> 1; // 第二步:首先n的補碼無符號右移1位,然后與原來的n的補碼執行或運算,最后賦值給n
n |= n >>> 2; // 第三步:首先n的補碼無符號右移2位,然后與原來的n的補碼執行或運算,最后賦值給n
n |= n >>> 4; // 第四步:首先n的補碼無符號右移4位,然后與原來的n的補碼執行或運算,最后賦值給n
n |= n >>> 8; // 第五步:首先n的補碼無符號右移8位,然后與原來的n的補碼執行或運算,最后賦值給n
n |= n >>> 16; // 第六步:首先n的補碼無符號右移16位,然后與原來的n的補碼執行或運算,最后賦值給n
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; // 第七步:判斷n是否小于0,如果小于0,就返回1;如果大于等于0,就判斷n是否大于等于最大容量1073741824,如果大于等于最大容量,就返回最大容量;如果小于最大容量,就返回n加1
}
我們假設傳入的cap的值是30,首先執行第一步,n的值就是29,29是正數,所以它的補碼和原碼一樣,補碼如下所示:
00000000 00000000 00000000 00011101
執行第二步,首先29的補碼無符號右移1位,補碼如下所示:
00000000 00000000 00000000 00001110
然后與第一步的補碼執行或運算,補碼如下所示,轉成十進制是31:
00000000 00000000 00000000 00011111
執行第三步,首先31的補碼無符號右移2位,補碼如下所示:
00000000 00000000 00000000 00000111
然后與第二步的補碼執行或運算,補碼如下所示,轉成十進制是31:
00000000 00000000 00000000 00011111
執行第四步,首先31的補碼無符號右移4位,補碼如下所示:
00000000 00000000 00000000 00000001
然后與第三步的補碼執行或運算,補碼如下所示,轉成十進制是31:
00000000 00000000 00000000 00011111
執行第五步,首先31的補碼無符號右移8位,補碼如下所示:
00000000 00000000 00000000 00000000
然后與第四步的補碼執行或運算,補碼如下所示,轉成十進制是31:
00000000 00000000 00000000 00011111
執行第六步,首先31的補碼無符號右移16位,補碼如下所示:
00000000 00000000 00000000 00000000
然后與第五步的補碼執行或運算,補碼如下所示,轉成十進制是31:
00000000 00000000 00000000 00011111
執行第七步,31大于0,并且小于最大容量1073741824,所以執行如下邏輯:
31 + 1 = 32
最后返回的值就是32,它是2的冪,也就是2的5次冪。
添加
HashMap的添加方法,源碼如下所示:
// HashMap.java
// 添加一個Map到HashMap,HashMap的構造函數和putAll方法都有調用這個方法
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) {
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
// 調用tableSizeFor方法,返回t的2的冪,并且賦值給成員變量threshold
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
// 遍歷Map
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
// 調用putVal方法
putVal(hash(key), key, value, false, evict);
}
}
}
// 將這個HashMap中指定的鍵和指定的值關聯,如果它之前就存在相同的鍵,那么就把用新值去替換它的舊值
public V put(K key, V value) {
// 調用putVal方法,這里有一個很重要的方法:hash方法,后面會詳細講解
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;
if ((tab = table) == null || (n = tab.length) == 0)
// 如果table數組還沒初始化,就調用resize方法初始化數組
n = (tab = resize()).length;
// 根據key計算出來的哈希值進行取模運算,得到要插入的元素的索引,后面會詳細講解
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果該索引所在的元素數據是空,就創建一個節點,并且把數據傳進去
tab[i] = newNode(hash, key, value, null);
else {
// 如果該索引所在的元素數據不是空的,也就是發生了哈希碰撞,就執行以下邏輯
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果該索引的元素的key和要插入的元素的key是相同的,就賦值給e
e = p;
else if (p instanceof TreeNode)
// 如果該索引的元素的數據結構是樹,就調用putTreeVal方法,使用紅黑樹插入數據,并且賦值給e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果該索引的元素的數據結構是鏈表,就執行以下邏輯
// 執行循環
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 如果鏈表中不存在要插入的元素,就創建一個節點,并且插入到鏈表
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 如果binCount大于等于TREEIFY_THRESHOLD減1,也就是binCount大于等于7,鏈表的長度大于等于8,就把鏈表轉化為紅黑樹
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 如果鏈表中存在要插入的元素,就跳出循環
break;
// 把e賦值給p,繼續執行循環
p = e;
}
}
if (e != null) {
// 如果這時候的e不為空,說明要插入的元素已經存在該HashMap,就執行以下邏輯
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 如果值是空,就把值賦值給那個元素
e.value = value;
afterNodeAccess(e);
// 返回舊值
return oldValue;
}
}
// modCount的值加1
++modCount;
// size的值加1
if (++size > threshold)
// 如果這時候的size大于HashMap所能容量的最大數據量的節點個數,就調用resize方法,進行擴容
resize();
afterNodeInsertion(evict);
return null;
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果table數組已經初始化,就執行以下邏輯
if (oldCap >= MAXIMUM_CAPACITY) {
// 如果threshold大于等于最大容量,就把threshold設為最大容量
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 如果新的容量(舊的容量的值的補碼左移1位,也就是舊的容量的兩倍)小于最大容量,并且舊的容量大于等于默認初始容量,就設新的容量是舊的容量的兩倍
newThr = oldThr << 1;
}
else if (oldThr > 0)
// 如果threshold大于0,就把該值賦值給newCap
newCap = oldThr;
else {
// 如果初始容量和HashMap所能容納的最大數據量的節點個數都是0,證明是第一次進行初始化
newCap = DEFAULT_INITIAL_CAPACITY;
// threshold的公式是負荷系數乘以數組長度
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// ft的公式是負荷系數乘以數組長度
float ft = (float)newCap * loadFactor;
// 判斷初始容量是否小于最大容量,并且ft是否小于最大容量,如果是就使用ft,否則使用Integer的最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新threshold的值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 更新table數組
table = newTab;
if (oldTab != null) {
// 如果之前的數組已經存在數據,由于table的大小發生變化,所以哈希值也會發生變化,需要重新計算索引
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 如果指定索引的元素有值,就把那個值設為空
oldTab[j] = null;
if (e.next == null)
// 如果該索引的元素只有一個,就把元素放到重新計算的索引所在的位置
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;
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 {
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;
}
// 將鏈表轉化成紅黑樹
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果tab數組是空或者tab數組小于容器變成樹的最小表容量(值是64),就進行擴容
resize();
// 根據key計算出來的哈希值進行取模運算,得到要插入的元素的索引,后面會詳細講解
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 如果tab數組不為空,并且tab數組大于等于容量變成樹的最小表容量(值是64),就執行以下邏輯
TreeNode<K,V> hd = null, tl = null;
do {
// 把該節點轉化為樹節點
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);
}
}
// 將指定映射中的所有映射復制到此映射,如果它之前就存在相同的鍵,那么就把用新值去替換它的舊值
public void putAll(Map<? extends K, ? extends V> m) {
// 調用putMapEntries方法,前面分析過
putMapEntries(m, true);
}
總結下添加單個元素執行的邏輯:
- 判斷table數組是否已經初始化,如果沒有初始化,就調用resize方法初始化數組。
- 根據key計算出來的哈希值進行取模運算,得到要插入的元素的索引,并且判斷該元素的值是否為空,如果是空,就創建一個節點,并且把數據傳進去,然后執行步驟6。
- 判斷該索引所在的元素的數據結構是否是樹,如果是,就調用putTreeVal方法,使用紅黑樹插入數據,然后執行步驟5。
- 如果該索引所在的元素的數據結構是鏈表,就執行循環,判斷鏈表是否存在要插入的元素,如果不存在,就創建一個節點,并且插入到鏈表中,然后判斷是否需要將鏈表轉化為紅黑樹,條件是鏈表長度是否大于等于8;如果存在,就跳出循環,最后執行步驟5。
- 判斷下該元素的值是否為空,如果是空,就把值賦值給它,并且返回舊值。
- 判斷數組的大小是否大于HashMap所能容納的最大數據量的節點個數,如果是,就擴容,然后返回空。
總結下擴容執行的邏輯:
- 判斷table數組是否已經初始化,如果已經初始化,就進行擴容,容量是原來的兩倍;如果沒有初始化,就進行初始化(更新threshold的值和更新table數組)。
- 對數組進行遍歷,按順序判斷步驟3、步驟4和步驟5。
- 判斷該索引的元素是否只有一個,如果是,就把元素放到重新計算的索引所在的位置。
- 判斷該索引的元素的數據結構是否為樹,如果是,就進行拆分操作。
- 如果該索引的元素的數據結構是否為鏈表,如果是,就重新計算索引,重新分組。
hash方法
我們看下hash方法,這個方法也被稱為擾動函數,源碼如下所示:
// HashMap.java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
判斷key是否為空,如果是空,就返回0;如果不是空,就調用key的hashCode方法,如果這個對象沒有重寫hashCode方法,就會根據內存地址得到一個int類型的值,然后將得到的哈希值無符號右移16位,最后把得到的值的二進制補碼與哈希值的二進制補碼進行異或運算,這樣做的目的是為了讓高位和低位都參與運算,讓哈希值的分布更加分散均勻。
取模運算
我們可以看到經常出現如下邏輯,源碼如下所示:
// HashMap.java
p = tab[i = (n - 1) & hash]
它用來根據key計算出來的哈希值進行取模運算,得到要插入的元素的索引,目的是為了使元素的分布更加分散均勻,HashMap沒有使用hash % n這樣的方式進行取模運算,因為在HashMap中,容量都是2的冪,使得(n - 1) & hash等效于hash % n,同時&運算的效率高于%運算,所以HashMap選擇使用(n - 1) & hash進行取模運算。
我們假設n的值是2,hash的值是4(2的2次冪),hash % n的值是0,(n - 1) & hash是多少呢?先執行n - 1,得到1,然后1和4進行與運算,因為都是正數,補碼和原碼一樣,補碼如下所示:
1(十進制) = 00000000 00000000 00000000 00000001(二進制)
4(十進制) = 00000000 00000000 00000000 00000100(二進制)
執行與運算后,補碼如下所示:
00000000 00000000 00000000 00000000
轉成十進制后就是0,與hash % n的結果相同。
刪除
HashMap的刪除方法,源碼如下所示:
// HashMap.java
// 根據key刪除該映射中指定的鍵值對(如果存在)
public V remove(Object key) {
Node<K,V> e;
// 調用removeNode方法
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 如果table數組不為空,并且table數組有元素,并且根據key計算出來的哈希值進行取模運算,得到要刪除的元素的索引,該索引的元素的數據不為空
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果該索引的元素的key與要刪除的元素的key相同,就賦值給node
node = p;
else if ((e = p.next) != null) {
// 如果該索引的元素有下一個節點,就執行以下邏輯
if (p instanceof TreeNode)
// 如果這個節點的數據結構是樹,就調用getTreeNode方法
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 如果這個節點的數據結構是鏈表,就執行以下邏輯
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
// 如果在鏈表中能找到該節點,就賦值給node
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果能找到要刪除的節點,就執行以下邏輯
if (node instanceof TreeNode)
// 如果這個節點的數據結構是樹,就調用removeTreeNode方法
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 如果這個節點是鏈表第一個節點,就把數組的索引指向下一個位置
tab[index] = node.next;
else
// 如果這個節點不是鏈表的第一個節點,就從鏈表中刪除這個節點
p.next = node.next;
// modCount的值加1
++modCount;
// size的值減1
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
查找
HashMap的查找方法,源碼如下所示:
// HashMap.java
// 返回該映射中指定鍵的值,如果不存在,就返回空
public V get(Object key) {
Node<K,V> e;
// 調用getNode方法
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) {
// 如果table數組不為空,并且table數組有元素,并且根據key計算出來的哈希值進行取模運算,得到要查找的元素的索引,該索引的元素的數據不為空
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
// 如果該索引的元素的key與要刪除的元素的key相同,就返回該元素
return first;
if ((e = first.next) != null) {
// 如果該索引的元素有下一個節點,就執行以下邏輯
if (first instanceof TreeNode)
// 如果該節點的數據結構是樹,就調用getTreeNode方法
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;
}
題外話
常見的Map實現類
Map是一個接口,它是將鍵映射到值的對象,映射不能包含重復的鍵,每個鍵最多可以映射一個值,常見的Map實現類有HashMap、ConcurrentHashMap、Hashtable、LinkedHashMap和TreeMap等,它們的UML類圖如下所示:
ConcurrentHashMap
ConcurrentHashMap是線程安全的HashMap,在JDK 1.8之前,ConcurrentHashMap引入了分段鎖,分段鎖的原理是將數據分成一段一段存儲,然后給每一段數據配一把鎖,當一個線程訪問其中一段數據的時候就會占用那把鎖,但是不影響其他線程訪問其他段的數據,從而提高效率;在JDK 1.8之后,拋棄了分段鎖,利用內置鎖synchronized和CAS(Compare And Swap)來保證線程安全。
Hashtable
Hashtable是遺留類,它和HashMap很相似,不同的是,它是繼承Dictionary類,而且它是線程安全的,但是并發性不如ConcurrentHashMap,不建議使用該類,如果需要線程安全,可以選擇使用ConcurrentHashMap。
LinkedHashMap
LinkedHashMap是HashMap的子類,它通過維護一個雙向鏈表來保證迭代順序,這個迭代順序會根據accessOrder(布爾值)來判斷是插入順序,還是訪問順序,默認實現是按插入順序排序的,它可以實現LRU(Least Recently Used)算法。
TreeMap
TreeMap基于紅黑樹的NavigableMap實現,它可以根據鍵的可比較的自然順序進行排序,或者通過它在創建的時候提供的比較器(Comparator)進行排序,具體取決于使用的構造函數。
解決哈希沖突的幾種方式
解決哈希沖突的四種方式:
開放地址法
開放地址法是指當發生地址沖突時,按照某種方法繼續探測哈希表中的其他存儲單元,直到找到空位置為止。公式是Hi(key) = (H(key) + di) mod m,其中,H(key)是key的哈希地址,di是每次再探測時的地址增量,m是哈希表的長度。
增量di可以用不同的取法,根據取法的不同有如下名稱:
線性探測法
線性探測法的增量di取1, 2, 3, ……, k(k <= m - 1)的值,當發生地址沖突時,在哈希表中順序探測下一個存儲單元,直到找到空位置為止。
二次探測法
二次探測法的增量di取1^2, -1^2, 2^2, -2^2,……, k^2, -k^2(k <= m / 2),當發生地址沖突時,在哈希表的左右進行跳躍式探測,雙向探測空位置。
隨機探測法
隨機探測法的增量di是用隨機函數計算得到,當發生地址沖突時,在哈希表中隨機探測空位置。
值得一提的是,ThreadLocal的內部類ThreadLocalMap是采用開放地址法來解決哈希沖突。
鏈地址法
鏈地址法是指將所有哈希地址相同的記錄都鏈接同一個鏈表中,它處理沖突簡單,而且沒有堆積現象,也就是非同義詞絕對不會發生沖突,各鏈表上的節點的空間都是動態申請的,所以更加適合無法確定哈希表長度的情況。
再哈希法
再哈希法是指同時構造多個不同的哈希函數,當使用其中一個哈希函數發生沖突時,就使用另外一個哈希函數,直到不再發生沖突為止,這種方法不易產生聚集,但是會增加計算時間。
建立公共溢出區
建立公共溢出區是指將哈希表分為基本表和溢出表,凡是和基本表發生沖突的元素都會被填入溢出表,而且溢出表也可以使用同樣的哈希函數,易于實現。
我的GitHub:TanJiaJunBeyond
Android通用框架:Android通用框架
我的掘金:譚嘉俊
我的簡書:譚嘉俊
我的CSDN:譚嘉俊