HashMap
一、HashMap簡介
HashMap基於哈希表的Map接口實現。是以key-value存儲形式存在。線程不安全,也就是說多個線程同時對HashMap進行增刪改操作時,不能保證數據時一致的。key和value都可以為null,無序存放。
JDK1.8之前由數組+鏈表組成,數組是HashMap主體,鏈表則主要是為了解決哈希沖突(兩個對象調用的hashCode方法計算的哈希碼值一致導致計算的數組索引值相同)而存在的(“拉鏈法”解決沖突),JDK1.8之后,當鏈表長度大於閾值(或者紅黑樹的邊界值,默認為8)並且當前數組的長度大於64時,此時此索引位置上的所有數據改為使用紅黑樹存儲。
注意:為了提高效率,將鏈表轉換為紅黑樹前會判斷,即使鏈表閾值大於8,但是數組長度小於64,此時並不會將鏈表變為紅黑樹,而是選擇進行數組擴容。
Java為數據結構中映射定義了一個接口java.util.Map,接口主要有四個常用的實現類,分別是HashMap、Hashtable、LinkedHashMap和TreeMap,類繼承關系如下圖所示:

對其中的HashMap進行介紹:
它根據鍵的hashCode值進行數據存儲,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但數據的存儲卻是無序的。HashMap最多只允許一條記錄的鍵為null,運行允許多條記錄值為null。HashMap非線程安全,如果需要滿足線程安全,可以用Collections的synchronizedMap方法使HashMap具有線程安全的能力,或者使用ConcurrentHashMap.
其余學習到再做介紹。
參考:https://zhuanlan.zhihu.com/p/21673805
HashMap集合底層的數據結構
JDK1.8之前,數組+鏈表,創建HashMap對象,會創建一個長度為Entry[] table來存儲鍵值對信息。
JDK1.8之后,數組+鏈表+紅黑樹,創建HashMap對象,不是在構造方法中創建了,而是在第一次調用put方法時創建,創建Node[] table用於存儲鍵值對信息。
面試常問:哈希表底層采用何種算法計算哈希值?還有哪些方法可以計算哈希值?
底層采用key的hashCode()的值結合數組長度進行無符號右移(>>>),按位異或(^)計算hash值,按位與(&)計算索引。
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 為第一步 取hashCode值
// h ^ (h >>> 16) 為第二步 高位參與運算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//返回索引值
static int indexFor(int hash, int length) { //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的
return h & (length-1); //第三步 取模運算
}
這里的Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算。
關鍵點介紹:
hashCode():Object類的一個本地方法,用於對象的存儲和查找的快捷性,在HashMap中發揮重要作用。
返回的是對象存儲物理地址的一個映射地址(並不一定等於這個地址),這個地址有什么用呢?
通過這個地址可以定位到它應該存放的物理位置上,如果這個這個位置上沒有元素,則直接插入到此位置;如果位置上有元素,Object中equals()進行判斷是不是用一個對象,如果是則不插入,如果不是,則替換這個位置的值。
hashCode()與String 的equals()之間的關系:
如果x.equals(y)返回“true”,那么x和y的hashCode()必須相等。
如果x.equals(y)返回“false”,那么x和y的hashCode()有可能相等,也有可能不等,例如:
System.out.println("重地".hashCode());//1179395不唯一
System.out.println("通話".hashCode());//1179395
計算公式:s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] //不展開
采用以上的hashCode()的優越性?
例如有四個桶來裝對象,每個桶裝的對象不能重復,A,B,C,D,Person類new出的對象可分別放任何一個桶中,如何判斷放進去對象重復?想通過hashCode定位放的桶,然后equals判斷是否是同一個對象,考慮要不要放進去,這樣比較的就是一個桶中的數據。
倘若沒有這個hashCode則需要一個一個桶去比較,對象進行比較是不是同一個對象,效率極低。所以一般要在重新equals方法的同時也重寫hashCode.
補充:本地方法是什么?
是由其他語言(如C、C++ 或其他匯編語言)編寫,編譯成和處理器相關的代碼。本地方法保存在動態連接庫中,格式是各個平台專用的,運行中的java程序調用本地方法時,虛擬機裝載包含這個本地方法的動態庫,並調用這個方法。
HashMap最為核心的put方法:

