Java集合容器系列04-HashMap
2019獨角獸企業重金招聘Python工程師標準>>>
一、HashMap介紹
? ? HashMap是基于哈希表實現的Map容器,存儲的元素是鍵值對映射。繼承自AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。是非線程安全的集合并且容器中存儲的鍵值對映射是無序的,HashMap允許鍵和值都為null這點與HashTable相反,HashTable是線程安全的且鍵和值均不能為null。
二、HashMap的數據結構
1 - 繼承結構
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable { }? ? HashMap繼承了AbstractMap,AbstractMap提供了Map操作的一些基本實現,實現了Map接口因為父類AbstractMap已經實現了Map接口此處只是起到類似文檔標識的作用,這種應用在jdk中還有很多,此外HashMap還實現了Cloneable和Serializable接口支持對象拷貝和序列化。
2 - 成員變量
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {private static final long serialVersionUID = 362498820763181265L;//容器默認初始容量16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //容器最大容量2的30次方static final int MAXIMUM_CAPACITY = 1 << 30;//默認負載因子static final float DEFAULT_LOAD_FACTOR = 0.75f;//閾值,容器保存hash沖突節點的桶上鏈表節點數超過這個值就會轉成紅黑樹結構存儲static final int TREEIFY_THRESHOLD = 8;//閾值,當桶的鏈表數小于這個值時,存儲hash沖突節點的紅黑樹會轉回鏈表存儲結構static final int UNTREEIFY_THRESHOLD = 6;//樹的最小容量static final int MIN_TREEIFY_CAPACITY = 64;//存儲鍵值對節點的數組,采用了拉鏈法解決Hash沖突,Node對象實際上是單鏈表或者紅黑樹,總是2的倍數,為什么要這樣設置 //分析后續的方法源碼就可以知道transient Node<K,V>[] table;//鍵值對映射集合transient Set<Map.Entry<K,V>> entrySet;//鍵值對個數transient int size;//容器結構修改計數器transient int modCount;//臨界值,會進行擴容int threshold;//填充因子final float loadFactor;}?
三、HashMap源碼分析
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);}public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}? ? HashMap類提供了三個構造函數,無參構造函數HashMap()很簡單就是初始化對象的負載因子為默認的負載因子。HashMap(int initialCapacity)在方法內部調用了HashMap(int initialCapacity, float loadFactor)方法,因此我們可以直接分析該構造方法,該構造方法首先對初始容量initialCapcity和加載因子loadFactor做了合法性校驗,如果初始容量大于Hashmap容量最大限制2的30次方,設置為最大容量,初始化根據方法指定參數初始化加載因子,調用tableSizeFor計算臨界值,跟進該方法源碼,tableSizeFor方法源碼如下:
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;}? ? 該方法通過一系列移位和邏輯運算保證計算出的臨界值是最小的大于方法指定初始化容量cap的2的指數次方,至于為什么臨界值要設置為2的指數次方,我們后續會講到。
2 - int hash(Object k)方法
? ? 因為HashMap底層是基于hashtable實現的,容器的各種操作包括元素插入、刪除、修改和查詢都需要調用hash函數計算key對應的hash值進而定位元素所屬槽(bucket)在哈希表(table數組)中索引,hash函數作為重點,我們首先進行分析,方法源碼如下:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}? ? 查看源碼可知,在HashMap中key的hash值計算邏輯為:key為null結束計算返回0,否則調用key.hashCode方法計算key的哈希值,把key的哈希值作為底數,key哈希值右移16位作為指數做冪運算,返回運算結果。
?
3 - V get(Object key)根據key獲取value方法
方法源碼:
public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;}? ? 方法內部核心邏輯在getNode(hash(key), key)),繼續跟進該方法源碼
final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;//哈希表判空,(n-1) & hash計算得出key所在槽的在哈希表中的索引位置,獲取到的可能是鏈表表頭也可能是紅黑樹樹 //根if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {//找到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) {//若是樹節點即當前槽保存的hash沖突節點個數超過8個,在紅黑樹中查找key對應的鍵值對節點if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);//否則繼續遍歷鏈表,如果鏈表中存在匹配指定key的鍵值對節點(equals且hashCode相等),結束返回節點do {if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null;}? ? 查看源碼我們大體了解了get方法的邏輯。它首先根據key計算hash然后與哈希表長度table.length-1做與運算獲取該key所在槽的頭節點根據該槽hash沖突情況的不同可能返回的是紅黑樹的樹根也可能是鏈表的頭節點,這里我們知道了為什么node數組table的長度總要設置為2的指數次方,因為2的指數次方-1,的二進制位是一連串1,HashMap中(n-1) & hash的計算結果更加分散,能降低Hash沖突的概率提升查詢效率。
?
4 - V put(K key, V value)插入鍵值對方法
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}? ? 方法內部調用putVal進行鍵值對插入,繼續跟進該方法源碼
/*** Implements Map.put and related methods** @param hash key的hash計算值* @param key 鍵值對中的鍵* @param value 鍵值對中的值* @param onlyIfAbsent 如果為true,當容器中已存在該key對應的鍵值對,不進行插入* @param evict 若為false,table處于創建模式* @return 返回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;//1.如果table為空即HashMap中不存在任何鍵值對映射,則為table分配內存if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;//2.(n-1) & hash獲取鍵值對在table中的索引位置,若索引所在的節點為空則說明當前日期不存在與指定鍵值對key的 //hash值相等的鍵值對節點,直接插入if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);//否則指定插入鍵值對存在hash沖突else {Node<K,V> e; K k;//若hash沖突槽第一個節點hash值相等且key值相等if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;//若hash沖突槽以紅黑樹數據結構存儲,在紅黑樹中插入鍵值對節點else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);//否則沖突槽的數據結構為單鏈表,若當前容器不存在key對應的鍵值對直接基于指定的鍵值對創建一個新節點在鏈表 //尾插入,若插入鍵值對后超過鏈表樹化閾值則將存儲hash沖突節點的鏈表轉化為紅黑樹結構,//否則獲取當前容器中hash相等且key相等的節點用于后續操作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 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}//若當前HashMap中存在key對應的鍵值對節點eif (e != null) { // existing mapping for keyV oldValue = e.value;//若onlyIfAbsent設置為false即允許覆蓋容器中鍵值對節點的value值或舊value值為null,則設置為//新值valueif (!onlyIfAbsent || oldValue == null)e.value = value;//在HashMap中是空實現留給子類做擴展afterNodeAccess(e);return oldValue;}}//3.HashMap插入鍵值對映射沒有Hash沖突時執行后續代碼//容器結構修改計數器遞增++modCount;//table中即哈希表中有存儲鍵值對映射的槽的個數大于閥值threadshold則調用resize方法擴容if (++size > threshold)resize();//空實現afterNodeInsertion(evict);return null;}? ? 該方法是HashMap中put和相關方法如putIfAbsent的底層實現方法,方法流程邏輯整理如下:
1)判斷存儲鍵值對節點數組table是否為空,若為空調用resize方法進行擴容,我們看下該方法源碼:
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) {//若數組table容量大于等于最大容量限制MAXIMUM_CAPACITYif (oldCap >= MAXIMUM_CAPACITY) {//將擴容臨界值設置為Integer的最大值,不再進行擴容threshold = Integer.MAX_VALUE;return oldTab;}//若擴容2倍之后容量小于HashMap最大容量限制且原來的容量大于初始容量則進行2倍擴容且臨界值*2else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}//若容器為空,且臨界值大于0,則容器擴容后的容量設置為臨界值else if (oldThr > 0) newCap = oldThr;//臨界值和容器初始容量均為0,則為臨界值和容器初始容量分別分配一個默認值,臨界值為初始容量乘以默認負載因子else {newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}//若擴容后的臨界值等于0根據負載因子和擴容后重新計算擴容后的臨界值,若計算后臨界值大于等于最大容量限制則需要重 //置為Integer.MAX_VALUEif (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;//為table重新一個新的節點數組,數組長度為擴容后的容器容量newCap@SuppressWarnings({"rawtypes","unchecked"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;//如果老table不為空,原來容器中存在鍵值對節點,需要在新的節點數組table中填入原來的數據if (oldTab != null) {//循環容器原來的節點數組table,將鍵值對數據填充到新數組tablefor (int j = 0; j < oldCap; ++j) {Node<K,V> e;//table當前下標位置保存hash相同(也可說沖突)節點的槽不為空if ((e = oldTab[j]) != null) {//釋放槽中的節點對象oldTab[j] = null;//槽中只包含單個節點,基于hash & (newCap-1)獲取該節點在新數組table的下標位置,在數組中對應 //位置保存該節點if (e.next == null)newTab[e.hash & (newCap - 1)] = e;//若槽的數據存儲結構是紅黑樹,則也為新table當前槽創建紅黑樹存儲槽中的鍵值對數據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;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.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分配一個默認初始容量的數組。
2)(n-1) & hash基于key的hash與容器長度n-1的二進制位做&運算,獲取插入鍵值對在哈希表table中的下標位置,因為n為2的指數故n-1的二進制是一系列1,key的hash與n-1做&運算能使鍵值對分散更隨機均勻,有利于提高查詢效率,如果哈希表table中存儲鍵值對節點的槽為null那么直接為指定鍵值對創建新節點,并在table對應下標保存新節點引用;
3)如果插入key存在Hash沖突,需要根據保存Hash沖突節點的槽的數據存儲結構是紅黑樹還是鏈表做處理,若是鏈表且插入指定節點后超出樹化閥值則需要將鏈表轉化為紅黑樹保存hash沖突節點;
4)若容器發生結構修改(指的是插入的鍵值對節點未發生hash沖突新占用了數組table的空間),modCount(結構修改計數器)++,判斷是否超過臨界值超過需要調用resize進行擴容,這個方法之前已經分析過了,可以回過頭看下方法實現邏輯,一般情況下是擴容2倍。
?
5 - 高效使用HashMap
????? ?HashMap性能消耗比較嚴重的主要有兩個過程,第一個是當發生Hash沖突時,table中存儲單個節點的槽會退化為鏈表查詢時需要額外遍歷這個鏈表,Hash沖突越劇烈查詢性能越低,盡管JDK1.8對此作了優化當鏈表節點數超過8會轉化為紅黑樹存儲,查詢花費的時間復雜度降低到O(logn),降低Hash沖突我們需要做的包括為Key對象類型選擇一個合理的hashCode函數,合理規劃HashMap的初始容量(table數組長度)讓插入的鍵值對基于Hash和初始容量計算出的數組table下標盡量分散;第二個是當容器中節點占用的槽的個數(也就是數組table中被占用的數據項個數)超過臨界值時會進行擴容,擴容需要將原HashMap中存儲的鍵值對數據填充到新的數組table中,過程中需要重新遍歷HashMap中的鍵值對數據,并重新定位他們在新節點數組table中的位置,涉及Hash沖突需要重構鏈表、紅黑樹,效率極低。針對第二點我們可以通過調整負載因子(loadFactor)和容器初始容量去減少擴容次數。一般情況下不建議去修改loadFactor的默認值,我們可以在使用HashMap前預估插入鍵值對的個數,通過調整初始容量initialCapcity大小,使threadshold=initialCapcity*loadFactor大于預估節點個數,或者調整到一個較為合理的值,防止擴容或降低插入過程中的擴容次數。
轉載于:https://my.oschina.net/zhangyq1991/blog/1921179
總結
以上是生活随笔為你收集整理的Java集合容器系列04-HashMap的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: katalon中REST URL占位参数
- 下一篇: Pymetrics开源公平性感知机器学习