【JAVA】HashMap

1、hashmap 的數(shù)據(jù)結(jié)構(gòu)

要知道 hashmap 是什么,首先要搞清楚它的數(shù)據(jù)結(jié)構(gòu),在 java 編程語言中,最基本的結(jié)構(gòu)就是兩種,一個是數(shù)組,另外一個是模擬指針(引用),所有的數(shù)據(jù)結(jié)構(gòu)都可以用這兩個基本結(jié)構(gòu)來構(gòu)造的,hashmap 也不例外。Hashmap 實(shí)際上是一個數(shù)組和鏈表的結(jié)合體(在數(shù)據(jù)結(jié)構(gòu)中,一般稱之為 “鏈表散列 “),請看下圖(橫排表示數(shù)組,縱排表示數(shù)組元素【實(shí)際上是一個鏈表】)。

構(gòu)造圖

從圖中我們可以看到一個 hashmap 就是一個數(shù)組結(jié)構(gòu),當(dāng)新建一個 hashmap 的時候,就會初始化一個數(shù)組。我們來看看 java 代碼:

    /** 
     * The table, resized as necessary. Length MUST Always be a power of two. 
     *  FIXME 這里需要注意這句話,至于原因后面會講到 
     */  
    transient Entry[] table;  

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

上面的 Entry 就是數(shù)組中的元素,它持有一個指向下一個元素的引用,這就構(gòu)成了鏈表。

當(dāng)我們往 hashmap 中 put 元素的時候,先根據(jù) key 的 hash 值得到這個元素在數(shù)組中的位置(即下標(biāo)),然后就可以把這個元素放到對應(yīng)的位置中了。如果這個元素所在的位子上已經(jīng)存放有其他元素了,那么在同一個位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從 hashmap 中 get 元素時,首先計(jì)算 key 的 hashcode,找到數(shù)組中對應(yīng)位置的某一元素,然后通過 key 的 equals 方法在對應(yīng)位置的鏈表中找到需要的元素。從這里我們可以想象得到,如果每個位置上的鏈表只有一個元素,那么 hashmap 的 get 效率將是最高的,但是理想總是美好的,現(xiàn)實(shí)總是有困難需要我們?nèi)タ朔?,哈哈?/p>

2、hash 算法

我們可以看到在 hashmap 中要找到某個元素,需要根據(jù) key 的 hash 值來求得對應(yīng)數(shù)組中的位置。如何計(jì)算這個位置就是 hash 算法。前面說過 hashmap 的數(shù)據(jù)結(jié)構(gòu)是數(shù)組和鏈表的結(jié)合,所以我們當(dāng)然希望這個 hashmap 里面的元素位置盡量的分布均勻些,盡量使得每個位置上的元素數(shù)量只有一個,那么當(dāng)我們用 hash 算法求得這個位置的時候,馬上就可以知道對應(yīng)位置的元素就是我們要的,而不用再去遍歷鏈表。

所以我們首先想到的就是把 hashcode 對數(shù)組長度取模運(yùn)算,這樣一來,元素的分布相對來說是比較均勻的。但是,“?!?運(yùn)算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java 中時這樣做的,

    static int indexFor(int h, int length) {  
       return h & (length-1);  
   }  

首先算得 key 得 hashcode 值,然后跟數(shù)組的長度 - 1 做一次 “與” 運(yùn)算(&)??瓷先ズ芎唵危鋵?shí)比較有玄機(jī)。比如數(shù)組的長度是 2 的 4 次方,那么 hashcode 就會和 2 的 4 次方 - 1 做 “與” 運(yùn)算。很多人都有這個疑問,為什么 hashmap 的數(shù)組初始化大小都是 2 的次方大小時,hashmap 的效率最高,我以 2 的 4 次方舉例,來解釋一下為什么數(shù)組大小為 2 的冪時 hashmap 訪問的性能最高。

看下圖,左邊兩組是數(shù)組長度為 16(2 的 4 次方),右邊兩組是數(shù)組長度為 15。兩組的 hashcode 均為 8 和 9,但是很明顯,當(dāng)它們和 1110 “與” 的時候,產(chǎn)生了相同的結(jié)果,也就是說它們會定位到數(shù)組中的同一個位置上去,這就產(chǎn)生了碰撞,8 和 9 會被放到同一個鏈表上,那么查詢的時候就需要遍歷這個鏈表,得到 8 或者 9,這樣就降低了查詢的效率。同時,我們也可以發(fā)現(xiàn),當(dāng)數(shù)組長度為 15 的時候,hashcode 的值會與 14(1110)進(jìn)行 “與”,那么最后一位永遠(yuǎn)是 0,而 0001,0011,0101,1001,1011,0111,1101 這幾個位置永遠(yuǎn)都不能存放元素了,空間浪費(fèi)相當(dāng)大,更糟的是這種情況中,數(shù)組可以使用的位置比數(shù)組長度小了很多,這意味著進(jìn)一步增加了碰撞的幾率,減慢了查詢的效率!

在這里插入圖片描述

所以說,當(dāng)數(shù)組長度為 2 的 n 次冪的時候,不同的 key 算得得 index 相同的幾率較小,那么數(shù)據(jù)在數(shù)組上分布就比較均勻,也就是說碰撞的幾率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

