Java面試知識點(一)(補充1)hashmap深度理解

1、hashmap 的數據結構

要知道 hashmap 是什麼,首先要搞清楚它的數據結構,在 java 編程語言中,最基本的結構就是兩種,一個是數組,另外一個是模擬指針(引用),所有的數據結構都可以用這兩個基本結構來構造的,hashmap 也不例外。Hashmap 實際上是一個數組和鏈表的結合體(在數據結構中,一般稱之爲 “鏈表散列 “),請看下圖(橫排表示數組,縱排表示數組元素【實際上是一個鏈表】)。
在這裏插入圖片描述
注:關於hashcode和equals方法的解釋,可以看
Java面試知識點(一)hashmap、hashtable和hashset
第5個知識點

從圖中我們可以看到一個 hashmap 就是一個數組結構,當新建一個 hashmap 的時候,就會初始化一個數組。我們來看看 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 就是數組中的元素,它持有一個指向下一個元素的引用,這就構成了鏈表。

當我們往 hashmap 中 put 元素的時候,先根據 key 的 hash 值得到這個元素在數組中的位置(即下標),然後就可以把這個元素放到對應的位置中了。如果這個元素所在的位子上已經存放有其他元素了,那麼在同一個位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從 hashmap 中 get 元素時,首先計算 key 的 hashcode,找到數組中對應位置的某一元素,然後通過 key 的 equals 方法在對應位置的鏈表中找到需要的元素。從這裏我們可以想象得到,如果每個位置上的鏈表只有一個元素,那麼 hashmap 的 get 效率將是最高的,但是理想總是美好的,現實總是有困難需要我們去克服,哈哈~

2、hash 算法

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

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

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

首先算得 key 得 hashcode 值,然後跟數組的長度 - 1 做一次 “與” 運算(&)。看上去很簡單,其實比較有玄機。比如數組的長度是 2 的 4 次方,那麼 hashcode 就會和 2 的 4 次方 - 1 做 “與” 運算。很多人都有這個疑問,爲什麼 hashmap 的數組初始化大小都是 2 的次方大小時,hashmap 的效率最高,我以 2 的 4 次方舉例,來解釋一下爲什麼數組大小爲 2 的冪時 hashmap 訪問的性能最高。

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

在這裏插入圖片描述
所以說,當數組長度爲 2 的 n 次冪的時候,不同的 key 算得得 index 相同的機率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

說到這裏,我們再回頭看一下 hashmap 中默認的數組大小是多少,查看源代碼可以得知是 16,爲什麼是 16,而不是 15,也不是 20 呢,看到上面 annegu 的解釋之後我們就清楚了吧,顯然是因爲 16 是 2 的整數次冪的原因,16-1的二進制是 1 1 1 1 ,能夠儘量報錯hash值的結果,在小數據量的情況下 16 比 15 和 20 更能減少 key 之間的碰撞,而加快查詢的效率。

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

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

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 的問題。

  • 如果需要擴容,調用擴容的方法 resize ()

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

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值和新數組的大小算出在當前數組中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

4、key 的 hashcode 與 equals 方法改寫

在第一部分 hashmap 的數據結構中,就寫了 get 方法的過程:首先計算 key 的 hashcode,找到數組中對應位置的某一元素,然後通過 key 的 equals 方法在對應位置的鏈表中找到需要的元素。所以,hashcode 與 equals 方法對於找到對應元素是兩個關鍵方法。

Hashmap 的 key 可以是任何類型的對象,例如 User 這種對象,爲了保證兩個具有相同屬性的 user 的 hashcode 相同,我們就需要改寫 hashcode 方法,比方把 hashcode 值的計算與 User 對象的 id 關聯起來,那麼只要 user 對象擁有相同 id,那麼他們的 hashcode 也能保持一致了,這樣就可以找到在 hashmap 數組中的位置了。如果這個位置上有多個元素,還需要用 key 的 equals 方法在對應位置的鏈表中找到需要的元素,所以只改寫了 hashcode 方法是不夠的,equals 方法也是需要改寫滴~當然啦,按正常思維邏輯,equals 方法一般都會根據實際的業務內容來定義,例如根據 user 對象的 id 來判斷兩個 user 是否相等。
在改寫 equals 方法的時候,需要滿足以下三點:
(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 方法,我們可以將任意的業務對象作爲 map 的 key (前提是你確實有這樣的需要)。

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

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