hash表是應用最廣泛的資料結構,是對鍵值對資料結構的一種重要實現。
它能夠將關鍵字key對映到記憶體中的某一位置,查詢和插入都能達到平均時間複雜度為O(1)的效能。
HashMap是java對hash表的實現,它是非執行緒安全的,也即不會考慮併發的場景。
<!– more –>
HashMap實現思路
hash表是常見的資料結構,大學都學過,以前也曾用C語言實現過一個:
https://github.com/frapples/c…
偷點懶,這裡就大概總結一下了,畢竟這篇博文jdk程式碼才是重點。
在使用者的角度來看,HashMap能夠儲存給定的鍵值對,並且對於給定key的查詢和插入都達到平均時間複雜度為O(1)。
實現hash表的關鍵在於:
- 對於給定的key,如何將其對應到記憶體中的一個對應位置。這通過hash演算法做到。
- 通過一個陣列儲存資料,通過hash演算法hash(K) % N來將關鍵字key對映陣列對應位置上。
-
hash演算法存在hash衝突,也即多個不同的K被對映到陣列的同一個位置上。如何解決hash衝突?有三種方法。
- 分離連結串列法。即用連結串列來儲存衝突的K。
- 開放定址法。當位置被佔用時,通過一定的演算法來試選其它位置。hash(i) = (hash(key) + d(i)) % N,i代表第i次試選。常用的有平方探測法,d(i) = i^2。
- 再雜湊。如果衝突,就再用hash函式再巢狀算一次,直到沒有衝突。
HashMap程式碼分析
Node節點
先來看Node節點。這表明HashMap採用的是分離連結串列的方法實現。
Node為連結串列節點,其中儲存了鍵值對,key和value。
不過實際上,HashMap
的真正思路更復雜,會用到平衡樹,這個後面再說。
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;
}
/* ... */
}
還能發現,這是一個單連結串列。對於HashMap來說,單連結串列就已經足夠了,雙向連結串列反而多一個浪費記憶體的欄位。
除此之外,還能夠注意到節點額外儲存了hash欄位,為key的hash值。
仔細一想不難明白,HashMap能夠儲存任意物件,物件的hash值是由hashCode
方法得到,這個方法由所屬物件自己定義,裡面可能有費時的操作。
而hash值在Hash表內部實現會多次用到,因此這裡將它儲存起來,是一種優化的手段。
TreeNode節點
這個TreeNode節點,實際上是平衡樹的節點。
看屬性有一個red
,所以是紅黑樹的節點。
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/* ... */
}
除此之外,還能發現這個節點有prev
屬性,此外,它還在父類那裡繼承了一個next
屬性。
這兩個屬性是幹嘛的?通過後面程式碼可以發現,這個TreeNode不僅用來組織紅黑樹,還用來組織雙向連結串列。。。
HashMap會在連結串列過長的時候,將其重構成紅黑樹,這個看後面的程式碼。
屬性欄位
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
最重要的是table
、size
、loadFactor
這三個欄位:
-
table
可以看出是個節點陣列,也即hash表中用於對映key的陣列。由於連結串列是遞迴資料結構,這裡陣列儲存的是連結串列的頭節點。 -
size
,hash表中元素個數。 -
loadFactor
,裝填因子,控制HashMap
擴容的時機。
至於entrySet
欄位,實際上是個快取,給entrySet
方法用的。
而modCount
欄位的意義和LinkedList
一樣,前面已經分析過了。
最後,threshold
這個欄位,含義是不確定的,像女孩子的臉一樣多變。。。
坦誠的說這樣做很不好,可能java為了優化時省點記憶體吧,看後面的程式碼就知道了,這裡總結下:
- 如果
table
還沒有被分配,threshold
為初始的空間大小。如果是0,則是預設大小,DEFAULT_INITIAL_CAPACITY
。 - 如果
table
已經分配了,這個值為擴容閾值,也就是table.length * loadFactor
。
建構函式
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
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;
}
第一個建構函式是重點,它接收兩個引數initialCapacity
代表初始的table也即hash桶陣列的大小,loadFactor
可以自定義擴容閾值。
this.threshold = tableSizeFor(initialCapacity);
這裡也用到了類似前面ArrayList
的“延遲分配”的思路,一開始table是null,只有在第一次插入資料時才會真正分配空間。
這樣,由於實際場景中會出現大量空表,而且很可能一直都不新增元素,這樣“延遲分配”的優化技巧能夠節約記憶體空間。
這裡就體現出threshold
的含義了,hash桶陣列的空間未分配時它儲存的是table初始的大小。
tableSizeFor
函式是將給定的數對齊到2的冪。這個函式用位運算優化過,我沒怎麼研究具體的思路。。。
但是由此可以知道,hash桶陣列的初始大小一定是2的冪,實際上,hash桶陣列大小總是為2的冪。
get函式
hash二次運算
先從get
函式看起。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
我們發現,呼叫getNode
時:
return (e = getNode(hash(key), key)) == null ? null : e.value;
其中呼叫了hash
這個靜態函式:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
也就是說,用於HashMap的hash值,還需要經過這個函式的二次計算。那這個二次計算的目的是什麼呢?
通過閱讀註釋:
- Computes key.hashCode() and spreads (XORs) higher bits of hash
- to lower. Because the table uses power-of-two masking, sets of
- hashes that vary only in bits above the current mask will
- always collide. (Among known examples are sets of Float keys
- holding consecutive whole numbers in small tables.) So we
- apply a transform that spreads the impact of higher bits
- downward. There is a tradeoff between speed, utility, and
- quality of bit-spreading. Because many common sets of hashes
- are already reasonably distributed (so don`t benefit from
- spreading), and because we use trees to handle large sets of
- collisions in bins, we just XOR some shifted bits in the
- cheapest possible way to reduce systematic lossage, as well as
- to incorporate impact of the highest bits that would otherwise
- never be used in index calculations because of table bounds.
嗯。。。大概意思是說,由於hash桶陣列的大小是2的冪次方,對其取餘隻有低位會被使用。這個特點用二進位制寫法研究一下就發現了:如1110 1100 % 0010 0000 為 0000 1100,高位直接被忽略掉了。
也即高位的資訊沒有被利用上,會加大hash衝突的概率。於是,一種思路是把高位的資訊混合到低位上去,提高區分度。就是上面這個hash
函式了。
getNode函式
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
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;
}
get
函式呼叫了getNode
,它接受給定的key,定位出對應的節點。這裡檢查了table為null的情況。此外first = tab[(n - 1) & hash]
實際上就是first = tab[hash % n]
的優化,這個細節太多,等會再分析。
程式碼雖然有點多,但是大部分都是一些特別情況的檢查。首先是根據key的hash值來計算這個key放在了hash桶陣列的哪個位置上。找到後,分三種情況處理:
- 這個位置上只有一個元素。
- 這個位置上是一個連結串列。
- 這個位置上是一棵紅黑樹。
三種情況三種不同的處理方案。比較奇怪的是為什麼1不和2合併。。。
如果是紅黑樹的話,呼叫紅黑樹的查詢函式來最終找到這個節點。
如果是連結串列的話,則遍歷連結串列找到這個節點。值得關注的是對key的比較:
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
類似於hashCode
方法,equals
方法也是所屬物件自定義的,比較可能比較耗時。
所以這裡先比較Node節點儲存的hash值和引用,這樣儘量減少呼叫equals
比較的時機。
模運算的優化
回到剛才的位運算:
first = tab[(n - 1) & hash]
這個位運算,實際上是對取餘運算的優化。由於hash桶陣列的大小一定是2的冪次方,因此能夠這樣優化。
思路是這樣的,bi是b二進位制第i位的值:
b % 2i = (2NbN + 2N-1 bN-1+ … + 2ibi + … 20b0) % 2i
設x >= i,則一定有2xbx % 2i = 0
所以,上面的式子展開後就是:
b % 2i = 2i-1bi-1 + 2i-2bi-2 + … 20b0
反映到二進位制上來說,以8位二進位制舉個例子:
- 顯然2的冪次方N的二進位制位是隻有一個1的。8的二進位制為00001000,1在第3位。
- 任何一個數B餘這個數N,反映二進位制上,就是高於等於第3位的置0,低於的保留。如10111010 % 00001000 = 00000010
這樣,就不難理解上面的(n - 1) & hash
了。以上面那個例子,
00001000 – 1 = 00000111,這樣減一之後,需要保留的對應位為全是1,需要置0的對應位全都是0。把它與B作與運算,就能得到結果。
put函式
沒想到寫這個比想象中的費時間。。。還有很多其他事情要做呢
這個put函式太長了,容我偷個懶直接貼程式碼和我自己的註釋吧
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent含義是如果那個位置已經有值了,是否替換
// evict什麼鬼?table處於創造模式?先不管
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table為null或者沒有值的時候reisze(),因此這個函式還負責初始分配
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 定位hash桶。如果是空連結串列的話(即null),直接新節點插入:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 如果hash桶掛的是二叉樹,呼叫TreeNode的putTreeVal方法完成插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果掛的是連結串列,插入實現
// 遍歷連結串列,順便binCount變數統計長度
for (int binCount = 0; ; ++binCount) {
// 情況一:到尾巴了,就插入一條
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 插入會導致連結串列變長
// 可以發現,TREEIFY_THRESHOLD是個閾值,超過了就呼叫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;
}
}
// 情況二的處理
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果hash桶陣列的大小超過了閾值threshold,就resize(),可見resize負責擴容
if (++size > threshold)
resize();
// evice的含義得看afterNodeInsertion函式才能知道
afterNodeInsertion(evict);
return null;
}
思路大概是這樣的邏輯:
- 判斷table是否分配,如果沒有就先分配空間,和前面提到的“延時分配”對應起來。
-
同樣,根據hash值定位hash桶陣列的位置。然後:
- 該位置為null。直接建立一個節點插入。
- 該位置為平衡樹。呼叫TreeNode的一個方法完成插入,具體邏輯在這個方法裡。
-
該位置為連結串列。遍歷連結串列,進行插入。會出現兩種情況:
- 遍歷到連結串列尾,說明這個key不存在,應該直接在連結串列尾插入。但是這導致連結串列增長,需要觸發連結串列重構成平衡樹的判斷邏輯。
- 找到一個key相同的節點,單獨拎出來處理,得看onlyIfAbsent的引數。
- 完畢之後,這個時候hash表中可能多了一個元素。也只有多了一個元素的情況下控制流才能走到這。這時維護size欄位,並且觸發擴容的判斷邏輯。
在這裡我有幾點疑惑:
- 為什麼null的情況、一個節點的情況、單連結串列的情況不合並在一起處理?因為效能?
- 為什麼採用尾插法不用頭插法?頭插法根據區域性性原理豈不是更好嗎?
在遍歷連結串列時會同時統計連結串列長度,然後連結串列如果被插入,會觸發樹化邏輯:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
TREEIFY_THRESHOLD
的值是8,也就是說,插入後的連結串列長度如果超過了8,則會將這條連結串列重構為紅黑樹,以提高定位效能。
在插入後,如果hash表中元素個數超過閾值,則觸發擴容邏輯:
if (++size > threshold)
resize();
記得前面說過,threshold
在table已經分配的時候,代表是擴容閾值,即table.length * loadFactor
。
最後
考慮到篇幅夠長了,還是拆分成兩篇比較好,剩下的留到下一篇博文再寫吧。