什么是哈希表?
在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能
數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)
線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。
我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數組。
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:
查找操作同理,先通過哈希函數計算出實際存儲地址,然后從數組中對應地址取出即可。
哈希沖突
然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式。
什么是HashMap?
HashMap 是一個利用哈希表原理來存儲元素的集合。遇到沖突時,HashMap 是采用的鏈地址法來解決,在 JDK1.7 中,HashMap 是由 數組+鏈表構成的。但是在 JDK1.8 中,HashMap 是由 數組+鏈表+紅黑樹構成,新增了紅黑樹作為底層數據結構,結構變得復雜了,但是效率也變的更高效。下面我們來具體介紹在 JDK1.8 中 HashMap 是如何實現的。
HashMap定義
HashMap 是一個散列表,它存儲的內容是鍵值對(key-value)映射,而且 key 和 value 都可以為 null。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
藍色實線箭頭是指Class繼承關系
綠色實線箭頭是指interface繼承關系
綠色虛線箭頭是指接口實現關系
字段屬性

//默認 HashMap 集合初始容量為16(必須是 2 的倍數) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //集合的最大容量,如果通過帶參構造指定的最大容量超過此數,默認還是使用此數 static final int MAXIMUM_CAPACITY = 1 << 30; //默認的填充因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //當桶(bucket)上的結點數大於這個值時會轉成紅黑樹(JDK1.8新增) static final int TREEIFY_THRESHOLD = 8; //當桶(bucket)上的節點數小於這個值時會轉成鏈表(JDK1.8新增) static final int UNTREEIFY_THRESHOLD = 6; /**(JDK1.8新增) * 當集合中的容量大於這個值時,表中的桶才能進行樹形化 ,否則桶內元素太多時會擴容, * 而不是樹形化 為了避免進行擴容、樹形化選擇的沖突,這個值不能小於 4 * TREEIFY_THRESHOLD */ static final int MIN_TREEIFY_CAPACITY = 64;

//初始化使用,長度總是 2的冪 transient Node<K,V>[] table; //保存緩存的entrySet() transient Set<Map.Entry<K,V>> entrySet; //此映射中包含的鍵值映射的數量。(集合存儲鍵值對的數量) transient int size; /** * 跟前面ArrayList和LinkedList集合中的字段modCount一樣,記錄集合被修改的次數 * 主要用於迭代器中的快速失敗 */ transient int modCount; //調整大小的下一個大小值(容量*加載因子)。capacity * load factor int threshold; //散列表的加載因子。 final float loadFactor;
①、Node<K,V>[] table
我們說 HashMap 是由數組+鏈表+紅黑樹組成,這里的數組就是 table 字段。后面對其進行初始化長度默認是 DEFAULT_INITIAL_CAPACITY= 16。
②、size
集合中存放key-value 的實時對數。
③、loadFactor
裝載因子,是用來衡量 HashMap 滿的程度,計算HashMap的實時裝載因子的方法為:size/capacity,而不是占用桶的數量去除以capacity。capacity 是桶的數量,也就是 table 的長度length。
默認的負載因子0.75 是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子loadFactor 的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子 loadFactor 的值,這個值可以大於1。
④、threshold
計算公式:capacity * loadFactor。這個值是當前已占用數組長度的最大值。過這個數目就重新resize(擴容),擴容后的 HashMap 容量是之前容量的兩倍
構造函數
①、無參構造函數

