白話解析Java中HashMap底層實現原理

作者:ZoroRen

https://www.cnblogs.com/yuanblog/p/4441017.html

一、Java中的hashCode和equals

1、關於hashCode

  1. hashCode的存在主要是用於查找的快捷性,如Hashtable,HashMap等,hashCode是用來在散列存儲結構中確定對象的存儲地址的
  2. 如果兩個對象相同,就是適用於equals(java.lang.Object) 方法,那麼這兩個對象的hashCode一定要相同
  3. 如果對象的equals方法被重寫,那麼對象的hashCode也儘量重寫,並且產生hashCode使用的對象,一定要和equals方法中使用的一致,否則就會違反上面提到的第2點
  4. 兩個對象的hashCode相同,並不一定表示兩個對象就相同,也就是不一定適用於equals(java.lang.Object) 方法,只能夠說明這兩個對象在散列存儲結構中,如Hashtable,他們“存放在同一個籃子裏“

再歸納一下就是hashCode是用於查找使用的,而equals是用於比較兩個對象的是否相等的。

以下對hashCode的解讀摘自其他博客:

1.hashcode是用來查找的,如果你學過數據結構就應該知道,在查找和排序這一章有例如內存中有這樣的位置

0 1 2 3 4 5 6 7

而我有個類,這個類有個字段叫ID,我要把這個類存放在以上8個位置之一,如果不用hashcode而任意存放,那麼當查找時就需要到這八個位置裏挨個去找,或者用二分法一類的算法。

但如果用hashcode那就會使效率提高很多。

我們這個類中有個字段叫ID,那麼我們就定義我們的hashcode爲ID%8,然後把我們的類存放在取得得餘數那個位置。比如我們的ID爲9,9除8的餘數爲1,那麼我們就把該類存在1這個位置,如果ID是13,求得的餘數是5,那麼我們就把該類放在5這個位置。這樣,以後在查找該類時就可以通過ID除8求餘數直接找到存放的位置了。

2.但是如果兩個類有相同的hashcode怎麼辦那(我們假設上面的類的ID不是唯一的),例如9除以8和17除以8的餘數都是1,那麼這是不是合法的,回答是:可以這樣。那麼如何判斷呢?在這個時候就需要定義 equals了。

也就是說,我們先通過 hashcode來判斷兩個類是否存放某個桶裏,但這個桶裏可能有很多類,那麼我們就需要再通過 equals 來在這個桶裏找到我們要的類。

那麼。重寫了equals(),爲什麼還要重寫hashCode()呢?

想想,你要在一個桶裏找東西,你必須先要找到這個桶啊,你不通過重寫hashcode()來找到桶,光重寫equals()有什麼用啊

2、關於equals

1.equals和==

==用於比較引用和比較基本數據類型時具有不同的功能:

比較基本數據類型,如果兩個值相同,則結果爲true

而在比較引用時,如果引用指向內存中的同一對象,結果爲true;

equals()作爲方法,實現對象的比較。由於 == 運算符不允許我們進行覆蓋,也就是說它限制了我們的表達。因此我們複寫equals()方法,達到比較對象內容是否相同的目的。而這些通過==運算符是做不到的。

2.object類的equals()方法的比較規則爲:如果兩個對象的類型一致,並且內容一致,則返回true,這些類有:

java.io.file,java.util.Date,java.lang.string,包裝類(Integer,Double等)

String s1=new String("abc");

String s2=new String("abc");

System.out.println(s1==s2);

System.out.println(s1.equals(s2));

運行結果爲false true

二、HashMap的實現原理

HashMap概述

HashMap是基於哈希表的Map接口的非同步實現。此實現提供所有可選的映射操作,並允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恆久不變。

在java編程語言中,最基本的結構就是兩種,一個是數組,另外一個是模擬指針(引用),所有的數據結構都可以用這兩個基本結構來構造的,HashMap也不例外。HashMap實際上是一個“鏈表散列”的數據結構,即數組和鏈表的結合體。

image

