準備了很長時間,終於理清了思路,鼓起勇氣,開始介紹本篇的主角——HashMap。說實話,這傢伙能說的內容太多了,要是像前面ArrayList那樣翻譯一下源碼,稍微說說重點,確定會讓不少人摸不着頭腦,不能把複雜的東西用盡可能簡單的方式說明白,那就說明講的挺失敗的(面壁中
)。因此此次決定把內容分四篇進行講解,node
第一篇主要講解HashMap中的結構,重要參數和重要方法,以及使用中須要注意的地方和應用場景。算法
第二篇主要講解HashMap中的散列算法,擾動函數以及擴容函數。普通節點的較深刻的解析其中算法的妙處。編程
第三篇主要講解HashMap中的EntrySet,KeySet和Values。數組
第四篇主要講解HashMap中的TreeNode結構以及元素增減時的結構調整方式。(以JDK8中的紅黑樹進行講解)。緩存
由於HashMap中能夠說說的點實在太多了,這裏選取了比較重要的幾點進行說明,四篇的角度和深度各不同,這樣不一樣階段的同窗也能夠選取不一樣的部分進行閱讀,第一篇屬於簡單易懂的初級部分,第2、第三篇和第四篇屬於HashMap的高級部分,若是閱讀有難度,能夠先跳過,之後再來進行閱讀。安全
好了,話很少說,接下來就進入咱們的主題了。數據結構
本篇將擯棄以前的講法,直接擺幾百行源碼實在是太乾了,咱們得弄溼一點纔好消化(滑稽),接下來將用圖文並茂的方式進行說明。併發
經過本篇,你將瞭解如下問題:app
1.HashMap的結構是什麼?less
2.HashMap的優勢和缺點是什麼?
3.何時該使用HashMap?
4.HashMap中的經常使用方法有哪些?
5.HashMap的get()方法和put()方法的工做原理是什麼?
6.HashMap中的碰撞探測(collision detection)以及碰撞的解決方法是什麼?
7.若是HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?
8.在設置HashMap的鍵是須要注意什麼?可使用自定義的對象做爲鍵嗎?
嗯,因此內容仍是挺多的,乾貨也很多。咱們先來看一個HashMap的小栗子:
public class Test { public static void main(String[] args){ Map<String, Integer> map = new HashMap(); map.put("小明", 66); map.put("小李", 77); map.put("小紅", 88); map.put("小剛", 89); map.put("小力", 90); map.put("小王", 91); map.put("小黃", 92); map.put("小青", 93); map.put("小綠", 94); map.put("小黑", 95); map.put("小藍", 96); map.put("小紫", 97); map.put("小橙", 98); map.put("小赤", 99); map.put("Frank", 100); for(Map.Entry<String, Integer> entry : map.entrySet()){ System.out.println(entry.getKey() + ":" + entry.getValue()); } } }
輸出也很簡單:
小剛:89 小橙:98 小藍:96 小力:90 小青:93 小黑:95 小明:66 小李:77 小王:91 小紫:97 小紅:88 小綠:94 Frank:100 小黃:92 小赤:99
能夠看到,HashMap中存儲的順序跟咱們放入的順序有些不太同樣,可是每次運行的結果都是同樣的,以一種神奇的順序輸出着,爲何會這樣呢?不要着急,讓咱們先來打個斷點看看。
能夠看到,這個HashMap對象裏,有一個table字段,能夠看出,它是一個數組,咱們put的成績信息,就在這個傢伙裏面了,你看看,這個順序跟上面的輸出順序是否是很像?
不過,等一下,你有沒有發現,小李,小紫,小赤失蹤了。。這個問題,不要着急,待會咱們就一塊兒去找他們。
HashMap裏的數據結構是數組+鏈表的形式來存儲節點的,每一個節點以鍵值對(Node<K,V>)的形式存儲,上面看到的table,就是HashMap中存放值的地方,它的數據結構是這樣的:Node<K,V>[] table;那這個Node究竟是什麼東西呢?咱們來看看它的代碼:
/** * 用於大多數鍵值對的普通節點 */ 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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { //返回key和value的哈希值的異或運算結果 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; } }
Node類繼承於Map.Entry接口,若是對這個接口沒有印象了能夠回過頭翻一下Map接口的內容,Node中的內容很簡單,hash,鍵值信息和下一個節點的引用,Node之間正是經過這樣的引用來鏈接起來造成一條鏈。再想一想table的結構,Node<K,V>[],如今是否是理解了什麼是數組+鏈表的存儲方式了?
什麼?這樣說的還不夠形象?好吧,一圖勝千言,以前說要用圖文並茂的方式來進行講解,因此仍是一塊兒來看幾張圖片:
嗯,咱們存儲的數據在內存裏就是這樣的,咱們再來看一下斷點裏的數據:
對比兩幅圖應該就能比較清楚的瞭解了,能夠看出裏面數組並非順序往裏存的,中間有不少空的桶(每一個格子稱爲一個bin,這裏蹩腳翻譯成桶),那爲何會是這樣的順序呢?咱們來看看它的put方法:
/** * 將map中指定key和value進行關聯,若是map中已經存在該key的映射,則舊的值將會被替換。 * 返回該key映射的舊值,若是該key的映射不存在的話則返回null。 */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
/** * 實現 Map.put 和相關方法 * * @param hash key的哈希值 * @param key key * @param value key將要映射的value * @param onlyIfAbsent 若是是true的話,將不會改變已存在的值 * @param evict 這個參數若是爲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未初始化,則先從新調整大小至初始容量 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //(n-1)& hash 這個地方即根據hash求序號,想了解更多散列相關內容能夠查看下一篇 if ((p = tab[i = (n - 1) & hash]) == null) //不存在,則新建節點 tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //先找到對應的node if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) //若是是樹節點,則調用相應的putVal方法 //todo 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 //若是鏈表長度達到樹化的最大長度,則進行樹化 //todo treeifyBin treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //若是已存在該key的映射,則將值進行替換 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //修改次數加一 ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
從這裏能夠看出往HashMap添加元素時的邏輯:
也許你想知道這個table是什麼東西,那咱們順便一塊兒看看那幾個重要的成員變量吧:
/* ---------------- 字段 -------------- */ /** * 哈希表,該表在初次使用時初始化,並根據須要調整大小。 分配時,長度老是2的冪 * * todo hashMap的結構 * todo transient */ transient Node<K,V>[] table; /** * 保存緩存的entrySet,注意AbstractMap中的字段是用於keySet() 和 values(). */ transient Set<Map.Entry<K,V>> entrySet; /** * map中的鍵值對個數 */ transient int size; /** * hashmap 結構性修改的次數,結構性修改是指改變hashmap中映射數量或者修改內部結構。 * 該字段用於在HashMap中建立基於集合視圖的可失敗快速的(fail-fast)迭代器。 */ transient int modCount; /** * 下一個調整大小的值(容量*加載因子)。 */ int threshold; /** * hashmap的裝載因子 */ final float loadFactor;
table字段是中保存了咱們的數據,類型是Node數組,Node的結構也很簡單,只是簡單的存放key和value,以及key的hash和指向下一個節點的引用。
/** * 用於大多數鍵值對的普通節點 */ 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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { //返回key和value的哈希值的異或運算結果 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; } }
在成員變量entrySet中緩存了Entry的集合(其實你仔細找找的話,會發現entrySet的存儲元素邏輯並不簡單,這將在第三篇裏講解)。threshold表示進行下一次從新調整的閾值(容量*裝載因子),轉載因子表示table最大裝滿程度,默認是0.75,即當容量被用掉75%後將會觸發擴容,由於當table中的元素足夠多時,發生衝突的機率就會大大增長,衝突的增多會致使每一個桶中的元素個數變多,這樣的話會使得查找元素效率變得低下,當同一個桶中元素個數達到8時,桶中的元素結構將轉換爲紅黑樹。
那麼,問題來了,爲何是8,而不是6或者7,10呢???這個話題若是要深刻探討的話,又要說上一篇了。。。
這裏我就引用一下JDK8中的HashMap的中註解:
* Ideally, under random hashCodes, the frequency of * nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average for the default resizing * threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006 * more: less than 1 in ten million *
大體意思是,理想狀況下,HashCode隨機分佈,當負載因子設置成0.75時,那麼在桶中元素個數的機率大體符合0.5的泊松分佈,桶中元素個數達到8的機率小於千萬分之一,由於轉化爲紅黑樹仍是比較耗時耗力的操做,天然不但願常常進行,但若是設置得過大,將失去設置該值的意義。
那麼,問題又來了。。爲何是0.75,而不是0.5,0.8??? 這是一個經驗值,在空間和時間成本中的折中,跟默認初始容量設置爲16同樣。看看JDK8中HashMap最開頭的註釋便可找到答案:(說實話,JDK中的註解真是太多太詳細了,教科書式的代碼人通常人寫的仍是不同的
)
* <p>As a general rule, the default load factor (.75) offers a good * tradeoff between time and space costs. Higher values decrease the * space overhead but increase the lookup cost (reflected in most of * the operations of the <tt>HashMap</tt> class, including * <tt>get</tt> and <tt>put</tt>). The expected number of entries in * the map and its load factor should be taken into account when * setting its initial capacity, so as to minimize the number of * rehash operations. If the initial capacity is greater than the * maximum number of entries divided by the load factor, no rehash * operations will ever occur.
再蹩腳的翻譯一次:
* 一般,默認的負載因子(0.75)是在時間和空間成本上比較好的折中選擇。若是設置成更高的值,雖然會 * 減小空間開銷,可是會增長查找的成本(反應在HashMap的大部分操做中,包括get和put方法)。 * 在設置初始容量時,應該考慮映射中的條目數量以及負載因子,以儘可能減小從新散列的次數。 * 若是初始容量大於最大詞條數量除以負載因子,那麼就不會發生從新散列操做。
並且,裝載因子和容量都是能夠在構造函數中指定的:
/** * 用指定初始容量和裝載因子構造一個空的hashmap, */ 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); } /** * 用指定初始容量和默認的裝載因子(0.75)構造一個空的hashmap */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * 用指定默認容量(16)和默認的裝載因子(0.75)構造一個空的hashmap */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 用另外一個map來構造一個新的hashmap,並保留相同的映射。新的HashMap使用默認加載因子(0.75)和適合裝下指定map中全部映射關係的 * 初始容量。 */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
若是不指定初始大小和加載因子,將使用默認的加載因子和默認的容量,並且HashMap中是使用懶加載的方式進行的,只有真正往裏添加元素時纔會初始化table。上面咱們已經看過了put方法的實現,那咱們再來看看get方法是怎樣實現的:
/** * 返回指定鍵映射的值,當該key不存在的時候返回null */ public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } /** * 實現了Map.get 和相關方法 */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //判斷是不是第一個節點 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) //若是是樹節點,則用TreeNode的getTreeNode方法來查找相應的key return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { //不然遍歷鏈表來查找 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
get的思路很簡單。大體思路以下:
好了,最重要的方法都介紹完了,是時候去救爺爺了,說錯了,是時候來回答最開頭提出的問題了:
1.HashMap的結構是什麼?
HashMap是數組+鏈表的存儲形式,默認的初始容量是16,默認的加載因子是0.75,當鏈表長度達到8時將會轉化爲紅黑樹來提升查找效率。
2.HashMap的優勢和缺點是什麼?
HashMap的優勢是查找速度很快,咱們能夠在常數時間內迅速定位到某個桶以及要找的對象。缺點嘛,就是它的拿手好戲——散列算法是依賴key的hashcode,因此若是key的hashcode設計的很爛,將會嚴重影響性能。
極端狀況下,若是每次計算hash值都是同一個值,那麼會形成鏈表中長度過長而後轉化成樹,擴容時再散列的效果也不好的問題。 另外一個極端狀況. 每次計算hash值都是不一樣的值,那麼就是HashMap中的數組會不斷的擴容,形成HashMap的容量不斷增大。
另外一方面,HashMap是線程不安全的,若是想在併發編程中使用到HashMap,就須要使用它的同步類,Collections.synchronizedMap()方法將普通的HashMap轉化成線程安全的,或者使用Concurrent包下的ConcurrentHashMap進行替換。
3.何時該使用HashMap?
由於HashMap查找速度很快,因此應用在常常須要存取元素的場景,好比要將一個List B中的元素根據另外一個List A的元素來進行排序,那麼須要常常將B中的元素來到A中進行查找,而查找通常都是使用遍歷的方式進行的,若是List很大的狀況下,效率問題仍是須要考慮的,這時候若是將A中的元素存儲在Map中,以B中的元素做爲key,那麼查找效率將大大提升,這是以空間換取時間的策略。
4.HashMap中的經常使用方法有哪些?
經常使用方法有put,get,putAll,remove,clear,replace,size。
5.HashMap的get()方法和put()方法的工做原理是什麼?
經過對key的hashCode()進行hashing,並計算下標( n-1 & hash),從而得到桶的位置。若是發生哈希衝突,則利用key.equals()方法去鏈表或樹中去查找對應的節點。
6.HashMap中的碰撞探測(collision detection)以及碰撞的解決方法是什麼?
當兩個key的hashCode相同時,就會發生碰撞,就像上面的小明和小李,這時候後添加的元素將會以鏈表或樹節點的形式掛在桶後面。
7.若是HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?
若是HashMap的大小超過了加載因子*容量,那麼將會進行擴容操做,擴容到原來的兩倍。
8.在設置HashMap的鍵是須要注意什麼?可使用自定義的對象做爲鍵嗎?
設置的key儘可能使用不可變對象,例如數值,String,這樣能夠保證key的不可變性,也可使用自定義的類對象,但須要對hashCode方法和equals方法有良好的設計。
此處應有掌聲,本篇終於講解完畢,這段時間由於其餘時間,一直沒多少時間來寫博客,耽擱了兩個星期,對不住各位看官啦,不過寫一篇博客真心挺花時間的,這篇文章我也是想了好幾天纔想好思路,HashMap的東西實在是太多了,細節不太可能面面俱到,並且若是事無鉅細所有介紹的話,顯然對初學者來講不夠友好,因此才決定分紅了四篇,這樣你們能夠根據本身能理解的程度選擇性的閱讀。
還望各位看官賞個贊,對個人文章感興趣的話,也歡迎動動小手點點關注,仍是持續更新的。也歡迎提出好的建議,若是有重要知識點遺漏或者說錯了的地方,還請各位看官及時指出,多多交流。