Map類集合中的存儲單位是Key-Value鍵值對,Map類使用一定的哈希算法形成比較均勻的哈希值作為Key,Value值掛在Key上。
一、Map類特點:
1、Key不能重復,Value可重復
2、Value可以是List、Map、Set類對象
3、KV是否允許為null,以實現類約束為准
二、Map除提供增刪改查外,還有三個Map特有方法。
1、返回所有的Key
Set<K> keySet();
返回Map類杜希昂中的Key的Set視圖。
2、返回所有value
Collection<V> values();
返回Map類對象中的所有Value的Collection視圖。
3、返回所有K-V鍵值對
Set<Map.Entry<K, V>> entrySet();
返回Map類對象中的K-V鍵值對的Set視圖。
這些函數返回的視圖支持清除操作(remove、clear),不支持修改和添加元素。
三、主要的Map類集合(圖來自Java開發手冊pdf)
Hashtable逐漸棄用,ConcurrentHashMap多線程比HashMap安全,但本文主要分析HashMap。
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
HashMap
一、哈希類集合的三個基本存儲概念
名稱 | 說明 |
table | 存儲所有節點數據的數組 |
slot | 哈希槽。即table[i]這個位置 |
bucket | 哈希桶。table[i]上所有元素形成的表或數的集合 |
圖示:
鏈表Node“Node是HashMap的一個靜態內部類。
//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; //構造函數 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } // getter and setter ... toString ... 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; } 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; } }
紅黑樹TreeNode:通過特定着色的旋轉(左旋、右旋)來保證從根節點到葉子節點的最長路徑不超過最短路徑的2倍的二叉樹,相比AVL樹,更加高效的完成插入和刪除操作后的自平衡調整。最壞運行時間為O(logN).
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); } /** * Returns root of tree containing this node. */ final TreeNode<K,V> root() { for (TreeNode<K,V> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } }
二、HashMap定義變量
/** * 默認初始容量16(必須是2的冪次方) */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; /** * 最大容量,2的30次方 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 默認加載因子,用來計算threshold */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 鏈表轉成樹的閾值,當桶中鏈表長度大於8時轉成樹 threshold = capacity * loadFactor */ static final int TREEIFY_THRESHOLD = 8; /** * 進行resize操作時,若桶中數量少於6則從樹轉成鏈表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 桶中結構轉化為紅黑樹對應的table的最小大小 當需要將解決 hash 沖突的鏈表轉變為紅黑樹時, 需要判斷下此時數組容量, 若是由於數組容量太小(小於 MIN_TREEIFY_CAPACITY ) 導致的 hash 沖突太多,則不進行鏈表轉變為紅黑樹操作, 轉為利用 resize() 函數對 hashMap 擴容 */ static final int MIN_TREEIFY_CAPACITY = 64; /** 保存Node<K,V>節點的數組 該表在首次使用時初始化,並根據需要調整大小。 分配時, 長度始終是2的冪。 */ transient Node<K,V>[] table; /** * 存放具體元素的集 */ transient Set<Map.Entry<K,V>> entrySet; /** * 記錄 hashMap 當前存儲的元素的數量 */ transient int size; /** * 每次更改map結構的計數器 */ transient int modCount; /** * 臨界值 當實際大小(容量*負載因子)超過臨界值時,會進行擴容 */ int threshold; /** * 負載因子 */ final float loadFactor;
默認容量:16 DEFAULT_INITIAL_CAPACITY = 1 << 4
自定義初始化容量:構造函數 ↓
Map容量一定為2的冪次。
默認加載因子:0.75 DEFAULT_LOAD_FACTOR = 0.75f
自定義負載因子:構造函數 ↓
桶中節點從鏈表轉化為紅黑樹:節點數大於8
桶中元素從紅黑樹返回為鏈表:節點數小於等於6
threshold:臨界值 = 容量×負載因子,當實際容量大於臨界值,為了減小哈希沖突,進行擴容。
三、構造函數
/** * 傳入初始容量大小,使用默認負載因子值 來初始化HashMap對象 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * 默認容量和負載因子 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 傳入初始容量大小和負載因子 來初始化HashMap對象 */ 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; // 初始化threshold大小 this.threshold = tableSizeFor(initialCapacity); } /** * 找到大於或等於 cap 的最小2的整數次冪的數。 */ 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; }
tableSizeFor(int cap):用位運算找到大於或等於 cap 的最小2的整數次冪的數。比如10,則返回16。
四、put函數
public V put(K key, V value) { // 調用hash(key)方法來計算hash 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為空,則調用resize()方法來初始化容器 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //確定元素存放在哪個桶中,桶為空,新生成結點放入桶中 if ((p = tab[i = (n - 1) & hash]) == null) 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)))) //如果鍵的值以及節點 hash 等於鏈表中的第一個鍵值對節點時,則將 e 指向該鍵值對 e = p; // 如果桶中的引用類型為 TreeNode,則調用紅黑樹的插入方法 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值與插入的元素的key值是否相等 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //判斷要插入的鍵值對是否存在 HashMap 中 if (e != null) { // existing mapping for key V oldValue = e.value; // onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 鍵值對數量超過閾值時,則進行擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
在new HashMap() 完成后,若沒有put操作,是不會分配存儲空間的。
-
當桶數組 table 為空時,通過擴容的方式初始化 table
-
查找要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
-
如果不存在,則將鍵值對鏈入鏈表中,並根據鏈表長度決定是否將鏈表轉為紅黑樹
-
判斷鍵值對數量是否大於閾值,大於的話則進行擴容操作
五、hash值的計算
- 根據存入的key-value對中的key計算出對應的hash值,然后放入對應的桶中,所以好的hash值計算方法十分重要,可以大大避免哈希沖突。
-
HashMap是以hash操作作為散列依據。但是又與傳統的hash存在着少許的優化。其hash值是key的hashcode與其hashcode右移16位的異或結果。在put方法中,將取出的hash值與當前的hashmap容量-1進行與運算。得到的就是位桶的下標。那么為何需要使用key.hashCode() ^ h>>>16的方式來計算hash值呢。其實從微觀的角度來看,這種方法與直接去key的哈希值返回在功能實現上沒有差別。但是由於最終獲取下表是對二進制數組最后幾位的與操作。所以直接取hash值會丟失高位的數據,從而增大沖突引起的可能。由於hash值是32位的二進制數。將高位的16位於低位的16位進行異或操作,即可將高位的信息存儲到低位。因此該函數也叫做擾亂函數。目的就是減少沖突出現的可能性。而官方給出的測試報告也驗證了這一點。直接使用key的hash算法與擾亂函數的hash算法沖突概率相差10%左右。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } n = table.length; index = (n-1) & hash;
- 根據以上可知,hashcode是一個32位的值,用高16位與低16位進行異或,原因在於求index是是用 (n-1) & hash ,如果hashmap的capcity很小的話,那么對於兩個高位不同,低位相同的hashcode,可能最終會裝入同一個桶中。那么會造成hash沖突,好的散列函數,應該盡量在計算hash時,把所有的位的信息都用上,這樣才能盡可能避免沖突。這就是為什么用高16位與低16位進行異或的原因。
- 為什么capcity是2的冪?因為 算index時用的是
(n-1) & hash
,這樣就能保證n -1是全為1的二進制數,如果不全為1的話,存在某一位為0,那么0,1與0與的結果都是0,這樣便有可能將兩個hash不同的值最終裝入同一個桶中,造成沖突。所以必須是2的冪。例子:十進制16 轉化為 二進制10000,則16-1為 1111 -
在算index時,用位運算
(n-1) & hash
而不是模運算hash % n
的好處(在HashTable中依舊是取模運算)?- 位運算消耗資源更少,更有效率
- 避免了hashcode為負數的情況
六、擴容機制resize
在 HashMap 中,桶數組的長度均是2的冪,閾值大小為桶數組長度與負載因子的乘積。當 HashMap 中的鍵值對數量超過閾值時,進行擴容。
HashMap 按當前桶數組長度的2倍進行擴容,閾值也變為原來的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; // 如果數組桶的容量大與0 if (oldCap > 0) { // 如果比最大值還大,則賦值為最大值 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 如果擴容后小於最大值 而且 舊數組桶大於初始容量16, 閾值左移1(擴大2倍) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } // 如果數組桶容量<=0 且 舊閾值 >0 else if (oldThr > 0) // initial capacity was placed in threshold // 新容量=舊閾值 newCap = oldThr; // 如果數組桶容量<=0 且 舊閾值 <=0 else { // zero initial threshold signifies using defaults // 新容量=默認容量 newCap = DEFAULT_INITIAL_CAPACITY; // 新閾值= 負載因子*默認容量 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 如果新閾值為0 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; // 如果舊數組桶不是空,則遍歷桶數組,並將鍵值對映射到新的桶數組中 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; }
整體步驟:
-
計算新桶數組的容量 newCap 和新閾值 newThr
-
根據計算出的 newCap 創建新的桶數組,桶數組 table 也是在這里進行初始化的
-
將鍵值對節點重新映射到新的桶數組里。如果節點是 TreeNode 類型,則需要拆分紅黑樹。如果是普通節點,則節點按原順序進行分組。
七、常用Map遍歷方法
public class Test { public static void main(String[] args) { List list = new ArrayList(); Map<String, String> map = new HashMap<String, String>(); map.put("1", "value1"); map.put("2", "value2"); map.put("3", "value3"); //第一種:普遍使用,二次取值 System.out.println("通過Map.keySet遍歷key和value:"); for (String key : map.keySet()) { System.out.println("key= " + key + " and value= " + map.get(key)); } //第二種:推薦,尤其是容量大時 System.out.println("通過Map.entrySet遍歷key和value"); for (Map.Entry<String, String> entry : map.entrySet()) { System.out.println(entry); System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue()); } } }
附:
1、JDK.7是基於數組加單鏈表實現(為什么不要雙鏈表)
首先,用鏈表是為了解決hash沖突。
單鏈表能實現為什么要用雙鏈表呢?(雙鏈表需要更大的存儲空間)
2、為什么要用紅黑樹,不是平衡二叉樹?
插入效率比平衡二叉樹高,查詢效率比普通二叉樹高。所以選擇性能相對折中的紅黑樹。
3、重寫對象的equals時一定需要重寫hashcode,為什么?
判斷兩個對象是否相同,首先判斷兩個對象的hashcode是否相等,若不相等,直接返回false;若相等,使用equals判斷。
即equals判斷相等,則hashcode一定相等,hashcode相等,他們並不一定相同。
4、為什么默認加載因子為0.75?
調低負載因子時,HashMap 所能容納的鍵值對數量變少。擴容時,重新將鍵值對存儲新的桶數組里,鍵的鍵之間產生的碰撞會下降,鏈表長度變短。此時,HashMap 的增刪改查等操作的效率將會變高,這里是典型的拿空間換時間。
相反,如果增加負載因子(負載因子可以大於1),HashMap 所能容納的鍵值對數量變多,空間利用率高,但碰撞率也高。這意味着鏈表長度變長,效率也隨之降低,這種情況是拿時間換空間。至於負載因子怎么調節,這個看使用場景了。
一般情況下,我們用默認值就可以了。大多數情況下0.75在時間跟空間代價上達到了平衡所以不建議修改。
參考資料:Java知音公眾號資源
博客 https://blog.csdn.net/zjxxyz123/article/details/81111627
List參見上一篇博客:Java集合之ArrayList與LinkedList