目錄java
@node
基於Hash的原理算法
最簡單形式的 hash,是一種在對任何變量/對象的屬性應用任何公式/算法後, 爲其分配惟一代碼的方法。數組
一個真正的hash方法必須遵循下面的原則app
哈希函數每次在相同或相等的對象上應用哈希函數時, 應每次返回相同的哈希碼。換句話說, 兩個相等的對象必須一致地生成相同的哈希碼。函數
Java 中全部的對象都有 Hash 方法性能
Java中的全部對象都繼承 Object
類中定義的 hashCode()
函數的默認實現。 此函數一般經過將對象的內部地址轉換爲整數來生成哈希碼,從而爲全部不一樣的對象生成不一樣的哈希碼。測試
Map的定義是: 將鍵映射到值的對象。優化
所以,HashMap
中必須有一些機制來存儲這個鍵值對。 答案是確定的。 HHashMap
有一個內部類 Node
,以下所示:this
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。
在下面中, 咱們將會理解這些屬性的必須性。
鍵值對在 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 一次。
在理想的狀況下, 哈希函數將每個 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;
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 的初始化, 該初始化要延遲到第一次使用時進行。
所謂的哈希表, 指的就是下面這個類型爲內部類Node
的 table 變量。
transient Node<K,V>[] table;
做爲數組, 其在初始化時就須要指定長度。在實際使用過程當中, 咱們存儲的數量可能會大於該長度,所以 HashMap
中定義了一個閾值參數(threshold), 在存儲的容量達到指定的閾值時, 須要進行擴容。
我我的認爲初始化也是動態擴容的一種, 只不過其擴容是容量從 0 擴展到構造函數中的數值(默認16)。 並且不須要進行元素的重hash.
初始化的話只要數值爲空或者數組長度爲 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 碰撞就不少, 此時存取的性能相對較低, 對應優勢是須要較少的內存; 不建議更改該默認值, 若是要更改, 建議進行相應的測試以後肯定。
前面說過了數組的容量爲 2 的整次冪, 同時, 數組的下標經過下面的代碼進行計算
index = (table.length - 1) & hash
該方法除了能夠很快的計算出數組的索引以外, 在擴容以後, 進行重 hash 時也會很巧妙的就能夠算出新的 hash 值。 因爲數組擴容以後, 容量是如今的 2 倍, 擴容以後 n-1 的有效位會比原來多一位, 而多的這一位與原容量二進制在同一個位置。 示例
這樣就能夠很快的計算出新的索引啦
具體的看代碼吧
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; }
雖然 HashMap 設計的很是優秀, 可是應該儘量少的避免 resize(), 該過程會很耗費時間。
同時, 因爲 hashmap 不能自動的縮小容量。 所以, 若是你的 hashmap 容量很大, 但執行了不少 remove 操做時, 容量並不會減小。 若是你以爲須要減小容量, 請從新建立一個 hashmap。
在使用屢次 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; }
在此過程當中, 會涉及到哈希碰撞的解決。
/** * 返回指定的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; }