HashMap簡介
HashMap是實現map接口的一個重要實現類,在我們無論是日常還是面試,以及工作中都是一個經常用到角色。它的結構如下:
它的底層是用我們的哈希表和紅黑樹組成的。所以我們在學習HashMap底層原理的時候,需要有這兩種數據結構的知識做鋪墊,才能有更好的理解!
哈希表
散列表是由我們的數組和鏈表組成的,集成了兩種數據結構的優點,我們先簡單介紹一下這兩種數據結構。
數組:數組存儲區間是連續的,占用內存嚴重,故空間復雜度很大,但數組的二分查找時間復雜度很小,為 o(1),數組的特點:查找速度快、插入和刪除效率低
鏈表:鏈表存儲區間離散,占用內存比較寬松,故空間復雜度很小,但時間復雜度很大,為 o(n),鏈表的特點:查找速度慢、插入和刪除效率高
哈希表:哈希表為每個對象計算出一個整數,稱為哈希碼。根據這些計算出來的整數(哈希碼)保存在對應的位置上!如果遇到了哈希沖突,也就是同一個坑遇到了被占用的情況下,那么我們就會以鏈表的形式添加在后面。
紅黑樹
關於紅黑樹的知識點比較多,如果過多介紹紅黑樹的話,那么HashMap就不好介紹了。這里給上一個連接,一篇關於紅黑樹非常好的文章。點擊這里
源碼解析
好了,開始解析我們的源碼,通過解析源碼更好的了解HashMap后,對那么常見的面試題也可以更加的吃透!
基本屬性
首先就是介紹我們的HashMap的基本屬性,對基本屬性介紹完之后,對后面方法里使用時才不會迷惑
1、我們的默認的初始化的hashmap的容量,如果沒有指定的話,就是我們的默認,1<<4就是16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
2、我們的hashmap最大容量,2的30次方。
static final int MAXIMUM_CAPACITY = 1 << 30;
3、默認的裝載因子,0.75。有什么用呢?比如我們的容量現在是16,16*0.75=12,也就是說,當我們的實際容量到了12的時候,那么就會觸發擴容機制,進行擴容!
static final float DEFAULT_LOAD_FACTOR = 0.75f;
4、我們知道哈希表是由數組和鏈表組成的,每一個位置都可以說是一個哈希桶。我們的哈希桶默認是鏈表,但是在JDK1.8之后我們的哈希桶中當有TREEIFY_THRESHOLD個節點的時候,也就是下面默認的8,我們桶中的鏈表會被轉換為紅黑樹的結構。
static final int TREEIFY_THRESHOLD = 8;
5、與上面相同,不過不同的是,會將紅黑樹轉換成鏈表。
static final int UNTREEIFY_THRESHOLD = 6;
6、當哈希桶的結構轉換成樹之前,還會有一次判斷,只有鍵值對大於64才會轉換!也就是我們下面定義的最小容量,這是為了避免哈希表建立初期多個鍵值對恰巧都在一個哈希桶上面,而導致了沒必要的轉換。
static final int MIN_TREEIFY_CAPACITY = 64;
7、內部結構靜態內部類
8、其他成員變量
思考
這里同時引發了我們一些思考?為什么要將轉換成樹形結構的閾值設置為8呢?為什么不將轉換成鏈表結構的閾值也設置為8呢?這里我們在最后面試題分析的時候統一進行回答!
構造方法
hashmap的構造方法有四個,不過我們重點介紹其中的一個,因為這一個理解了,其他的也不成問題。
//initialCapacity:初始大小
//loadFactor:裝載因子
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);
}
總結了構造方法進行的操作:
- 首先是邊界處理,如果初始大小小於0,拋異常。如果大於最大,那也只能賦予我們默認的最大值!如果裝載因子小於0或者不是數字的話,拋異常!
- 然后就是進行我們的賦值,裝載因子賦值,還有就是調用我們的
tableSizeFor
來返回一個大於等於initialCapacity的2次冪。
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;
}
關於為什么做了位運算后可以返回大於等於它的二次冪,可以看一下這篇博文!點擊跳轉
這里的threshold也就是我們的閾值,當達到了這個閾值的時候我們會進行擴容!但是這里可能也會覺得疑惑,閾值不是容量*裝載因子嗎?不應該寫成下面這樣子嗎?
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
注意,在構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會用到resize()方法,然后對threshold重新計算。后面我們對方法分析時會談到。
核心方法
關於hashmap和核心方法和考點,其實都集中在put方法和resize()方法,這也會是我們下面重點要介紹到的。
put方法
我們首先來看put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
調用了我們的putval方法,參入了一個以key計算的哈希值,key,value,還有兩個其他參數。在看putVal方法之前先來看一下hash方法,看看它是如何計算哈希值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這是一個三目運算符,如果key不為null的話,返回我們的key的哈希值(低十六位)同時與高16位的異或運算。這一步的操作意義何為呢?我們先臨時跳到putVal方法里面可以看到有這么一步操作
它將我們計算出來的哈希值,與我們的哈希表長度-1(為了獲得)進行&
運算,這是為了獲取我的table下標。至於為什么-1呢?因為我們的長度都是2的整數次冪,轉換成2進制也就是1000000....這種的形式,為了更好的隨機,所有我們進行了-1操作,也就是變成11111111這種。因為&
操作是都為1的時候才會為1,所以我的的1多的時候隨機性才會更大,畢竟一個1能干過那么多的1嗎?這是減少哈希沖突的第一步操作。舉個例子說明一下:
比如我們的長度轉換為2進制為 1000 0000 ,進行-1操作后就是 0111 1111
而這個時候我們原來的二進制數
1000 0000
&
0101 1011 = 0000 0000
與任何最高位不為1的數進行&運算,都會變成0,也就讓我們的哈希沖突變大了!
而我們-1操作后
0111 1111
&
0101 1011 = 0101 1011
可以看出來,這樣比原來的減少了很多的哈希沖突。
同時這也是為什么我們要讓哈希的容量大小一定要為2的整數次冪
好了,我們要回答一下再上面那個問題了,為什么要返回低16位與高16位的異或作為key的最終hash值呢?同樣舉個例子演示一下這個流程:
假設length為8,HashMap的默認初始容量為16;
length = 8 ,(length-1) = 7 , 轉換二進制為111;
假設一個key的 hashcode = 78897121 ,轉換二進制:100101100111101111111100001,與(length-1)& 運算如下
0000 0100 1011 0011 1101 1111 1110 0001
&運算
0000 0000 0000 0000 0000 0000 0000 0111
= 0000 0000 0000 0000 0000 0000 0000 0001 (就是十進制1,所以下標為1)
上述運算實質是:001 與 111 & 運算。也就是哈希值的低三位與length與運算。如果讓哈希值的低三位更加隨機,那么&結果就更加隨機,就更能減少我們的哈希沖突了。如何讓哈希值的低三位更加隨機,那么就是讓其與高位異或,所以我們才在返回的時候與高位異或了再返回。低位與高位異或的過程舉個例子如下:
然后總結一下在與我們與哈希值進行運算的時候有這么一個規律:
-
當length=8時 下標運算結果取決於哈希值的低三位
-
當length=16時 下標運算結果取決於哈希值的低四位
-
當length=32時 下標運算結果取決於哈希值的低五位
-
當length=2的N次方, 下標運算結果取決於哈希值的低N位。
好了,我們繼續回到我們的putVal方法。下面我直接在注釋里面進行分析
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為空的時候調用resize()進行擴容初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 沒有發生碰撞,初始化我們的第一個節點
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//發生碰撞
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//hashcode和key相等,記錄下原先的值
e = p;
//如果這個時候我們的哈希桶已經是紅黑樹結構,那么調用樹的插入函數
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//鏈表結構,同時我們的hashcode不相等
//找到與key相等的節點,更新value,退出循環
//如果沒有找到與key相等的節點,在鏈表尾部插入,如果插入后節點數量大於
//我們變成紅黑樹的閾值,那么進行轉換成紅黑樹
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//達到臨界值轉換成紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//新值覆蓋舊值,返回舊值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//空實現,為LinkedHashMap預留
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//鍵值對達到閾值,進行擴容
if (++size > threshold)
resize();
//空實現,為LinkedHashMap預留
afterNodeInsertion(evict);
return null;
}
resize()方法
我們在上面不管是源碼分析還是在哪分析,都說到了我們的resize()方法,下面我們將正式開始講到
final Node<K,V>[] resize() {
//原table數組賦值
Node<K,V>[] oldTab = table;
//如果原數組為null,那么原數組長度為0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//賦值閾值
int oldThr = threshold;
//newCap 新數組長度
//newThr 下次擴容的閾值
int newCap, newThr = 0;
// 1. 如果原數組長度大於0
if (oldCap > 0) {
//如果大於最大長度1 << 30 = 1073741824,那么閾值賦值為Integer.MAX_VALUE后直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 2. 如果原數組長度的2倍小於最大長度,並且原數組長度大於默認長度16,那么新閾值為原閾值的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 3. 如果原數組長度等於0,但原閾值大於0,那么新的數組長度賦值為原閾值大小
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 4. 如果原數組長度為0,閾值為0,那么新數組長度,新閾值都初始化為默認值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 5.如果新的閾值等於0
if (newThr == 0) {
//計算臨時閾值
float ft = (float)newCap * loadFactor;
//新數組長度小於最大長度,臨時閾值也小於最大長度,新閾值為臨時閾值,否則是Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//計算出來的新閾值賦值給對象的閾值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//用新計算的數組長度新建一個Node數組,並賦值給對象的table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//后面是copy數組和鏈表數據邏輯
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
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;
}
這個時候我們以最初的三種構造方法來模擬一下流程。上面每一個擴容情況都標注了記號
//①
Map<String, String> map = new HashMap<>();
map.put("1", "1");
//②
Map<String, String> map1 = new HashMap<>(2);
map1.put("2", "2");
//③
Map<String, String> map2 = new HashMap<>(2, 0.5f);
map2.put("3", "3");
- ① 沒有設置initialCapacity,也沒有設置負載因子,第一次put的時候會觸發擴容。第一次的時候,數組長度為默認值16,閾值為160.75=12,走的
代碼4
邏輯,等到數組長度超過閾值12后,觸發第二次擴容,此時table數組,和threshold都不為0,即oldTab、oldCap、oldThr都不為0,先走代碼1
,如果oldCap長度的2倍沒有超過最大容量,並且oldCap 長度大於等於 默認容量16,那么下次擴容的閾值 變為oldThr大小的兩倍即 12 2 = 24,newThr = 24,newCap=32 - ② 設置了initialCapacity,沒有設置負載因子,此時hashMap使用默認負載因子0.75,本實例設置的初始容量為2,通過計算閾值為2,第一次put的時候由於還沒初始化table數組,因此觸發第一次擴容。此時oldCap為0,oldThr為2,走
代碼3
,確定這次擴容的新數組大小為2,此時還沒有確定newThr 下次擴容的大小,於是進入代碼5
確定newThr為 2 0.75 = 1.5 取整 1 ,及下次擴容閾值為1。當數組已有元素大於閾值及1時,觸發第二次擴容,此時oldCap為1,oldThr為1,走代碼1
newCap = oldCap << 1 結果為 4 小於最大容量, 但oldCap 小於hashMap默認大小16,結果為false,跳出判斷,此時由於newThr等於0,進入代碼5
,確定newThr為 4 0.75 = 3,下次擴容閾值為3 - ③ 設置了initialCapacity=2,負載因子為0.5,通過tableSizeFor計算閾值為2,第一次put的時候,進行擴容,此時oldCap為2,oldThr為2,進入
代碼1
,同實例②,newCap = oldCap << 1 結果為 4 小於最大容量, 但oldCap 小於hashMap默認大小16,結果為false,跳出判斷,進入代碼5
,確定newThr為 4 * 0.5 = 2,下次擴容閾值為2
get()方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
獲取了我們的key的hashcode然后作為參數傳入getNode方法中!
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) {
//如果這個時候我們的哈希桶已經是樹形結構了,調用樹形查找
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);
}
}
//如果沒有找到的話,返回null
return null;
}
remove方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
首先是計算出我們的hash,然后調用removeNode方法來移除
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;
//剛好我們的哈希桶首位就是要刪除的,記錄下來
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//如果不是,進行遍歷查找
else if ((e = p.next) != null) {
//如果是紅黑樹結構的話,調用樹的查找方法
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//對鏈表進行查找key
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;
}
總結
-
擴容是一個特別耗性能的操作,所以當我們在使用 HashMap,正確估算 map 的大小,初始化的時候給一個大致的數值,避免 map 進行頻繁的擴容。
-
負載因子 loadFactor 是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況特殊。
-
HashMap 是非線程安全的,不要在並發的情況下使用 HashMap,建議使用 ConcurrentHashMap!
面試題分析
關於HashMap的源碼就分析這些,因為這些足夠我們去了解它的一些基本特性和常見面試足夠用了。下面我收集了一些面試題和我們上面的留下的思考題進行分析!
1、為什么要將轉換成樹形結構的閾值設置為8呢?為什么不將轉換成鏈表結構的閾值也設置為8呢?
-
當初始閾值為8時,鏈表的長度達到8的概率變的很小,如果再大概率減小的並不明顯
-
樹結構查找的時間復雜度是O(log(n)),而鏈表的時間復雜度是O(n),當閾值為8時,long8 = 3,相比鏈表更快,但樹結構比鏈表占用的空間更多,所以這是一種時間和空間的平衡
至於為什么不將轉換鏈表的閾值也設置為8,是因為如果兩個值太接近的話,就會造成頻繁的轉換,導致我們的時間復雜度變高。而在6是經過計算后最合適的數值
2、HashMap 為什么不用平衡樹,而用紅黑樹?
這一題應該歸類與數據結構了,不過這里同樣給出分析
-
紅黑樹也是一種平衡樹,但不是嚴格平衡,平衡樹是左右子樹高度差不超過1,紅黑樹可以是2倍
-
紅黑樹在插入、刪除的時候旋轉的概率比平衡樹低很多,效率比平衡樹高
查找時間復雜度都維持在O(logN),具體的還望查看紅黑樹的特性,上面最開始也給了一篇關於紅黑樹的介紹。
3、HashMap在並發下會產生什么問題?有什么替代方案?
HashMap並發下產生問題:由於在發生hash沖突,插入鏈表的時候,多線程會造成環鏈,再get的時候變成死循環,Map.size()不准確,數據丟失。
關於為什么會造成環鏈的話,可以看這里!
替代方案:
- HashTable: 通過synchronized來修飾,效率低,多線程put的時候,只能有一個線程成功,其他線程都處於阻塞狀態
- ConcurrentHashMap:
1.7 采用鎖分段技術提高並發訪問率
1.8 數據依舊是分段存儲,但鎖采用了synchronized
4、HashMap中的key可以是任何對象或數據類型嗎?
-
可以是null,但不能是可變對象,如果是可變對象,對象中的屬性改變,則對象的HashCode也相應改變,導致下次無法查找到已存在Map中的數據
-
如果要可變對象當着鍵,必須保證其HashCode在成員屬性改變的時候保持不變
5、為什么不直接將key作為哈希值而是與高16位做異或運算?
這個我們在上面說過了,還用圖和樣例解釋,是為了更好的隨機性,解決哈希碰撞。
6、關於更多的面試題
這里提供了一篇關於面試題挺多的博文,通過閱讀源碼,里面大部分的面試題都可以解答了!
HashMap與HashTable有什么不同?
因為HashTable和HashMap很是類似,就跟我們的Vector與ArrayList的關系一樣。提供了線程安全的解決方案,所有我們在這里通過區別,就相當與對HashTable進行了源碼分析!
從存儲結構和實現來講基本上都是相同的。
它和HashMap的最大的不同是它是線程安全的,另外它不允許key和value為null。
Hashtable是個過時的集合類,不建議在新代碼中使用,不需要線程安全的場合可以用HashMap替換,需要線程安全的場合可以用ConcurrentHashMap替換或者Collections的synchronizedMap方法使HashMap具有線程安全的能力。
不同點 | HashMap | HashTable |
---|---|---|
數據結構 | 數組+鏈表+紅黑樹 | 數組+鏈表 |
繼承的類不同 | 繼承AbstractMap | 繼承Dictionary |
是否線程安全 | 否 | 是 |
性能高低 | 高 | 低 |
默認初始化容量 | 16 | 11 |
擴容方式不同 | 原始容量*2 | 原始容量*2+1 |
底層數組的容量為2的整數次冪 | 要求為2的整數次冪 | 不要求 |
確認key在數組中的索引的方法不同 | i = (n - 1) & hash; | index = (hash & 0x7FFFFFFF) % tab.length; |
遍歷方式 | Iterator(迭代器) | Iterator(迭代器)和Enumeration(枚舉器) |
Iterator遍歷數組順序 | 索引從小到大 | 索引從大到小 |
參考資料
公眾號《Java3y》文章
知乎專欄《Java那些事兒》