HashMap
在瞭解HashMap
之前, 首先談一談經常和它一起出現的HashTable
.Hashtable
是早期 Java 類庫提供的一個哈希表實現,本身是同步的,不支持 null 鍵和值,由於同步導致的性能開銷,所以已經很少被推薦使用. TreeMap
是基於紅黑樹實現的Map類, 其put
,get
操作的時間複雜度在O(logN), 但是它的鍵值存儲是有序的, 順序與compareTo
或Comparator
有關.
下面是Map家族的結構概覽
在以上的類中, 如果需要常數時間複雜度的增刪改查, 並且對順序沒有要求, 最常用的就是HashMap
類. 哈希是通過哈希函數, 將任意長度的輸入變成固定長度的輸出, HashMap
正是利用這一性質, 將鍵值對的特徵(hash)轉化爲固定長度的整型輸出, 將輸出作爲數組下標, 將鍵值對存儲到數組中, 由於數組的隨機訪問的性能, 能夠保證其常數時間的讀寫.
用到hash的地方, 必須要考慮的問題就是哈希衝突的解決, 哈希衝突的解決思路有以下幾種:
- 開放定址法: 當發生衝突時, 採用線性探測或二次探測等方法, 在初始位置的周圍尋找一個空的位置放置. 封閉哈希, 元素數量不能超過桶數量, 高負載下性能下降嚴重
- 再哈希法: 當第一個hash函數產生的值發生碰撞, 用第二個hash函數再產生一個值
- 鏈地址法: 也就是拉鍊法, 每個地址相當於一個桶, 當發生衝突時, 還是將鍵值對放到桶中, 同一個桶的鍵值對構成鏈表. 適用頻繁插入刪除的情況
- 溢出區法: 專門建立一個溢出區, 將發生碰撞的元素用鏈表存儲起來. 和拉鍊法的思想相似, 但是溢出集中時, 性能不如拉鍊法.
HashMap
採用的方法是拉鍊法, 當同時當元素數/桶數達到一定比例(負載因子)時, 進行擴容. 接下來分析源碼. 以下分析基於JDK1.8
HashMap#常量
首先是HashMap中有幾個重要的常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 HashMap的默認初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap的最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 推薦的負載因子, 通常情況下, 不用修改
static final int TREEIFY_THRESHOLD = 8; // 單個桶中元素數量, 達到樹化門限則會從鏈表調整爲紅黑樹, 防止哈希碰撞拒絕服務攻擊
static final int UNTREEIFY_THRESHOLD = 6; // 單個桶中數量小於門限, 則會退化成鏈表
static final int MIN_TREEIFY_CAPACITY = 64; // 最小的樹化容量, 當Map的容量小於該值時, 如果一個桶的元素過多, 會首先採用擴容方法嘗試緩解. 超過該值時, 如果達到TREEIFY-THRESH則會樹化
HashMap#構造器
從構造器開始看, HashMap
提供了以下幾種構造器
public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)
後面的幾種構造函數, 無非就是將一些參數設爲了默認值, 我們直接看第一個構造器
public HashMap(int initialCapacity, float loadFactor) {
// 判斷initialCapacity和loadFactor的合法性, 大於0, 不爲NaN等
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
這個構造器總共就做了兩個參數的賦值, 沒有其他實際的創建結構, 所以這裏其實採用了懶惰加載的方法, 在真正調用的時候,纔去初始化內部的結構.
HashMap#tableSize()
然後這裏的tableSizeFor()
方法, 其目的是將輸入的容量, 轉換爲大於等於輸入容量的2的冪.爲什麼HashMap
的容量總要保持2的冪, 我們之後再討論, 但是這裏好像還是不對.
返回值爲什麼是賦值給的threshold
, 這個參數不是擴容的門限值麼?
其實這個參數的解釋是: 如果table數組還沒有被創建, 這個參數等於數組的初始大小, 如果爲0則採用默認初始大小.
接着我們看一看tableSizeFor
是怎麼轉成2的冪的
// 1.8的實現
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;
}
// 早期版本的實現
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
從早期版本實現, 我們很容易看懂, 就是找一個最小的2的冪, 大於等於initalCapacity
嘛, 但是新版的好像就有點懵了, 這是在幹什麼, 我們不妨舉個例子.
- 假設我們的輸入是65, 二進制0100 0001, 減1得到0100 0000
- 注意觀察最高位的1, 當第一次右移, 然後做或運算, (0100 0000 |0010 0000) = 0110 0000
- 這樣最高位的1就變成了兩個1, 接着做右移 2位, 然後或運算, (0110 0000 | 0001 1000) = (0111 1000)
- 現在從最高位往右就有至少4個1了, 再用它們往右移4位覆蓋低位, 最終得到的從最高位開始往右都是1的結果, 0111 1111
- 最後加1, 得到了1000 0000, 128
這樣相比原來循環1位一位往上移, 又快了一些, 如果還沒有看懂, 可以看下這個參考資料, 裏面帶了一些示意圖.
HashMap#put
接下來看下具體的初始化工作. 初始化發生在第一次put
, 這個函數只有一行, 但是有個細節, 這裏用hash
函數處理了key, 然後再進行實際的putVal
操作.
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
HashMap#hash
我一開始以爲, hash函數只是返回了key.hashCode()
的值, 然後發現並不是.
// 1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 以往版本
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
在 JDK1.8 的實現中,將 hashCode 的高 16 位與 hashCode 進行異或運算,主要是爲了在 table 的 length 較小的時候,讓高位也參與運算,並且不會有太大的開銷。爲什麼比以往版本的hash有所簡化, 我所看到的資料主張是因爲加入了樹化後, 碰撞情況的查找成本小了, 所以hash的計算可以簡化.
HashMap#putVal
這個函數是整個類中, 邏輯最密集的之一, 所以相當精彩, 函數中要做的包括懶惰加載-初始化, 檢查是否key已經存在, 檢查是否要樹化等.
/**
* Implements Map.put and related methods
*
* @param hash 經過hash函數得到的hash值
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 爲true時, 如果已經存在就不修改
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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; // resize函數進行初始化
if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash相當於hash對n取模, 詳細看後面的解釋.
// 如果找到的桶爲空, 則key肯定不存在
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// p是桶的頭節點 e是p的後一個節點, 兩個指針一前一後遍歷鏈表
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // 如果頭結點就是要找的key的node
e = p;
else if (p instanceof TreeNode) // 如果頭節點是個樹節點, 則調用樹插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 如果有key, e=node 否則e = null
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 如果整個鏈沒有找到, e= null
p.next = newNode(hash, key, value, null); // 創建一個新節點
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); // 如果達到樹化條件, 則樹化或擴容
break;
}
if (e.hash == hash && // 找到了key
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 和e = p.next 構成鏈表往下走
}
}
if (e != null) { // existing mapping for key
// 找到key所在節點, 修改value並返回舊值
}
}
++modCount;
if (++size > threshold) // 超過門限, 擴容
resize();
afterNodeInsertion(evict);
return null;
}
這裏留個懸念, 爲什麼在創建新節點的時候, 調用的是newNode()
方法而不是直接new Node()
產生一個對象? 具體原因請看JDK1.8 Collection知識點與代碼分析–LinkedHashMap(LinkedHashMap需要將新節點連接到鏈表上)
HashMap#resize
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;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 不進行擴容
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else
// 初始化時, thresh的值不爲0, 則按照thresh初始化, 否則按照默認的初始化容量初始化
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 創建新的table
if (oldTab != null) { // 非初始化, 則需要從舊數組移動到新數組
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 { // preserve order // 尾插法保持順序
// 如果(e.hash & oldCap) == 0 則留在原桶中, 否則進入新桶
}
}
}
}
return newTab;
}
HashMap#Treeify
樹化是JDK1.8中HashMap實現的一大亮點, 代碼量也很多, 如果一起分析的話, 篇幅過長了, 但是這個放一個關於樹化部分的詳細分析的文章, 供大家參考. 本文中, 僅對幾個和樹化相關的非常有意思的核心方法進行分析.
樹化的整體流程如下: treeifyBin
方法把一個Bin
鏈表上的節點全部包裝成TreeNode
, 然後由treeify
對鏈表進行建樹, log(N)的時間複雜度下將bin中的元素插入到新建的樹中, 每插入一個元素需要通過balanceInsertion
對樹進行再平衡.
我們直接來看treeify
的方法源碼:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
// 當不是根節點時,
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph; // dir表示方向 -1 左, 1 右
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// hash 相等的情況
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) || // 如果class實現了Comparable 返回class 否則返回null
(dir = compareComparables(kc, k, pk)) == 0) // 如果pk的getClass是kc, 返回k.compareTo(pk), 否則返回0
// 如果兩者不可比較
// 如果k, pk的class名不同, 比較兩個string
// 如果class相同或者兩個之間有至少一個null, 比較Object.hashCode返回的地址
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p; // 記住當前p, 嘗試左右兒子
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 通過log(n)的複雜度找到插入位置, 插入後, 重新做balance
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
這個方法中有一步調用了tieBreakOrder
方法, 這個方法的具體功能我在註釋中進行了解釋, 但是這個方法非常有意思, 一個二叉搜索樹需要是嚴格有序的, 在map中又允許null作爲key(只有一個) 因此這個方法的作用就是在兩個key不可比的時候, 通過穩定的比較方法計算兩者誰更大, 保證之後查找這個key的時候, 可以通過該tieBreakOrder
方法, 再次找到保存在樹中的key.
在上述方法的最後, 調用了balanceInsertion
方法進行再平衡, 該方法也就是最關鍵的判斷是否左右旋, flipColor邏輯的代碼, 我這裏直接貼上我自己的對balanceInsertion
方法的註釋貼上來, 作爲資料的補充
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true; // 2-3樹嘗試同一層插入
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // xp x-parent, xpp x-parent-parent, xppl xpp-left, xppr xpp-right
// 找到root節點了
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
// 如果父節點就是root節點,
// 如果父節點非紅, 不需要提高層數, 注意這裏沒有要求只有左兒子能是紅邊
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 1. x的父節點是紅邊, 遇到了兩個紅邊相連的狀態
// 2. x的祖父xpp不爲空
// 父節點是祖先節點的左兒子
if (xp == (xppl = xpp.left)) {
// 祖先節點的左兒子是紅邊
// 如果祖先節點的右兒子也是紅邊, flip
// 這是由於將xpp變成紅色以後可能與xpp的父節點發生兩個相連紅色節點的衝突,這就又構成了第二種旋轉變換,所以必須從底向上的進行變換,直到根。
// 所以令x = xpp,然後進行下下一層循環,接着往上走。
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp; // 轉到這個flip後持有紅邊的祖先節點
}
else {
// 進入else說明 祖先節點xpp的右兒子不是紅邊
if (x == xp.right) {
// x是右兒子,
/*
xpp
/
xp紅
\
x紅
需要先左旋, 再右旋
*/
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
/*
xpp
/
xp紅
/
x紅
需要xp右旋, flip
*/
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
// 父節點是祖先節點的右兒子, 且父節點爲紅節點
else {
// flip
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
/*
xpp
\
xp紅
/
x紅
需要先x右旋, 再xp左旋
*/
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
/*
xpp
\
xp紅
\
x紅
需要xp左旋, 再flip
*/
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
/*
pp pp
/ /
p r
\ /
r p
/ \
rl rl
或
pp pp
\ \
p r
\ /
r p
/ \
rl rl
*/
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) {
if ((rl = p.right = r.left) != null)
rl.parent = p;
if ((pp = r.parent = p.parent) == null) // 說明p就是root
(root = r).red = false;
else if (pp.left == p)
pp.left = r;
else
pp.right = r;
r.left = p;
p.parent = r;
}
return root;
}
HashMap常考知識點總結
-
爲什麼要保證容量是2的冪?
對於任意給定的對象,只要它的 hashCode() 返回值相同,那麼計算得到的 hash 值總是相同的。我們首先想到的就是把 hash 值對 table 長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。
但是模運算消耗還是比較大的,我們知道計算機比較快的運算爲位運算,因此 JDK 團隊對取模運算進行了優化,使用上面代碼2的位與運算來代替模運算。這個方法非常巧妙,它通過 “(table.length -1) & h” 來得到該對象的索引位置,這個優化是基於以下公式:。我們知道 HashMap 底層數組的長度總是 2 的 n 次方,並且取模運算爲 “h mod table.length”,對應上面的公式,可以得到該運算等同於“h & (table.length - 1)”。這是 HashMap 在速度上的優化,因爲 & 比 % 具有更高的效率。 -
爲什麼要鏈表要從頭插法變成尾插法?
頭插法還是尾插法指的都是resize
函數, 在將節點移動到新的桶中時, 節點如何插入的方法, 頭插法的考量是後插入的數據可能是熱點數據, 頭插更容易訪問, 但是存在的問題是頭插法在每次resize的時候, 節點之間的前後關係都是倒置, 所以這種優化並不成立. 然而, 在下面的競態條件下,頭插法可能引起鏈表成環, 而尾插法不會, 因此當被錯誤用在併發情形下, 尾插法只有可能丟失一部分數據, 而頭插法會導致死循環, 徹底不可用. -
如果把HashMap用在併發情形下, 會導致的問題?
出現很典型的競態條件, 在resize中將鏈表變成環導致resize無限循環, cpu佔用, HashMap無法正常繼續.具體情形這篇博客講的很清楚, 還帶有配圖.
其他參考資料
Java集合:HashMap詳解(JDK 1.8)