JDK10原始碼分析之HashMap
HashMap在工作中大量使用,但是具體原理和實現是如何的呢?技術細節是什麼?帶著很多疑問,我們來看下JDK10原始碼吧。
1、資料結構
採用Node<K,V>[]陣列,其中,Node<K,V>這個類實現Map.Entry<K,V>,是一個連結串列結構的物件,並且在一定條件下,會將連結串列結構變為紅黑樹。所以,JDK10採用的是陣列+連結串列+紅黑樹的資料結構。貼上Node的原始碼
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; } public final K getKey() { returnkey; } 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; } }
2、靜態變數(預設值)
- DEFAULT_INITIAL_CAPACITY= 1 << 4:初始化陣列預設長度。1左移4位,為16。
- MAXIMUM_CAPACITY = 1 << 30:初始化預設容量大小,2的30次方。
- DEFAULT_LOAD_FACTOR = 0.75f:負載因子,用於和陣列長度相乘,當陣列長度大於得到的值後,會進行陣列的擴容,擴容倍數是2^n。
- TREEIFY_THRESHOLD = 8:連結串列長度達到該值後,會進行資料結構轉換,變成紅黑樹,優化速率。
- UNTREEIFY_THRESHOLD = 6:紅黑樹的數量小於6時,在resize中,會轉換成連結串列。
3、建構函式
/** * Constructs an empty {@code HashMap} 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); } /** * Constructs an empty {@code HashMap} with the specified initial * capacity and the default load factor (0.75). * * @param initialCapacity the initial capacity. * @throws IllegalArgumentException if the initial capacity is negative. */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * Constructs an empty {@code HashMap} with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * Constructs a new {@code HashMap} with the same mappings as the * specified {@code Map}. The {@code HashMap} is created with * default load factor (0.75) and an initial capacity sufficient to * hold the mappings in the specified {@code Map}. * * @param m the map whose mappings are to be placed in this map * @throws NullPointerException if the specified map is null */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
四個建構函式,這裡不細說,主要說明一下一個方法。
1、tableSizeFor(initialCapacity)
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; }
這個方法返回一個2^n的值,用於初始化陣列的大小,可以看到,入參的數值不是實際的陣列長度,是經過計算得來的大於該值的第一個2^n值,並且,計算後大於2^30時,直接返回2^30。來說明下這個演算法的原理,為什麼會返回2^n。至於返回2^n有什麼用,後面會有說明。
為什麼會得到2^n,舉個例子。比如13。13的2進位制是0000 1101,上面運算相當於以下算式。
0000 1101 右移一位 0000 0110 ,取或0000 1111 一直運算下去,最後+1,確實是2^n。
下面,由於是取或,我們現在只關心二進位制最高位的1,後面不管是1或0,都先不看,我們來看以下運算。
000... 1 ... 右移一位與原值取或後,得到 000... 11 ...
000... 11 ... 右移兩位與原值取或後,得到 000... 11 11 ...
000... 1111 ... 右移四位與原值取或後,得到 000... 1111 1111 ...
以此下去,在32位範圍內的值,通過這樣移動後,相當於用最高位的1,將之後的所有值,都補為1,得到一個2^n-1的值。最後+1自然是2^n。
4、主要方法
- put(K key, V value)
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; //計算key的hash值,落在陣列的哪一個區間,如果不存在則新建Node元素 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //陣列存在的情況下,判斷key是否已有,如果存在,則返回該值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果p是紅黑樹,則直接加入紅黑樹中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //如果不是紅黑樹,則遍歷連結串列 for (int binCount = 0; ; ++binCount) { //如果p的next(連結串列中的下一個值)為空,則直接追加在該值後面 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //如果該連結串列存完之後,長度大於8,則轉換為紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //如果next不為空,則比較該連結串列節點時候就是存入的key,如果是,直接返回 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; //陣列中元素個數如果大於陣列容量*負載因子,則觸發陣列resize操作。 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
HashMap,hash是雜湊演算法,所以HashMap中,主要也用了雜湊的原理。就是將資料通過hash的雜湊演算法計算其分佈情況,存入map中。上面是put的程式碼,可以看出主要的流程是:初始化一個Node陣列,長度為2^n,計算key值落在陣列的位置,如果該位置沒有Node元素,則用該key建立一個Node插入,如果存在hash碰撞,即不同key計算後的值落在了同一位置,則將該值存到Node連結串列中。其餘具體細節,在上面原始碼中已經標註。
- hash(key)
計算put的hash入參,原始碼如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
可以看到,用到了key的hashCode方法,這個不細說,主要是計算key的雜湊值。主要講一下後面為什麼要和h右移16後相異或。實際上,是為了讓這個hashCode的二進位制值的1分佈更雜湊一些。因為後面的運算需要,需要這樣做(為什麼後面的運算需要讓1分散,這個我們下面會講)。下面我們來看,為什麼這樣運算後,會增加1的雜湊性。可以看到,16位以內的二進位制hashCode和它右移16位後取異或得到的值是一樣的。我們舉例時,用8位二進位制和它右移4位取異或來舉例,
比如 1101 1000 0001 0101,
右移8位為 0000 0000 1101 1000,
取異或後 1101 1000 1100 1101,可以看到1的分佈更均勻了一些。
舉個極端點的例子 1000 0000 0000 0000
右移8為 0000 0000 1000 0000
取異或後 1000 0000 1000 0000,可以明顯看到,1多了一個。所以這樣運算是有一定效果的,使hash碰撞的機率要低了一些。
3. resize()
該方法在陣列初始化,陣列擴容,轉換紅黑樹(treeifyBin中,if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize();)中會觸發。主要用於陣列長度的擴充套件2倍,和資料的重新分佈。原始碼如下
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //如果原陣列存在,且大於2^30,則設定陣列長度為0x7fffffff if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //如果原陣列存在,則將其長度擴充套件為2倍。 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; 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); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //如果原陣列不為空,則取出陣列中的元素,進行hash位置的重新計算,可以看到,重新計算耗時較多,所以儘量用多大陣列就初始化多大最好。 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) 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; }
4. p = tab[i = (n - 1) & hash]
計算key的hash落在陣列的哪個位置,它決定了陣列長度為什麼是2^n。主要是(n-1) & hash,這裡就會用到上面hash()方法中,讓1雜湊的作用。這個方法也決定了,為什麼陣列長度為2^n,下面我們具體解釋一下。由於初始化中,n的值是resize方法返回的,resize中用到的就是tableSizeFor方法返回的2^n的值。如16,下面我舉例說明,如陣列長度是16:則n-1為15,二進位制是 0000 1111與hash取與時,由於0與1/0都為0,所以我們只看後四位1111和hash的後四位。可以看到,與1111取與,可以得到0-15的值,這時,保證了hash能實現落在陣列的所有下標。假想一下,如果陣列長度為15或其他非二進位制值,15-1=14,14的二進位制為1110,由於最後一位是0,和任何二進位制取與,最後一位都是0,則hash落不到陣列下標為0,2,4,6,8,10,12,14的偶數下標,這樣資料分佈會更集中,加重每個下標Node的負擔,且陣列中很多下標無法利用。原始碼作者正是利用了2^n-1,得到二進位制最後全為1,並且與hash相與後,能讓hash分佈覆蓋陣列所有下標上的特性。之前hash()方法通過HashCode與HashCode右移16位取異或,讓1分佈更加均勻,也是為了讓hash在陣列中的分佈更加均勻,從而避免某個下標Node元素過多,效率下降,且過多元素會觸發resize耗費時間的缺點,當然,可以看到極端情況下,hash()計算的值並不能解決hash碰撞問題,但是為了HashMap的效能設計者沒有考慮該極端情況,也是通過16位hashCode右移8位來舉例說明。
如: 1000 1000 0000 0000和1000 1100 0000 0000,如果不移位取異或,這兩個hash值與1111取與,都是分佈在同一位置,分佈情況不良好。
右移8位: 1000 1000 1000 1000和1000 1100 1000 1100,可以看到兩個值與1111取與分佈在陣列的兩個下標。
極端情況:1000 0000 0000 0000和1100 0000 0000 0000,該值又移8為取異或後,並不能解決hash碰撞。