深刻理解 hashcode 和 hash 算法

深刻理解 hashcode 和 hash 算法

摘要

  1. 二進制計算的一些基礎知識
  2. 爲何使用 hashcode
  3. String 類型的 hashcode 方法
  4. 爲何大部分 hashcode 方法使用 31
  5. HashMap 的 hash 算法的實現原理(爲何右移 16 位,爲何要使用 ^ 位異或)
  6. HashMap 爲何使用 & 與運算代替模運算?
  7. HashMap 的容量爲何建議是 2的冪次方?
  8. 咱們自定義 HashMap 容量最好是多少?

前言

做爲一個有抱負的 Java 程序員,在通過長期的CRUD 和 HTML 填空以後必須有所思考,由於好奇心是驅動人類進步的動力之一,咱們好奇,好比咱們經常使用的 HashMap 究竟是如何實現的?我想,說到這裏,稍微有點經驗的大佬都會說:擦,面試必問好嘛?怎麼可能不知道?java

可是,咱們真的瞭解他嗎?node

咱們知道 HashMap 依賴的 hashcode 和 hash 算法究竟是怎麼實現的嘛?若是大佬說:早他麼知道了。那就裝不知道,聽樓主吹吹牛逼好不啦。。。。c++

今天樓主不會講 HashMap 的 put 方法實現和 get 方法實現,樓主要講的是 HashMap 高度依賴的 hashcode 和 hash 算法,雖然在不少書裏面,都說這是數學家應該去研究的事情,但我想,程序員也應該瞭解他是怎麼實現的。爲何這麼作?就像娶老婆,你可能作不到創造老婆,可是你得知道你老婆是怎麼來的?家是哪的?爲何喜歡你?扯遠了,回來,那麼今天咱們就開始吧!程序員

1. 二進制計算的一些基礎知識

首先,由於今天的文章會涉及到一些位運算,所以樓主怕你們忘了(其實樓主本身也忘了),所以貼出一些位運算符號的意思,以避免看代碼的時候懵逼。面試

<< : 左移運算符,num << 1,至關於num乘以2 低位補0 >> : 右移運算符,num >> 1,至關於num除以2 高位補0 >>> : 無符號右移,忽略符號位,空位都以0補齊 % : 模運算 取餘 ^ : 位異或 第一個操做數的的第n位於第二個操做數的第n位相反,那麼結果的第n爲也爲1,不然爲0 & : 與運算 第一個操做數的的第n位於第二個操做數的第n位若是都是1,那麼結果的第n爲也爲1,不然爲0 | : 或運算 第一個操做數的的第n位於第二個操做數的第n位 只要有一個是1,那麼結果的第n爲也爲1,不然爲0 ~ : 非運算 操做數的第n位爲1,那麼結果的第n位爲0,反之,也就是取反運算(一元操做符:只操做一個數)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

好了,大概瞭解一下就行了,由於位運算平時在項目裏真的用不上,在咱們普通的業務項目裏,代碼易讀性比這點位運算性能要重要的多。可是,在框架中,位運算的必要性就顯示出來的了。由於須要服務大量的運算,性能要求也極高,若是性能渣渣,誰還用你?算法

2. 爲何使用 hashcode

那麼咱們就說說爲何使用 hashcode ,hashCode 存在的第一重要的緣由就是在 HashMap(HashSet 其實就是HashMap) 中使用(其實Object 類的 hashCode 方法註釋已經說明了 ),我知道,HashMap 之因此速度快,由於他使用的是散列表,根據 key 的 hashcode 值生成數組下標(經過內存地址直接查找,沒有任何判斷),時間複雜度完美狀況下能夠達到 n1(和數組相同,可是比數組用着爽多了,可是須要多出不少內存,至關於以空間換時間)。數組

3. String 類型的 hashcode 方法

在 JDK 中,Object 的 hashcode 方法是本地方法,也就是用 c 語言或 c++ 實現的,該方法直接返回對象的 內存地址。這麼作會有說明問題呢?咱們用代碼看看:markdown

class Test1{

  String name;