方法執行流程:
(1)put方法傳入鍵值
(2)Node<K,V>[] table 是否為空 (JDK1.8),如果為空,則進行resize()擴容
(3)table 不為空,根據hash值+高位右移+異或+取模計算索引值。確定存放的位置。
(4)如果存放的位置為空,則直接插入,++size
(5)如果存放的位置不為空,通過重寫Object的equals的方法進行遍歷鏈表中是否存在相等的key
(6)若存在相等的,則直接覆蓋value值
(7)否則判斷鏈表的閾值是否>8 ,數組長度是否>64(滿不滿足生成紅黑二叉樹),若滿足,則將鍵值對插入紅黑二叉樹中
(8)如果不滿足,則開始遍歷鏈表插入,如果插入后鏈表長度 > 8且table長度 > 64,則轉換稱紅黑樹后插入
(9)倘若仍不滿足紅黑樹,則遍歷鏈表插入,遇到相同的key,覆蓋value插入
源碼解析:
HashMap:
// key --- value
public V put(K key, V value) {
/* 1.hash(key):計算key的hash值 2.key 3.value 4.onlyIfAbsent:當鍵相同時不修改已存在的值 5.true:如果為false,那么數組就處於創建模式中,所以一般為true */
return putVal(hash(key), key, value, false, true);
}
其中 putVal(hash(key), key, value, false, true);
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab數組:存儲鍵值對 p:當前插入的鍵值對
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果數組為空 ,則新建一個數組
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//計算出索引值,並賦值給當前p,並判斷是否沖突,如果不沖突則開辟出一個Node空間存儲鍵值對
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果沖突
else {
Node<K,V> e; K k;
//當前的節點p和要插入的節點key相等,就將p值賦給e,用於后面對節點e進行afterNodeAccess(e);
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果當前節點p是紅黑樹的頭節點,則將節點插入到紅黑樹中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是紅黑樹,則遍歷鏈表。如果沒找到和要插入節點相同的節點,則插入到鏈表的最后面,此時如果鏈表的閾值 >8 且數組的長度 > 64則轉換成紅黑樹,break退出循環
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//鏈表長度大於8轉換為紅黑樹進行處理
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 = e;
}
}
//說明找到了和要插入節點key相等的節點,將value賦值給它,並將e節點進行 afterNodeAccess(e);
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//插入成功后判斷實際存在的鍵值對數量size是否大於閾值threshold,如果大於則進行擴容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
擴容 resize();
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//threshold = 初始容量 * 加載因子。也就是擴容的 門檻。相當於實際使用的容量
int oldThr = threshold;
int newCap, newThr = 0;
//
if (oldCap > 0) {
//如果超過了數組的最大值,則將閾值設置為最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;擴容
return oldTab;
}
//如果沒有超過則設置為原來的兩倍 <<1
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);
}
// 計算新的resize上限
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;
if (oldTab != null) {
// 把每個bucket都移動到新的buckets中
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
HashMap核心get方法:
//注意:返回的是value
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) {
//table[i]首元素則返回first滿足key與待查相等,則返回first
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//遍歷鏈表
if ((e = first.next) != null) {
//如果結構是紅黑樹,則開始遍歷
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//滿足key相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null); //遍歷next結束
}
}
return null;
}
面試可能遇到的問題:
(1)為什么不直接使用hashCode計算hash值,還要經過右移16位且異或的操作?
答:如果一個key經過hashCode()得到
h = 1110 1010 1110 0011 1010 0101 0001 1110
table[]的默認長度是16,進行了h & (n-1) = h&15
1110 1010 1110 0011 1010 0101 0001 1110
&
0000 0000 0000 0000 0000 0000 0000 1111
=
0000 0000 0000 0000 0000 0000 0000 1110
無論高位是什么值,只有1110會被分配在一起(只有低位參與的運算),哈希碰撞的概率將會變得很高。
而如果進行右移16位的異或操作
1110 1010 1110 0011 1010 0101 0001 1110 >>>16 (高位向低位移動10位,高位補0)
=
0000 0000 0000 0000 1110 1010 1110 0011
再進行異或操作(相異為1)
0000 0000 0000 0000 1110 1010 1110 0011
^
1110 1010 1110 0011 1010 0101 0001 1110
=
1110 1010 1110 0011 0100 1111 1111 1101
得到的數據再進行取模運算得到的索引值將大大減少了哈希碰撞的概率。
(2)Hash算法是如何實現的?
答:通過計算key的hashCode值,再將值進行高位右移16位后異或剛剛得到的hashCode值,即hash值。
(3)為什么是線程不安全?
答:因為多個線程同時操作HashMap,並進行put操作如果hash值相同,可能會遇到解決沖突,由於put方法里面沒有加入同步鎖synchronized機制,因此容易造成數據的不一致,類似addEntry()、resize()方法都不是同步的,因此HashMap是線程不安全。
(4)HashMap的數據結構是什么?
在JDK1.8之前HashMap是數組+鏈表的形式,
在JDK1.8包括之后是數組+鏈表+紅黑樹,當鏈表超過8且數組總量超過64才會轉紅黑樹。
(5)HashMap是如何解決hash碰撞的?
答:HashMap采用采用 “拉鏈法” ,將hash值相同的元素放到同一個鏈表下面,還可以采用的方法:平方取中法,偽隨機數法,取余法
(6)HashMap的put方法是怎么實現的?
答:上圖⬆
(7)HashMap的get方法是怎么實現的?
- 將查詢的key傳入進行hash計算得到hash值
- 再通過tab[i = (n - 1) & hash]計算索引值定位到table[i]
- 判斷首元素的key是否和待查的key == ,若是則直接返回節點
- 如果不是則開始遍歷鏈表判斷是否結構是紅黑樹,若是,則進行紅黑樹樹的遍歷
- 若不是,則開始遍歷單鏈表,找到key == 就返回節點
(8)拉鏈法導致的鏈表過深問題為什么不用二叉查找樹代替,而選擇紅黑樹?為什么不一直使用紅黑樹?
答:選擇紅黑樹是為了解決二叉查找樹的缺陷,因為二叉查找樹在特殊的情況下會變成一條線性結構,類似與單鏈表,造成二叉樹出現不平衡現象,遍歷查找的時候會很慢。引入紅黑二叉樹就是因為他是一個自平衡的二叉樹,會自己調整到二叉樹平衡這樣就可以提高遍歷和查找的效率
(8)默認加載因子為什么是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
答:加載因子需要在時間和空間成本上尋求一種折衷。
加載因子: 是表示Hash表中元素的填滿的程度。
加載因子越大,填滿的元素越多,空間利用率越高,但沖突的機會加大了。
反之,加載因子越小,填滿的元素越少,沖突的機會減小,但空間浪費多了。
沖突的機會越大,則查找的成本越高。反之,查找的成本越小。
(9)HashMap桶中鏈表轉紅黑樹為什么選擇數字8?
答:通過閱讀源碼發現:
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are
rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-pow(0.5, k) / factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
簡單的說就是按照泊松分布的計算公式計算出了放入桶中元素個數和概率的對照表,可以看到鏈表中元素個數為8時的概率已經非常小,再多的就更少了,所以原作者在選擇鏈表元素個數時選擇了8,是根據概率統計而選擇的,這樣就相當於在鏈表轉紅黑樹之間取一個適中,也是為了提高效率而設定的。
(10)HashMap的resize()擴容機制?
答:當put進去元素后,table中的元素個數> table*loadFactor(默認加載因子0.75) ,那么數組就開始擴容,例如:table數組的默認大小是16,當put后的數組長度超過12 * 0.75 = 12時,數組開始擴容,擴容大小 = 原來的一倍,然后重新計算每個元素在數組中的位置。
(10)可以使用CocurrentHashMap來代替Hashtable嗎??
