Java 8 HashMap 實現機制簡析
最近看《java核心思想》看到了容器部分,本書著重描述了HashMap 的實現機制,對於Map,我們的固有印象便是存取很快,特別是HashMap,我們知道底層是雜湊表結構。但HashMap具體怎麼維護這個資料結構,這是我們今天要記錄的問題。
HashMap的基本組成
要知道HashMap為什麼存取效能優異,就要了解它內部的構造。hashmap實質是由 陣列+連結串列 構成,在java 8 中,連結串列被優化成 在資料量比較大的情況下轉變成紅黑樹。下圖是HashMap的基本結構。
對著圖,我們分析它內部一些必要組成結構。
陣列table
/**
* 這個表陣列,會在初次使用的時候初始化,並且在必要的時候重新設定大小,當空間重新分配時,長度總是2的倍數
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
*/
transient Node<K,V>[] table;
所以HashMap的第一層,是一個數組table ,通過構造器你會發現,陣列table 不會在構造器內被初始化,而是在真正業務操作時初始化,比如put。陣列table裡面存的是Node<K,V>,這也就是HashMap的第二層。
Node<K,V>
Node<K,V>是一個內部類,結構如下:
可以看出這是一個自定義的容器 – HashMap真正存每個key-value資料的地方。通過 最前面的示例圖和 table,node的簡單介紹,我們可以總結出,hashmap先是一個數組,然後每個陣列內部是一個node連結串列,我們的key-value資料被存放在每個Node節點中,
/**
* Basic hash bin node, used for most entries.
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的hash值
final K key; //key
V value; //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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
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;
}
}
HashMap的存取機制
這張圖以put方法為例,精要的總結了HashMap存的流程,但是我們是不是看的一臉懵逼?那我們分步驟講解.
當我們呼叫put方法時,我們需要先計算key的hash值
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hashcode的意義和設計
hash()方法其實是呼叫了key的hashCode方法,每個Object都有equals()和hashCode方法,正常來說,我們都需要複寫這個方法。普通的線性查詢演算法從頭查到尾, 效率很慢,如果我們給每個key帶上一個索引,程式根據這個索引直接找到這個key,便可以直接鎖定這個值,這個索引便是我們說的hash值。折射到hashmap裡的設計,陣列table的下標儲存的不是key,而是hash值, 陣列的隨機訪問很快,通過索引我們可以做到快速定位。
根據《java程式設計思想》的說法,如果我們不復寫hashcode方法,預設的返回值是物件地址,也就是說hashcode返回的都是唯一值,但是這樣設計會要求陣列table的空間很大,並不利於效率,最好的方法hash值的設計應該根據物件內容來設計,物件內容一致的hash值應該要一樣。
這是Integer的hashcode,返回的就是實際的內容
public static int hashCode(int value) {
return value;
}
但是還有一個問題,根據內容生成的hash值不就存在hash值相同的情況了嗎,table陣列怎麼存啊?這種情況稱為hash衝突,但是還記得上面說的 hashmap是由陣列+連結串列組成的嗎?所以hash值一樣的資料被組成了一個連結串列,先通過hash值確定物件在table陣列中的具體位置,然後通過equals()方法對連結串列上的資料進行一一比較,最終確定這個物件應該存在的位置。這種資料結構比從頭到尾的線性查詢更有效率。
所以在HashMap中 元素的操作都是通過 hashCode() 和 equals()組合來確定元素位置的。
再回到HashMap中的hash方法中,裡面除了提取物件的hashcode值,自己也做了一些位運算處理.
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap擴容機制
計算完hash後開始呼叫putVal方法,因為在呼叫get put之前,陣列table是沒有初始化的,所以會先做一個數組初始化操作,初始化操作都在resize()方法中進行,但是resize方法不止是初始化陣列,更重要的是後期陣列table的擴容操作。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
HashMap預設的初始化大小為16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
下面這段程式碼
final Node<K,V>[] resize() {
//現在的table陣列
Node<K,V>[] oldTab = table;
//現在的table陣列容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//threshold 為擴容的臨界點,公式為 capacity(現在的table陣列容量) * load factor(裝載因子,衡量hashmap滿的程度,預設0.75),超過臨界點,就會擴容
int oldThr = threshold;
int newCap, newThr = 0;
//如果現在的table陣列容量大於0
if (oldCap > 0) {
//如果table長度大於規定的最大容量,就不擴容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否則newCap變成當前容量的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//newThr 變成當前擴容臨界點的2倍
newThr = oldThr << 1; // double threshold
}
//這個是針對建立hashmap時指定了initialCapacity 和 loadFactor的情況,會將容器擴容為計算好的threshold大小
//HashMap(int initialCapacity, float loadFactor)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//table還未初始化,初始化為系統預設大小,並且計算好擴容的臨界值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//總之,之前的都在計算newThr和newCap,確保它們有有意義的值。這樣做是為了生成新的table,眾所周知,當容器擴容時,為了保證kv元素能均勻的分佈,我們需要重新計算遷移元素。並把舊的table釋放。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//根據計算的newCap生成新的table
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;
if (e.next == null)
// 資料的遷移注意看這個公式,這也是計算某個kv元素存在table陣列具體位置的公式 e.hash & (newCap - 1)
// 假設初始容量為16 ,A元素沒擴容前hash值為15,則A在原來的table上的位置為(15& (16-1)) = 15,那麼擴容後為32,則新的hash值為 15&(32-1) = 15,這個元素不用做變動;
// B元素沒擴容前hash值為20,則B在原來的table上的位置為(20& (16-1)) = 4,那麼擴容後新的hash值為20&(32-1) = 20,這個元素正好向右偏移了oldCap個單位。
//這個巧妙的設計保證了一半元素原地不動,一半元素向右偏移了oldCap個單位(此結論未作嚴謹驗證,但是意在表達hashmap擴容後,元素的遷移思路)
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;
}
hashmap擴容機制都寫在了上面程式碼的註釋裡,光從容器擴容的程式碼我們就可以大概瞭解到hashmap 的運作原理,其中包括了:
- capacity,loadFactor,threshold的基本概念和作用
- kv元素如何確定在table中的位置以及擴容後的位置
走完resize()方法,我們繼續走完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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通過公式e.hash & (newCap - 1)確定kv元素的位置,如果該位置沒有其它元素,直接在table[i]中插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果有其它元素
else {
Node<K,V> e; K k;
//如果舊kv的key跟新kv的key內容完全一樣,則先用e記錄,後面會覆蓋其value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果該連結串列是一個紅黑樹結構,且兩個key內容不相等,則會用putTreeVal將這新的記錄插入到紅黑樹中去
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果這個連結串列不是紅黑樹結構,且兩個key內容不相等,會進入這段程式碼
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//這裡要注意,在java8中,如果一個連結串列裡的kv元素大於8,則會用treeifyBin將這個連結串列轉化成紅黑樹結構,紅黑樹的查詢效率更高。
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;
}
}
//這裡是針對key內容一樣需要做覆蓋處理的具體程式碼
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap的取過程
通過漫長的程式碼分析,我們走完hashmap是如何存一個元素的,下面再看看它是如何取的。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//依舊從公式(n - 1) & hash 確定kv元素的具體位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//總是檢查第一個元素,如果第一個直接key內容完全匹配上,就返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果第一個元素取不到,且接下來是個紅黑樹結構,就用getTreeNode方法去取值
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);
}
}
return null;
}
可以看出,存和取是一個互逆過程,兩者原理相似。
結語
這篇文章從程式碼層面解釋了HashMap的存取機制,它利用了陣列的高隨機訪問能力,但是對於資料大容量問題,又用 連結串列/紅黑樹 與陣列配合 作為解決方案,可見HashMap設計的精妙之處。
本文內容也參考了該文章,對該文章作者表示感謝
http://www.jianshu.com/p/aa017a3ddc40