  public Test1(String name) { this.name = name; } public static void main(String[] args) { Map<Test1, String> map = new HashMap<>(4); map.put(new Test1("hello"), "hello"); String hello = map.get(new Test1("hello")); System.out.println(hello); } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

這段代碼打印出來的會是什麼呢? 答: null。由於咱們沒有重寫 hashCode 方法,全部,HashMap 內部使用的是該對象的內存地址,那麼確定不同。咱們第一個對象根本就沒有存,所以,返回就是 null。這裏就能夠看出來重寫 hashCode 的重要性。框架

JDK 中,咱們常常把 String 類型做爲 key,那麼 String 類型是如何重寫 hashCode 方法的呢?eclipse

咱們看看代碼:

public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

代碼很是簡單,就是使用 String 的 char 數組的數字每次乘以 31 再疊加最後返回,所以,每一個不一樣的字符串,返回的 hashCode 確定不同。那麼爲何使用 31 呢?

4. 爲何大部分 hashcode 方法使用 31

若是有使用 eclipse 的同窗確定知道,該工具默認生成的 hashCode 方法實現也和 String 類型差很少。都是使用的 31 ,那麼有沒有想過:爲何要使用 31 呢?

在名著 《Effective Java》第 42 頁就有對 hashCode 爲何採用 31 作了說明:

之因此使用 31, 是由於他是一個奇素數。若是乘數是偶數,而且乘法溢出的話,信息就會丟失,由於與2相乘等價於移位運算(低位補0)。使用素數的好處並不很明顯,可是習慣上使用素數來計算散列結果。 31 有個很好的性能,即用移位和減法來代替乘法,能夠獲得更好的性能: 31 * i == (i << 5) - i, 現代的 VM 能夠自動完成這種優化。這個公式能夠很簡單的推導出來。

這個問題在 SO 上也有討論: https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier%EF%BC%89

能夠看到,使用 31 最主要的仍是爲了性能。固然用 63 也能夠。可是 63 的溢出風險就更大了。那麼15 呢?仔細想一想也能夠。

在《Effective Java》也說道:編寫這種散列函數是個研究課題,最好留給數學家和理論方面的計算機科學家來完成。咱們這次最重要的是知道了爲何使用31。

5. HashMap 的 hash 算法的實現原理(爲何右移 16 位,爲何要使用 ^ 位異或)

好了,知道了 hashCode 的生成原理了,咱們要看看今天的主角,hash 算法。

其實,這個也是數學的範疇,從咱們的角度來說,只要知道這是爲了更好的均勻散列表的下標就行了,可是,就是耐不住好奇心啊! 能多知道一點就是一點,咱們來看看 HashMap 的 hash 算法(JDK 8).

static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 
  • 1
  • 2
  • 3
  • 4
  • 5

乍看一下就是簡單的異或運算和右移運算,可是爲何要異或呢?爲何要移位呢?並且移位16?

在分析這個問題以前,咱們須要先看看另外一個事情,什麼呢?就是 HashMap 如何根據 hash 值找到數組種的對象,咱們看看 get 方法的代碼:

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) { 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; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

咱們看看代碼中註釋下方的一行代碼:first = tab[(n - 1) & hash])。

使用數組長度減一 與運算 hash 值。這行代碼就是爲何要讓前面的 hash 方法移位並異或。

咱們分析一下:

首先,假設有一種狀況,對象 A 的 hashCode 爲 1000010001110001000001111000000,對象 B 的 hashCode 爲 0111011100111000101000010100000。

若是數組長度是16,也就是 15 與運算這兩個數, 你會發現結果都是0。這樣的散列結果太讓人失望了。很明顯不是一個好的散列算法。

可是若是咱們將 hashCode 值右移 16 位,也就是取 int 類型的一半,恰好將該二進制數對半切開。而且使用位異或運算(若是兩個數對應的位置相反,則結果爲1,反之爲0),這樣的話,就能避免咱們上面的狀況的發生。

總的來講,使用位移 16 位和 異或 就是防止這種極端狀況。可是,該方法在一些極端狀況下仍是有問題,好比:10000000000000000000000000 和 1000000000100000000000000 這兩個數,若是數組長度是16,那麼即便右移16位,在異或,hash 值仍是會重複。可是爲了性能,對這種極端狀況,JDK 的做者選擇了性能。畢竟這是少數狀況,爲了這種狀況去增長 hash 時間,性價比不高。

6. HashMap 爲何使用 & 與運算代替模運算?

