一、什麼是HashMap
顧名思義,HashMap就是基於哈希表的Map實現。哈希表(散列)是一種數據結構,其主幹是數組,但存儲方式是利用哈希函數將元素映射到數組(哈希表)中的位置,通過下標一次定位。
根據哈希表的定義,當我們需要查找過添加某個元素時,通過哈希函數即可快速定位,理想情況下每一個元素都會通過哈希函數一映射唯一的地址,但這只是理想情況。
哈希衝突
事實上,由於數據的無限和地址的有限,無論怎麼設計哈希函數,都無法保證不同的元素可以映射到不同的地址。當兩個不同的元素通過哈希函數計算得到了相同的值,此時就發生了哈希衝突。HashMap解決哈希衝突採用了鏈地址法,將哈希值相同的元素構成一個鏈表,head放在哈希表中,當鏈表達到一定長度時又將鏈表轉換爲紅黑樹,也就是說HashMap採用的是數組+鏈表+紅黑樹的結構,如下圖所示。
有了以上基礎認知,我們以JDK8爲例,看看Java是如何實現HashMap的。
二、源碼分析
HashMap的源碼接近2000行,以下只選取核心的代碼進行分析。
//初始化容量,默認16,且必須是2的次冪(有原因)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//鏈表轉爲tree結構的閾值,當前索引位置添加的元素超過該值,則轉爲tree存儲
static final int TREEIFY_THRESHOLD = 8;
//樹轉爲鏈表的閾值
static final int UNTREEIFY_THRESHOLD = 6;
//tree的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
//節點數組
transient Node<K,V>[] table;
//map中所有節點個數
transient int size;
//map在結構上的修改次數,用於迭代過程中的fail-fast機制,保證線程安全問題
transient int modCount;
以上是HashMap類的重要參數及其默認值,其中加載因子表示的是table中元素的填滿程度,比如默認0.75,則會認爲當table的元素個數爲16*0.75 = 12時,map已滿,需要擴容。
//HashMap的節點類
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//這裏的hash是key的特殊的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;
}
//節點的Hash值
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;
}
}
以上是HashMap的數據節點的類,很容易理解,下面看看HashMap是如何存儲數據的。
//hash:key的hash值,onlyIfAbsent:默認fasle,即key相同時覆蓋原來的value,evict在hashmap無作用
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab以數組的結構存儲節點,p爲tab上的某一點,n爲tab數組的長度,i爲數組索引
Node<K,V>[] tab; Node<K,V> p; int n, i;
//當table爲null或者長度爲0時,需要通過resize將tab初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//將key的hash和數組長度-1進行與操作,作爲該元素所在哈希表的下標,這麼做的原因後面會分析
if ((p = tab[i = (n - 1) & hash]) == null) //如果爲空,則說明沒有數據,直接插入
tab[i] = newNode(hash, key, value, null);
else {//該點有數據,則分情況
Node<K,V> e; K k;
//該點的key和要插入的key相同時,即hash和值都相同,直接賦值給e,後續統一處理
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//如果當前節點是樹節點,按樹的方式存儲
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);
//如果插入後當前鏈表的個數超過閾值-1,即下一個插入將到達閾值,則轉爲樹結構
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;//如果沒到達閾值,則插完就退出遍歷
}//鏈表中某節點和新增節點的key相同,e已是當前節點,則直接退出,
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//繼續鏈表的下一節點
}
}
//經過上面幾種情況,當e不爲null時,則說明存在相同的key,需要以下處理
if (e != null) { // existing mapping for key
V oldValue = e.value;//保存舊value
if (!onlyIfAbsent || oldValue == null)
e.value = value;//改爲新value
afterNodeAccess(e);//空函數,linkedhashmap後續操作
return oldValue;//返回舊value
}
}
//插入新key-value的後續處理,注意覆蓋舊元素時已直接return,不算插入新節點
++modCount;//增加修改次數,此變量是爲了線程安全問題,迭代時檢查此變量
if (++size > threshold)//超過節點閾值需要擴容
resize();
afterNodeInsertion(evict);//linkedhashmap後續操作
return null;//插入成功返回null
}
以上是put的源碼,每一行做了簡要的註釋,下面解釋幾個本人理解上有難度的點。
- 計算元素在數組的下標
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
在這之前先看一下計算可以key的hash的方法。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
根據源碼,下標計算有三步,先計算key的hashcode,再將其低16位與高16位進行異或操作,對數組長度取模運算。這樣做的原因有兩個。一是如果直接用int型的hashcode,那麼哈希表的索引範圍將在到共幾十億,遠遠大於可用內存,但僅僅用低16位進行取模運算會增加hash的衝突率,因此加入了高位特徵,將低16位與高16位進行異或(也稱擾動函數),既減小了空間,又能保證hash儘可能散列。最後爲什麼要和n-1與操作呢?因爲取餘(%)操作中如果除數是2的冪次則等價於與其除數減一的與(&)操作,且二進制運算效率比%高.在數組長度n爲2的冪次條件下,n-1的二進制就是當前位變爲0,低位全爲1,比如8(1000)->7(0111) ,而取餘就是爲計算低位的值,因此,hash(key)&(n-1) = hash(key) % n。所以n必須爲2的冪次,每次擴容乘2。
-
判斷當前節點是樹節點
else if (p instanceof TreeNode)//如果當前節點是樹節點,按樹的方式存儲 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
p是以Node類型聲明的,怎麼會是TreeNode的實例且強制轉換爲TreeNode了呢。原來在後面插入新節點後需要轉化爲樹結構時,將該鏈表上的所節點都轉爲了TreeNode類型,因此後面可以做到將父類Node強制轉化爲子類TreeNode。
-
如何將鏈表轉爲紅黑樹treeifBin以及resize擴容機制,後續會分析。
- 擴容:當插入新節點時,判斷當前size是否超過閾值,超過的話先擴容再插入
1.新建一個新的Node數組,容量爲原來的2倍。
2.將舊數組複製到新數組,由於擴容了一倍,所以n左移了一位,n-1也高了一位,而高的這一位剛好可以將原鏈表上的節點散列開,如果某節點hash值的高位爲1,則新的index = 原index+原n,若爲0則index = 原index。代替rehash操作
5.併發引發的問題:
1.7:擴容時,尾插,環形
1.8:put時,A判斷了index沒有元素,準備插入,掛起,B判斷沒有元素後插入,掛起,A繼續,則直接插到index上,導致B的丟失數據。size++也不安全。