說到這里,我們再回頭看一下 hashmap 中默認(rèn)的數(shù)組大小是多少,查看源代碼可以得知是 16,為什么是 16,而不是 15,也不是 20 呢,看到上面 annegu 的解釋之后我們就清楚了吧,顯然是因?yàn)?16 是 2 的整數(shù)次冪的原因,16-1的二進(jìn)制是 1 1 1 1 ,能夠盡量報錯hash值的結(jié)果,在小數(shù)據(jù)量的情況下 16 比 15 和 20 更能減少 key 之間的碰撞,而加快查詢的效率。

所以,在存儲大容量數(shù)據(jù)的時候,最好預(yù)先指定 hashmap 的 size 為 2 的整數(shù)次冪次方。就算不指定的話,也會以大于且最接近指定值大小的 2 次冪來初始化的,代碼如下 (HashMap 的構(gòu)造方法中):

// Find a power of 2 >= initialCapacity  
        int capacity = 1;  
        while (capacity < initialCapacity)   
            capacity <<= 1;  

3、hashmap 的 resize

當(dāng) hashmap 中的元素越來越多的時候,碰撞的幾率也就越來越高(因?yàn)閿?shù)組的長度是固定的),所以為了提高查詢的效率,就要對 hashmap 的數(shù)組進(jìn)行擴(kuò)容,數(shù)組擴(kuò)容這個操作也會出現(xiàn)在 ArrayList 中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的 “均攤” 原理,就釋然了,而在 hashmap 數(shù)組擴(kuò)容之后,最消耗性能的點(diǎn)就出現(xiàn)了:原數(shù)組中的數(shù)據(jù)必須重新計(jì)算其在新數(shù)組中的位置,并放進(jìn)去,這就是 resize。

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

  • 如果需要擴(kuò)容,調(diào)用擴(kuò)容的方法 resize ()

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //判斷是否有超出擴(kuò)容的最大值,如果達(dá)到最大值則不進(jìn)行擴(kuò)容操作
    if (oldCapacity == MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return;
    }
 
    Entry[] newTable = new Entry[newCapacity];
    // transfer()方法把原數(shù)組中的值放到新數(shù)組中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //設(shè)置hashmap擴(kuò)容后為新的數(shù)組引用
    table = newTable;
    //設(shè)置hashmap擴(kuò)容新的閾值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }
  • transfer () 在實(shí)際擴(kuò)容時候把原來數(shù)組中的元素放入新的數(shù)組中

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
      while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
          e.hash = null == e.key ? 0 : hash(e.key);
        }
        //通過key值的hash值和新數(shù)組的大小算出在當(dāng)前數(shù)組中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

4、key 的 hashcode 與 equals 方法改寫

在第一部分 hashmap 的數(shù)據(jù)結(jié)構(gòu)中,就寫了 get 方法的過程:首先計(jì)算 key 的 hashcode,找到數(shù)組中對應(yīng)位置的某一元素,然后通過 key 的 equals 方法在對應(yīng)位置的鏈表中找到需要的元素。所以,hashcode 與 equals 方法對于找到對應(yīng)元素是兩個關(guān)鍵方法。

Hashmap 的 key 可以是任何類型的對象,例如 User 這種對象,為了保證兩個具有相同屬性的 user 的 hashcode 相同,我們就需要改寫 hashcode 方法,比方把 hashcode 值的計(jì)算與 User 對象的 id 關(guān)聯(lián)起來,那么只要 user 對象擁有相同 id,那么他們的 hashcode 也能保持一致了,這樣就可以找到在 hashmap 數(shù)組中的位置了。如果這個位置上有多個元素,還需要用 key 的 equals 方法在對應(yīng)位置的鏈表中找到需要的元素,所以只改寫了 hashcode 方法是不夠的,equals 方法也是需要改寫滴~當(dāng)然啦,按正常思維邏輯,equals 方法一般都會根據(jù)實(shí)際的業(yè)務(wù)內(nèi)容來定義,例如根據(jù) user 對象的 id 來判斷兩個 user 是否相等。
在改寫 equals 方法的時候,需要滿足以下三點(diǎn):
(1) 自反性:就是說 a.equals (a) 必須為 true。
(2) 對稱性:就是說 a.equals (b)=true 的話,b.equals (a) 也必須為 true。
(3) 傳遞性:就是說 a.equals (b)=true,并且 b.equals (\c)=true 的話,a.equals (\c) 也必須為 true。
通過改寫 key 對象的 equals 和 hashcode 方法,我們可以將任意的業(yè)務(wù)對象作為 map 的 key (前提是你確實(shí)有這樣的需要)。

總結(jié):
本文主要描述了 HashMap 的結(jié)構(gòu),和 hashmap 中 hash 函數(shù)的實(shí)現(xiàn),以及該實(shí)現(xiàn)的特性,同時描述了 hashmap 中 resize 帶來性能消耗的根本原因,以及將普通的域模型對象作為 key 的基本要求。尤其是 hash 函數(shù)的實(shí)現(xiàn),可以說是整個 HashMap 的精髓所在,只有真正理解了這個 hash 函數(shù),才可以說對 HashMap 有了一定的理解。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容