Java Map類集合,與Collections類集合存在很大不一樣。它是與Collection 類平級的一個接口。java
在集合框架中,經過部分視圖方法這一根 微弱的線聯繫起來。node
(在以後的分享中,咱們會討論到Collections 框架的內容)面試
Map類集合中的存儲單位是K-V鍵值對,就是 使用必定的哈希算法造成一組比較均勻的哈希值做爲Key,Value值掛在Key上。算法
Map類 的特色:bootstrap
Map集合類 | Key | Value | Super | JDK | 說明 |
---|---|---|---|---|---|
Hashtable | 不容許爲 null | 不容許爲 null | Dictionary | 1.0 | (過期)線程安全類 |
ConcurrentHashMap | 不容許爲 null | 不容許爲 null | AbstractMap | 1.5 | 鎖分段技術或CAS(JDK8 及以上) |
TreeMap | 不容許爲 null | 容許爲 null | AbstractMap | 1.2 | 線程不安全(有序) |
HashMap | 容許爲 null | 容許爲 null | AbstractMap | 1.2 | 線程不安全(resize 死鏈問題) |
從jdk1.0-1.5,這幾個重點KV集合類,見證了Java語言成爲工業級語言的成長曆程。
知識點:數組
keySet()
、values()
、entrySet()
,其中values()
方法返回的視圖的集合實現類是Values extends AbstractCollection<V>
,沒有實現add操做,實現了remove/clear等相關操做,調用add方法時會拋出異常。哈希算法 哈希值
在Object 類中,hashCode()方法是一個被native修飾的類,JavaDoc中描述的是返回該對象的哈希值。安全
那麼哈希值這個返回值是有什麼做用呢?數據結構
主要是保證基於散列的集合,如HashSet、HashMap以及HashTable等,在插入元素時保證元素不可重複,同時爲了提升元素的插入刪除便利效率而設計;主要是爲了查找的便捷性而存在。
拿Set進行舉例,app
衆所周知,Set集合是不能重複,若是每次添加數據都拿新元素去和集合內部元素進行逐一地equal()比較,那麼插入十萬條數據的效率能夠說是很是低的。框架
因此在添加數據的時候就出現了哈希表的應用,哈希算法也稱之爲散列算法,當添加一個值的時候,先去計算出它的哈希值,根據算出的哈希值將數據插入指定位置。這樣的話就避免了一直去使用equal()比較的效率問題。
具體表如今:
上述第二種狀況中,若是兩個元素不相同,可是hashCode()相同,那就是發生了咱們所謂的哈希碰撞。
哈希碰撞的機率取決於hashCode()計算方式和空間容量的大小。
這種狀況下,會在相同的位置,建立一個鏈表,把key值相同的元素存放到鏈表中。
在HashMap中就是使用拉鍊法來解決hashCode衝突。
hashCode是一個對象的標識,Java中對象的hashCode是一個int類型值。經過hashCode來指定數組的索引能夠快速定位到要找的對象在數組中的位置,以後再遍歷鏈表找到對應值,理想狀況下時間複雜度爲O(1),而且不一樣對象能夠擁有相同的hashCode。
在 JDK1.8 中,HashMap 是由 數組+鏈表+紅黑樹構成,新增了紅黑樹做爲底層數據結構。
經過哈希來確認到數組的位置,若是發生哈希碰撞就以鏈表的形式存儲 ,可是這樣若是鏈表過長來的話,HashMap會把這個鏈表轉換成紅黑樹來存儲,閾值爲8。
下面是HashMap的結構圖:
/** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table;
在JDK1.8中咱們瞭解到HashMap是由數組加鏈表加紅黑樹來組成的結構其中table就是HashMap中的數組。
/** * The number of key-value mappings contained in this map. */ transient int size;
HashMap中 鍵值對存儲數量。
/** * The load factor for the hash table. * * @serial */ final float loadFactor;
負載因子。負載因子是權衡資源利用率與分配空間的係數。當元素總量 > 數組長度 * 負載因子
時會進行擴容操做。
/** * The next size value at which to resize (capacity * load factor). * * @serial */ // (The javadoc description is true upon serialization. // Additionally, if the table array has not been allocated, this // field holds the initial array capacity, or zero signifying // DEFAULT_INITIAL_CAPACITY.) int threshold;
擴容閾值。threshold = 數組長度 * 負載因子
。超事後執行擴容操做。
/** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. */ static final int TREEIFY_THRESHOLD = 8; /** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. */ static final int UNTREEIFY_THRESHOLD = 6;
樹形化閾值。當一個哈希桶存儲的鏈表長度大於8 會將鏈表轉換成紅黑樹,小於6時則從紅黑樹轉換成鏈表。
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
能夠看到實際執行添加元素的是putVal()操做,在執行putVal()以前,先是對key執行了hash()方法,讓咱們看下里面作了什麼
static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位異或 // >>>:無符號右移,忽略符號位,空位都以0補齊 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
key==null
說明,HashMap中是支持key爲null的狀況的。
一樣的方法在Hashstable中是直接用key來獲取hashCode,沒有key==null
的判斷,因此Hashstable是不支持key爲null的。
再回來講這個hash()方法。這個方法用專業術語來稱呼就叫作擾動函數。
使用hash()也就是擾動函數,是爲了防止一些實現比較差的hashCode()方法。換句話來講,就是爲了減小哈希碰撞。
JDK 1.8 的 hash方法 相比於 JDK 1.7 hash 方法更加簡化,可是原理不變。咱們再看下JDK1.7中是怎麼作的。
// code in JDK1.7 static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
相比於 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能會稍差一點點,由於畢竟擾動了 4 次。
知識延伸外鏈: JDK 源碼中 HashMap 的 hash 方法原理是什麼? - 胖君的回答 - 知乎
https://www.zhihu.com/questio...
再來看真正執行增長元素操做的putVal()方法,
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 當數組爲空或長度爲0,初始化數組容量(resize() 方法是初始化或者擴容用的) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 計算數組下標 i = (n-1) & hash // 若是這個位置沒有元素,則直接建立Node並存值 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 這個位置已有元素 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // hash值、key值相等,用e變量獲取到當前位置這個元素的引用,後面用於替換已有的值 e = p; else if (p instanceof TreeNode) // 當前是以紅黑樹方式存儲,執行其特有的putVal方法 -- putTreeVal 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; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) // onlyIfAbsent 若是爲true - 不覆蓋已存在的值 // 把新值賦值進去 e.value = value; afterNodeAccess(e); return oldValue; } } // 記錄修改次數 ++modCount; // 判斷元素數量是否超過閾值 超過則擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
這是一個常見的面試題。這個問題描述的設計,實際上爲了服務於從Key映射到數組下標index的Hash算法。
前面提到了,咱們爲了讓HashMap存儲高效,應該儘可能減小哈希碰撞,也就是說,應該讓元素分配得儘量均勻。
Hash 值的範圍值-2147483648
到2147483647
,先後加起來大概40億的映射空間,只要哈希函數映射得比較均勻鬆散,通常應用是很難出現碰撞的。但問題是一個40億長度的數組,內存是放不下的。因此這個散列值是不能直接拿來用的。
因此才須要一個映射的算法。這個計算方式就是3.2中有出現的(n - 1) & hash
。
咱們來進一步演示一下這個算法:
key="book"
book
的hashCode值,結果爲十進制的3029737,二進制的101110001110101110 1001。經過這種與運算的方式,可以和取模運算同樣的效果hashCode % length
,在上述例子中就是3029737 % 16=9
。
而且經過位運算的方式大大提升了性能。
可能到這裏,你仍是不知道爲何長度必須是2的冪次方,也是由於這種位運算的方法。
長度16或者其餘2的冪,Length-1的值是全部二進制位全爲1,這種狀況下,index的結果等同於HashCode後幾位的值。只要輸入的HashCode自己分佈均勻,Hash算法的結果就是均勻的。若是HashMap的長度不是2的冪次方,會出現某些index永遠不會出現的狀況,這個顯然不符合均勻分佈的原則和指望。因此在源碼裏面一直都在強調power-of-two expansion
和size must be power of two
。
另外,HashMap 構造函數容許用戶傳入的容量不是 2 的 n 次方,由於它能夠自動地將傳入的容量轉換爲 2 的 n 次方。
/** * Returns a power of two size for the given target capacity. */ 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; }
接下來咱們來說講HashMap擴容相關的知識。
HashMap的初始長度是16,假設HashMap中的鍵值對一直在增長,可是table數組容量一直不變,那麼就會發生哈希碰撞,查找的效率確定會愈來愈低。因此當鍵值對數量超過某個閾值的時候,HashMap就會執行擴容操做。
那麼擴容的閾值是怎麼計算的呢?
閾值 = 數組長度 * 負載因子threshold = capacity * loadFactor
每次擴容後,threshold 加倍
上述計算就出如今resize()方法中。下面會詳細解析這個方法。咱們先繼續往下講。
loadFactor這個參數,咱們以前提到過,負載因子是權衡資源利用率與分配空間的係數。至於爲何是0.75呢?這個實際上就是一個做者認爲比較好的權衡,固然你也能夠經過構造方法手動設置負載因子 。public HashMap(int initialCapacity, float loadFactor) {...)
。
接下去再來到這裏的主角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) { if (oldCap >= MAXIMUM_CAPACITY) { // 舊數組已經超過了數組的最大容量 // 閾值改爲最大,直接返回舊數組,不操做了 threshold = Integer.MAX_VALUE; return oldTab; } // newCap 變成原來的 兩倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 執行擴容操做,新閾值 = 舊閾值 * 2 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引用的數組 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 .... } } } } return newTab; }
// 鏈表的處理 這個鏈表處理實際上很是的巧妙 // 定義了兩條鏈 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; }
上述代碼紅黑樹和鏈表的處理不知道你們看懂了沒有,我反正在第一次看的時候有點暈乎。可是理解了以後有感受很是的巧妙。
拿鏈表處理打比方,它乾的就是把在遍歷舊的table數組的時候,把該位置的鏈表分紅high鏈表和low鏈表。具體是什麼意思呢?看下下面的舉例。
A->B->C->D->E->F
這樣一個鏈表。Hash & oldCapacity
的操做,Hash值爲5的A/B/C計算以後爲0,被分到了low鏈表,Hash爲21的D/E/F被分到了high鏈表。紅黑樹相關的操做雖然代碼不一樣,可是實際上要乾的事情是同樣的。就是把相同位置的不一樣Hash大小的鏈表元素在新table數組中進行分離。但願講到這裏你能聽懂。
Java7的HashMap會存在死循環的問題,主要緣由就在於,Java7中,HashMap擴容轉移後,先後鏈表順序倒置,在轉移過程當中其餘線程修改了原來鏈表中節點的引用關係,致使在某Hash桶位置造成了環形鏈表,此時get(key),若是key不存在於這個HashMap且key的Hash結果等於那個造成了循環鏈表的Hash位置,那麼程序就會進入死循環;
Java8在一樣的前提下並不會引發死循環,緣由是Java8擴容轉移後先後鏈表順序不變,保持以前節點的引用關係。
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; // JDK8 移出了hashSeed計算,由於計算時會調用Random.nextInt(),存在性能問題 // 很重要的transfer() transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 在此步驟完成以前,舊錶上依然能夠進行元素的增長操做,這就是對象丟失緣由之一 table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
// 寥寥幾行,卻極爲重要 void transfer(Entry[] newTable, boolean rehash) { // newCapacity 是舊錶的兩倍,這個擴容大小 int newCapacity = newTable.length; // 使用foreach 方式遍歷整個數組下標 for (Entry<K,V> e : table) { // 若是在這個slot上面存在元素,則開始遍歷上面的鏈表,知道e==null,退出循環 while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 當前元素老是直接放在數組下標的slot上,而不是放在鏈表的最後 // 倒序插入新表 // 這裏是造成死鏈的關鍵步驟 e.next = newTable[i]; newTable[i] = e; e = next; } } }
延伸閱讀。
不少人可能都會答上一句,爲了提升查找性能,但更確切地來講的話,採用紅黑樹的方法是爲了提升在極端哈希衝突的狀況下提升HashMap的性能。
極端哈希衝突的狀況下,去測量Java7和Java8版本的HashMap的查詢性能差距。
Java 7的結果是能夠預期的。 HashMap.get()的性能損耗與HashMap自己的大小成比例增加。 因爲全部鍵值對都在一個巨大的鏈表中的同一個桶中,查找一個條目須要平均遍歷一半這樣的列表(大小爲n)。 所以O(n)複雜性在圖上可視化。
與此相對的是Java8,性能提升了不少,發生災難性哈希衝突的狀況下,在JDK 8上執行的相同基準測試會產生O(logn)最差狀況下的性能。
關於此處的算法優化實際上在 JEP-180中有描述到,
另外若是Key對象若是不是Comparable的話,那麼發生重大哈希衝突時,插入和刪除元素的效率會變不好。(由於底層實現時紅黑樹,須要經過compare方法去肯定順序)
當HashMap想要爲一個鍵找到對應的位置時,它會首先檢查新鍵和當前檢索到的鍵之間是否能夠比較(也就是實現了Comparable接口)。若是不能比較,它就會經過調用tieBreakOrder(Objecta,Object b) 方法來對它們進行比較。這個方法首先會比較兩個鍵對象的類名,若是相等再調用System.identityHashCode 方法進行比較。這整個過程對於咱們要插入的500000個元素來講是很耗時的。另外一種狀況是,若是鍵對象是可比較的,整個流程就會簡化不少。由於鍵對象自身定義瞭如何與其它鍵對象進行比較,就沒有必要再調用其餘的方法,因此整個插入或查找的過程就會快不少。值得一提的是,在兩個可比的鍵相等時(compareTo 方法返回 0)的狀況下,仍然會調用tieBreakOrder 方法。
又可能會有人說了,哪有這麼極端的哈希衝突?
這個其實是一個安全性的考慮,雖然在正常狀況下不多有可能發生不少衝突。可是想象一下,若是Key來自不受信任的來源(例如從客戶端收到的HTTP頭名稱),那麼就有可能收到僞造key值,而且這種作法不難,由於哈希算法是你們都知道的,假設有人有心去僞造相同的哈希值的key值,那麼你的HashMap中就會出現上述這種極端哈希衝突的狀況。 如今,若是你去對這個HashMap執行屢次的查詢請求,就會發現程序執行查詢的效率會變得很慢,cpu佔用率很高,程序甚至會拒絕對外提供服務。
延伸外鏈: https://www.yuque.com/docs/sh...