入門JDK集合之HashMap解析
0.前言
HashMap簡述:
- HashMap 基于哈希表的 Map 接口實現(xiàn),是以 key-value 存儲形式存在,即主要用來存放鍵值對。HashMap 的實現(xiàn)不是同步的,這意味著它不是線程安全的。它的 key、value 都可以為 null,此外,HashMap 中的映射不是有序的。
- jdk1.8 之前 HashMap 由 數(shù)組 + 鏈表 組成,數(shù)組是 HashMap 的主體,鏈表則是主要為了解決哈希沖突(兩個對象調(diào)用的 hashCode 方法計算的哈希值經(jīng)哈希函數(shù)算出來的地址被別的元素占用)而存在的(“拉鏈法”解決沖突)。jdk1.8 以后在解決哈希沖突時有了較大的變化,當鏈表長度大于閾值(或者紅黑樹的邊界值,默認為 8 )并且當前數(shù)組的長度大于 64 時,此時此索引位置上的所有數(shù)據(jù)改為使用紅黑樹存儲。
HashMap擴容機制簡述:
HashMap == 數(shù)組+散鏈表+紅黑樹
- HashMap 默認初始桶位數(shù)16,如果某個桶中的鏈表長度大于8,則先進行判斷:
- 如果桶位數(shù)小于64,則先進行擴容(2倍),擴容之后重新計算哈希值,這樣桶中的鏈表長度就變短了(之所以鏈表長度變短與桶的定位方式有關(guān),請接著往下看)。
- 如果桶位數(shù)大于64,且某個桶中的鏈表長度大于8,則對鏈表進行樹化(紅黑樹,即自平衡的二叉樹)
- 如果紅黑樹的節(jié)點數(shù)小于6,樹也會重新變會鏈表。
所以得出樹化條件:鏈表閾值大于8,且桶位數(shù)大于64(數(shù)組長度),才進行樹化。
元素放入桶(數(shù)組)中,定位桶的方式:通過數(shù)組下標 i 定位,添加元素時,目標桶位置 i 的計算公式,i = hash & (cap - 1),cap為容量。
為什么優(yōu)先擴容桶位數(shù)(數(shù)組長度),而不是直接樹化?
- 這樣做的目的是因為,當桶位數(shù)(數(shù)組長度)比較小時,應(yīng)盡量避開紅黑樹結(jié)構(gòu),這種情況下變?yōu)榧t黑樹結(jié)構(gòu),反而會降低效率。因為紅黑樹需要逬行左旋,右旋,變色這些操作來保持平衡。同時數(shù)組長度小于64時,搜索時間相對要快些。所以結(jié)上所述為了提高性能和減少搜索時間,底層閾值大于8并且數(shù)組長度大于64時,鏈表才轉(zhuǎn)換為紅黑樹,具體可以參考下文要講述的 treeifyBin() 方法。
- 而當閾值大于 8 并且數(shù)組長度大于 64 時,雖然增了紅黑樹作為底層數(shù)據(jù)結(jié)構(gòu),結(jié)構(gòu)變得復雜了,但是,長度較長的鏈表轉(zhuǎn)換為紅黑樹時,效率也變高了。
HashMap 特點:
- 存儲無序;
- 鍵和值位置都可以是 null,但是鍵位置只能存在一個 null;
- 鍵位置是唯一的,是由底層的數(shù)據(jù)結(jié)構(gòu)控制的;jdk1.8 前數(shù)據(jù)結(jié)構(gòu)是鏈表+數(shù)組,jdk1.8 之后是鏈表+數(shù)組+紅黑樹;
- 閾值(邊界值)> 8 并且桶位數(shù)(數(shù)組長度)大于 64,才將鏈表轉(zhuǎn)換為紅黑樹,變?yōu)榧t黑樹的目的是為了高效的查詢;
1.HashMap存儲數(shù)據(jù)的過程
注意:相對于直接去讀HashMap源碼來說,先debug一下其執(zhí)行數(shù)據(jù)存儲的流程,更方便大家理解!
測試代碼:
@Test public void test01() { HashMap<String, Integer> hashMap = new HashMap(); hashMap.put("a", 3); hashMap.put("b", 4); hashMap.put("c", 5); hashMap.put("a", 88888);// 修改 System.out.println(hashMap); }
輸出結(jié)果:
{a=88888, b=456, c=789}
執(zhí)行流程分析:
1.首先,HashMap<String, Integer> hashMap = new HashMap();
當創(chuàng)建 HashMap 集合對象的時候,HashMap 的構(gòu)造方法并沒有創(chuàng)建數(shù)組,而是在第一次調(diào)用 put 方法時創(chuàng)建一個長度是16 的數(shù)組(即,16個桶) ,Node[] table
(jdk1.8 之前是 Entry[] table)用來存儲鍵值對數(shù)據(jù)。
2.當向哈希表中存儲put("a", 3)
的數(shù)據(jù)時,根據(jù)"a"
字符串調(diào)用 String 類中重寫之后的 hashCode() 方法計算出哈希值,然后結(jié)合數(shù)組長度(桶數(shù)量)采用某種算法計算出向 Node 數(shù)組中存儲數(shù)據(jù)的空間索引值(比如table[i],
這里的i就是該Node數(shù)組的空間索引)。如果計算出的索引空間沒有數(shù)據(jù)(即,這個桶是空的),則直接將<"a", 3>
存儲到數(shù)組中。
舉例:如果計算出的索引是 3,則存儲到如下桶位:
3.當向哈希表中存儲數(shù)據(jù)<"b", 4>
時,假設(shè)算出的 hashCode() 方法結(jié)合數(shù)祖長度計算出的索引值也是3,那么此時數(shù)組空間不是 null(即,這個桶目前不為空),此時底層會比較 "a"
和 "b"
的 hash 值是否一致,如果不一致,則在空間上劃出一個結(jié)點來存儲鍵值對數(shù)據(jù)對 <"b", 4>
,這種方式稱為拉鏈法。
4.當向哈希表中存儲數(shù)據(jù)<"a", 88888>
時,那么首先根據(jù) "a"
調(diào)用 hashCode() 方法結(jié)合數(shù)組長度計算出索引肯定是 3,此時比較后存儲的數(shù)據(jù)"a"
和已經(jīng)存在的數(shù)據(jù)的 hash 值是否相等,如果 hash 值相等,此時發(fā)生哈希碰撞。那么底層會調(diào)用 "a"所屬類 String 中的 equals() 方法比較兩個內(nèi)容是否相等:
相等:將后添加的數(shù)據(jù)的 value 覆蓋之前的 value。
不相等:繼續(xù)向下和其他的數(shù)據(jù)的 key 進行比較,如果都不相等,則劃出一個結(jié)點存儲數(shù)據(jù),如果結(jié)點長度即鏈表長度大于閾值 8 并且數(shù)組長度大于 64 則將鏈表變?yōu)榧t黑樹。
5.在不斷的添加數(shù)據(jù)的過程中,會涉及到擴容問題,當超出閾值(且要存放的位置非空)時,擴容。默認的擴容方式:擴容為原來容量的 2 倍,并將原有的數(shù)據(jù)復制過來。
6.綜上描述,當位于一個表中的元素較多,即 hash 值相等但是內(nèi)容不相等的元素較多時,通過 key 值依次查找的效率較低。而 jdk1.8 中,哈希表存儲采用數(shù)組+鏈表+紅黑樹實現(xiàn),當鏈表長度(閾值)超過8且當前數(shù)組的長度大于64時,將鏈表轉(zhuǎn)換為紅黑樹,這樣大大減少了查找時間。
簡單的來說,哈希表是由數(shù)組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現(xiàn)的。如下圖所示:
7.jdk1.8 中引入紅黑樹的進一步原因:
- jdk1.8 以前 HashMap 的實現(xiàn)是數(shù)組+鏈表,即使哈希函數(shù)取得再好,也很難達到元素百分百均勻分布。當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當于一個單鏈表,假如單鏈表有n個元素,遍歷的時間復雜度就是O(n),完全失去了它的優(yōu)勢。
- 針對這種情況,jdk1.8 中引入了紅黑樹(查找時間復雜度為 O(logn))來優(yōu)化這個問題。當鏈表長度很小的時候,即使遍歷,速度也非??欤钱旀湵黹L度不斷變長,肯定會對查詢性能有一定的影響,所以才需要轉(zhuǎn)成樹。
8.總結(jié):
說明:
- size 表示 HashMap 中鍵值對的實時數(shù)量(即,所存儲元素的數(shù)量),注意這個不等于數(shù)組的長度。
- threshold(臨界值)= capacity(容量)* loadFactor(負載因子)。這個值是當前已占用數(shù)組長度的最大值。size 超過這個值就重新 resize(擴容),擴容后的 HashMap 容量是之前容量的2倍。
2.HashMap相關(guān)面試題
具體原理我們下文會具體分析,這里先大概了解下面試的時候會問什么,帶著問題去讀源碼,便于理解!
1.shMap 中 hash 函數(shù)是怎么實現(xiàn)的?還有哪些hash函數(shù)的實現(xiàn)方式?
答:
- 對 key 的 hashCode 做 hash 操作,如果key為null則直接賦哈希值為0,否則,無符號右移 16 位然后做異或位運算,如,代碼所示:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
- 除上面的方法外,還有平方取中法,偽隨機數(shù)法 和 取余數(shù)法。這三種效率都比較低,而無符號右移 16 位異或運算效率是最高的。
2.當兩個對象的 hashCode 相等時會怎么樣?
答:會產(chǎn)生哈希碰撞。若 key 值內(nèi)容相同則替換舊的 value,不然連接到鏈表后面,鏈表長度超過閾值 8 就轉(zhuǎn)換為紅黑樹存儲。
3.什么是哈希碰撞,如何解決哈希碰撞?
答:只要兩個元素的 key 計算的哈希碼值相同就會發(fā)生哈希碰撞。jdk8 之前使用鏈表解決哈希碰撞。jdk8之后使用鏈表 + 紅黑樹解決哈希碰撞。
4.如果兩個鍵的 hashCode 相同,如何存儲鍵值對?
答:通過 equals 比較內(nèi)容是否相同。
- 相同:則新的 value 覆蓋之前的 value。
- 不相同:遍歷該桶位的鏈表(或者樹):
- 如果找到相同key,則覆蓋該key對應(yīng)的value;
- 如果找不到,則將新的鍵值對添加到鏈表(或者樹)中;
3.HashMap繼承體系
從繼承體系可以看出:
- HashMap 實現(xiàn)了Cloneable接口,可以被克隆。
- HashMap 實現(xiàn)了Serializable接口,屬于標記性接口,HashMap 對象可以被序列化和反序列化。
- HashMap 繼承了AbstractMap,父類提供了 Map 實現(xiàn)接口,具有Map的所有功能,以最大限度地減少實現(xiàn)此接口所需的工作。
知識擴展:
通過上述繼承關(guān)系我們發(fā)現(xiàn)一個很奇怪的現(xiàn)象,就是 HashMap 已經(jīng)繼承了AbstractMap 而 AbstractMap 類實現(xiàn)了Map 接口,那為什么 HashMap 還要在實現(xiàn) Map 接口呢?同樣在 ArrayList 中 LinkedLis 中都是這種結(jié)構(gòu)。
據(jù) Java 集合框架的創(chuàng)始人 Josh Bloch 描述,這樣的寫法是一個失誤。在 Java 集合框架中,類似這樣的寫法很多,最幵始寫 Java 集合框架的時候,他認為這樣寫,在某些地方可能是有價值的,直到他意識到錯了。顯然的,jdk 的維護者,后來不認為這個小小的失誤值得去修改,所以就這樣保留下來了。
存儲結(jié)構(gòu)(再過一遍)
在Java中,HashMap的實現(xiàn)采用了(數(shù)組 + 鏈表 + 紅黑樹)的復雜結(jié)構(gòu),數(shù)組的一個元素又稱作桶。
在添加元素時,會根據(jù)hash值算出元素在數(shù)組中的位置,如果該位置沒有元素,則直接把元素放置在此處,如果該位置有元素了,則把元素以鏈表的形式放置在鏈表的尾部。
當一個鏈表的元素個數(shù)達到一定的數(shù)量(且數(shù)組的長度達到一定的長度)后,則把鏈表轉(zhuǎn)化為紅黑樹,從而提高效率。
數(shù)組的查詢效率為O(1),鏈表的查詢效率是O(k),紅黑樹的查詢效率是O(log k),k為桶中的元素個數(shù),所以當元素數(shù)量非常多的時候,轉(zhuǎn)化為紅黑樹能極大地提高效率。
4.HashMap基本屬性與常量
/* * 序列化版本號 */ private static final long serialVersionUID = 362498820763181265L; /** * HashMap的初始化容量(必須是 2 的 n 次冪)默認的初始容量為16 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; /** * 最大的容量為2的30次方 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 默認的裝載因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 樹化閾值,當一個桶中的元素個數(shù)大于等于8時進行樹化 */ static final int TREEIFY_THRESHOLD = 8; /** * 樹降級為鏈表的閾值,當一個桶中的元素個數(shù)小于等于6時把樹轉(zhuǎn)化為鏈表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 當桶的個數(shù)達到64的時候才進行樹化 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * Node數(shù)組,又叫作桶(bucket) */ transient Node<K,V>[] table; /** * 作為entrySet()的緩存 */ transient Set<Map.Entry<K,V>> entrySet; /** * 元素的數(shù)量 */ transient int size; /** * 修改次數(shù),用于在迭代的時候執(zhí)行快速失敗策略 */ transient int modCount; /** * 當桶的使用數(shù)量達到多少時進行擴容,threshold = capacity * loadFactor */ int threshold; /** * 裝載因子 */ final float loadFactor;
(1)容量:容量為數(shù)組的長度,亦即桶的個數(shù),默認為16 ,最大為2的30次方,當容量達到64時才可以樹化。
(2)裝載因子:裝載因子用來計算容量達到多少時才進行擴容,默認裝載因子為0.75。
(3)樹化:樹化,當容量達到64且鏈表的長度達到8時進行樹化,當鏈表的長度小于6時反樹化。
4.1 DEFAULT_INITIAL_CAPACITY
集合的初始化容量(必須是 2 的 n 次冪):
// 默認的初始容量是16 1 << 4 相當于 1*2的4次方 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
面試問題:為什么必須是 2 的 n 次冪?如果輸入值不是 2 的冪比如 10 會怎么樣?
HashMap 構(gòu)造方法可以指定集合的初始化容量大小,如:
// 構(gòu)造一個帶指定初始容量和默認負載因子(0.75)的空 HashMap。 HashMap(int initialCapacity)
根據(jù)上述講解我們已經(jīng)知道,當向 HashMap 中添加一個元素的時候,需要根據(jù) key 的 hash 值,去確定其在數(shù)組中的具體位置。HashMap 為了存取高效,減少碰撞,就是要盡量把數(shù)據(jù)分配均勻,每個鏈表長度大致相同,這個實現(xiàn)的關(guān)鍵就在把數(shù)據(jù)存到哪個鏈表中的算法。
這個算法實際就是取模,hash % length,而計算機中直接求余效率不如位移運算。所以源碼中做了優(yōu)化,使用 hash & (length - 1),而實際上 hash % length 等于 hash & ( length - 1) 的前提是 length 是 2 的 n 次冪。(這段話是摘抄傳智播客鎖哥的,這個解釋確實很完美!)
例如,數(shù)組長度為 8 的時候,3 & (8 - 1) = 3,2 & (8 - 1) = 2,桶的位置是(數(shù)組索引)3和2,不同位置上,不碰撞。
再來看一個數(shù)組長度(桶位數(shù))不是2的n次冪的情況:
從上圖可以看出,當數(shù)組長度為9(非2 的n次冪)的時候,不同的哈希值hash, hash & (length - 1)所得到的數(shù)組下標相等(很容易出現(xiàn)哈希碰撞)。
小結(jié)一下HashMap數(shù)組容量使用2的n次冪的原因:(面試也會問)
問題:如果創(chuàng)建HashMap對象時,輸入的數(shù)組長度length是10,而不是2的n次冪會怎么樣呢?
HashMap<String, Integer> hashMap = new HashMap(10);
HashMap雙參構(gòu)造函數(shù)會通過tableSizeFor(initialCapacity)方法,得到一個最接近length且大于length的2的n次冪數(shù)(比如最接近10且大于10的2的n次冪數(shù)是16)
這一塊兒比較難理解,下文講構(gòu)造方法的時候還會再舉例一個例子:
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; }
說明:
當在實例化 HashMap 實例時,如果給定了 initialCapacity,由于 HashMap 的 capacity 必須是 2 的冪,因此這個方法tableSizeFor(initialCapacity);用于找到大于等于 initialCapacity 的最小的 2 的冪。
分析:
int n = cap - 1;為什么要減去1呢?
防止 cap 已經(jīng)是 2 的冪。如果 cap 已經(jīng)是 2 的冪,又沒有這個減 1 操作,則執(zhí)行完后面的幾條無符號操作之后,返回的 capacity 將是這個 cap 的 2 倍(后面還會再舉個例子講這個)。
最后為什么有個 n + 1 的操作呢?
如果 n 這時為 0 了(經(jīng)過了cap - 1后),則經(jīng)過后面的幾次無符號右移依然是 0,返回0是肯定不行的,所以最后返回n+1最終得到的 capacity 是1。
注意:容量最大也就是 32bit 的正數(shù),因此最后 n |= n >>> 16;最多也就 32 個 1(但是這已經(jīng)是負數(shù)了,在執(zhí)行 tableSizeFor 之前,對 initialCapacity 做了判斷,如果大于MAXIMUM_CAPACITY(2 ^ 30),則取 MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY,會執(zhí)行位移操作。所以這里面的位移操作之后,最大 30 個 1,不會大于等于 MAXIMUM_CAPACITY。30 個 1,加 1 后得 2 ^ 30)。
完整例子:
所以由結(jié)果可得,當執(zhí)行完tableSizeFor(initialCapacity);方法后,得到的新capacity是最接近initialCapacity且大于initialCapacity的2的n次冪的數(shù)。
4.2 DEFAULT_LOAD_FACTOR
默認的負載因子(默認值 0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
4.3 MAXIMUM_CAPACITY
集合最大容量
static final int MAXIMUM_CAPACITY = 1 << 30; // 2的30次冪
4.4 TREEIFY_THRESHOLD
當鏈表的值超過8則會轉(zhuǎn)為紅黑樹(jdk1.8新增)
// 當桶(bucket)上的結(jié)點數(shù)大于這個值時會轉(zhuǎn)為紅黑樹 static final int TREEIFY_THRESHOLD = 8;
面試問題:為什么 Map 桶中結(jié)點個數(shù)超過 8 才轉(zhuǎn)為紅黑樹?
8這個閾值定義在HashMap中,針對這個成員變量,在源碼的注釋中只說明了 8 是 bin( bucket 桶)從鏈表轉(zhuǎn)成樹的閾值,但是并沒有說明為什么是 8。
在 HashMap 中有一段注釋說明:
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(-0.5) * pow(0.5, k) / factorial(k)). The first values are: 翻譯:因為樹結(jié)點的大小大約是普通結(jié)點的兩倍,所以我們只在箱子包含足夠的結(jié)點時才使用樹結(jié)點(參見TREEIFY_THRESHOLD)。 當它們變得太?。ㄓ捎趧h除或調(diào)整大小)時,就會被轉(zhuǎn)換回普通的桶。在使用分布良好的用戶 hashCode 時,很少使用樹箱。 理想情況下,在隨機哈希碼下,箱子中結(jié)點的頻率服從泊松分布。 默認調(diào)整閾值為0.75,平均參數(shù)約為0.5,盡管由于調(diào)整粒度的差異很大。忽略方差,列表大小k的預朗出現(xiàn)次數(shù)是(exp(-0.5)*pow(0.5, k) / factorial(k) 第一個值是: 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
TreeNodes(樹) 占用空間是普通 Nodes(鏈表) 的兩倍,所以只有當 bin(bucket 桶) 包含足夠多的結(jié)點時才會轉(zhuǎn)成 TreeNodes,而是否足夠多就是由 TREEIFY_THRESH〇LD 的值決定的。當 bin(bucket 桶) 中結(jié)點數(shù)變少時,又會轉(zhuǎn)成普通的 bin(bucket 桶)。并且我們查看源碼的時候發(fā)現(xiàn),鏈表長度達到 8 就轉(zhuǎn)成紅黑樹,當長度降到 6 就轉(zhuǎn)成普通 bin(bucket 桶)。
這樣就解釋了為什么不是一開始就將其轉(zhuǎn)換為 TreeNodes,而是需要一定結(jié)點數(shù)之后才轉(zhuǎn)為 TreeNodes,說白了就是權(quán)衡空間和時間。
這段內(nèi)容還說到:當 hashCode 離散性很好的時候,樹型 bin 用到的概率非常小,因為數(shù)據(jù)均勻分布在每個 bin 中,幾乎不會有 bin 中鏈表長度會達到閾值。但是在隨機 hashCode 下,離散性可能會變差,然而 jdk 又不能阻止用戶實現(xiàn)這種不好的 hash 算法,因此就可能導致不均勻的數(shù)據(jù)分布。不理想情況下隨機 hashCode 算法下所有 bin 中結(jié)點的分布頻率會遵循泊松分布,我們可以看到,一個 bin 中鏈表長度達到 8 個元素的槪率為 0.00000006,幾乎是不可能事件。所以,之所以選擇 8,不是隨便決定的,而是裉據(jù)概率統(tǒng)計決定的。甶此可見,發(fā)展將近30年的 Java 每一項改動和優(yōu)化都是非常嚴謹和科學的。
面試答案:hashCode 算法下所有 桶 中結(jié)點的分布頻率會遵循泊松分布,這時一個桶中鏈表長度超過 8 個元素的槪率非常小,權(quán)衡空間和時間復雜度,所以選擇 8 這個數(shù)宇。
擴展補充:
Poisson 分布(泊松分布),是一種統(tǒng)計與概率學里常見到的離散[概率分布]。泊松分布的概率函數(shù)為:
泊松分布的參數(shù) A 是單位時間(或單位面積)內(nèi)隨機事件的平均發(fā)生次數(shù)。泊松分布適合于描述單位時間內(nèi)隨機事件發(fā)生的次數(shù)。
以下是我在研究這個問題時,在一些資料上面翻看的解釋,供大家參考:
紅黑樹的平均查找長度是 log(n),如果長度為 8,平均查找長度為 log(8) = 3,鏈表的平均查找長度為 n/2,當長度為 8 時,平均查找長虔為 8/2 = 4,這才有轉(zhuǎn)換成樹的必要;鏈表長度如果是小于等于 6, 6/2 = 3,而 log(6) = 2.6,雖然速度也很快的,但是轉(zhuǎn)化為樹結(jié)構(gòu)和生成樹的時間并不會太短。
4.5 UNTREEIFY_THRESHOLD
當鏈表的值小于 6 則會從紅黑樹轉(zhuǎn)回鏈表
// 當桶(bucket)上的結(jié)點數(shù)小于這個值,樹轉(zhuǎn)為鏈表 static final int UNTREEIFY_THRESHOLD = 6;
4.6 MIN_TREEIFY_CAPACITY
當 Map 里面的數(shù)量超過這個值時,表中的桶才能進行樹形化,否則桶內(nèi)元素太多時會擴容,而不是樹形化為了避免進行擴容、樹形化選擇的沖突,這個值不能小于4*TREEIFY_THRESHOLD(8)
// 桶中結(jié)構(gòu)轉(zhuǎn)化為紅黑樹對應(yīng)的數(shù)組長度最小的值 static final int MIN_TREEIFY_CAPACITY = 64;
4.7 table(重點)
table 用來初始化(必須是二的n次冪)
// 存儲元素的數(shù)組 transient Node<K,V>[] table;
在 jdk1.8 中我們了解到 HashMap 是由數(shù)組加鏈表加紅黑樹來組成的結(jié)構(gòu),其中 table 就是 HashMap 中的數(shù)組,jdk8 之前數(shù)組類型是 Entry<K,V> 類型。從 jdk1.8 之后是 Node<K,V> 類型。只是換了個名字,都實現(xiàn)了一樣的接口:Map.Entry<K,V>。負責存儲鍵值對數(shù)據(jù)的。
4.8 entrySet
用來存放緩存
// 存放具體元素的集合 transient Set<Map.Entry<K,V>> entrySet;
4.9 size(重點)
HashMap 中存放元素的個數(shù)
// 存放元素的個數(shù),注意這個不等于數(shù)組的長度 transient int size;
size 為 HashMap 中 K-V 的實時數(shù)量,不是數(shù)組 table 的長度。
4.10 modCount
用來記錄 HashMap 的修改次數(shù)
// 每次擴容和更改 map 結(jié)構(gòu)的計數(shù)器 transient int modCount;
4.11 threshold(重點)
用來調(diào)整大小下一個容量的值計算方式為(容量*負載因子)
// 臨界值 當實際大?。ㄈ萘?負載因子)超過臨界值時,會進行擴容 int threshold;
4.12 loadFactor(重點)
哈希表的負載因子
// 負載因子 final float loadFactor;// 0.75f
說明:
loadFactor 是用來衡量 HashMap 滿的程度,表示HashMap的疏密程度,影響 hash 操作到同一個數(shù)組位置的概率,計算 HashMap 的實時負載因子的方法為:size/capacity,而不是占用桶的數(shù)量去除以 capacity。capacity 是桶的數(shù)量,也就是 table 的長度 length。loadFactor 太大導致查找元素效率低,太小導致數(shù)組的利用率低,存放的數(shù)據(jù)會很分散。loadFactor 的默認值為 0.75f 是官方給出的一個比較好的臨界值。當 HashMap 里面容納的元素已經(jīng)達到 HashMap 數(shù)組長度的 75% 時,表示 HashMap 太擠了,需要擴容,而擴容這個過程涉及到 rehash、復制數(shù)據(jù)等操作,非常消耗性能。所以開發(fā)中盡量減少擴容的次數(shù),可以通過創(chuàng)建 HashMap 集合對象時指定初始容量來盡量避免。在 HashMap 的構(gòu)造器中可以定制 loadFactor。
// 構(gòu)造方法,構(gòu)造一個帶指定初始容量和負載因子的空HashMap HashMap(int initialCapacity, float loadFactor);
為什么負載因子loadFactor 設(shè)置為0.75,初始化臨界值threshold是12?
loadFactor 越趨近于1,那么 數(shù)組中存放的數(shù)據(jù)(entry)也就越多,也就越密,也就是會讓鏈表的長度增加,loadFactor 越小,也就是趨近于0,數(shù)組中存放的數(shù)據(jù)(entry)也就越少,也就越稀疏。
如果希望鏈表盡可能少些,要提前擴容。有的數(shù)組空間有可能一直沒有存儲數(shù)據(jù),負載因子盡可能小一些。
舉例:
例如:負載因子是0.4。 那么16*0.4--->6 如果數(shù)組中滿6個空間就擴容會造成數(shù)組利用率太低了。
負載因子是0.9。 那么16*0.9--->14 那么這樣就會導致鏈表有點多了,導致查找元素效率低。
所以既兼顧數(shù)組利用率又考慮鏈表不要太多,經(jīng)過大量測試 0.75 是最佳方案。
threshold 計算公式:capacity(數(shù)組長度默認16) * loadFactor(負載因子默認0.75)==12。
這個值是當前已占用數(shù)組長度的最大值。當 Size >= threshold(12) 的時候,那么就要考慮對數(shù)組的 resize(擴容),也就是說,這個的意思就是 衡量數(shù)組是否需要擴增的一個標準。 擴容后的 HashMap 容量是之前容量的兩倍。
5.內(nèi)部類
5.1Node內(nèi)部類
Node是一個典型的單鏈表節(jié)點,其中,hash用來存儲key計算得來的hash值。
static class Node<K,V> implements Map.Entry<K,V> { final int hash;// hash用來存儲key計算得來的hash值 final K key;// 鍵 V value;// 值 Node<K,V> next;// 下一個node節(jié)點 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() {// 調(diào)用底層c++ 返回Key/Value的哈希碼值,如果此對象為null,則返回0 return Objects.hashCode(key) ^ Objects.hashCode(value);// 將Key/Vaule } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
5.2TreeNode內(nèi)部類
TreeNode內(nèi)部類,它繼承自LinkedHashMap中的Entry類,關(guān)于LInkedHashMap.Entry這個類之后會單獨發(fā)文章論述,TreeNode是一個典型的樹型節(jié)點,其中,prev是鏈表中的節(jié)點,用于在刪除元素的時候可以快速找到它的前置節(jié)點。
// 位于HashMap中,文章接下來會逐步分析 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; } // 位于LinkedHashMap中,典型的雙向鏈表節(jié)點,這個類之后會單獨發(fā)文章論述 static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }
6.HashMap構(gòu)造方法
HashMap 中重要的構(gòu)造方法,它們分別如下:
6.1 HashMap()
構(gòu)造一個空的HashMap,默認初始容量(16)和默認負載因子(0.75)。
public HashMap() { // 將默認的負載因子0.75賦值給loadFactor,并沒有創(chuàng)建數(shù)組 this.loadFactor = DEFAULT_LOAD_FACTOR; }
6.2 HashMap(int initialCapacity)
構(gòu)造一個具有指定的初始容量和默認負載因子(0.75)HashMap 。
// 指定“容量大小”的構(gòu)造函數(shù) public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
6.3 HashMap(int initialCapacity,float loadFactor)構(gòu)造方法
構(gòu)造一個具有指定的初始容量和負載因子的 HashMap。
/* 指定“容量大小”和“負載因子”的構(gòu)造函數(shù) initialCapacity:指定的容量 loadFactor:指定的負載因子 */ public HashMap(int initialCapacity, float loadFactor) { // 判斷初始化容量initialCapacity是否小于0 if (initialCapacity < 0) // 如果小于0,則拋出非法的參數(shù)異常IllegalArgumentException throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 判斷初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) // 如果超過MAXIMUM_CAPACITY,會將MAXIMUM_CAPACITY賦值給initialCapacity initialCapacity = MAXIMUM_CAPACITY; // 判斷負載因子loadFactor是否小于等于0或者是否是一個非數(shù)值 if (loadFactor <= 0 || Float.isNaN(loadFactor)) // 如果滿足上述其中之一,則拋出非法的參數(shù)異常IllegalArgumentException throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 將指定的負載因子賦值給HashMap成員變量的負載因子loadFactor this.loadFactor = loadFactor;// 一般不建議修改默認的負載因子 this.threshold = tableSizeFor(initialCapacity); } // 最后調(diào)用了tableSizeFor,來看一下方法實現(xiàn): /* 返回比指定cap容量大的最小2的n次冪數(shù): 前面第一遍講述的應(yīng)該有些小伙伴難以理解,這里我在舉例解析一下: ------------------------------------------------------- 首先假定傳入的cap = 10 則,n = 10 -1 => 9 n |= n >>> 1 就等同于 n = (n | n >>> 1),所以: (位運算不懂的可以去看我的《Java基礎(chǔ)提高之位運算》這篇文章) 9 => 0b1001 9 >>> 1 => 0b0100 n |= n >>> 1; ===> 0b1001 | 0b0100 => 0b1101 n |= n >>> 2; ===> 0b1101 | 0b0011 => 0b1111 n |= n >>> 4; ===> 0b1111 | 0b0000 => 0b1111 n |= n >>> 8; ===> 0b1111 | 0b0000 => 0b1111 n |= n >>> 16; ===> 0b1111 | 0b0000 => 0b1111 得到: 0b1111 => 15 返回: return 15 + 1 => 16 ------------------------------------------------------- 如果cap 不減去1,即直接使n等于cap的話,int n = cap; 我們繼續(xù)用上邊返回的cap => 16 傳入tableSizeFor(int cap): cap = 16 n = 16 16 => 0b10000 16 >>> 1 => 0b01000 n |= n >>> 1; ===> 0b10000 | 0b01000 => 0b11000 n |= n >>> 2; ===> 0b11000 | 0b00110 => 0b11110 n |= n >>> 4; ===> 0b11110 | 0b00001 => 0b11111 n |= n >>> 8; ===> 0b11111 | 0b00000 => 0b11111 n |= n >>> 16; ===> 0b11111 | 0b00000 => 0b11111 得到: 0b11111 => 31 返回 return 31 +1 => 32 而實際情況是應(yīng)該傳入cap = 16 , n = cap -1 = 15 15 => 0b1111 n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; 經(jīng)過上面運算后得到:還是15 返回結(jié)果: return 15 + 1 = 16 所以我們得出結(jié)果: 防止 cap 已經(jīng)是 2 的冪數(shù)情況下。沒有這個減 1 操作, 則執(zhí)行完幾條無符號位移或位運算操作之后,返回的cap(32)將是實際所需cap(16)的 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; }
說明:
對于this.threshold = tableSizeFor(initialCapacity); 疑問?
**tableSizeFor(initialCapacity)**判斷指定的初始化容量是否是2的n次冪,如果不是那么會變?yōu)楸戎付ǔ跏蓟萘看蟮淖钚〉?的n次冪。
但是注意,在tableSizeFor方法體內(nèi)部將計算后的數(shù)據(jù)返回給調(diào)用這里了,并且直接賦值給threshold邊界值了。有些人會覺得這里是一個bug,應(yīng)該這樣書寫:
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
這樣才符合threshold的意思(當HashMap的size到達threshold這個閾值時會擴容)
但是請注意,在jdk8以后的構(gòu)造方法中,并沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。
6.4 HashMap(Map<? extends K, ? extends V> m)
包含另一個 “Map” 的構(gòu)造函數(shù)
// 構(gòu)造一個映射關(guān)系與指定 Map 相同的新 HashMap。 public HashMap(Map<? extends K, ? extends V> m) { // 負載因子loadFactor變?yōu)槟J的負載因子0.75 this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
最后調(diào)用了 putMapEntries(),來看一下方法實現(xiàn):
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { //獲取參數(shù)集合的長度 int s = m.size(); if (s > 0) {//判斷參數(shù)集合的長度是否大于0 if (table == null) { // 判斷table是否已經(jīng)初始化 // 未初始化,s為m的實際元素個數(shù) float ft = ((float)s / loadFactor) + 1.0F;// 得到新的擴容閾值 int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);// 新的擴容閾值float自動向下轉(zhuǎn)型為int // 計算得到的t大于閾值,則初始化閾值,將其變?yōu)榉弦蟮?的n次冪數(shù) if (t > threshold) threshold = tableSizeFor(t); } // 如果table已初始化過了,并且m元素個數(shù)大于閾值,進行擴容處理 else if (s > threshold) resize(); // 將m中的所有元素添加至HashMap中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); // 得到的key 和 value 放入 hashmap putVal(hash(key), key, value, false, evict); } } }
(小結(jié)):
面試問題:float ft = ((float)s / loadFactor) + 1.0F; 這一行代碼中為什么要加 1.0F ?
(float)s/loadFactor 的結(jié)果是小數(shù),加 1.0F 與 (int)ft 相當于是對小數(shù)做一個向上取整以盡可能的保證更大容量,更大的容量能夠減少 resize 的調(diào)用次數(shù)(為了效率,應(yīng)當盡量減少擴容的次數(shù))。所以 + 1.0F 是為了獲取更大的容量。
例如:原來集合的元素個數(shù)是 6 個,那么 6/0.75 是8,由于8是 2 的n次冪,那么
if (t > threshold) threshold = tableSizeFor(t);執(zhí)行過后,新的數(shù)組大小就是 8 了。然后原來數(shù)組的數(shù)據(jù)就會存儲到長度是 8 的新的數(shù)組中了,這樣會導致在存儲元素的時候,容量不夠,還得繼續(xù)擴容,那么性能降低了,而如果 +1 呢,數(shù)組長度直接變?yōu)?6了,這樣可以減少數(shù)組的擴容。
7.HashMap的成員方法
7.1 put(K key, V value)方法
put方法是比較復雜的,實現(xiàn)步驟大致如下:
1.先通過 hash 值計算出 key 映射到哪個桶;
2.如果桶上沒有碰撞沖突,則直接插入;
3.如果出現(xiàn)碰撞沖突了,則需要處理沖突:
a 如果該桶使用紅黑樹處理沖突,則調(diào)用紅黑樹的方法插入數(shù)據(jù);
b 否則采用傳統(tǒng)的鏈式方法插入。如果鏈的長度達到臨界值,則把鏈轉(zhuǎn)變?yōu)榧t黑樹;
4.如果桶中存在重復的鍵,則為該鍵替換新值 value;
5.如果 size 大于閾值 threshold,則進行擴容;
具體的方法如下:
public V put(K key, V value) { // 調(diào)用hash(key)計算出key的hash值 return putVal(hash(key), key, value, false, true); }
說明:
- HashMap 只提供了 put 用于添加元素,putVal 方法只是給 put 方法調(diào)用的一個方法,并沒有提供給用戶使用。 所以我們重點看 putVal 方法。
- 我們可以看到在 putVal 方法中 key 在這里執(zhí)行了一下 hash 方法,來看一下 hash 方法是如何實現(xiàn)的。
static final int hash(Object key) {
int h;
// 如果key為null,則hash值為0,
// 否則調(diào)用key的hashCode()方法計算出key的哈希值然后賦值給h,
// 后與h無符號右移16位后的二進制進行按位異或得到最后的hash值,
// 這樣做是為了使計算出的hash更分散
// 為什么要更分散呢?因為越分散,某個桶的鏈表長度就越短,之后生成的紅黑樹越少,效率越高
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
從上面可以得知 HashMap 是支持 key 為空的,而 HashTable 是直接用 Key 來獲取hashCode 所以 key 為空會拋異常。
解讀上述 hash 方法:
我們先研究下 key 的哈希值是如何計算出來的。key 的哈希值是通過上述方法計算出來的。
這個哈希方法首先計算出 key 的 hashCode 賦值給 h,然后與 h 無符號右移 16 位后的二進制進行按位異或得到最后的 hash 值。
在 putVal 函數(shù)中使用到了上述 hash 函數(shù)計算的哈希值:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... if ((p = tab[i = (n - 1) & hash]) == null) // 這里的n表示數(shù)組長度16 ,公式 // (length - 1) & hash = 桶位下標 當數(shù)組長度為2的n次冪數(shù)時, // 該公式相當于:hash % length 哈希值對數(shù)組長度取余 // 例如:hash % 32 = hash & (32-1) ... }
計算過程如下所示:
說明:
- key.hashCode();返回散列值也就是 hashcode,假設(shè)隨便生成的一個值。
- n 表示數(shù)組初始化的長度是 16。
- &(按位與運算):運算規(guī)則:相同的二進制數(shù)位上,都是 1 的時候,結(jié)果為 1,否則為0。
- ^(按位異或運算):運算規(guī)則:相同的二進制數(shù)位上,數(shù)字相同,結(jié)果為 0,不同為 1。
最后獲得0101==> 下標為5的捅。
簡單來說就是:
高 16bit 不變,低 16bit 和高 16bit 做了一個異或(得到的 hashCode 轉(zhuǎn)化為 32 位二進制,前 16 位和后 16 位低 16bit 和高 16bit 做了一個異或)。
問題:為什么要這樣操作呢?
如果當 n 即數(shù)組長度很小,假設(shè)是 16 的話,那么 n - 1 即為 1111 ,這樣的值和 hashCode 直接做按位與操作,實際上只使用了哈希值的后 4 位。如果當哈希值的高位變化很大,低位變化很小,這樣就很容易造成哈希沖突了,所以這里把高低位都利用起來,從而解決了這個問題。
下面,我們來看看 putVal 方法,看看它到底做了什么。
主要參數(shù):
- hash:key 的 hash 值
- key:原始
- keyvalue:要存放的值
- onlyIfAbsent:如果
- true :代表不更改現(xiàn)有的值
- evict:如果為
- false:表示
- table:為創(chuàng)建狀態(tài)
putVal 方法源代碼如下所示:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } static final int hash(Object key) { int h;// 如果key是null 則hash值為0,否則調(diào)用key的hashCode()方法,并讓高16位參與整個hash異或,如果數(shù)組長度比較小的情況下,讓高16位也參與尋址, // 尋址公式:(length - 1) & hash // 這樣可以使計算出的結(jié)果更分散,不容易產(chǎn)生哈希沖突 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; /* 1)transient Node<K,V>[] table; 表示存儲Map集合中元素的數(shù)組。 2)(tab = table) == null 表示將空的table賦值給tab,然后判斷tab是否等于null,第一次肯定是null。 3)(n = tab.length) == 0 表示將數(shù)組的長度0賦值給n,然后判斷n是否等于0,n等于0,由于if判斷使用雙或,滿足一個即可,則執(zhí)行代碼 n = (tab = resize()).length; 進行數(shù)組初始化,并將初始化好的數(shù)組長度賦值給n。 4)執(zhí)行完n = (tab = resize()).length,數(shù)組tab每個空間都是null。 */ if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /* 1)i = (n - 1) & hash 表示計算數(shù)組的索引賦值給i,即確定元素存放在哪個桶中。 2)p = tab[i = (n - 1) & hash]表示獲取計算出的位置的數(shù)據(jù)賦值給結(jié)點p。 3) (p = tab[i = (n - 1) & hash]) == null 判斷結(jié)點位置是否等于null,如果為null,則執(zhí)行代碼:tab[i] = newNode(hash, key, value, null);根據(jù)鍵值對創(chuàng)建新的結(jié)點放入該位置的桶中。 小結(jié):如果當前桶沒有哈希碰撞沖突,則直接把鍵值對插入空間位置。 */ if ((p = tab[i = (n - 1) & hash]) == null) // 創(chuàng)建一個新的結(jié)點存入到桶中 tab[i] = newNode(hash, key, value, null); else { // 執(zhí)行else說明tab[i]不等于null,表示這個位置已經(jīng)有值了 Node<K,V> e; K k; /* 比較桶中第一個元素(數(shù)組中的結(jié)點)的hash值和key是否相等 1)p.hash == hash :p.hash表示原來存在數(shù)據(jù)的hash值 hash表示后添加數(shù)據(jù)的hash值 比較兩個hash值是否相等。 說明:p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node對象。 Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); } 而在Node類中具有成員變量hash用來記錄著之前數(shù)據(jù)的hash值的。 2)(k = p.key) == key :p.key獲取原來數(shù)據(jù)的key賦值給k key 表示后添加數(shù)據(jù)的key比較兩個key的地址值是否相等。 3)key != null && key.equals(k):能夠執(zhí)行到這里說明兩個key的地址值不相等,那么先判斷后添加的key是否等于null,如果不等于null再調(diào)用equals方法判斷兩個key的內(nèi)容是否相等。 */ if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) /* 說明:兩個元素哈希值相等,并且key的值也相等, 將舊的元素整體對象賦值給e,用e來記錄 */ e = p; // hash值不相等或者key不相等;判斷p是否為紅黑樹結(jié)點 else if (p instanceof TreeNode) // 放入樹中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 說明是鏈表結(jié)點 else { /* 1)如果是鏈表的話需要遍歷到最后結(jié)點然后插入 2)采用循環(huán)遍歷的方式,判斷鏈表中是否有重復的key */ for (int binCount = 0; ; ++binCount) { /* 1)e = p.next 獲取p的下一個元素賦值給e。 2)(e = p.next) == null 判斷p.next是否等于null,等于null,說明p沒有下一個元素,那么此時到達了鏈表的尾部,還沒有找到重復的key,則說明HashMap沒有包含該鍵,將該鍵值對插入鏈表中。 */ if ((e = p.next) == null) { /* 1)創(chuàng)建一個新的結(jié)點插入到尾部 p.next = newNode(hash, key, value, null); Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); } 注意第四個參數(shù)next是null,因為當前元素插入到鏈表末尾了,那么下一個結(jié)點肯定是null。 2)這種添加方式也滿足鏈表數(shù)據(jù)結(jié)構(gòu)的特點,每次向后添加新的元素。 */ p.next = newNode(hash, key, value, null); /* 1)結(jié)點添加完成之后判斷此時結(jié)點個數(shù)是否大于TREEIFY_THRESHOLD臨界值8,如果大于則將鏈表轉(zhuǎn)換為紅黑樹。 2)int binCount = 0 :表示for循環(huán)的初始化值。從0開始計數(shù)。記錄著遍歷結(jié)點的個數(shù)。值是0表示第一個結(jié)點,1表示第二個結(jié)點。。。。7表示第八個結(jié)點,加上數(shù)組中的的一個元素,元素個數(shù)是9。 TREEIFY_THRESHOLD - 1 --》8 - 1 ---》7 如果binCount的值是7(加上數(shù)組中的的一個元素,元素個數(shù)是9) TREEIFY_THRESHOLD - 1也是7,此時轉(zhuǎn)換紅黑樹。 */ if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 轉(zhuǎn)換為紅黑樹 treeifyBin(tab, hash); // 跳出循環(huán) break; } /* 執(zhí)行到這里說明e = p.next 不是null,不是最后一個元素。繼續(xù)判斷鏈表中結(jié)點的key值與插入的元素的key值是否相等。 */ if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循環(huán) /* 要添加的元素和鏈表中的存在的元素的key相等了,則跳出for循環(huán)。不用再繼續(xù)比較了 直接執(zhí)行下面的if語句去替換去 if (e != null) */ break; /* 說明新添加的元素和當前結(jié)點不相等,繼續(xù)查找下一個結(jié)點。 用于遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表 */ p = e; } } /* 表示在桶中找到key值、hash值與插入元素相等的結(jié)點 也就是說通過上面的操作找到了重復的鍵,所以這里就是把該鍵的值變?yōu)樾碌闹担⒎祷嘏f值 這里完成了put方法的修改功能 */ if (e != null) { // 記錄e的value V oldValue = e.value; // onlyIfAbsent為false或者舊值為null if (!onlyIfAbsent || oldValue == null) // 用新值替換舊值 // e.value 表示舊值 value表示新值 e.value = value; // 訪問后回調(diào) afterNodeAccess(e); // 返回舊值 return oldValue; } } // 修改記錄次數(shù) ++modCount; // 判斷實際大小是否大于threshold閾值,如果超過則擴容 if (++size > threshold) resize(); // 插入后回調(diào) afterNodeInsertion(evict); return null; }
(1)計算key的hash值;
(2)如果桶(數(shù)組)數(shù)量為0,則初始化桶;
(3)如果key所在的桶沒有元素,則直接插入;
(4)如果key所在的桶中的第一個元素的key與待插入的key相同,說明找到了元素,轉(zhuǎn)后續(xù)流程(9)處理;
(5)如果第一個元素是樹節(jié)點,則調(diào)用樹節(jié)點的putTreeVal()尋找元素或插入樹節(jié)點;
(6)如果不是以上三種情況,則遍歷桶對應(yīng)的鏈表查找key是否存在于鏈表中;
(7)如果找到了對應(yīng)key的元素,則轉(zhuǎn)后續(xù)流程(9)處理;
(8)如果沒找到對應(yīng)key的元素,則在鏈表最后插入一個新節(jié)點并判斷是否需要樹化;
(9)如果找到了對應(yīng)key的元素,則判斷是否需要替換舊值,并直接返回舊值;
(10)如果插入了元素,則數(shù)量加1并判斷是否需要擴容;
7.2 擴容方法 resize()
擴容機制:
1.什么時候才需要擴容?
當 HashMap 中的元素個數(shù)超過數(shù)組大小(數(shù)組長度)*loadFactor(負載因子)時,就會進行數(shù)組擴容,loadFactor 的默認值是 0.75。
2.HashMap 的擴容是什么?
進行擴容,會伴隨著一次重新 hash 分配,并且會遍歷 hash 表中所有的元素,是非常耗時的。在編寫程序中,要盡量避免 resize。
HashMap 在進行擴容時,使用的 rehash 方式非常巧妙,因為每次擴容都是翻倍,與原來計算的 (n - 1) & hash 的結(jié)果相比,只是多了一個 bit 位,所以結(jié)點要么就在原來的位置,要么就被分配到 “原位置 + 舊容量” 這個位置。
例如我們從 16 擴展為 32 時,具體的變化如下所示:
因此元素在重新計算 hash 之后,因為 n 變?yōu)?2 倍,那么 n - 1 的標記范圍在高位多 1bit(紅色),因此新的 index 就會發(fā)生這樣的變化。
說明:
5 是假設(shè)計算出來的原來的索引。這樣就驗證了上述所描述的:擴容之后所以結(jié)點要么就在原來的位置,要么就被分配到 “原位置 + 舊容量” 這個位置。
因此,我們在擴充 HashMap 的時候,不需要重新計算 hash,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就可以了,是 0 的話索引沒變,是 1 的話索引變成 “原位置 + 舊容量” 。可以看看下圖為 16 擴充為 32 的 resize 示意圖:
正是因為這樣巧妙的 rehash 方式,既省去了重新計算 hash 值的時間,而且同時,由于新增的 1bit 是 0 還是 1 可以認為是隨機的,在 resize 的過程中保證了 rehash 之后每個桶上的結(jié)點數(shù)一定小于等于原來桶上的結(jié)點數(shù),保證了 rehash 之后不會出現(xiàn)更嚴重的 hash 沖突,均勻的把之前的沖突的結(jié)點分散到新的桶中了。
源碼 resize 方法的解讀
下面是代碼的具體實現(xiàn):
/** * 為什么需要擴容呢? * 為了解決哈希沖突導致的鏈化影響查詢效率問題,擴容會緩解該問題 */ final Node<K,V>[] resize() { // oldTab:表示擴容前的哈希表數(shù)組 Node<K,V>[] oldTab = table; // oldCap:表示擴容之前table數(shù)組長度 // 如果當前哈希表數(shù)組等于null 長度返回0,否則返回當前哈希表數(shù)組的長度 int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldThr:表示擴容之前的閥值(觸發(fā)本次擴容的閾值) 默認是12(16*0.75) int oldThr = threshold; // newCap:擴容之后的table散列表數(shù)組長度 // newThr: 擴容之后,下次再出發(fā)擴容的條件(新的擴容閾值) int newCap, newThr = 0; // 如果老的哈希表數(shù)組長度oldCap > 0 // 如果該條件成立,說明hashMap 中的散列表數(shù)組已經(jīng)初始化過了,是一次正常擴容 // 開始計算擴容后的大小 if (oldCap > 0) { // 擴容之前的table數(shù)組大小已經(jīng)達到 最大閾值后,則不再擴容 // 且設(shè)置擴容條件為:int的最大值 if (oldCap >= MAXIMUM_CAPACITY) { // 修改閾值為int的最大值 threshold = Integer.MAX_VALUE; return oldTab; } // 擴容之前的table數(shù)組大小沒超過最大值,則擴充為原來的2倍 // (newCap = oldCap << 1) < MAXIMUM_CAPACITY 擴大到2倍之后容量要小于最大容量 // oldCap >= DEFAULT_INITIAL_CAPACITY 原哈希表數(shù)組長度大于等于數(shù)組初始化長度16 // 如果oldCap 小于默認初始容量16,比如傳入的默認容量為8,則不執(zhí)行下面代碼 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 新的擴容閾值擴大一倍 newThr = oldThr << 1; // double threshold } // 如果老的哈希表數(shù)組長度oldCap == 0 // 說明hashMap中的散列表還沒有初始化,這時候是null // 如果老閾值oldThr大于0 直接賦值 /* 以下三種情況會直接進入該判斷:(即,這時候oldThr擴容閾值已存在) 1.new HashMap(initCap,loadFactor); 2.new HashMap(initCap); 3.new HashMap(Map);// 這個傳入的map中已經(jīng)有數(shù)據(jù) */ else if (oldThr > 0) // 老閾值賦值給新的數(shù)組長度 newCap = oldThr; // 如果老的哈希表數(shù)組長度oldCap == 0 // 說明hashMap中的散列表還沒有初始化,這時候是null // 此時,老擴容閾值oldThr == 0 else { // 直接使用默認值 newCap = DEFAULT_INITIAL_CAPACITY;//16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12 } // 如果執(zhí)行到這個位置新的擴容閾值newThr還沒有得到賦值,則 // 需要計算新的resize最大上限 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 將新的閥值newThr賦值給threshold threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) // 創(chuàng)建新的散列表 // newCap是新的數(shù)組長度---> 32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 說明:hashMap本次擴容之前,table不為null if (oldTab != null) { // 把每個bucket桶的數(shù)據(jù)都移動到新的散列表中 // 遍歷舊的哈希表的每個桶,重新計算桶里元素的新位置 for (int j = 0; j < oldCap; ++j) { // 當前node節(jié)點 Node<K,V> e; // 說明:此時的當前桶位中有數(shù)據(jù),但是數(shù)據(jù)具體是 // 1.單個數(shù)據(jù) 、 2.還是鏈表 、 3.還是紅黑樹 并不能確定 if ((e = oldTab[j]) != null) { // 原來的數(shù)據(jù)賦值為null 便于GC回收 oldTab[j] = null; // 第一種情況:判斷數(shù)組是否有下一個引用(是否是單個數(shù)據(jù)) if (e.next == null) // 沒有下一個引用,說明不是鏈表, // 當前桶上只有單個數(shù)據(jù)的鍵值對, // 可以將數(shù)據(jù)直接放入新的散列表中 // e.hash & (newCap - 1) 尋址公式得到的索引結(jié)果有兩種: // 1.和原來舊散列表中的索引位置相同, // 2.原來舊散列表中的索引位置i + 舊容量oldCap newTab[e.hash & (newCap - 1)] = e; //第二種情況:桶位已經(jīng)形成紅黑樹 else if (e instanceof TreeNode) // 說明是紅黑樹來處理沖突的,則調(diào)用相關(guān)方法把樹分開 // 紅黑樹這塊,我會單獨寫一篇博客給大家詳細分析一下 // 紅黑樹相關(guān)可以先跳過 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 第三種情況:桶位已經(jīng)形成鏈表 else { // 采用鏈表處理沖突 // 低位鏈表: // 擴容之后數(shù)組的下標位置,與當前數(shù)組的下標位置一致 時使用 Node<K,V> loHead = null, loTail = null; // 高位鏈表:擴容之后數(shù)組的下標位置等于 // 當前數(shù)組下標位置 + 擴容之前數(shù)組的長度oldCap 時使用 Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 通過上述講解的原理來計算結(jié)點的新位置 do { // 原索引 next = e.next; // 這里來判斷如果等于true // e這個結(jié)點在resize之后不需要移動位置 // 舉例: // 假如hash1 -> ...... 0 1111 // 假如oldCap=16 -> ...... 1 0000 // e.hash & oldCap 結(jié)果為0,則 // 擴容之后數(shù)組的下標位置j,與當前數(shù)組的下標位置一致 // 使用低位鏈表 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 舉例: // 假如hash2 -> ...... 1 1111 // 假如oldCap=16 -> ...... 1 0000 // e.hash & oldCap 結(jié)果不為0,則 // 擴容之后數(shù)組的下標位置為: // 當前數(shù)組下標位置j + 擴容之前數(shù)組的長度oldCap // 使用高位鏈表 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 將低位鏈表放到bucket桶里 if (loTail != null) { loTail.next = null; // 索引位置=當前數(shù)組下標位置j newTab[j] = loHead; } // 將高位鏈表放到bucket里 if (hiTail != null) { hiTail.next = null; // 索引位置=當前數(shù)組下標位置j + 擴容之前數(shù)組的長度oldCap newTab[j + oldCap] = hiHead; } } } } } // 返回新散列表 return newTab; }
(1)如果使用是默認構(gòu)造方法,則第一次插入元素時初始化為默認值,容量為16,擴容門檻為12;
(2)如果使用的是非默認構(gòu)造方法,則第一次插入元素時初始化容量等于擴容門檻,擴容門檻在構(gòu)造方法里等于傳入容量向上最近的2的n次方;
(3)如果舊容量大于0,則新容量等于舊容量的2倍,但不超過最大容量2的30次方,新擴容門檻為舊擴容門檻的2倍;
(4)創(chuàng)建一個新容量的桶;
(5)搬移元素,原鏈表分化成兩個鏈表,低位鏈表存儲在原來桶的位置,高位鏈表搬移到原來桶的位置加舊容量的位置;
7.3 刪除方法 remove()
刪除方法就是首先先找到元素的位置,如果是鏈表就遍歷鏈表找到元素之后刪除。如果是用紅黑樹就遍歷樹然后找到之后做刪除,樹小于 6 的時候要轉(zhuǎn)鏈表。
刪除 remove() 方法:
// remove方法的具體實現(xiàn)在removeNode方法中,所以我們重點看下removeNode方法 // 根據(jù)key刪除 public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } // 根據(jù)key,value 刪除 @Override public boolean remove(Object key, Object value) { return removeNode(hash(key), key, value, true, true) != null; }
removeNode() 方法:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { // 參數(shù): // matchValue 當根據(jù) key和value 刪除的時候該參數(shù)為true // movable 可以先不用考慮這個參數(shù) // tab:引用當前haashMap中的散列表 // p:當前node元素 // n:當前散列表數(shù)組長度 // index:表示尋址結(jié)果 Node<K,V>[] tab; Node<K,V> p; int n, index; // 根據(jù)hash找到位置 // 如果當前key映射到的桶不為空 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { // 進入這個if判斷內(nèi)部,說明桶位是有數(shù)據(jù)的,需要進行查詢操作,并且執(zhí)行刪除 // node:通過查找得到的要刪除的元素 // e:表示當前node的下一個元素 // k,v 鍵 值 Node<K,V> node = null, e; K k; V v; // 第一種情況:當前桶位中的元素 即為我們要刪除的元素 // 如果桶上的結(jié)點就是要找的key,則將node指向該結(jié)點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 如果桶位中的頭一個元素不是我們要找的元素,且桶位中的e = p.next不為null // 說明該桶位中的節(jié)點存在下一個節(jié)點 else if ((e = p.next) != null) { // 說明:當前桶位,要么是 鏈表,要么是 紅黑樹 // 第二種情況:判斷桶位中是否已經(jīng)形成了紅黑樹 if (p instanceof TreeNode) // 說明是以紅黑樹來處理的沖突,則獲取紅黑樹要刪除的結(jié)點 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); // 第三種情況:桶位中已經(jīng)形成鏈表 else { // 判斷是否以鏈表方式處理hash沖突 // 是的話則通過遍歷鏈表來尋找要刪除的結(jié)點 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } // 比較找到的key的value和要刪除的是否匹配 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 第一種情況:如果桶位中是紅黑樹,通過調(diào)用紅黑樹的方法來刪除結(jié)點 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 第二種情況:如果桶位中是鏈表 else if (node == p) // 鏈表刪除 tab[index] = node.next; // 如果桶位中 else // 第三種情況:將當前元素p的下一個元素設(shè)置為 要刪除元素的 下一個元素 p.next = node.next; // 記錄修改次數(shù) ++modCount; // 變動的數(shù)量 --size; afterNodeRemoval(node); return node; } } return null; }
7.4 查找元素方法 get()
查找方法,通過元素的 key 找到 value。
代碼如下:
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
get 方法主要調(diào)用的是 getNode 方法,代碼如下:
final Node<K,V> getNode(int hash, Object key) { // tab:引用當前hashMap的散列表 // first:桶位中的頭元素 // e:臨時node元素 // n:table數(shù)組的長度 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 如果哈希表不為空,并且key對應(yīng)的桶不為空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { /* 判斷數(shù)組元素是否相等 根據(jù)索引的位置檢查第一個元素 注意:總是檢查第一個元素 */ // 第一種情況:定位出來的桶位元素 就是我們要get的數(shù)據(jù) if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 桶位第一個元素不是我們要找的目標元素,且first.next不為null // 說明當前桶位不止一個元素,可能是鏈表,也可能是紅黑樹 if ((e = first.next) != null) { // 第二種情況:桶位已經(jīng)升級成了紅黑樹 // 判斷是否是紅黑樹,是的話調(diào)用紅黑樹中的getTreeNode方法獲取結(jié)點 if (first instanceof TreeNode) // 調(diào)用與樹相關(guān)的方法得到目標元素 return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 第三種情況:桶位已經(jīng)形成鏈表 do { // 不是紅黑樹的話,那就是鏈表結(jié)構(gòu)了 // 通過循環(huán)的方法判斷鏈表中是否存在該key if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } // 如果沒找到返回null return null; }
小結(jié):
get 方法實現(xiàn)的步驟:
a. 通過 hash 值獲取該 key 映射到的桶
b. 桶上的 key 就是要查找的 key,則直接找到并返回
c. 桶上的 key 不是要找的 key,則查看后續(xù)的結(jié)點:
如果后續(xù)結(jié)點是紅黑樹結(jié)點,通過調(diào)用紅黑樹的方法根據(jù) key 獲取 value
如果后續(xù)結(jié)點是鏈表結(jié)點,則通過循環(huán)遍歷鏈表根據(jù) key 獲取 value
8. 遍歷HashMap的幾種方式
分別遍歷 Key 和 Values
for (String key : map.keySet()) { System.out.println(key); } for (Object vlaue : map.values() { System.out.println(value); }
使用 Iterator 迭代器迭代
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, Object> mapEntry = iterator.next(); System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue()); }
通過 get 方式(不建議使用)
Set<String> keySet = map.keySet(); for (String str : keySet) { System.out.println(str + "---" + map.get(str)); }
說明:
根據(jù)阿里開發(fā)手冊,不建議使用這種方式,因為迭代兩次。keySet 獲取 Iterator一次,還有通過 get 又迭代一次,降低性能。
jdk8 以后使用 Map 接口中的默認方法:
default void forEach(BiConsumer<? super K,? super V> action) // BiConsumer接口中的方法: void accept(T t, U u) 對給定的參數(shù)執(zhí)行此操作。 參數(shù) t - 第一個輸入?yún)?shù) u - 第二個輸入?yún)?shù)
遍歷代碼:
HashMap<String,String> map = new HashMap(); map.put("001", "zhangsan"); map.put("002", "lisi"); map.forEach((key, value) -> { System.out.println(key + "---" + value); });
9、總結(jié)
(1)HashMap是一種散列表,采用(數(shù)組 + 鏈表 + 紅黑樹)的存儲結(jié)構(gòu);
(2)HashMap的默認初始容量為16(1<<4),默認裝載因子為0.75f,容量總是2的n次方;
(3)HashMap擴容時每次容量變?yōu)樵瓉淼膬杀叮?/p>
(4)當桶的數(shù)量小于64時不會進行樹化,只會擴容;
(5)當桶的數(shù)量大于64且單個桶中元素的數(shù)量大于8時,進行樹化;
(6)當單個桶中元素數(shù)量小于6時,進行反樹化;
(7)HashMap是非線程安全的容器;
(8)HashMap查找添加元素的時間復雜度都為O(1);
這篇文章就到這里了,也希望大家多多關(guān)注腳本之家的其他內(nèi)容!
相關(guān)文章
Spring源碼之事件監(jiān)聽機制詳解(@EventListener實現(xiàn)方式)
這篇文章主要介紹了Spring源碼之事件監(jiān)聽機制(@EventListener實現(xiàn)方式),具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08java volatile關(guān)鍵字作用及使用場景詳解
在本文里我們給大家分享的是關(guān)于java volatile關(guān)鍵字作用及使用場景的相關(guān)知識點內(nèi)容,需要的朋友們學習下。2019-08-08Mybatis的parameterType造成線程阻塞問題分析
這篇文章主要詳細分析了Mybatis的parameterType造成線程阻塞問題,文中有詳細的解決方法,及相關(guān)的代碼示例,具有一定的參考價值,感興趣的朋友可以借鑒閱讀2023-06-06使用@DS輕松解決動態(tài)數(shù)據(jù)源的問題
這篇文章主要介紹了使用@DS輕松解決動態(tài)數(shù)據(jù)源的問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-05-05Springboot訪問templates html頁面過程詳解
這篇文章主要介紹了Springboot訪問templates html頁面過程詳解,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-05-05如何使用SpringBoot集成Kafka實現(xiàn)用戶數(shù)據(jù)變更后發(fā)送消息
Spring Boot集成Kafka實現(xiàn)用戶數(shù)據(jù)變更后,向其他廠商發(fā)送消息,我們需要考慮配置Kafka連接、創(chuàng)建Kafka Producer發(fā)送消息、監(jiān)聽用戶數(shù)據(jù)變更事件,并將事件轉(zhuǎn)發(fā)到Kafka,本文分步驟給大家講解使用SpringBoot集成Kafka實現(xiàn)用戶數(shù)據(jù)變更后發(fā)送消息,感興趣的朋友一起看看吧2024-07-07