在前面的文章中,咱們以及介紹了 List
你們族的相關知識:html
在接下來的文章,則主要爲你們介紹一下Java
集合家庭中另外一小分隊 Map
,咱們先來看看 Map
家庭的總體架構:java
在這篇文章中,咱們主要介紹一下HashMap:程序員
HashMap 的依賴關係:算法
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
複製代碼
從依賴關係上面來看,HashMap
並無 List
集合 那麼的複雜,主要是由於在迭代上面,HashMap 區別 key-value 進行迭代,而他們的迭代又依賴與keySet-valueSet 進行,所以,雖然依賴關係上面HashMap 看似簡單,可是內部的依賴關係更爲複雜。數組
默認 桶(數組) 容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
負載因子
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;
存儲數據節點
static class Node<K,V> implements Map.Entry<K,V>
節點數組
transient Node<K,V>[] table;
數據容量
transient int size;
操做次數
transient int modCount;
擴容大小
int threshold;
複製代碼
對比於JDK8以前的HashMap ,成員變量主要的區別在於多了紅黑樹的相關變量,用於標示咱們在何時進行 list
-> Tree
的轉換。安全
附上Jdk8 中HashMap 的數據結構展現圖:bash
HashMap 提供了四種構造函數:數據結構
m
中內容存入HashMap中接下來咱們主要講解一下,HashMap 在JDK8中的添加數據過程(引用):架構
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製代碼
上述方法是咱們在開發過程當中最常使用到的方法,可是卻不多人知道,其實內部真正調用的方法是這個putVal(hash(key), key, value, false, true)
方法。這裏稍微介紹一下這幾個參數:併發
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
這裏的Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算。 這裏引用一張圖,易於你們瞭解相關機制
因爲源碼篇幅過長,這裏我進行分開講解,同窗們能夠對照源碼進行閱讀
Node<K,V>[] tab; Node<K,V> p; int n, i;
複製代碼
第一部分主要縣聲明幾個須要使用到的成員變量:
table 爲空說明當前操做爲第一次操做,經過上面構造函數的閱讀,咱們能夠了解到,咱們並無對table 進行初始化,所以在第一次put 操做的時候,咱們須要先將table 進行初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
複製代碼
從上述代碼能夠看到,table 的初始化和擴容,都依賴於 resize()
方法,在後面咱們會對該方法進行詳細分析。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
複製代碼
在上一步咱們以及確認當前table不爲空,而後咱們須要計算咱們對象須要存儲的下標了。
若是該下標中並無數據,咱們只需建立一個新的節點,而後將其存入 tab[]
便可。
與上述過程相反,Hash碰撞結果後,發現該下標有保存元素,將其保存到變量 p = tab[i = (n - 1) & hash]
,如今 p
保存的是目標數組下標中的元素。如上圖所示(引用):
在獲取到 p
後,咱們首先判斷它的 key 是否與咱們此次插入的key 相同,若是相同,咱們將其引用傳遞給 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);
複製代碼
因爲在JDK 8後,會對過長的鏈表進行處理,即 鏈表 -> 紅黑樹,所以對應的節點也會進行相關的處理。紅黑樹的節點則爲TreeNode,所以在獲取到p
後,若是他跟首位元素不匹配,那麼他就有可能爲紅黑樹的內容。因此進行putTreeVal(this, tab, hash, key, value)
操做。該操做的源碼,將會在後續進行細述。
else {
//for 循環遍歷鏈表,binCount 用於記錄長度,若是過長則進行樹的轉化
for (int binCount = 0; ; ++binCount) {
// 若是發現p.next 爲空,說明下一個節點爲插入節點
if ((e = p.next) == null) {
//建立一個新的節點
p.next = newNode(hash, key, value, null);
//判斷是否須要轉樹
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
p = e;
}
}
複製代碼
鏈表遍歷處理,整個過程就是,遍歷全部節點,當發現若是存在key 與插入的key 相同,那麼退出遍歷,不然在最後插入新的節點。判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操做,不然進行鏈表的插入操做;遍歷過程當中若發現key已經存在直接覆蓋value便可;
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
複製代碼
若是 e
不爲空,說明在校驗 key 的hash 值,發現存在相同的 key,那麼將會在這裏進行判斷是否對其進行覆蓋。
if (++size > threshold)
resize();
複製代碼
若是 size
大於 threshold
則進行擴容處理。
在上面的構造函數,和 put
過程都有調用過resize()
方法,那麼,咱們接下來將會分析一下 resize()
過程。因爲JDK 8
引入了紅黑樹,咱們先從JDK 7
開始閱讀 resize()
過程。下面部份內容參考:傳送門
在 JDK 7
中,擴容主要分爲了兩個步驟:
1 void resize(int newCapacity) { //傳入新的容量
2 Entry[] oldTable = table; //引用擴容前的Entry數組
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的數組大小若是已經達到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣之後就不會擴容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry數組
10 transfer(newTable); //!!將數據轉移到新的Entry數組裏
11 table = newTable; //HashMap的table屬性引用新的Entry數組
12 threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }
複製代碼
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了舊的Entry數組
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
5 Entry<K,V> e = src[j]; //取得舊Entry數組的每一個元素
6 if (e != null) {
7 src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組再也不引用任何對象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!從新計算每一個元素在數組中的位置
11 e.next = newTable[i]; //標記[1]
12 newTable[i] = e; //將元素放在數組上
13 e = next; //訪問下一個Entry鏈上的元素
14 } while (e != null);
15 }
16 }
17 }
複製代碼
下面舉個例子說明下擴容過程。假設了咱們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。其中的哈希桶數組table的size=2, 因此key = 三、七、5,put順序依次爲 五、七、3。在mod 2之後都衝突在table[1]這裏了。這裏假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是哈希桶數組 resize成4,而後全部的Node從新rehash的過程。
因爲擴容部分代碼篇幅比較長,童鞋們能夠對比着博客與源碼進行閱讀。 與上述流程類似,JDK 8
中擴容過程主要分紅兩個部分:
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) { //當容器大小以及是最大值時
threshold = Integer.MAX_VALUE; //設置閥值爲最大值,而且再也不作擴容處理
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 容器擴容一倍,而且將閥值設置爲原來的一倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//若是閥值不爲空,那麼將容量設置爲當前閥值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//若是數組長度與閥值爲空,建立一個默認長度的數組長度
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 第二步,建立新數組
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
複製代碼
從上面的流程分析,咱們能夠看到在 JDK 8 HashMap
中,開始使用位運算進行擴容計算,主要優勢將會在後續數據拷貝中具體表現。
在上述容器擴容結束後,若是發現 oldTab
不爲空,那麼接下來將會進行內容拷貝:
if (oldTab != null) {
//對舊數組進行遍歷
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//
if ((e = oldTab[j]) != null) {
//將舊數組中的內容清空
oldTab[j] = null;
//若是 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);
else { // preserve order
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;
}
//高位與運算,確認索引爲 願索引+ oldCap
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;
}
}
}
}
}
複製代碼
內容拷貝,在JDK 8
中優化,主要是:
咱們來看一下 JDK 8
是如何經過高位與運算確認存儲位置的:
HashMap中,若是key通過hash算法得出的數組索引位置所有不相同,即Hash算法很是好,那樣的話,getKey方法的時間複雜度就是O(1),若是Hash算法技術的結果碰撞很是多,假如Hash算極其差,全部的Hash算法結果得出的索引位置同樣,那樣全部的鍵值對都集中到一個桶中,或者在一個鏈表中,或者在一個紅黑樹中,時間複雜度分別爲O(n)和O(lgn)。
(1) 擴容是一個特別耗性能的操做,因此當程序員在使用HashMap的時候,估算map的大小,初始化的時候給一個大體的數值,避免map進行頻繁的擴容。
(2) 負載因子是能夠修改的,也能夠大於1,可是建議不要輕易修改,除非狀況很是特殊。
(3) HashMap是線程不安全的,不要在併發的環境中同時操做HashMap,建議使用ConcurrentHashMap。
(4) JDK1.8引入紅黑樹大程度優化了HashMap的性能。
(5) 還沒升級JDK1.8的,如今開始升級吧。HashMap的性能提高僅僅是JDK1.8的冰山一角。