本文參考:https://blog.csdn.net/m0_37914588/article/details/82287191
HashMap
HashMap是基於哈希表實現的,每個元素都是一個鍵值對(key-value)。
HashMap在JDK1.7和JDK1.8中變化比較大,在JDK1.7中,HashMap的底層是採用以個Entry數組來存儲數據,當發生hash衝突時,採用鏈地址法(即鏈表存儲相同hashcode的元素)來解決。而在JDK1.8的HashMap使用一個Node數組來存儲數據,但當存儲元素的鏈表超過閾值之後,會將鏈表轉換爲紅黑樹,查詢時間從O(n)轉換爲O(logn)。
接下來就讓我們進入HashMap的源碼吧(以下源碼爲1.8)!
首先我們來看看HashMap的屬性
//數組的初始化容量-數值必須時2的冪
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量,可以使用一個帶參數的構造函數來隱式的改變容量大小,但必須時2的冪且小於等於1<<30
static final int MAXIMUM_CAPACITY = 1 << 30;
//負載因子初始值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//使用樹(而不是列表)來設置bin計數閾值。當向至少具有這麼多節點的bin添加元素時,bin將轉換爲樹。該值必須大於2,並且應該至少爲8,以便與刪除樹時關於轉換回普通桶的假設相匹配收縮。
static final int TREEIFY_THRESHOLD = 8;
//當桶(bucket)上的結點數小於該值是應當樹轉鏈表
static final int UNTREEIFY_THRESHOLD = 6;
//桶中結構轉化爲紅黑樹時對應的table的最小值
static final int MIN_TREEIFY_CAPACITY = 64;
//tables數組,在必要時會重新調整大小,但長度總是2的冪
transient Node<K,V>[] table;
//保存緩存的entrySet()。注意,使用了AbstractMap字段用於keySet()和values()。
transient Set<Map.Entry<K,V>> entrySet;
//在該map中映射的key-value對數量
transient int size;
//這個HashMap在結構上被修改的次數結構修改是指改變HashMap中映射的數量或修改其內部結構的次數(例如,rehash)。此字段用於使HashMap集合視圖上的迭代器快速失效。(見ConcurrentModificationException)。
transient int modCount;
//要下一次調整大小的臨界值(capacity * load factor)
int threshold;
//哈希表的加載因子
final float loadFactor;
//內部類Node,用於存放元素
static class Node<K,V> implements Map.Entry<K,V> {
final int 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;
}
}
很多人不知道這個負載因子的變量是幹什麼用的,負載因子表示一個散列表的使用程度。有這樣一個公式:initailCapacity*loadFactor=HashMap的容量。可以看出,當自定義容量不變的時候,負載因子越大,散列表在該容量下可以裝填的元素越多,但是由此會導致鏈表更長,索引的效率降低。如果負載因子過小,則會導致散列表中的元素很少,對空間造成浪費。
接下來是HashMap的構造函數。
構造函數 |
---|
HashMap()
構造一個空的 HashMap ,默認初始容量(16)和默認負載係數(0.75)。 |
HashMap(int initialCapacity)
構造一個空的 HashMap具有指定的初始容量和默認負載因子(0.75)。 |
HashMap(int initialCapacity, float loadFactor)
構造一個空的 HashMap具有指定的初始容量和負載因子。 |
HashMap(Map<? extends K,? extends V> m)
構造一個新的 HashMap與指定的相同的映射 Map 。 |
源碼如下
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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);
}
可以看出,HashMap提供了一個修改初始化容量和負載因子的構造方法,這樣我們可以根據不同的使用環境,來決定我們HashMap的負載因子應該爲多少。
接下來看看HashMap一些常用的方法。
返回類型 | 方法名(參數) |
---|---|
void |
clear()
從這張地圖中刪除所有的映射。 |
Object |
clone()
返回此 HashMap實例的淺拷貝:鍵和值本身不被克隆。 |
boolean |
containsKey(Object key)
如果此映射包含指定鍵的映射,則返回 true 。 |
void |
forEach(BiConsumer<? super K,? super V> action)
對此映射中的每個條目執行給定的操作,直到所有條目都被處理或操作引發異常。 |
V |
get(Object key)
返回到指定鍵所映射的值,或 |
boolean |
isEmpty()
如果此地圖不包含鍵值映射,則返回 true 。 |
Set<K> |
keySet()
返回此地圖中包含的鍵的 |
V |
put(K key, V value)
將指定的值與此映射中的指定鍵相關聯。 |
void |
putAll(Map<? extends K,? extends V> m)
將指定地圖的所有映射覆制到此地圖。 |
V |
remove(Object key)
從該地圖中刪除指定鍵的映射(如果存在)。 |
boolean |
remove(Object key, Object value)
僅當指定的密鑰當前映射到指定的值時刪除該條目。 |
V |
replace(K key, V value)
只有當目標映射到某個值時,才能替換指定鍵的條目。 |
void |
replaceAll(BiFunction<? super K,? super V,? extends V> function)
將每個條目的值替換爲對該條目調用給定函數的結果,直到所有條目都被處理或該函數拋出異常。 |
int |
size()
返回此地圖中鍵值映射的數量。 |
我們通過put()和remove()兩個最常用的方法來了解一下HashMap的增刪過程。
在看這兩個方法的源碼之前,我們需要先來看看HashMap是如何計算哈希值的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put()方法
public V put(K key, V value) {
//四個參數,第一個hash值,第四個參數表示如果該key存在值,如果爲null的話,則插入新的value,最後一個參數,在hashMap中沒有用,可以不用管,使用默認的即可*
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab 哈希數組,p 該哈希桶的首節點,n hashMap的長度,i 計算出的數組下標
Node<K,V>[] tab; Node<K,V> p; int n, i;
//獲取長度並進行擴容,使用的是懶加載,table一開始是沒有加載的,等put後纔開始加載
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果計算出的該哈希桶的位置沒有值,則把新插入的key-value放到此處,此處就算沒有插入成功,也就是發生哈希衝突時也會把哈希桶的首節點賦予p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//發生哈希衝突的幾種情況
else {
// e 臨時節點的作用, k 存放該當前節點的key
Node<K,V> e; K k;
//第一種,插入的key-value的hash值,key都與當前節點的相等,e = p,則表示爲首節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第二種,hash值不等於首節點,判斷該p是否屬於紅黑樹的節點
else if (p instanceof TreeNode)
//爲紅黑樹的節點,則在紅黑樹中進行添加,如果該節點已經存在,則返回該節點(不爲null),該值很重要,用來判斷put操作是否成功,如果添加成功返回null
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三種,hash值不等於首節點,不爲紅黑樹的節點,則爲鏈表的節點
else {
//遍歷該鏈表
for (int binCount = 0; ; ++binCount) {
//如果找到尾部,則表明添加的key-value沒有重複,在尾部進行添加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判斷是否要轉換爲紅黑樹結構
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//如果鏈表中有重複的key,e則爲當前重複的節點,結束循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//有重複的key,則用待插入值進行覆蓋,返回舊值。
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//到了此步驟,則表明待插入的key-value是沒有key的重複,因爲插入成功e節點的值爲null
//修改次數+1
++modCount;
//實際長度+1,判斷是否大於臨界值,大於則擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//添加成功
return null;
}
插入過程可以總結爲一下幾個步驟:
- 計算索引
- 檢查是否hash衝突,不發生則直接插入
- 發生hash衝突後,判斷插入方式,是鏈表插入還是紅黑樹插入
- 如果key重複,覆蓋值
- 計算容量,是否進行擴容
HashMap計算索引的方式爲:hash&(length-1)。
HashMap的擴容機制
final Node<K,V>[] resize() {
//把沒插入之前的哈希數組做oldTal
Node<K,V>[] oldTab = table;
//old的長度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//old的臨界值
int oldThr = threshold;
//初始化new的長度和臨界值
int newCap, newThr = 0;
//oldCap > 0也就是說不是首次初始化,因爲hashMap用的是懶加載
if (oldCap > 0) {
//大於最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//臨界值爲整數的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
//標記##,其它情況,擴容兩倍,並且擴容後的長度要小於最大值,old長度也要大於16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//臨界值也擴容爲old的臨界值2倍
newThr = oldThr << 1;
}
//如果oldCap<0,但是已經初始化了,像把元素刪除完之後的情況,那麼它的臨界值肯定還存在,如果是首次初始化,它的臨界值則爲0
else if (oldThr > 0)
newCap = oldThr;
//首次初始化,給與默認的值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
//臨界值等於容量*加載因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//此處的if爲上面標記##的補充,也就是初始化時容量小於默認值16的,此時newThr沒有賦值
if (newThr == 0) {
//new的臨界值
float ft = (float)newCap * loadFactor;
//判斷是否new容量是否大於最大值,臨界值是否大於最大值
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
table = newTab;
//此處自然是把old中的元素,遍歷到new中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//臨時變量
Node<K,V> e;
//當前哈希桶的位置值不爲null,也就是數組下標處有值,因爲有值表示可能會發生衝突
if ((e = oldTab[j]) != null) {
//把已經賦值之後的變量置位null,當然是爲了好回收,釋放內存
oldTab[j] = null;
//如果下標處的節點沒有下一個元素
if (e.next == null)
//把該變量的值存入newCap中,e.hash & (newCap - 1)並不等於j
newTab[e.hash & (newCap - 1)] = e;
//該節點爲紅黑樹結構,也就是存在哈希衝突,該哈希桶中有多個元素
else if (e instanceof TreeNode)
//把此樹進行轉移到newCap中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//此處表示爲鏈表結構,同樣把鏈表轉移到newCap中,就是把鏈表遍歷後,把值轉過去,在置位null
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;
}
}
}
}
}
//返回擴容後的hashMap
return newTab;
}
可能源碼看起來很喫力,但是耐心看下去就會發現其實也沒有那麼難。在這麼長的代碼裏,我們其實需要關注的就是HashMap具體的擴容方式:newThr = oldThr << 1,也就是將原來的容量乘2。那麼爲什麼偏偏是乘2呢?
乘2可以保證擴容後的容量爲偶數,且方便於擴容後重新分桶的操作。上邊講過HashMap計算索引的方式是hash&(length-1)。如果length爲奇數,就會導致length-1轉換爲二進制後末位爲0,從而使得奇數索引沒有用到,浪費了一半的內存空間。
那麼它又是如何方便分桶的呢?假設我們桶的長度爲4,擴容後爲8。那麼我們進行重新分桶時,原先桶0的元素只會分配到新的桶0和桶4,原桶1只會分配到新的桶1和桶5,以此類推。減少了重新分桶的hash衝突,肯定方便重新分桶呀。
擴容的時機也是一個知識點。什麼時候進行擴容呢,HashMap中規定了一個閾值(閾值=容量*負載因子,初始閾值爲16*0.75=12),當HashMap中存儲的元素超過這個閾值時,HashMap就會採取一個擴容操作。
remove()
public V remove(Object key) {
//臨時變量
Node<K,V> e;
//調用removeNode(hash(key), key, null, false, true)進行刪除,第三個value爲null,表示,把key的節點直接都刪除了,不需要用到值,如果設爲值,則還需要去進行查找操作
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
//第一參數爲哈希值,第二個爲key,第三個value,第四個爲是爲true的話,則表示刪除它key對應的value,不刪除key,第四個如果爲false,則表示刪除後,不移動節點
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
//tab 哈希數組,p 數組下標的節點,n 長度,index 當前數組下標
Node<K,V>[] tab; Node<K,V> p; int n, index;
//哈希數組不爲null,且長度大於0,然後獲得到要刪除key的節點所在是數組下標位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//nodee 存儲要刪除的節點,e 臨時變量,k 當前節點的key,v 當前節點的value
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) {
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不再是數組下標的節點,而是要刪除結點的上一個結點**/
p = e;
} while ((e = e.next) != null);
}
}
//找到要刪除的節點後,判斷!matchValue,我們正常的remove刪除,!matchValue都爲true
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;
//此方法在hashMap中是爲了讓子類去實現,主要是對刪除結點後的鏈表關係進行處理
afterNodeRemoval(node);
//返回刪除的節點
return node;
}
}
//返回null則表示沒有該節點,刪除失敗
return null;
}
remove()的過程其實與其他容器的差不多,就是尋址、刪除。在這裏就不多講。
鏈表轉紅黑樹的時機
鏈表轉紅黑樹的時機由兩個變量決定TREEIFY_THRESHOLD(默認爲8)和MIN_TREEIFY_CAPACITY(默認爲64)當HashMap中元素的個數小於MIN_TREEIFY_CAPACITY時,HashMap優先採用擴容的方式,而不是採用樹化。當元素個數超過了6MIN_TREEIFY_CAPACITY的值後,我們通過TREEIFY_THRESHOLD來決定是否樹化,當一個鏈表的長度超過TREEIFY_THRESHOLD後,就會將鏈表轉化爲紅黑樹。
HashMap的特點
- HashMap的key不可重複,key和value允許爲null
- HashMap是非線程安全的,只能在單線程的環境下使用,多線程環境需要外部加鎖