簡介
HashMap在1.8之後通過數組(table)屬性使用單向鏈表 + 紅黑樹的結構組合提高查找效率,於是我大致的畫了下圖:
後來寫着寫着發現我還是太年輕了,有什麼比親手實踐更值得讓人信服呢?
類圖分析(只標註主要屬性方法)
Map<K,V>
:鍵值映射的基礎接口,提供常用的鍵值映射操作方法的抽象Map.Entry<K,V>
:鍵值對條目(單個鍵值)抽象接口AbstractMap<K,V>
: 簡單實現了Map
接口的部分方法HashMap<K,V>
: 基於Map
接口的哈希實現HashMap.Node<K,V>
:HashMap
鍵值對條目鏈表結構的實現類HashMap.TreeNode<K,V>
:HashMap
鍵值對條目紅黑樹結構的實現類LinkedHashMap<K,V>
:基於Map
接口的哈希表和鏈表結構實現,與HashMap
的主要區別在於鍵值對都是有序的LinkedHashMap.Entry<K,V>
:LinkedHashMap
鍵值條目鏈表結構的實現類
屬性解析
DEFAULT_INITIAL_CAPACITY
:默認16,默認初始容量,必須爲2的冪值MAXIMUM_CAPACITY
:默認1<<30(即2^29),最大容量值,如果有參構造函數容量值比該值高,則使用該值作爲容量值DEFAULT_LOAD_FACTOR
:默認0.75f,構造函數中未指定時使用的負載因子TREEIFY_THRESHOLD
:默認8,容器樹化閾值,達到閾值(8)後容器將使用紅黑樹結果存儲數據UNTREEIFY_THRESHOLD
:默認6,非樹化閾值,應小於TREEIFY_THRESHOLD
。MIN_TREEIFY_CAPACITY
:默認64,表中節點鏈表結構被樹化的最小表容量值,實際會根據容器大小判斷是隻進行擴容還是進行樹化。loadFactor
:哈希表的負載因子threshold
:下一次調整大小的容量閾值(capacity * load factor)modCount
:對該HashMap進行結構修改的次數。結構修改是指更改HashMap中的映射數目或以其他方式修改其內部結構的修改(如重新哈希)。size
:包含的鍵-值映射數table
:該表(節點Node數組)在首次使用時初始化,並根據需要調整大小,分配後的長度始終是2的冪。entrySet
:用於獲取key-value映射集合Set<Map.Entry<K,V>>
,在首次調用entrySet()
方法時被初始化
文章使用的術語摻雜了一些個人根據源碼與文檔的理解,需瞭解注意的如下:
- 表(table數組) = 容器
- table.length = 容量 != 映射數目
- table中的節點Node元素 = 桶bin
- table中的鍵值對節點Node以外的元素都以null填充
方法解析,窺遍HashMap
HashMap
的三個構造函數
- 無參構造函數
/**
* 使用默認的初始容量(16)和默認的加載因子(0.75)構造一個空的HashMap。
* 注:該方法沒有初始化閾值threshold
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 帶初始容量的構造函數
public HashMap(int initialCapacity) {
// 初始化容量initialCapacity、負載因子loadFactor=DEFAULT_LOAD_FACTOR(0.75)、閾值threshold
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 帶初始容量與f負載因子的構造函數
/**
* 以特定的容量與負載係數構建一個空的HashMap
*
* @param initialCapacity 初始容量
* @param loadFactor 負載因子
* @throws IllegalArgumentException initialCapacity爲負數、負載因子爲負數或非數字時拋錯
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY; // MAXIMUM_CAPACITY = 1 << 30
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 該方法主要用於設置閾值threshold,返回大於或等於參數容量cap的的2次冪,如cap=9、11、12、15、16時都會返回16,cap=5、6、7、8時返回8
* @param cap 容量參數
* @return 返回大於或等於參數容量cap的的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;
}
put(K, V)添加鍵值
由HashMap
的三個構造函數可以看出構造HashMap
時主要初始化了負載因子loadFactor、table擴容閾值threshold,若是無參構造函數則只初始化閾值,所以一般table的初始化都是在put第一個鍵值對時初始化的。
/**
* 獲取鍵的哈希值
*
* @param key
* @return key的哈希值
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 創建鏈表節點並設置該節點指向的下一節點
*
* @param hash 鍵哈希值
* @param key 鍵
* @param value 值
* @param next 新建節點指向的下一節點
* @return 鏈表節點
*/
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
/**
* 實現Map.put和相關的方法
*
* @param hash key的哈希值
* @param key
* @param value
* @param onlyIfAbsent true則不替換已存在的key值
* @param evict 標誌表是否處於創建模式
* @return 返回key之前的值,之前值不存在則返回true
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// 引用屬性table的臨時變量
Node<K,V>[] tab;
// table上的節點引用(p = pointer = 鏈表指針)
Node<K,V> p;
// n用於記錄Node<K,V>[] table長度的臨時變量,i爲key哈希與table.length相與後的table索引index
int n, i;
// 1. 若table爲空則初始化鍵值對數組table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2
// 2.1 判斷key哈希值與長度相與運算獲取相應的數組索引值i,判斷table數組i節點是否空,空則直接在該索引放置新節點,跳到`3`;不爲空則該位置上的節點將會形成桶(鏈表|紅黑樹結構)鏈接多個節點
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 2.2 table[i]上的元素節點p不爲空
else {
// e可看作遍歷table的結果節點(e = end),k用於引用p節點的key值
Node<K,V> e; K k;
// 2.2.1 判斷節點p與參數的key是否相同,相同即只需執行替換value,e不爲null,跳到`2.2`
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 2.2.2 參數key與table[i]上的key不匹配,若table[i]的節點爲TreeNode,則以TreeNode結構存放新節點,e爲null,跳到`2`
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 2.2.3 參數key與table[i]上的key不匹配,且該節點並非TreeNode,則以鏈式節點存儲
else {
// binCount:桶中的元素數目,即Node<K,V>[] table中的Node節點元素內的key-value鍵值對數目
for (int binCount = 0; ; ++binCount) {
// p下一節點e爲空節點,則新建節點賦到當前節點的next
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 根據桶中節點數目與容量大小判斷是進行擴容還是進行樹化,若桶中已有的節點數>=8且容量>=64則進行樹化;e爲null,跳到`3`
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// p下一節點e不爲空
// p下一節點e的key與參數key相同,e不爲null,跳到`2.2`
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// p下一節點e的hash或key屬性與新增的key不同,則把下一節點賦給p繼續進行鏈表遍歷,直到遇到空的節點或hash、key相同的節點
p = e;
}
}
// 2.2.4 e不爲空,即Node[] table中已含鍵爲參數key的節點,則替換key的舊值
if (e != null) { // existing mapping for key
// 獲取參數key對應節點的舊映射值
V oldValue = e.value;
// 設置key新的映射值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // HashMap中爲空實現,主要應用在LinkedHashMap中
return oldValue;
}
}
// 3. 節點新增成功後的容器相關屬性更改
// e爲空,則有新的鍵值對節點添加,結構發生改變,結構修改次數自增
++modCount;
// 鍵值對數目自增,並判斷自增後若大於擴容閾值,則調整容量大小
if (++size > threshold)
resize();
afterNodeInsertion(evict); // HashMap中爲空實現,主要應用在LinkedHashMap中
// 不存在舊節點,返回空
return null;
}
/**
* 若表太小,則resize調整表大小;否則將替換bin(桶)中給定哈希值的索引中所有鏈接的節點Node爲TreeNode。
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// tab爲空或tab數組長度 < MIN_TREEIFY_CAPACITY(64),即元素數量不多,只重新調整tab長度(容量),節點維持鏈表結構而無需更改爲紅黑樹結構
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 判斷參數hash值在tab數組中相應位置的節點是否不爲空
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
// 將e與e鏈表上鍊接的所有Node節點轉換爲TreeNode節點
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
// 將table轉爲紅黑樹結構
hd.treeify(tab);
}
}
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
綜上,HashMap添加鍵值對的流程如下:
- 判斷table是否爲空,空則調用resize()初始化table數組,一般發生在初次添加鍵值對
- 參數key哈希值hash與數組table長度相與獲取到索引值i,根據table[i]節點是否爲空執行不同的操作
- 2.1 table[i]節點p爲空:在table[i]創建新節點,跳到步驟3
- 2.2 table[i]節點p不爲空,根據以下不同的情況執行相應的操作
- 2.2.1 節點p的key、hash與參數的key、hash匹配:即最終操作只需替換原有key的值,將最終節點e設爲p,跳到2.2.4
- 2.2.2 節點p是一個紅黑樹節點(TreeNode)類型:根據參數創建新的紅黑樹節點TreeNode,根據紅黑樹規則把新節點插入到p節點所在的紅黑樹上
- 2.2.3 其它情況(即鏈表結構,頭節點key與參數key不同):創建新節點並鏈接到鏈表末尾,若鏈表節點數大於8個,則調用treeifyBin()根據table.length長度進行不同的操作
- table.length >= 64,將所有的Node鏈表節點轉爲TreeNode紅黑樹節點
- table.length < 64,調用resize()將table.length左移一位(原lengh乘2),即使鏈表節點數>8依舊保持鏈表結構
- 2.2.4 設置e節點key新值,返回key舊值
- 節點新增成功後的HashMap相關操作屬性更新
實力(例)驗證
爲了簡單直接的顯示
HashMap
結構,這個博主直接的把HashMap
實例裏的核心屬性都print出來了。
- table容量<64時,key hashCode相同的節點數>8依舊會保持鏈表結構
public static void stringKey() throws NoSuchFieldException, IllegalAccessException {
HashMap<String, Integer> map = new HashMap<>(32);
// 9個hashKey相同的字符串集合,map容量<64時會在添加第9個"20kf"進行擴容而不會樹化,即32則左移一位爲64,集合刪除"20kf"則不會擴容仍爲32
Set<String> sameHashKeys = Sets.newHashSet("30lG", "31MG", "31Lf", "30kf", "1nlG", "2PLf", "1oLf", "2OlG", "2Okf");
Field thresholdField = HashMap.class.getDeclaredField("threshold");
Field tableField = HashMap.class.getDeclaredField("table");
thresholdField.setAccessible(true);
tableField.setAccessible(true);
Random random = new Random();
IntStream.range(0, 10).forEach(i -> map.put(String.valueOf(i), i));
sameHashKeys.forEach(key -> map.put(key, random.nextInt(25)));
Set<Map.Entry<String, Integer>> sets = map.entrySet();
Map.Entry<String, Integer>[] table = (Map.Entry<String, Integer>[]) tableField.get(map);
long notNullCount = Arrays.stream(table)
.filter(Objects::nonNull)
.count();
Set<Class> nodeClasses = Arrays.stream(table)
.filter(Objects::nonNull)
.map(Object::getClass)
.collect(Collectors.toSet());
// 輸出格式化
String tableString = JSONObject.toJSONString(table)
.replaceAll(",null", "")
.replaceAll("null,", "")
.replaceAll("\\{|}|\"", "")
.replaceAll(":", "=")
.replaceAll(",", ", ");
System.out.println("map.size():" + map.size() + ", table.length: " + table.length
+ ", table node count: " + notNullCount + ", entrySet size: " + sets.size());
System.err.println("node classes: " + nodeClasses);
map.remove("30lG");
System.out.println("table: " + JSONObject.toJSONString(table));
System.out.println("evict null table: " + tableString);
System.out.println("entrySet: " + sets);
}
控制檯輸出:
map.size():19, table.length: 64, table node count: 11, entrySet size: 19
node classes: [class java.util.HashMap$Node]
table: [null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,{"31MG":22},{"0":0},{"1":1},{"2":2},{"3":3},{"4":4},{"5":5},{"6":6},{"7":7},{"8":8},{"9":9},null,null,null,null,null,null]
evict null table: [30lG=13, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
entrySet: [31MG=22, 31Lf=1, 30kf=14, 1nlG=7, 2PLf=13, 1oLf=13, 2OlG=21, 2Okf=16, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
- table容量>=64時,key hashCode相同的節點數>8後HashMap會樹化
HashMap<String, Integer> map = new HashMap<>(33); // 有參容量自動調整爲64
控制檯輸出:
map.size():19, table.length: 64, table node count: 11, entrySet size: 19
node classes: [class java.util.HashMap$TreeNode, class java.util.HashMap$Node]
table: [null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,{"30kf":19},{"0":0},{"1":1},{"2":2},{"3":3},{"4":4},{"5":5},{"6":6},{"7":7},{"8":8},{"9":9},null,null,null,null,null,null]
evict null table: [30kf=19, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
entrySet: [30kf=19, 31MG=8, 31Lf=16, 1nlG=3, 2PLf=19, 1oLf=20, 2OlG=12, 2Okf=15, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
可以看到"30kf"是該紅黑樹上的root。
那麼問題來了,添加映射會調整結構(即更改節點Node類型),那麼刪除呢?這個博主有點懶,所以不講源碼給答案:
HashMap
刪除節點的過程十分簡單,獲取節點類型->根據節點類型進行相應的刪除操作,若節點是樹節點結構,則判斷該樹的節點數是否較少(源碼文檔標註一般爲2~6),較少則把樹節點TreeNode轉換爲普通節點Node,如上例中刪除最後4個hashCode相同的節點後(即只剩下5個Node)再打印會發現沒有TreeNode了。
調用鏈:hashmap.remove() -> hashmap.removeNode() -> treeNode.removeTreeNode() -> treeNode.untreeify(map)
reszie()調整table容量
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// table未初始化則設舊容量爲0,構造函數並沒有初始化table,即首次put添加元素時oldCap都爲0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// oldCap > 0意味着並未首次添加元素時調用方法,而是鍵值對數量>閾值(即size>threshold)時需要擴容調用該方法,可能發生在上文put(K,V)步驟3
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
}
// oldCap=0 & oldThr>0,該條件可能發生在調用了有參構造函數初始化threshold、loadFactor後通過put首次添加鍵值對,並把此時的threshold賦給newCap(有參構造函數處提及有參初始化後threshold是2的冪值)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// oldCap=0 & oldThr=0,該條件發生在調用了`HashMap`的無參構造函數情況下,容量與閾值皆設爲默認值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY; // default cap 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // default threshold 12
}
if (newThr == 0) {
// float threshold,計算後的浮點型閾值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 設置調整後的閾值
threshold = newThr;
// 創建新的數組進行鍵值對遷移,所以建議預估好鍵值對數量調用有參構造函數初始化`HashMap`
@SuppressWarnings({"rawtypes","unchecked"})
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;
// Node單節點遷移到新的數組
if (e.next == null)
// 重新哈希
newTab[e.hash & (newCap - 1)] = e;
// TreeNode拆分爲較高樹與較低樹,若拆分後的樹桶節點數< UNTREEIFY_THRESHOLD = 6,則取消樹化
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 類似樹節點,鏈表拆分爲較高鏈與較低鏈
// loHead = Low Head, Lo Tail = Low Tail
Node<K,V> loHead = null, loTail = null;
// hiHead = High Head, hiTail = High Tail
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;
}
綜上,HashMap.put(K,V)
調用resize()調整大小主要分以下幾種情況:
- 擴容:
oldCap = table.length > 0
,resize()結果:table.length = oldCap * 2;
threshold = table.length * loadFactor
- 有參函數初始化:
oldCap = table.length = 0 & oldThr = threshold > 0
,resize()結果:table.length = threshold;
,此處threshold值爲跟有參構造函數運算後的threshold值,運算後的threshold值是比構造函數參數threshold大的最小一個2的冪值threshold = table.length * loadFactor
- 無參函數初始化:
oldCap = table.length = 0 & oldThr = threshold = 0
table.length = 16;
threshold = 12
容量調整後會再根據table中的節點結構進行相應的操作:
- 單節點Node無鏈接:重新哈希
- 樹節點TreeNode:將樹箱中的節點拆分爲較高和較低的樹箱,如果拆分後的樹箱容量<6,則取消樹化;拆分後較低的樹箱放在新table[舊table原索引],較高的樹箱遷移到新table[原索引+原table.length],詳細可看
TreeNode.split()
源碼 - 鏈表結構Node:拆分規則遷移與TreeNode類似,低位鏈表head node放在新table[原索引],高位鏈表遷移到新table[原索引+原table.length]
table.length=newCap
和threshold
調整後會創建新的節點數組進行鍵值對遷移,所以一般建議初始化時配置好容量避免擴容時的遷移損失。
clear()清空映射
clear()只是簡單的把table數組的所有元素置爲null
public void clear() {
Node<K,V>[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
疑問record & 總結
-
entrySet()
方法返回的明明是一個new EntrySet()
,爲什麼卻打印出了完整的HashMap
映射?
EntrySet
繼承了AbstractSet
類並實現了iterator()
方法,AbstractSet
類toString()
方法會調用iterator()
方法獲取迭代器,再通過Iterator.next()
進行元素的字符串拼接。EntrySet。iterator()
返回一個實現了Iterator
接口的類EntryIterator
,通過EntryIterator.next()
可以遍歷獲取HasMap
中的所有Node
,所以即使HashMap
的entrySet屬性沒有初始化但通過entrySet()
方法依舊可以獲取HashMap
的完整映射。 -
hashCode相同的key映射數超過8個並不一定就會轉爲紅黑樹結構
HashMap
當同hashCode的節點Node超過8個且table數組容量>=64纔會轉爲紅黑樹結構,否則容量<64時只會進行擴容保存鏈表結構,Result.png:
-
resize()進行容量調整並不一定會使每個節點進行重新哈希(rehash),重新哈希只會出現在無鏈接的單節點上
如果看完這篇文章有人問你HashMap
的相關結構知識但還是忘了,建議你這樣回答:
(我一個星期前寫的代碼都不一定記得,你問我這個?)我想起早上的麪包還沒吃,先告辭!
如有筆誤,還請指出