摘要
對於Java開發人員來說,能夠熟練地掌握java的集合類是必須的,本節想要跟大家共同學習一下JDK1.8中HashMap的底層實現與源碼分析。HashMap是開發中使用頻率最高的用於映射(鍵值對)處理的數據結構,而在JDK1.8中HashMap采用位桶數組+鏈表+紅黑樹實現的,現在我們深入探究一下HashMap的結構實現
一、HashMap簡介
1、特點
- HashMap根據鍵的hashcode值存儲數據,大多數情況可以直接定位到它的值,因而具有很快的訪問速度,但遍歷順序是不確定的
想要使得遍歷的順序就是插入的順序,可以使用LinkedHashMap,LinkedHashMap是HashMap的一個子類,保存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照訪問次序排序。
public class HashMapTest { public static void main(String[] args) { HashMap hashMap = new HashMap(); hashMap.put(2,"bbb"); hashMap.put(3,"ccc"); hashMap.put(1,"aaa"); System.out.println("HashMap的遍歷順序:"+hashMap); LinkedHashMap linkedHashMap = new LinkedHashMap(); linkedHashMap.put(2,"bbb"); linkedHashMap.put(3,"ccc"); linkedHashMap.put(1,"aaa"); System.out.println("LinkedHashMap的遍歷順序:"+linkedHashMap); } } Console輸出 HashMap的遍歷順序:{1=aaa, 2=bbb, 3=ccc} LinkedHashMap的遍歷順序:{2=bbb, 3=ccc, 1=aaa}
- HashMap最多只允許一條記錄的鍵為null,允許多條記錄的值為null
- HashMap非線程安全,如果需要滿足線程安全,可以一個Collections的synchronizedMap方法使HashMap具有線程安全能力,或者使用ConcurrentHashMap。
HashTable容器在競爭激烈的並發環境下表現出效率低下的原因,是因為所有訪問HashTable的線程都必須競爭同一把鎖,那假如容器里有多把鎖,每一把鎖用於鎖容器其中一部分數據,那么當多線程訪問容器里不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高並發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然后給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
順便說一下Hashtable,Hashtable是遺留類,很多映射的常用功能與HashMap類似,不同的是它承自Dictionary類,並且是線程安全的,任一時間只有一個線程能寫Hashtable,並發性不如ConcurrentHashMap,因為ConcurrentHashMap引入了分段鎖。Hashtable不建議在新代碼中使用,不需要線程安全的場合可以用HashMap替換,需要線程安全的場合可以用ConcurrentHashMap替換。
2、結構
從實現結構上看,HashMap是數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現的,如上圖所示,當鏈表長度超過闕值(8)時,將鏈表轉化成紅黑樹,這樣大大減少了查找時間
實現原理
首先每一個元素都是鏈表的數組,當添加一個元素(key-value)時, 就首先計算元素key的hash值,以此確定插入數組的位置,但是可能存在同一hash值的元素已經被放到數組的同一位置,這是就添加到同一hash值的元素的后面,他們在數組的同一位置形成鏈表,同一鏈表上的Hash值是相同的,所以說數組存放的是鏈表,而當鏈表長度太長時,鏈表就轉換為紅黑樹這樣大大提高了查找效率。
當鏈表數組的容量超過初始容量的0.75時,再散列將鏈表數組擴大2倍,把原鏈表數組的元素搬移到新的數組中
二、HashMap源碼分析
1、核心成員變量
transient Node<K,V>[] table; //HashMap的哈希桶數組,非常重要的存儲結構,用於存放表示鍵值對數據的Node元素。 transient Set<Map.Entry<K,V>> entrySet; //HashMap將數據轉換成set的另一種存儲形式,這個變量主要用於迭代功能。 transient int size; //HashMap中實際存在的Node數量,注意這個數量不等於table的長度,甚至可能大於它,因為在table的每個節點上是一個鏈表(或RBT)結構,可能不止有一個Node元素存在。 transient int modCount;
//HashMap的數據被修改的次數,這個變量用於迭代過程中的Fail-Fast機制,其存在的意義在於保證發生了線程安全問題時,能及時的發現(操作前備份的count和當前modCount不相等)並拋出異常終止操作。 int threshold; //HashMap的擴容閾值,在HashMap中存儲的Node鍵值對超過這個數量時,自動擴容容量為原來的二倍。 final float loadFactor; //HashMap的負載因子,可計算出當前table長度下的擴容閾值:threshold = loadFactor * table.length。
2、HashMap常量
//默認的初始容量為16,必須是2的冪次 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //最大容量即2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默認加載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //當put一個元素時,其鏈表長度達到8時將鏈表轉換為紅黑樹 static final int TREEIFY_THRESHOLD = 8; //鏈表長度小於6時,解散紅黑樹 static final int UNTREEIFY_THRESHOLD = 6; //默認的最小的擴容量64,為避免重新擴容沖突,至少為4 * TREEIFY_THRESHOLD=32,即默認初始容量的2倍 static final int MIN_TREEIFY_CAPACITY = 64;
3、構造函數
//構造函數1 指定初始容量以及負載因子 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); } //構造函數2 指定初始容量 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //構造函數3 什么都不指定 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //構造函數4 指定一個map用來初始化 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
4、設計到的數據結構
(1)數組元素Node<k,v>實現了Entry接口
//Node是單向鏈表,它實現了Map.Entry接口 static class Node<k,v> implements Map.Entry<k,v> { final int hash; final K key; V value; Node<k,v> next; //構造函數Hash值 鍵 值 下一個節點 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; } //判斷兩個node是否相等,若key和value都相等,返回true。可以與自身比較為true 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; }
從這個Node<k,v>內部類可知,它實現了Map.Entry接口。內部定義的變量 有hash值、key/value鍵值對和實現鏈表和紅黑樹所需要的指針索引
(2)紅黑樹
//紅黑樹 static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> { TreeNode<k,v> parent; // 父節點 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); } //返回當前節點的根節點 final TreeNode<k,v> root() { for (TreeNode<k,v> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } }
5、HashMap的常用方法(put、get)
(1)put方法
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } 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[i]是否為空或為null,否則執行resize()進行擴容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
//根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,否則如果table[i]不為null,看下面注釋 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k;
//如果table[i]不為null,則判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則如果不一樣,則看下面注釋 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
//判斷table[i]是否為treeNode,即table[i]是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則,看下面注釋 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
//遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作,遍歷過程中若發現key已經存在直接覆蓋value即可。 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; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } }
//key已經存在,將新value替換舊value值具體操作 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount;
//插入成功之后,判斷實際存在的鍵值對數量size是否超過了最大容量threshold,如果超過,進行擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
為了更好的理解hashmap如何進行過put操作,可以看下圖
重點理解(求元素在node數組的下標)
主要分為三個階段:計算hashcode、高位運算與取模運算
i = (n - 1) & hash
· 首先上面的hash是由put方法中的hash(key)產生的,源碼為:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
這里通過key.hashCode()計算出key的哈希值,然后將哈希值h右移16位,再與原來的h做異或^運算——這一步是高位運算。設想一下,如果沒有高位運算,那么hash值將是一個int型的32位數。而從2的-31次冪到2的31次冪之間,有將近幾十億的空間,如果我們的HashMap的table有這么長,內存早就爆了。所以這個散列值不能直接用來最終的取模運算,而需要先加入高位運算,將高16位和低16位的信息"融合"到一起,也稱為"擾動函數"。這樣才能保證hash值所有位的數值特征都保存下來而沒有遺漏,從而使映射結果盡可能的松散。最后,根據 n-1 做與操作的取模運算。這里也能看出為什么HashMap要限制table的長度為2的n次冪,因為這樣,n-1可以保證二進制展示形式是(以16為例)0000 0000 0000 0000 0000 0000 0000 1111。在做"與"操作時,就等同於截取hash二進制值得后四位數據作為下標。這里也可以看出"擾動函數"的重要性了,如果高位不參與運算,那么高16位的hash特征幾乎永遠得不到展現,發生hash碰撞的幾率就會增大,從而影響性能。
(2)get方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; //根據key及其hash值查詢node節點,如果存在,則返回該節點的value值。 } final Node<K,V> getNode(int hash, Object key) { //根據key搜索節點的方法。記住判斷key相等的條件:hash值相同 並且 符合equals方法。 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && //根據輸入的hash值,可以直接計算出對應的下標(n - 1)& hash,縮小查詢范圍,如果存在結果,則必定在table的這個位置上。 (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) //判斷第一個存在的節點的key是否和查詢的key相等。如果相等,直接返回該節點。 return first; if ((e = first.next) != null) { //遍歷該鏈表/紅黑樹直到next為null。 if (first instanceof TreeNode) //當這個table節點上存儲的是紅黑樹結構時,在根節點first上調用getTreeNode方法,在內部遍歷紅黑樹節點,查看是否有匹配的TreeNode。 return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && //當這個table節點上存儲的是鏈表結構時,用跟第11行同樣的方式去判斷key是否相同。 ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); //如果key不同,一直遍歷下去直到鏈表盡頭,e.next == null。 } } return null; }
因為查詢過程不涉及到HashMap的結構變動,所以get方法的源碼顯得很簡潔。核心邏輯就是遍歷table某特定位置上的所有節點,分別與key進行比較看是否相等。
(3)resize方法(擴容機制)
擴容時機
- 在jdk1.8中,resize方法是在hashmap中的鍵值對大於闕值時,
- 初始化時,
- 鏈表轉紅黑樹時,
- putAll時,就會調用resize()方法進行擴容
final Node<K,V>[] resize() { //保存舊的 Hash 數組 Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //超過最大容量,不再進行擴充 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //容量沒有超過最大值,容量變為原來的兩倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //閥值變為原來的兩倍 newThr = oldThr << 1; } else if (oldThr > 0) newCap = oldThr; else { //閥值和容量使用默認值 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"}) //創建新的 Hash 表 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 { //以鏈表形式存在的節點; //這一段就是新優化的地方,見下面分析 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; }
由代碼可以看出,其實就是通過復制,將table數據保存到舊的hash數組oldTab,然后循環遍歷oldTab,根據oldTab.next判斷有沒有值,沒有值就意味着就是桶數組元素,直接復制到新建的newTab,然后有值的話則判斷是否是紅黑樹,若是紅黑樹的話調用split修剪方法進行拆分放置,若是鏈表的話,根據一定的規則分兩種情況,一種是留在舊鏈表,一種是去新鏈表(do-while循環)
鏈表情況詳解
假如現在容量為初始容量16,再假如5,21,37,53的hash自己(二進制),
所以在oldTab中的存儲位置就都是 hash & (16 - 1)【16-1就是二進制1111,就是取最后四位】,
5 :00000101
21:00010101
37:00100101
53:00110101
四個數與(16-1)相與后都是0101
即原始鏈為:5--->21--->37--->53---->null
此時進入代碼中 do-while 循環,對鏈表節點進行遍歷,判斷是留下還是去新的鏈表:
lo就是擴容后仍然在原地的元素鏈表
hi就是擴容后下標為 原位置+原容量 的元素鏈表,從而不需要重新計算hash。
因為擴容后計算存儲位置就是 hash & (32 - 1)【取后5位】,但是並不需要再計算一次位置,
此處只需要判斷左邊新增的那一位(右數第5位)是否為1即可判斷此節點是留在原地lo還是移動去高位hi:(e.hash & oldCap) == 0 (oldCap是16也就是10000,相與即取新的那一位)
5 :00000101——————》0留在原地 lo鏈表
21:00010101——————》1移向高位 hi鏈表
37:00100101——————》0留在原地 lo鏈表
53:00110101——————》1移向高位 hi鏈表
退出循環后只需要判斷lo,hi是否為空,然后把各自鏈表頭結點直接放到對應位置上即可完成整個鏈表的移動。
(4)remove(Object key)方法0
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 待刪除元素在桶中,但不是桶中首元素 else if ((e = p.next) != null) { // 待刪除元素在紅黑樹結構的桶中 if (p instanceof TreeNode) // 查找紅黑樹 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 遍歷鏈表,查找待刪除元素 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } // p保存待刪除節點的前一個節點,用於鏈表刪除操作 p = e; } while ((e = e.next) != null); } } /** * matchValue為true:表示必須value相等才進行刪除操作 * matchValue為false:表示無須判斷value,直接根據key進行刪除操作 */ if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 桶為紅黑數結構,刪除節點 if (node instanceof TreeNode) // movable參數用於紅黑樹操作 ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 待刪除節點是桶鏈表表頭,將子節點放進桶位 else if (node == p) tab[index] = node.next; // 待刪除節點在桶鏈表中間 else p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } } // 待刪除元素不存在,返回null return null; }
(5)還有size()、isEmpty()、clear()、containsValue(Object value)、values()等等方法,在這就不一一列舉了,大家可以查看JDK1.8 HashMap源碼
三、HashMap為什么要改進使用紅黑樹
在jdk1.7中,HashMap處理“碰撞”的時候,都是采用鏈表來存儲的,當碰撞的結點很多的時候(也就是hash值相同、key不同的元素很多時),查詢時間是O(n)(最壞的情況)。查詢時間從O(1)到O(n)。
而在jdk1.8中,HashMap處理“碰撞”增加了紅黑樹這種數據結構,當碰撞結點少時,采用鏈表存儲,當較大的時候(>8),采用紅黑樹存儲,查詢時間是O(log n)。
到這里,我們一起學習了HashMap的結構實現以及核心源碼,HashMap還有一些重要的知識要了解,比如說並發安全問題、內部紅黑樹的實現、與其他Map子類、其他集合類的聯系等等,之后會陸續剖析,大家一起學習,共同進步吧!