HashMap是java中相當重要的數據結構,使用HashMap的場景非常之多,因此,了解HashMap實現的過程和原理,是非常有必要的,在一些面試中也會經常被問到。好了,我們趕緊來研究java內部是怎么實現HashMap的吧!
首先,我們都知道,數組的元素查找的效率是不錯的,但是涉及到插入操作和刪除操作,效率低下,因為可能會涉及到后續元素位置的遷移。而另外一個數據結構鏈表則很好的解決了這個問題,插入和刪除操作都只需要改變節點的指針就行,但是鏈表的檢索的效率就很低了,試想一下,要檢索的元素在鏈表的末尾,而我們只能從鏈表頭開始走完整個鏈表,才能檢索到這個元素。而Hash表能給我們帶來高效查詢,插入和刪除。它是怎么做到的呢?
Hash表的實質是構造記錄的存儲位置和其對應的關鍵字之間的映射函數f,關於Hash函數的構造方法,主要有如下幾種:
(1)直接定址法,取關鍵字的某個線性函數作為Hash函數即Hash(key) = a*key+b。這種方法很少使用,雖然不會發生沖突,但是當key非常多的時候,整張Hash表也會非常大,畢竟是一一映射的。
(2)平方取中法,將key的平方的中間幾位數作為得到的Hash地址。
(3)除留余數法,將key除以某個數,得到的余數作為Hash地址。
還有一些方法我們在此就不說了。當多個關鍵字經過這個Hash函數的映射而得到同一個值的時候,就發生了Hash沖突。解決Hash沖突主要有兩種方法:
(1)開放定址法:
其中i=1,2,3。。。。,k(k<=m-1),H(key)為哈希函數,m為哈希表表長,di為增量序列,可能有下列2種情況:
當 di=1,2,3....,m-1時,稱線性探測在散列;
當 時,稱為二次探測再散列。
(2)鏈地址法:
即將所有關鍵字為同義詞的記錄存儲在同一線性表中。假設某哈希函數產生的哈希地址在區間[0,m-1]上,則設立一個指針型向量 ChainHash[m];
其每個分量的初始狀態都是空指針。凡是哈希地址為i的記錄都插入到頭指針為ChainHash[i]的鏈表中。在列表中的插入位置可以在表頭或表尾;也可以在中間,以保持同義詞在同一線性表中按關鍵字有序。
例如:已知一組關鍵字為(19,14,23,01,68,20,84,27,55,11,10,79),則按哈希函數H(key)=key MOD 13 和鏈地址法處理沖突構造所得的哈希表,如下圖所示:
Java中的HashMap的基本結構就如上圖所示,豎着看是一個數組,橫着看就是多個鏈表。當新建一個HashMap的時候,就初始化了一個數組:
1 /** 2 * The table, resized as necessary. Length MUST Always be a power of two. 3 */ 4 5 transient Entry[] table;
關於transient關鍵字,是為了使其修飾的對象不參與序列化,也就是說,這個對象無法被持久化,這里用這個關鍵字是有原因的,由於HashCode()方法是一個本地方法(由java調用本地的外部函數執行),所以不同的虛擬機,對於相同的hashCode 產生的 Code 值可能是不一樣的,如果你使用默認的序列化,那么反序列化后,元素的位置和之前的是保持一致的,可是由於 hashCode 的值不一樣了,那么定位函數 indexOf()返回的元素下標就會不同,這樣不是我們所想要的結果.舉個網上大神的例子:
向HashMap存一個entry, key為 字符串"STRING", 在第一個java程序里, "STRING"的hashcode()為1, 存入第1號bucket; 在第二個java程序里, "STRING"的hashcode()有可能就是2, 存入第2號bucket. 如果用默認的串行化(Entry[] table不用transient), 那么這個HashMap從第一個java程序里通過串行化導入第二個java程序后, 其內存分布是一樣的,那么我取1號bucket能拿到“STRING”這個key,取2號bucket也能拿到相同的key,這是不合理的。
因此,HashMap這個entry數組是不可以串行化的。因此,HashMap自己實現了readObject和writeObject,在其中它只保存了bucket size,entry count(這兩個其實不是必需的,但有助於提高效率)和所有的key/value(這個才是必須的)。
這就是數組內的鏈表:
1 static class Node<K,V> implements Map.Entry<K,V> { 2 final int hash; 3 final K key; 4 V value; 5 Node<K,V> next; //持有的一個指向下一個元素的引用,構成鏈表。 6 .... 7 }
下面讓我們來看看Hash在put新元素時所做的操作:
1 public V put(K key, V value) { // HashMap允許存放null鍵和null值, 當key為null時,調用putForNullKey方法,將value放置在數組第一個位置。 2 if (key == null) 3 return putForNullKey(value); //null key 存放的總是數組的第一個元素中 4 int hash = hash(key.hashCode()); // 根據key的HashCode重新計算hash值 5 int i = indexFor(hash, table.length); //通過hash值算出在對應table中的索引(下標)。 6 for (Entry<K,V> e = table[i]; e != null; e = e.next) { // 如果 i 索引處的 Entry 不為 null,通過循環不斷遍歷 e 元素的下一個元素 7 Object k; 8 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //如果存在key相等的情形時,則用新的value值覆蓋老的value的值 9 V oldValue = e.value; 10 e.value = value; 11 e.recordAccess(this); 12 return oldValue; 13 } 14 } 15 modCount++; // 如果i索引處的Entry為null,表明此處還沒有Entry。 16 addEntry(hash, key, value, i); // 將key、value添加到i索引處。 17 return null; 18 }
怎么新加傳進來的entry呢:
1 void addEntry(int hash, K key, V value, int bucketIndex) { // 獲取指定 bucketIndex 索引處的 Entry,bucketIndex可以理解為存放的table中的index,當這個bucketIndex相同時,就是發生了Hash沖突, 2 Entry<K,V> e = table[bucketIndex]; 3 table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry
4 if (size++ >= threshold) // 如果新加入后的大小超過了當前最大容量,則把 table 對象的長度擴充到原來的2倍。
5 resize(2 * table.length);
6 }
原來新加的entry都是加在了鏈表的頭端。
在取Entry的時候就非常簡單了,如果key等於null,直接取數組的第一個元素,如果不是,先計算出key的hashcode找到下標,再用key的equals方法判斷是否相等,如果相等,則返回對應的entry,如果不相等,則返回null:
1 public V get(Object key) { 2 if (key == null) 3 return getForNullKey(); 4 int hash = hash(key.hashCode()); 5 for (Entry<K,V> e = table[indexFor(hash, table.length)]; 6 e != null; 7 e = e.next) { 8 Object k; 9 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 10 return e.value; 11 } 12 return null; 13 }
關於HashMap的存儲原理就說到這里啦,有什么錯誤,請歡迎指正。
參考資料:http://zhangshixi.iteye.com/blog/672697