HashMap 和 HashSet 是 Java Collection Framework 的兩個重要成員,其中 HashMap 是 Map 接口的經常使用實現類,HashSet 是 Set 接口的經常使用實現類。雖然 HashMap 和 HashSet 實現的接口規範不一樣,但它們底層的 Hash 存儲機制徹底同樣,甚至 HashSet 自己就採用 HashMap 來實現的。 html
集合和引用
就像引用類型的數組同樣,當咱們把 Java 對象放入數組之時,並非真正的把 Java 對象放入數組中,只是把對象的引用放入數組中,每一個數組元素都是一個引用變量。
java
HashMap 的存儲實現
當程序試圖將多個 key-value 放入 HashMap 中時,以以下代碼片斷爲例: 程序員
HashMap<String , Double> map = new HashMap<String , Double>(); map.put("語文" , 80.0); map.put("語文" , 80.0); map.put("語文", 80.2); map.put("數學", 89.0); map.put("英語", 78.2); map.put(null , 78.5); map.put("null" , 78.6); System.out.println("null : "+map.get(null)); System.out.println("\"null\" : "+map.get("null"));
運行後的結果以下:數組
null : 78.5 "null" hashCode: 78.6
下面這幅圖描述了一個HashMap實例的內部存儲,它包含一個nullable對象組成的數組。每一個對象都鏈接到另一個對象,這樣就構成了一個鏈表。安全
HashMap將數據存儲到多個單向Entry鏈表中(有時也被稱爲桶bucket或者容器orbins)。全部的列表都被註冊到一個Entry數組中(Entry<K, V>[]數組),這個內部數組的默認長度是16。多線程
全部具備相同哈希值的鍵都會被放到同一個鏈表(桶)中。具備不一樣哈希值的鍵最終可能會在相同的桶中。app
當用戶調用 put(K key, V value) 或者 get(Object key) 時,程序會計算對象應該在的桶的索引。而後,程序會迭代遍歷對應的列表,來尋找具備相同鍵的Entry對象(使用鍵的equals()方法)。ide
對於調用get()的狀況,程序會返回值所對應的Entry對象(若是Entry對象存在)。函數
對於調用put(K key, V value)的狀況,若是Entry對象已經存在,那麼程序會將值替換爲新值,不然,程序會在單向鏈表的表頭建立一個新的Entry(從參數中的鍵和值)。性能
桶(鏈表)的索引,是經過map的3個步驟生成的:
首先獲取鍵的散列碼。
程序重複散列碼,來阻止針對鍵的糟糕的哈希函數,由於這有可能會將全部的數據都放到內部數組的相同的索引(桶)上。
程序拿到重複後的散列碼,並對其使用數組長度(最小是1)的位掩碼(bit-mask)。這個操做能夠保證索引不會大於數組的大小。你能夠將其看作是一個通過計算的優化取模函數。
看 HashMap 類的 put(K key , V value) 方法的源代碼:
/** * Associates the specified value with the specified key in this map. * If the map previously contained a mapping for the key, the old * value is replaced. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with <tt>key</tt>, or * <tt>null</tt> if there was no mapping for <tt>key</tt>. * (A <tt>null</tt> return can also indicate that the map * previously associated <tt>null</tt> with <tt>key</tt>.) */ public V put(K key, V value) { if (key == null) return putForNullKey(value); // 若是 key 爲 null,調用 putForNullKey 方法進行處理 int hash = hash(key.hashCode()); // 根據 key 的 keyCode 計算 Hash 值 int i = indexFor(hash, table.length); // 搜索指定 hash 值在對應 table 中的索引 // 若是 i 索引處的 Entry 不爲 null,經過循環不斷遍歷 e 元素的下一個元素 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; // 找到指定 key 與須要放入的 key 相等(hash 值相同經過 equals 比較放回 true) if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 若是 i 索引處的 Entry 爲 null,代表此處尚未 Entry modCount++; // 將 key、value 添加到 i 索引處 addEntry(hash, key, value, i); return null; }
從上面程序中能夠看出:當系統決定存儲 HashMap 中的 key-value 對時,徹底沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每一個 Entry 的存儲位置。
這也說明:咱們徹底能夠把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的存儲位置以後,value 隨之保存在那裏便可。
上面方法提供了一個根據 hashCode() 返回值來計算 Hash 碼的方法:hash(),這個方法是一個純粹的數學計算,其方法以下:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
「按位異或」運算符,是雙目運算。
>>> 是轉化爲二進制右移位,空出來的補0
按位異或運算符^
例如:10100001^00010001=10110000 0^0=0,0^1=1 0異或任何數=任何數 1^0=1,1^1=0 1異或任何數=任何數取反 任何數異或本身=把本身置0
對於任意給定的對象,只要它的 hashCode() 返回值相同,那麼程序調用 hash(int h) 方法所計算獲得的 Hash 碼值老是相同的。接下來程序會調用 indexFor(int h, int length) 方法來計算該對象應該保存在 table 數組的哪一個索引處。indexFor(int h, int length) 方法的代碼以下:
static int indexFor(int h, int length) { //位運算,相同爲1不一樣爲0 return h & (length-1); }
這個方法很是巧妙,它老是經過 h &(table.length -1) 來獲得該對象的保存位置——而 HashMap 底層數組的長度老是 2 的 n 次方。
當 length 老是 2 的倍數時,h & (length-1) 將是一個很是巧妙的設計:
假設 h=5,length=16, 那麼 h & length - 1 將獲得 5;
0101
1111
--------------
0101
若是 h=6,length=16, 那麼 h & length - 1 將獲得 6 ;
0110
1111
-------------
0110
……
若是 h=15,length=16, 那麼 h & length - 1 將獲得 15;
可是當 h=16 時 , length=16 時,那麼 h & length - 1 將獲得 0 了;
當 h=17 時 , length=16 時,那麼 h & length - 1 將獲得 1 了……
這樣保證計算獲得的索引值老是位於 table 數組的索引以內。
上面程序中還調用了 addEntry(hash, key, value, i); 代碼,其中 addEntry 是 HashMap 提供的一個包訪問權限的方法,該方法僅用於添加一個 key-value 對。下面是該方法的代碼:
void addEntry(int hash, K key, V value, int bucketIndex) { // 獲取指定 bucketIndex 索引處的 Entry Entry<K,V> e = table[bucketIndex]; // ① // 將新建立的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 若是 Map 中的 key-value 對的數量超過了極限 if (size++ >= threshold) // 把 table 對象的長度擴充到 2 倍。 resize(2 * table.length); // ② }
上面方法的代碼很簡單,但其中包含了一個很是優雅的設計:系統老是將新添加的 Entry 對象放入 table 數組的 bucketIndex 索引處——若是 bucketIndex 索引處已經有了一個 Entry 對象,那新添加的 Entry 對象指向原有的 Entry 對象(產生一個 Entry 鏈),若是 bucketIndex 索引處沒有 Entry 對象,也就是上面程序①號代碼的 e 變量是 null,也就是新放入的 Entry 對象指向 null,也就是沒有產生 Entry 鏈。
上面程序中使用的 table 其實就是一個普通數組,每一個數組都有一個固定的長度,這個數組的長度就是 HashMap 的容量。HashMap 包含以下幾個構造器:
* HashMap():構建一個初始容量爲 16,負載因子爲 0.75 的 HashMap。 * HashMap(int initialCapacity):構建一個初始容量爲 initialCapacity,負載因子爲 0.75 的 HashMap。 * HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。
當建立 HashMap 時,有一個默認的負載因子(load factor),其默認值爲 0.75,這是時間和空間成本上一種折衷:
增大負載因子能夠減小 Hash 表(就是那個 Entry 數組)所佔用的內存空間,但會增長查詢數據的時間開銷,而查詢是最頻繁的的操做(HashMap 的 get() 與 put() 方法都要用到查詢);
減少負載因子會提升數據查詢的性能,但會增長 Hash 表所佔用的內存空間。
掌握了上面知識以後,咱們能夠在建立 HashMap 時根據實際須要適當地調整 load factor 的值;若是程序比較關心空間開銷、內存比較緊張,能夠適當地增長負載因子;若是程序比較關心時間開銷,內存比較寬裕則能夠適當的減小負載因子。
一般狀況下,程序員無需改變負載因子的值。
爲何將字符串和整數做爲HashMap的鍵是一種很好的實現?主要是由於它們是不可變的!若是你選擇本身建立一個類做爲鍵,但不能保證這個類是不可變的,那麼你可能會在HashMap內部丟失數據。
咱們來看下面的用例:
你有一個鍵,它的內部值是「1」。
你向HashMap中插入一個對象,它的鍵就是「1」。
HashMap從鍵(即「1」)的散列碼中生成哈希值。
Map在新建立的記錄中存儲這個哈希值。
你改動鍵的內部值,將其變爲「2」。
鍵的哈希值發生了改變,可是HashMap並不知道這一點(由於存儲的是舊的哈希值)。
你試着經過修改後的鍵獲取相應的對象。
Map會計算新的鍵(即「2」)的哈希值,從而找到Entry對象所在的鏈表(桶)。
狀況1: 既然你已經修改了鍵,Map會試着在錯誤的桶中尋找Entry對象,沒有找到。
狀況2: 你很幸運,修改後的鍵生成的桶和舊鍵生成的桶是同一個。Map這時會在鏈表中進行遍歷,已找到具備相同鍵的Entry對象。可是爲了尋找鍵,Map首先會經過調用equals()方法來比較鍵的哈希值。由於修改後的鍵會生成不一樣的哈希值(舊的哈希值被存儲在記錄中),那麼Map沒有辦法在鏈表中找到對應的Entry對象。
下面是一個Java示例,咱們向Map中插入兩個鍵值對,而後我修改第一個鍵,並試着去獲取這兩個對象。你會發現從Map中返回的只有第二個對象,第一個對象已經「丟失」在HashMap中:
package com.sunsharing.ningyp.test; import java.util.HashMap; import java.util.Map; /** * Created by Administrator on 2015/9/13. */ public class MutableKeyTest { public static void main(String[] args) { class MyKey { Integer i; public void setI(Integer i) { this.i = i; } public MyKey(Integer i) { this.i = i; } @Override public int hashCode() { return i; } @Override public boolean equals(Object obj) { if (obj instanceof MyKey) { return i.equals(((MyKey) obj).i); } else return false; } } Map<MyKey, String> map = new HashMap<MyKey, String>(); MyKey key1 = new MyKey(1); MyKey key2 = new MyKey(2); map.put(key1, "value " + 1); map.put(key2, "value " + 2); // modifying key1 key1.setI(3); String test1 = map.get(key1); String test2 = map.get(key2); System.out.println("test1= " + test1 + " test2=" + test2); } }
上述代碼的輸出是「test1= null test2=value 2」。如咱們指望的那樣,Map沒有能力獲取通過修改的鍵 1所對應的字符串1。
若是你已經很是熟悉HashMap,那麼你確定知道它不是線程安全的,可是爲何呢?例如假設你有一個Writer線程,它只會向Map中插入已經存在的數據,一個Reader線程,它會從Map中讀取數據,那麼它爲何不工做呢?
由於在自動調整大小的機制下,若是線程試着去添加或者獲取一個對象,Map可能會使用舊的索引值,這樣就不會找到Entry對象所在的新桶。
在最糟糕的狀況下,當2個線程同時插入數據,而2次put()調用會同時出發數組自動調整大小。既然兩個線程在同時修改鏈表,那麼Map有可能在一個鏈表的內部循環中退出。若是你試着去獲取一個帶有內部循環的列表中的數據,那麼get()方法永遠不會結束。
HashTable提供了一個線程安全的實現,能夠阻止上述狀況發生。可是,既然全部的同步的CRUD操做都很是慢。例如,若是線程1調用get(key1),而後線程2調用get(key2),線程2調用get(key3),那麼在指定時間,只能有1個線程能夠獲得它的值,可是3個線程均可以同時訪問這些數據。
從Java 5開始,咱們就擁有一個更好的、保證線程安全的HashMap實現:ConcurrentHashMap。對於ConcurrentMap來講,只有桶是同步的,這樣若是多個線程不使用同一個桶或者調整內部數組的大小,它們能夠同時調用get()、remove()或者put()方法。在一個多線程應用程序中,這種方式是更好的選擇。