/** * 默認構造函數,初始化加載因子loadFactor = 0.75 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; }
②、指定初始容量的構造函數

/** * * @param initialCapacity 指定初始化容量 * @param loadFactor 加載因子 0.75 */ public HashMap(int initialCapacity, float loadFactor) { //初始化容量不能小於 0 ,否則拋出異常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //如果初始化容量大於2的30次方,則初始化容量都為2的30次方 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //如果加載因子小於0,或者加載因子是一個非數值,拋出異常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; //tableSizeFor()的主要功能是返回一個比給定整數大且最接近的2的冪次方整數,如給定10,返回2的4次方16. this.threshold = tableSizeFor(initialCapacity); } // 返回大於等於initialCapacity的最小的二次冪數值。 // >>> 操作符表示無符號右移,高位取0。 // | 按位或運算 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; }
添加元素

//hash(key)獲取Key的哈希值,equls返回為true,則兩者的hashcode一定相等,意即相等的對象必須具有相等的哈希碼。 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * * @param hash Key的哈希值 * @param key 鍵 * @param value 值 * @param onlyIfAbsent true 表示不要更改現有值 * @param evict false表示table處於創建模式 * @return */ 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為null或者長度為0,則進行初始化 //resize()方法本來是用於擴容,由於初始化沒有實際分配空間,這里用該方法進行空間分配,后面會詳細講解該方法 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //(n - 1) & hash:確保索引在數組范圍內,相當於hash % n 的值 //通過 key 的 hash code 計算其在數組中的索引:為什么不直接用 hash 對 數組長度取模?因為除法運算效率低 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);//tab[i] 為null,直接將新的key-value插入到計算的索引i位置 else {//tab[i] 不為null,表示該位置已經有值了 Node<K,V> e; K k; //e節點表示已經存在Key的節點,需要覆蓋value的節點 //table[i]的首個元素是否和key一樣,如果相同直接覆蓋value if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;//節點key已經有值了,將第一個節點賦值給e //該鏈是紅黑樹 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //該鏈是鏈表 else { //遍歷鏈表 for (int binCount = 0; ; ++binCount) { //先將e指向下一個節點,然后判斷e是否是鏈表中最后一個節點 if ((e = p.next) == null) { 創建一個新節點加在鏈表結尾 p.next = newNode(hash, key, value, null); //鏈表長度大於8,轉換成紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //key已經存在直接終止,此時e的值已經為 p.next 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) //修改已經存在Key的節點的value e.value = value; afterNodeAccess(e); //返回key的原始值 return oldValue; } } ++modCount;//用作修改和新增快速失敗 if (++size > threshold)//超過最大容量,進行擴容 resize(); afterNodeInsertion(evict); return null; }
①、判斷鍵值對數組 table 是否為空或為null,否則執行resize()進行擴容;
②、根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向⑥,如果table[i]不為空,轉向③;
③、判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向④,這里的相同指的是hashCode以及equals;
④、判斷table[i] 是否為treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;
⑤、遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
⑥、插入成功后,判斷實際存在的鍵值對數量size是否超過了最大容量threshold,如果超過,進行擴容。
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) 此處先判斷p.hash == hash是為了提高效率,僅通過(k = e.key) == key || key.equals(k)其實也可以進行判斷,但是equals方法相當耗時!如果兩個key的hash值不同,那么這兩個key肯定不相同,進行equals比較是扯淡的! 所以先通過p.hash == hash該條件,將桶中很多不符合的節點pass掉。然后對剩下的節點繼續判斷。
h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率。
數組的長度按規定一定是2的冪。因此,數組的長度的二進制形式是:10000…000, 1后面有偶數個0。 那么,length - 1 的二進制形式就是01111.111, 0后面有偶數個1。
這看上去很簡單,其實比較有玄機的,我們舉個例子來說明:
假設數組長度分別為15和16,優化后的hash碼分別為8和9,那么&運算后的結果如下:
h & (table.length-1) hash table.length-1
8 & (15-1): 0100 & 1110 = 0100
9 & (15-1): 0101 & 1110 = 0100
---------------------------------------------------
8 & (16-1): 0100 & 1111 = 010
9 & (16-1): 0101 & 1111 = 0101
從上面的例子中可以看出:當它們和15-1(1110)“與”的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到數組中的同一個位置上形成鏈表,那么查詢的時候就需要遍歷這個鏈
表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度為15的時候,hash值會與15-1(1110)進行“與”,那么最后一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是 這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的幾率,減慢了查詢的效率!
當n為2次冪時,會滿足一個公式:(n - 1) & hash = hash % n,計算更加高效。
只有是2的冪數的數字經過n-1之后,二進制肯定是 ...11111111 這樣的格式,這種格式計算的位置的時候,完全是由產生的hash值類決定
奇數n-1為偶數,偶數2進制的結尾都是0,經過&運算末尾都是0,會 增加hash沖突。
擴容
擴容(resize),我們知道集合是由數組+鏈表+紅黑樹構成,向 HashMap 中插入元素時,如果HashMap 集合的元素已經大於了最大承載容量threshold(capacity * loadFactor),這里的threshold不是數組的最大長度。那么必須擴大數組的長度,Java中數組是無法自動擴容的,我們采用的方法是用一個更大的數組代替這個小的數組