從上圖中可以看出,HashMap底層就是一個數組結構,數組中的每一項又是一個鏈表。當新建一個HashMap的時候,就會初始化一個數組。

其中Java源碼如下:

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry[] table;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;
    ……
}

可以看出,Entry就是數組中的元素,每個 Map.Entry 其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了鏈表。

2、HashMap實現存儲和讀取

1)存儲

public V put(K key, V value) {
    // HashMap允許存放null鍵和null值。
    // 當key爲null時,調用putForNullKey方法,將value放置在數組第一個位置。
    if (key == null)
        return putForNullKey(value);
    // 根據key的keyCode重新計算hash值。
    int hash = hash(key.hashCode());
    // 搜索指定hash值在對應table中的索引。
    int i = indexFor(hash, table.length);
    // 如果 i 索引處的 Entry 不爲 null,通過循環不斷遍歷 e 元素的下一個元素。
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        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;
}

根據hash值得到這個元素在數組中的位置(即下標),如果數組該位置上已經存放有其他元素了,那麼在這個位置上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果數組該位置上沒有元素,就直接將該元素放到此數組中的該位置上。

hash(int h)方法根據key的hashCode重新計算一次散列。此算法加入了高位計算,防止低位不變,高位變化時,造成的hash衝突。

static int hash(int h) {
     h ^= (h >>> 20) ^ (h >>> 12);
     return h ^ (h >>> 7) ^ (h >>> 4);
}

我們可以看到在HashMap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過HashMap的數據結構是數組和鏈表的結合,所以我們當然希望這個HashMap裏面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表,這樣就大大優化了查詢的效率。

根據上面 put 方法的源代碼可以看出,當程序試圖將一個key-value對放入HashMap中時,程序首先根據該 key的 hashCode() 返回值決定該 Entry 的存儲位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的存儲位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新添加 Entry 的 value 將覆蓋集合中原有 Entry的 value,但key不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新添加的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新添加的 Entry 位於 Entry 鏈的頭部——具體說明繼續看 addEntry() 方法的說明。

通過這種方式就可以高效的解決HashMap的衝突問題。

2)讀取

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    int hash = hash(key.hashCode());
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
        e != null;
        e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}

從HashMap中get元素時,首先計算key的hashCode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。

3)歸納起來簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 對象。HashMap 底層採用一個 Entry[] 數組來保存所有的 key-value 對,當需要存儲一個 Entry 對象時,會根據hash算法來決定其在數組中的存儲位置,在根據equals方法決定其在該數組位置上的鏈表中的存儲位置;當需要取出一個Entry時,也會根據hash算法找到其在數組中的存儲位置,再根據equals方法從該位置上的鏈表中取出該Entry。

3、HashMap的resize

當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因爲數組的長度是固定的),所以爲了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。

那麼hashmap什麼時候進行擴容呢?當hashmap中的元素個數超過數組大小* loadFactor時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16* 0.75=12的時候,就把數組的大小擴展爲2* 16=32,即擴大一倍,然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知hashmap中元素的個數,那麼預設元素的個數能夠有效的提高hashmap的性能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設置爲1024。 但是new HashMap(1024)還不是更合適的,因爲0.75*1000 < 1000, 也就是說爲了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。

總結:HashMap的實現原理:

  1. 利用key的hashCode重新hash計算出當前對象的元素在數組中的下標
  2. 存儲時,如果出現hash值相同的key,此時有兩種情況。(1)如果key相同,則覆蓋原始值;(2)如果key不同(出現衝突),則將當前的key-value放入鏈表中
  3. 獲取時,直接找到hash值對應的下標,在進一步判斷key是否相同,從而找到對應值。
  4. 理解了以上過程就不難明白HashMap是如何解決hash衝突的問題,核心就是使用了數組的存儲方式,然後將衝突的key的對象放入鏈表中,一旦發現衝突就在鏈表中做進一步的對比。

想學習分佈式、微服務、JVM、多線程、架構、java、python的童鞋,千萬不要掃碼,否則後果自負~

林老師帶你學編程https://wolzq.com

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章