hash表是應用最普遍的數據結構,是對鍵值對數據結構的一種重要實現。
它可以將關鍵字key映射到內存中的某一位置,查詢和插入都能達到平均時間複雜度爲O(1)的性能。
HashMap是java對hash表的實現,它是非線程安全的,也即不會考慮併發的場景。java
<!-- more -->node
hash表是常見的數據結構,大學都學過,之前也曾用C語言實現過一個:
https://github.com/frapples/c...git
偷點懶,這裏就大概總結一下了,畢竟這篇博文jdk代碼纔是重點。github
在使用者的角度來看,HashMap可以存儲給定的鍵值對,而且對於給定key的查詢和插入都達到平均時間複雜度爲O(1)。算法
實現hash表的關鍵在於:數組
hash算法存在hash衝突,也即多個不一樣的K被映射到數組的同一個位置上。如何解決hash衝突?有三種方法。緩存
先來看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節點,其實是平衡樹的節點。
看屬性有一個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
函數看起。
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
函數了。
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位二進制舉個例子:
這樣,就不難理解上面的(n - 1) & hash
了。以上面那個例子,
00001000 - 1 = 00000111,這樣減一以後,須要保留的對應位爲全是1,須要置0的對應位全都是0。把它與B做與運算,就能獲得結果。
沒想到寫這個比想象中的費時間。。。還有不少其餘事情要作呢
這個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; }
思路大概是這樣的邏輯:
一樣,根據hash值定位hash桶數組的位置。而後:
該位置爲鏈表。遍歷鏈表,進行插入。會出現兩種狀況:
在這裏我有幾點疑惑:
在遍歷鏈表時會同時統計鏈表長度,而後鏈表若是被插入,會觸發樹化邏輯:
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
。
考慮到篇幅夠長了,仍是拆分紅兩篇比較好,剩下的留到下一篇博文再寫吧。