final Node<K,V>[] resize() { //將原始數組數據緩存起來 Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length;//原數組如果為null,則長度賦值0 int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) {//如果原數組長度大於0 if (oldCap >= MAXIMUM_CAPACITY) {//數組大小如果已經大於等於最大值(2^30) threshold = Integer.MAX_VALUE;//修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了 return oldTab; } //原數組長度擴大1倍(此時將原數組擴大一倍后的值賦給newCap)也小於2^30次方,並且原數組長度大於等於初始化長度16 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 閥值擴大1倍 //如果原數組長度擴大一倍后大於MAXIMUM_CAPACITY后,newThr還是0 } else if (oldThr > 0) //舊容量為0,舊閥值大於0,則將新容量直接等於就閥值 //在第一次帶參數初始化時候會有這種情況 //newThr在面算 newCap = oldThr; else { //閥值等於0,oldCap也等於0(集合未進行初始化) //在默認無參數初始化會有這種情況 newCap = DEFAULT_INITIAL_CAPACITY;//數組長度初始化為16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//閥值等於16*0.75=12 } //計算新的閥值上限 //此時就是上面原數組長度擴大一倍后大於MAXIMUM_CAPACITY和舊容量為0、舊閥值大於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"}) //創建容器大小為newCap的新數組 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //將新數組賦給table table = newTab; //如果是第一次,擴容的時候,也就是原來沒有元素,下面的代碼不會運行,如果原來有元素,則要將原來的元素,進行放到新擴容的里面 if (oldTab != null) { //把每個bucket都移動到新的buckets中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null;//元數據j位置置為null,便於垃圾回收 if (e.next == null)//數組沒有下一個引用(不是鏈表) //直接將e的key的hash與新容量重新計算下標,新下標的元素為e 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; } //原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); //原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
if ((e.hash & oldCap) == 0)如果判斷成立,那么該元素的地址在新的數組中就不會改變。因為oldCap的最高位的1,在e.hash對應的位上為0,所以擴容后得到的地址是一樣的,位置不會改變 ,在后面的代碼的執行中會放到loHead中去,最后賦值給newTab[j];如果判斷不成立,那么該元素的地址變為 原下標位置+oldCap,也就是lodCap最高位的1,在e.hash對應的位置上也為1,所以擴容后的地址改變了,在后面的代碼中會放到hiHead中,最后賦值給newTab[j + oldCap] 舉個栗子來說一下上面的兩種情況:
設:oldCap=16 二進制為:0001 0000
oldCap-1=15 二進制為:0000 1111
e1.hash=10 二進制為:0000 1010
e2.hash=26 二進制為:0101 1010
e1在擴容前的位置為:e1.hash & oldCap-1 結果為:0000 1010
e2在擴容前的位置為:e2.hash & oldCap-1 結果為:0000 1010
結果相同,所以e1和e2在擴容前在同一個鏈表上,這是擴容之前的狀態。 現在擴容后,需要重新計算元素的位置,在擴容前的鏈表中計算地址的方式為e.hash & oldCap-1 那么在擴容后應該也這么計算呀,擴容后的容量為oldCap*2=32 0010 0000 newCap=32,新的計算 方式應該為
e1.hash & newCap-1
即:0000 1010 & 0001 1111
結果為0000 1010與擴容前的位置完全一樣。
e2.hash & newCap-1
即:0101 1010 & 0001 1111
結果為0001 1010,為擴容前位置+oldCap。
而這里卻沒有e.hash & newCap-1
而是 e.hash & oldCap,其實這兩個是等效的,都是判斷倒數第五位是0,還是1。如果是0,則位置不變,是1則位置改變為擴容前位置+oldCap。
查找元素
①、get(Object key)
通過 key 查找 value:首先通過 key 找到計算索引,找到桶元素的位置,先檢查第一個節點,如果是則返回,如果不是,則遍歷其后面的鏈表或者紅黑樹。其余情況全部返回 null。

public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } 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) { //根據key計算的索引檢查第一個索引 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)//遍歷樹查找元素 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; }
②、判斷是否存在給定的 key 或者 value

public boolean containsKey(Object key) { return getNode(hash(key), key) != null; } public boolean containsValue(Object value) { Node<K,V>[] tab; V v; if ((tab = table) != null && size > 0) { //遍歷數組 for (int i = 0; i < tab.length; ++i) { //遍歷數組中的每個節點元素 for (Node<K,V> e = tab[i]; e != null; e = e.next) { if ((v = e.value) == value || (value != null && value.equals(v))) return true; } } } return false; }
刪除元素

public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; //(n - 1) & hash找到桶的位置 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; //如果鍵的值與鏈表第一個節點相等,則將 node 指向該節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; //如果桶節點存在下一個節點 else if ((e = p.next) != null) { //節點為紅黑樹 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key);//找到需要刪除的紅黑樹節點 else { do {//遍歷鏈表,找到待刪除的節點 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; //找到就停止,如果此時是第一次遍歷就找到,則node指向鏈表中第二個元素,p還是第一個元素 //第一次沒找到,第二次找到,則node指向鏈表中第三個元素,p指向第二個元素,p是找到元素節點的父節點 //所以需要遍歷的時候p和node 是不相等的,只有鏈表第一個元素就判斷相等時,p和node 相等 break; } //第一次遍歷沒找到, 此時p指向第二個元素 p = e; } while ((e = e.next) != null); } } //刪除節點,並進行調節紅黑樹平衡 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); else if (node == p) //如果鍵的值與鏈表第一個節點相等,則將元素位置指向 node的下一個節點(鏈表的第二個節點),有可能node.next 為null tab[index] = node.next; else //如果鍵的值與鏈表第一個節點不相等,node的父節點的next指向node的next p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } } return null; }
遍歷元素

HashMap<String, String> map = new HashMap<>(); map.put("1", "A"); map.put("2", "B"); map.put("3", "C"); map.put("4", "D"); map.put("5", "E"); map.put("6", "F"); for(String str : map.keySet()){ System.out.print(map.get(str)+" "); } for(HashMap.Entry entry : map.entrySet()){ System.out.print(entry.getKey()+" "+entry.getValue()); }
重寫equals方法需同時重寫hashCode方法
各種資料上都會提到,“重寫equals時也要同時覆蓋hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發生什么樣的問題

/** * Created by chenhao on 2018/9/28. */ public class MyTest { private static class Person{ int idCard; String name; public Person(int idCard, String name) { this.idCard = idCard; this.name = name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()){ return false; } Person person = (Person) o; //兩個對象是否等值,通過idCard來確定 return this.idCard == person.idCard; } } public static void main(String []args){ HashMap<Person,String> map = new HashMap<Person, String>(); Person person = new Person(123,"喬峰"); //put到hashmap中去 map.put(person,"天龍八部"); //get取出,從邏輯上講應該能輸出“天龍八部” System.out.println("結果:"+map.get(new Person(123,"蕭峰"))); } }
如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。盡管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode2)-->hash-->indexFor-->最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)
再想象一下,假如兩個Java對象A和B,A和B相等(eqauls結果為true),但A和B的哈希碼不同,則A和B存入HashMap時的哈希碼計算得到的HashMap內部數組位置索引可能不同,那么A和B很有可能允許同時存入HashMap,顯然相等/相同的元素是不允許同時存入HashMap,HashMap不允許存放重復元素。
所以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個對象,調用hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(只不過會發生哈希沖突,應盡量避免)。