[java]HashMap原理剖析
1、HashMap在JAVA中的怎麼工作的?
基於Hash的原理。
2、什麼是雜湊?
最簡單形式的 hash,是一種在對任何變數/物件的屬性應用任何公式/演算法後, 為其分配唯一程式碼的方法。
一個真正的hash方法必須遵循下面的原則。
雜湊函式每次在相同或相等的物件上應用雜湊函式時, 應每次返回相同的雜湊碼。換句話說, 兩個相等的物件必須一致地生成相同的雜湊碼。
Java 中所有的物件都有 Hash 方法。
Java中的所有物件都繼承 Object 類中定義的 hashCode() 函式的預設實現。 此函式通常通過將物件的內部地址轉換為整數來生成雜湊碼,從而為所有不同的物件生成不同的雜湊碼。
3、你清楚HashMap 中的 Node 類的結構嗎?
Map的定義是: 將鍵對映到值的物件。
因此,HashMap 中必須有一些機制來儲存這個鍵值對。 答案是肯的。 HashMap 有一個內部類 Node,如下所示。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;// 記錄hash值, 以便重hash時不需要再重新計算
final K key;
V value;
Node<K,V> next;
...// 其餘的程式碼
}
當然,Node 類具有儲存為屬性的鍵和值的對映。 key 已被標記為 final,另外還有兩個欄位:next 和 hash。
在下面中, 我們將會理解這些屬性的必須性。
4、鍵值對在 HashMap中是如何儲存的?
鍵值對在 HashMap 中是以 Node 內部類的陣列存放的,如下所示。
transient Node<K,V>[] table;
雜湊碼計算出來之後, 會轉換成該陣列的下標, 在該下標中儲存對應雜湊碼的鍵值對, 在此先不詳細講解hash碰撞的情況。
該陣列的長度始終是2的次冪, 通過以下的函式實現該過程。
static final int tableSizeFor(int cap) {
int n = cap - 1;// 如果不做該操作, 則如傳入的 cap 是 2 的整數冪, 則返回值是預想的 2 倍
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;
}
其原理是將傳入引數 (cap) 的低二進位制全部變為1,最後加1即可獲得對應的大於 cap 的 2 的次冪作為陣列長度。
為什麼要使用2的次冪作為陣列的容量呢?
在此有涉及到 HashMap 的 hash 函式及陣列下標的計算, 鍵(key)所計算出來的雜湊碼有可能是大於陣列的容量的,那怎麼辦? 可以通過簡單的求餘運算來獲得,但此方法效率太低。HashMap中通過以下的方法保證 hash 的值計算後都小於陣列的容量。
(n - 1) & hash
這也正好解釋了為什麼需要2的次冪作為陣列的容量。由於n是2的次冪,因此,n-1類似於一個低位掩碼。通過與操作,高位的hash值全部歸零,保證低位才有效 從而保證獲得的值都小於n。
同時,在下一次 resize() 操作時, 重新計算每個 Node 的陣列下標將會因此變得很簡單,具體的後文講解。以預設的初始值16為例。
01010011 00100101 01010100 00100101
& 00000000 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000000 00000101 //高位全部歸零,只保留末四位
// 保證了計算出的值小於陣列的長度 n
但是,使用了該功能之後,由於只取了低位,因此 hash 碰撞會也會相應的變得很嚴重。這時候就需要使用「擾動函式」。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
該函式通過將雜湊碼的高16位的右移後與原雜湊碼進行異或而得到,以上面的例子為例。
此方法保證了高16位不變, 低16位根據異或後的結果改變。計算後的陣列下標將會從原先的5變為0。
使用了 「擾動函式」 之後, hash 碰撞的概率將會下降。 有人專門做過類似的測試, 雖然使用該 「擾動函式」 並沒有獲得最大概率的避免 hash 碰撞,但考慮其計算效能和碰撞的概率, JDK 中使用了該方法,且只hash一次。
5、雜湊碰撞是如何處理的?
在理想的情況下, 雜湊函式將每一個 key 都對映到一個唯一的 bucket, 然而, 這是不可能的。哪怕是設計在良好的雜湊函式,也會產生雜湊衝突。
前人研究了很多雜湊衝突的解決方法,在維基百科中,總結出了四大類。
在 Java 的 HashMap 中, 採用了第一種 Separate chaining 方法(大多數翻譯為拉鍊法)+連結串列和紅黑樹來解決衝突。
在 HashMap 中, 雜湊碰撞之後會通過 Node 類內部的成員變數 Node<K,V> next; 來形成一個連結串列(節點小於8)或紅黑樹(節點大於8, 在小於6時會從新轉換為連結串列), 從而達到解決衝突的目的。
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
6、HashMap 是如何初始化的?
public HashMap();
public HashMap(int initialCapacity);
public HashMap(Map<? extends K, ? extends V> m);
public HashMap(int initialCapacity, float loadFactor);
HashMap 中有四個建構函式, 大多是初始化容量和負載因子的操作。以 public HashMap(int initialCapacity, float loadFactor) 為例。
public HashMap(int initialCapacity, float loadFactor) {
// 初始化的容量不能小於0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始化容量不大於最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 負載因子不能小於 0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
通過該函式進行了容量和負載因子的初始化,如果是呼叫的其他的建構函式, 則相應的負載因子和容量會使用預設值(預設負載因子=0.75, 預設容量=16)。在此時, 還沒有進行儲存容器 table 的初始化, 該初始化要延遲到第一次使用時進行。
7、HashMap 中雜湊表是如何動態擴容的?
所謂的雜湊表, 指的就是下面這個型別為內部類Node的 table 變數。
transient Node<K,V>[] table;
作為陣列, 其在初始化時就需要指定長度。在實際使用過程中, 我們儲存的數量可能會大於該長度,因此 HashMap 中定義了一個閾值引數(threshold), 在儲存的容量達到指定的閾值時, 需要進行擴容。
我個人認為初始化也是動態擴容的一種, 只不過其擴容是容量從 0 擴充套件到建構函式中的數值(預設16)。 而且不需要進行元素的重hash.
7.1 擴容發生的條件
初始化的話只要數值為空或者陣列長度為 0 就會進行。 而擴容是在元素的數量大於閾值(threshold)時就會觸發。
threshold = loadFactor * capacity
比如 HashMap 中預設的 loadFactor=0.75, capacity=16, 則。
threshold = loadFactor * capacity = 0.75 * 16 = 12
那麼在元素數量大於 12 時, 就會進行擴容。 擴容後的 capacity 和 threshold 也會隨之而改變。
負載因子影響觸發的閾值,因此,它的值較小的時候,HashMap 中的 hash 碰撞就很少, 此時存取的效能都很高,對應的缺點是需要較多的記憶體;而它的值較大時,HashMap 中的 hash 碰撞就很多,此時存取的效能相對較低,對應優點是需要較少的記憶體;不建議更改該預設值,如果要更改,建議進行相應的測試之後確定。
7.2 再談容量為2的整數次冪和陣列索引計算
前面說過了陣列的容量為 2 的整次冪, 同時, 陣列的下標通過下面的程式碼進行計算。
index = (table.length - 1) & hash
該方法除了可以很快的計算出陣列的索引之外, 在擴容之後, 進行重 hash 時也會很巧妙的就可以算出新的 hash 值。 由於陣列擴容之後, 容量是現在的 2 倍, 擴容之後 n-1 的有效位會比原來多一位, 而多的這一位與原容量二進位制在同一個位置。 示例。
這樣就可以很快的計算出新的索引啦。
7.3 步驟
-
先判斷是初始化還是擴容, 兩者在計算newCap和newThr時會不一樣
-
計算擴容後的容量,臨界值。
-
將hashMap的臨界值修改為擴容後的臨界值
-
根據擴容後的容量新建陣列,然後將hashMap的table的引用指向新陣列。
-
將舊陣列的元素複製到table中。在該過程中, 涉及到幾種情況, 需要分開進行處理(只存有一個元素, 一般連結串列, 紅黑樹)
具體的看程式碼吧。
final Node<K, V>[] resize() {
//新建oldTab陣列儲存擴容前的陣列table
Node<K, V>[] oldTab = table;
//獲取原來陣列的長度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//原來陣列擴容的臨界值
int oldThr = threshold;
int newCap, newThr = 0;
//如果擴容前的容量 > 0
if (oldCap > 0) {
//如果原來的陣列長度大於最大值(2^30)
if (oldCap >= MAXIMUM_CAPACITY) {
//擴容臨界值提高到正無窮
threshold = Integer.MAX_VALUE;
//無法進行擴容,返回原來的陣列
return oldTab;
//如果現在容量的兩倍小於MAXIMUM_CAPACITY且現在的容量大於DEFAULT_INITIAL_CAPACITY
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//臨界值變為原來的2倍
newThr = oldThr << 1;
} else if (oldThr > 0) //如果舊容量 <= 0,而且舊臨界值 > 0
//陣列的新容量設定為老陣列擴容的臨界值
newCap = oldThr;
else { //如果舊容量 <= 0,且舊臨界值 <= 0,新容量擴充為預設初始化容量,新臨界值為DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
newCap = DEFAULT_INITIAL_CAPACITY;//新陣列初始容量設定為預設值
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//計算預設容量下的閾值
}
// 計算新的resize上限
if (newThr == 0) {//在當上面的條件判斷中,只有是初始化時(oldCap=0, oldThr > 0)時,newThr == 0
//ft為臨時臨界值,下面會確定這個臨界值是否合法,如果合法,那就是真正的臨界值
float ft = (float) newCap * loadFactor;
//當新容量< MAXIMUM_CAPACITY且ft < (float)MAXIMUM_CAPACITY,新的臨界值為ft,否則為Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
(int) ft : Integer.MAX_VALUE);
}
//將擴容後hashMap的臨界值設定為newThr
threshold = newThr;
//建立新的table,初始化容量為newCap
@SuppressWarnings({"rawtypes", "unchecked"})
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
//修改hashMap的table為新建的newTab
table = newTab;
//如果舊table不為空,將舊table中的元素複製到新的table中
if (oldTab != null) {
//遍歷舊雜湊表的每個桶,將舊雜湊表中的桶複製到新的雜湊表中
for (int j = 0; j < oldCap; ++j) {
Node<K, V> e;
//如果舊桶不為null,使用e記錄舊桶
if ((e = oldTab[j]) != null) {
//將舊桶置為null
oldTab[j] = null;
//如果舊桶中只有一個node
if (e.next == null)
//將e也就是oldTab[j]放入newTab中e.hash & (newCap - 1)的位置
newTab[e.hash & (newCap - 1)] = e;
//如果舊桶中的結構為紅黑樹
else if (e instanceof TreeNode)
//將樹中的node分離
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else { //如果舊桶中的結構為連結串列,連結串列重排,jdk1.8做的一系列優化
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 {// 原索引+oldCap
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;
}
7.4 注意事項
雖然 HashMap 設計的非常優秀, 但是應該儘可能少的避免 resize(), 該過程會很耗費時間。
同時, 由於 hashmap 不能自動的縮小容量 因此,如果你的 hashmap 容量很大,但執行了很多 remove操作時,容量並不會減少。如果你覺得需要減少容量,請重新建立一個 hashmap。
8、HashMap.put() 函式內部是如何工作的?
在使用多次 HashMap 之後, 大體也能說出其新增元素的原理:計算每一個key的雜湊值, 通過一定的計算之後算出其在雜湊表中的位置,將鍵值對放入該位置,如果有雜湊碰撞則進行雜湊碰撞處理。
而其工作時的原理如下。
原始碼如下。
/* @param hash 指定引數key的雜湊值
* @param key 指定引數key
* @param value 指定引數value
* @param onlyIfAbsent 如果為true,即使指定引數key在map中已經存在,也不會替換value
* @param evict 如果為false,陣列table在建立模式中
* @return 如果value被替換,則返回舊的value,否則返回null。當然,可能key對應的value就是null。
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
//如果雜湊表為空,呼叫resize()建立一個雜湊表,並用變數n記錄雜湊表長度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
* 如果指定引數hash在表中沒有對應的桶,即為沒有碰撞
* Hash函式,(n - 1) & hash 計算key將被放置的槽位
* (n - 1) & hash 本質上是hash % n,位運算更快
*/
if ((p = tab[i = (n - 1) & hash]) == null)
//直接將鍵值對插入到map中即可
tab[i] = newNode(hash, key, value, null);
else {// 桶中已經存在元素
Node<K, V> e;
K k;
// 比較桶中第一個元素(陣列中的結點)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 將第一個元素賦值給e,用e來記錄
e = p;
// 當前桶中無該鍵值對,且桶是紅黑樹結構,按照紅黑樹結構插入
else if (p instanceof TreeNode)
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
// 當前桶中無該鍵值對,且桶是連結串列結構,按照連結串列結構插入到尾部
else {
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;
}
// 連結串列節點的<key, value>與put操作<key, value>相同時,不做重複操作,跳出迴圈
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 找到或新建一個key和hashCode與插入元素相等的鍵值對,進行put操作
if (e != null) { // existing mapping for key
// 記錄e的value
V oldValue = e.value;
/**
* onlyIfAbsent為false或舊值為null時,允許替換舊值
* 否則無需替換
*/
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 訪問後回撥
afterNodeAccess(e);
// 返回舊值
return oldValue;
}
}
// 更新結構化修改資訊
++modCount;
// 鍵值對數目超過閾值時,進行rehash
if (++size > threshold)
resize();
// 插入後回撥
afterNodeInsertion(evict);
return null;
}
在此過程中, 會涉及到雜湊碰撞的解決。
9、HashMap.get() 方法內部是如何工作的?
/**
* 返回指定的key對映的value,如果value為null,則返回null
* get可以分為三個步驟:
* 1.通過hash(Object key)方法計算key的雜湊值hash。
* 2.通過getNode( int hash, Object key)方法獲取node。
* 3.如果node為null,返回null,否則返回node.value。
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K, V> e;
//根據key及其hash值查詢node節點,如果存在,則返回該節點的value值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
其最終是呼叫了 getNode 函式。 其邏輯如下。
原始碼如下。
/**
* @param hash 指定引數key的雜湊值
* @param key 指定引數key
* @return 返回node,如果沒有則返回null
*/
final Node<K, V> getNode(int hash, Object key) {
Node<K, V>[] tab;
Node<K, V> first, e;
int n;
K k;
//如果雜湊表不為空,而且key對應的桶上不為空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果桶中的第一個節點就和指定引數hash和key匹配上了
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
//返回桶中的第一個節點
return first;
//如果桶中的第一個節點沒有匹配上,而且有後續節點
if ((e = first.next) != null) {
//如果當前的桶採用紅黑樹,則呼叫紅黑樹的get方法去獲取節點
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);
}
}
//如果雜湊表為空,或者沒有找到節點,返回null
return null;
}
相關文章
- HashMap原理底層剖析HashMap
- HashMap原始碼剖析HashMap原始碼
- Java中HashMap的實現原理JavaHashMap
- Java引用型別原理剖析Java型別
- Java原子類操作原理剖析Java
- Java:HashMap原理與設計緣由JavaHashMap
- java面試題-HashMap的工作原理Java面試題HashMap
- HashMap原理HashMap
- Java常用演算法原理剖析Java演算法
- Java併發之AQS原理剖析JavaAQS
- Java集合詳解(三):HashMap原理解析JavaHashMap
- Java執行緒池核心原理剖析Java執行緒
- Java HashMap原理及內部儲存結構JavaHashMap
- Java進階:HashMap底層原理(通俗易懂篇)JavaHashMap
- HashMap擴容原理HashMap
- HashMap原理21問HashMap
- Java物件複製原理剖析及最佳實踐Java物件
- AbstractQueuedSynchronizer原理剖析
- JVM原理剖析JVM
- Memcached 原理剖析
- KVC原理剖析
- Eureka原理剖析
- HashMap的底層原理HashMap
- JDK1.7-HashMap原理JDKHashMap
- HashMap jdk1.7和1.8原始碼剖析HashMapJDK原始碼
- HashMap就是這麼簡單【原始碼剖析】HashMap原始碼
- HashMap原理詳解,包括底層原理HashMap
- Java集合:HashMapJavaHashMap
- Java執行緒池ThreadPoolExecutor實現原理剖析 #28Java執行緒thread
- ReactDom render原理剖析React
- Module Federation原理剖析
- redux applyMiddleware 原理剖析ReduxAPP
- HashMap底層實現原理HashMap
- HashMap的底層原理分析HashMap
- HashMap底層實現原理/HashMap與HashTable區別/HashMap與HashSet區別HashMap
- Java集合之HashMapJavaHashMap
- Java HashMap merge() 方法JavaHashMap
- HashMap原理(二) 擴容機制及存取原理HashMap