好了,知道了 hash 算法的實現原理還有他的一些取捨,咱們再看看剛剛說的那個根據hash計算下標的方法:

tab[(n - 1) & hash];

其中 n 是數組的長度。其實該算法的結果和模運算的結果是相同的。可是,對於現代的處理器來講,除法和求餘數(模運算)是最慢的動做。

上面狀況下和模運算相同呢?

a % b == (b-1) & a ,當b是2的指數時,等式成立。

咱們說 & 與運算的定義:與運算 第一個操做數的的第n位於第二個操做數的第n位若是都是1,那麼結果的第n爲也爲1,不然爲0;

當 n 爲 16 時, 與運算 101010100101001001101 時,也就是
1111 & 101010100101001001000 結果:1000 = 8
1111 & 101000101101001001001 結果:1001 = 9
1111 & 101010101101101001010 結果: 1010 = 10
1111 & 101100100111001101100 結果: 1100 = 12

能夠看到,當 n 爲 2 的冪次方的時候,減一以後就會獲得 1111* 的數字,這個數字正好能夠掩碼。而且獲得的結果取決於 hash 值。由於 hash 值是1,那麼最終的結果也是1 ,hash 值是0,最終的結果也是0。

7. HashMap 的容量爲何建議是 2的冪次方?

到這裏,咱們提了一個關鍵的問題: HashMap 的容量爲何建議是 2的冪次方?正好能夠和上面的話題接上。樓主就是這麼設計的。

爲何要 2 的冪次方呢?

咱們說,hash 算法的目的是爲了讓hash值均勻的分佈在桶中(數組),那麼,如何作到呢?試想一下,若是不使用 2 的冪次方做爲數組的長度會怎麼樣?

假設咱們的數組長度是10,仍是上面的公式:
1010 & 101010100101001001000 結果:1000 = 8
1010 & 101000101101001001001 結果:1000 = 8
1010 & 101010101101101001010 結果: 1010 = 10
1010 & 101100100111001101100 結果: 1000 = 8

看到結果咱們驚呆了,這種散列結果,會致使這些不一樣的key值所有進入到相同的插槽中,造成鏈表,性能急劇降低。

因此說,咱們必定要保證 & 中的二進制位全爲 1,才能最大限度的利用 hash 值,並更好的散列,只有全是1 ,纔能有更多的散列結果。若是是 1010,有的散列結果是永遠都不會出現的,好比 0111,0101,1111,1110…….,只要 & 以前的數有 0, 對應的 1 確定就不會出現(由於只有都是1纔會爲1)。大大限制了散列的範圍。

8. 咱們自定義 HashMap 容量最好是多少?

那咱們如何自定義呢?自從有了阿里的規約插件,每次樓主都要初始化容量,若是咱們預計咱們的散列表中有2個數據,那麼我就初始化容量爲2嘛?

絕對不行,若是你們看過源碼就會發現,若是Map中已有數據的容量達到了初始容量的 75%,那麼散列表就會擴容,而擴容將會從新將全部的數據從新散列,性能損失嚴重,因此,咱們能夠必需要大於咱們預計數據量的 1.34 倍,若是是2個數據的話,就須要初始化 2.68 個容量。固然這是開玩笑的,2.68 不能夠,3 可不能夠呢?確定也是不能夠的,我前面說了,若是不是2的冪次方,散列結果將會大大降低。致使出現大量鏈表。那麼我能夠將初始化容量設置爲4。 固然了,若是你預計大概會插入 12 條數據的話,那麼初始容量爲16簡直是完美,一點不浪費,並且也不會擴容。

總結

好了,分析完了 hashCode 和 hash 算法,讓咱們對 HashMap 又有了全新的認識。固然,HashMap 中還有不少有趣的東西值得挖掘,樓主會繼續寫下去。爭取將 HashMap 的衣服扒光。

總的來講,經過今天的分析,對咱們從此使用 HashMap 有了更多的把握,也可以排查一些問題,好比鏈表數不少,確定是數組初始化長度不對,若是某個map很大,注意,確定是事先沒有定義好初始化長度,假設,某個Map存儲了10000個數據,那麼他會擴容到 20000,實際上,根本不用 20000,只須要 10000* 1.34= 13400 個,而後向上找到一個2 的冪次方,也就是 16384 初始容量足夠。

good luck !!!!

相關文章
相關標籤/搜索