相信大多數朋友都使用過HashMap,面試也常常會被問到,但每每都回答的都不盡人意,確實,HashMap還算是比較複雜的一個數據結構,尤爲是在JDK1.8以後又引入了紅黑樹以後。本文就基於JDK1.8的HashMap源碼,帶你們將經常使用方法、重要屬性及相關方法進行分析,HashMap 源碼中可分析的點不少,本文很難一一覆蓋,請見諒。java
本文篇幅較長,請客官耐心觀看node
若是本文中有不正確的結論、說法,請你們提出和我討論,共同進步,謝謝。面試
HashMap是基於hash算法實現的,也就是不一樣於數組,每次添加數據時,下標自增的操做,而是根據Key的hash值以及數組的長度計算出對應的下標,放入元素,那麼在查找的時候就直接可以定位到對應的元素,若是在沒有hash衝突的時候,時間複雜度基本就是O(1)了,引用一張圖大體總體看下HashMap的數據結構。算法
有朋友可能就會有疑惑了,那當元素愈來愈多的時候,就算經過hash算法計算,那萬一兩個元素計算出的下標同樣呢?那後面的元素往哪放?這裏採用的是鏈表的形式,當發生hash衝突的時候,第一個元素直接指向第二個元素,再有hash衝突元素時,直接插到鏈表尾部,這樣造成一條鏈。segmentfault
那麼若是衝突的元素不少,那麼鏈表豈不是會很長,由於咱們知道鏈表查詢是效率很低的,須要一個一個的遍歷,那麼在JDK1.8中,當鏈表長度超過必定閾值時,直接進行數據結構轉換,將鏈表轉化成紅黑樹,紅黑樹是一種平衡二叉樹,時間複雜度是O(logn),具體紅黑樹的原理就不分析了,不在此文章範圍內。數組
從上面分析,咱們也能夠看明白,HashMap的數據結構是由數組和鏈表(或樹形結構)組成,因此本質仍是由數組開始,咱們知道數組是須要提早知道容量的,好比初始位10,那麼當元素愈來愈多,由於下標範圍是0-9,因此hash衝突會愈來愈多,這樣造成不少鏈表或者樹,查詢時效率很是低,這時候就須要擴容了,也就是擴大原有數組的長度,至於擴多大,何時該擴容,下面分析源碼時,將一一給你們講解,可是咱們要注意的一點是,擴容是須要再次Hash的,爲何呢,由於hash算法是hash值取餘數組長度,因此必需要再次Hash肯定每一個元素的位置安全
hash算法是基於key的hashcode方法的,hashcode是object的方法,每一個對象均可以進行復寫,這裏就衍生出一個問題,什麼類適合做爲更適合做爲HashMap的鍵?答案是String, Interger這樣的wrapper類,由於String是不可變的,也是final的,並且已經重寫了equals()和hashCode()方法了。其餘的wrapper類也有這個特色。不可變性是必要的,由於爲了要計算hashCode(),就要防止鍵值改變,若是鍵值在放入時和獲取時返回不一樣的hashcode的話,那麼就不能從HashMap中找到你想要的對象,並且比較安全,碰撞的概率就會小些,這樣就能提升HashMap的性能。數據結構
老規矩,先上構造方法老是沒錯的多線程
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
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;
this.threshold = tableSizeFor(initialCapacity);
}
複製代碼
能夠看到重載了4個構造方法,咱們大多數基本用的就是第一個無參方法,其餘的幾個方法也是作一些初始化操做,主要關心這幾個變量:app
名稱 | 用途 |
---|---|
initialCapacity | HashMap 初始容量 |
loadFactor | 負載因子 |
threshold | 當前 HashMap 所能容納鍵值對數量的最大值,超過這個值,則需擴容 |
HashMap 初始容量是16,負載因子爲 0.75,可是有的朋友會細心發現,第一個構造方法,擺明就只是賦值了負載因子,初始容量和閾值都沒有被初始化,這裏先不解釋,後面擴容機制會告訴你答案,而後看最後一個構造函數,咱們能夠把初始容量和負載因子做爲值傳遞進來,threshold是經過一個方法計算出來的,看看方法具體實現:
/** * Returns a power of two size for the given target capacity. */
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;
}
複製代碼
相信你們和我同樣,第一次看到這個方法是矇蔽的....先把結論給出來:找到大於或等於 cap 的最小2的冪,這裏引用一張圖解釋下,侵刪:
好比cap等於5,那麼最終返回的就是8,若是cap等於10,返回的就是16,這樣一說你們結合上面的應該能理解了。
插入邏輯算是比較複雜的了,咱們先來看看put方法代碼:
public V put(K key, V value) {
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;
//初始化數組table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//經過hash算法找到下標,若是對應的位置爲空,直接將數據放進去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//對應的位置不爲空,hash衝突
Node<K,V> e; K k;
//判斷插入的key若是等於當前位置的key的話,先將 e 指向該鍵值對,後續覆蓋
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//若是桶中的引用類型爲 TreeNode,則調用紅黑樹的插入方法
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);
//若是鏈表長度大於一個閾值,鏈表變樹!
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//若是鏈表中包含該節點,賦值,後續覆蓋,跳出循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//判斷插入的是否存在HashMap中,上面e被賦值,不爲空,則說明存在,更新舊的鍵值對
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//當前HashMap鍵值對超過閾值時,進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製代碼
能夠看到主要邏輯在putVal()方法中,不清楚的能夠看下注釋,總結一下主要是幾個方面:
那麼重點固然就是擴容方法了,看看具體實現:
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) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//經過位運算,計算出新的容量以及新的閾值,2倍計算
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//使用 threshold 變量暫時保存 initialCapacity 參數的值
else if (oldThr > 0)
newCap = oldThr;
else {
//這裏就能回答上面的初始化的問題了,調用空的構造函數時的賦值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr 爲 0 時,按閾值計算公式進行計算,容量*負載因子
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新當前最新的閾值
threshold = newThr;
//建立新的桶數組,調用空的構造方法,這裏也就是桶數組的初始化
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//若是舊的數組不爲空,遍歷,將值移植到新的數組中去
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 {
//遍歷整個鏈表,從新hash,根據新的下標從新分組
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;
}
複製代碼
代碼稍微長了點,你們耐心點看下邏輯,總結也就幾點
這裏主要說明下鏈表拆分是什麼意思,咱們知道下標計算是hash&(n-1),假如原始數組長度爲16,進行求餘計算:那麼n-1也就是15,對應二進制 0000 1111,這時候分別有2個hash值分別爲:1101 1100和1110 1100,計算能夠獲得,獲得的下標都是0000 1100,也就是12,若是進行擴容以後呢?長度變成32,n-1也就對應 0001 1111,2個hash再次進行計算獲得的就是 0001 1100 和 0000 1100,一個下標仍是12,而另外一個則是28了
能夠看到擴容後,參與模運算的位數由4位變爲了5位,因此對應得出來的值天然就不同了,相信你們也應該理解了
相對於複雜的插入操做,查找的邏輯相對就相對簡單點了,代碼以下:
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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//定位下標,若是第一個節點是所要查找的值,直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//若是第一個節點是TreeNode類型,去遍歷紅黑樹
if (first instanceof TreeNode)
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;
}
複製代碼
上面也提到了,經過(n - 1) & hash
便可算出在數組中的位置,這裏簡單解釋一下。HashMap 中桶數組的大小 length 老是2的冪,此時,(n - 1) & hash
等價於對 length 取餘。但取餘的計算效率沒有位運算高,因此(n - 1) & hash
也是一個小的優化
還有一個計算hash值得方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
能夠看到,這裏的hash並非用原有對象的hashcode最爲最終的hash值,而是作了必定位運行,具體緣由我的想法以下:
由於若是(n-1)的值過小的話
,(n - 1) & hash
的值就徹底依靠hash的低位值,好比n-1
爲0000 1111,那麼最終的值就徹底依賴於hash值的低4位了,這樣的話hash的高位就玩徹底失去了做用,h ^ (h >>> 16)
,經過這種方式,讓高位數據與低位數據進行異或,也是變相的加大了hash的隨機性,這樣就不單純的依賴對象的hashcode方法了。
有了前面一些鋪墊,刪除操做也並不複雜
public V remove(Object key) {
Node<K,V> e;
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) {
Node<K,V> node = null, e; K k; V v;
//若是鍵的值與鏈表第一個節點相等,則將 node 指向該節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//若是是TreeNode類型,指向該節點
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//遍歷鏈表,找到該節點
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
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)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
複製代碼
相信有了以前的基礎,這裏理解就不困難了,具體實現就很少說了,有興趣的朋友能夠深刻源碼看下
大體分析就到一段落了,這裏總結下幾個問題,但願可以幫助到你們一些面試過程。
key
通過擾動函數擾動後獲得hash
值,而後再經過hash & (length - 1)
代替取模的方式進行元素定位,查找效率最好狀況是O(1)JDK 源碼中 HashMap 的 hash 方法原理是什麼?