深入理解 hashcode 和 hash 演算法
摘要
二進位制計算的一些基礎知識
為什麼使用 hashcode
String 型別的 hashcode 方法
為什麼大部分 hashcode 方法使用 31
HashMap 的 hash 演算法的實現原理(為什麼右移 16 位,為什麼要使用 ^ 位異或)
HashMap 為什麼使用 & 與運算代替模運算?
HashMap 的容量為什麼建議是 2的冪次方?
我們自定義 HashMap 容量最好是多少?
前言
作為一個有抱負的 Java 程式設計師,在經過長期的CRUD 和 HTML 填空之後必須有所思考,因為好奇心是驅動人類進步的動力之一,我們好奇,比如我們常用的 HashMap 到底是如何實現的?我想,說到這裡,稍微有點經驗的大佬都會說:擦,面試必問好嘛?怎麼可能不知道?
但是,我們真的瞭解他嗎?
我們知道 HashMap 依賴的 hashcode 和 hash 演算法到底是怎麼實現的嘛?如果大佬說:早他麼知道了。那就裝不知道,聽樓主吹吹牛逼好不啦。。。。
今天樓主不會講 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,反之,也就是取反運算(一元操作符:只操作一個數)
好了,大概瞭解一下就好了,因為位運算平時在專案裡真的用不上,在我們普通的業務專案裡,程式碼易讀性比這點位運算效能要重要的多。但是,在框架中,位運算的必要性就顯示出來的了。因為需要服務大量的運算,效能要求也極高,如果效能渣渣,誰還用你?
2. 為什麼使用 hashcode
那麼我們就說說為什麼使用 hashcode ,hashCode 存在的第一重要的原因就是在 HashMap(HashSet 其實就是HashMap) 中使用(其實Object 類的 hashCode 方法註釋已經說明了 ),我知道,HashMap 之所以速度快,因為他使用的是散列表,根據 key 的 hashcode 值生成陣列下標(通過記憶體地址直接查詢,沒有任何判斷),時間複雜度完美情況下可以達到 n1(和陣列相同,但是比陣列用著爽多了,但是需要多出很多記憶體,相當於以空間換時間)。
3. String 型別的 hashcode 方法
在 JDK 中,Object 的 hashcode 方法是本地方法,也就是用 c 語言或 c++ 實現的,該方法直接返回物件的 記憶體地址。這麼做會有說明問題呢?我們用程式碼看看:
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);
}
}
這段程式碼打印出來的會是什麼呢?
答: null。
從某個角度說,這兩個物件是一樣的,因為名稱一樣,name 屬性都是 hello,當我們使用這個 key 時,按照邏輯,應該返回 hello 給我們。但是,由於沒有重寫 hashcode 方法,JDK 預設使用 Objective 類的 hashcode 方法,返回的是一個虛擬記憶體地址,而每個物件的虛擬地址都是不同的,所以,這個肯定不會返回 hello 。
如果我們重寫 hashcode 和 equals 方法:
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Test1 test1 = (Test1) o;
return Objects.equals(name, test1.name);
}
public int hashCode() {
return Objects.hash(name);
}
再次執行:得到的結果就不是 null 了,而是 hello。
這才是比較符合邏輯,符合直覺的。
JDK 中,我們經常把 String 型別作為 key,那麼 String 型別是如何重寫 hashCode 方法的呢?
我們看看程式碼:
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;
}
4. 為什麼大部分 hashcode 方法使用 31
如果有使用 eclipse 的同學肯定知道,該工具預設生成的 hashCode 方法實現也和 String 型別差不多。都是使用的 31 ,那麼有沒有想過:為什麼要使用 31 呢?
在名著 《Effective Java》第 42 頁就有對 hashCode 為什麼採用 31 做了說明:
之所以使用 31, 是因為他是一個奇素數。如果乘數是偶數,並且乘法溢位的話,資訊就會丟失,因為與2相乘等價於移位運算(低位補0)。使用素數的好處並不很明顯,但是習慣上使用素數來計算雜湊結果。 31 有個很好的效能,即用移位和減法來代替乘法,可以得到更好的效能: 31 * i == (i << 5) - i, 現代的 VM 可以自動完成這種優化。這個公式可以很簡單的推匯出來。
可以看到,使用 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);
}
乍看一下就是簡單的異或運算和右移運算,但是為什麼要異或呢?為什麼要移位呢?而且移位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;
}
我們看看程式碼中註釋下方的一行程式碼: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 初始容量足夠。
原文:https://blog.csdn.net/qq_38182963/article/details/78940047
推薦閱讀:
歡迎點贊並轉發給小夥伴。
相關推薦
深入理解 hashcode 和 hash 演算法
摘要二進位制計算的一些基礎知識為什麼使用 hashcodeString 型別的 hashcode
深入理解 hashcode() 和 HashMap 中的hash 演算法
前言 Java中的HashMap非常常用也非常重要, 提到HashMap是離不開hashcode()方法的, 整天嘴邊掛著HashMap、Hashtable、TreeMap、LinkedHashMap、IdentityHashMap、ConcurrentHashMap和WeakHashMap等詞
hashcode 和 hash的深入
摘要 二進位制計算的一些基礎知識 為什麼使用 hashcode String 型別的 hashcode 方法 為什麼大部分 hashcode 方法使用 31 HashMap 的 hash 演算法的實現原理(為什麼右移 16 位,為什麼要使用 ^ 位異或) Ha
OpenStack實踐系列⑦深入理解neutron和虛擬機
ice 賬號 由器 1.0 鏡像 多租戶 ridge 不同的 img OpenStack實踐系列⑦深入理解neutron和虛擬機 五、深入理解Neutron 5.1 虛擬機網卡和網橋 [[email protected]/* */ ~]# ifconfig
深入理解cookie和session
上一個 action inpu 用戶 character method 角色 默認 image cookie和session在java web開發中扮演了十分重要的作用,本篇文章對其中的重要知識點做一些探究和總結。(轉發自https://www.cnblogs.com/ro
c#基礎系列3---深入理解ref 和out
ref 聲明 函數的參數 .... -- 新增 tel struct 結果 “大菜”:源於自己剛踏入猿途混沌時起,自我感覺不是一般的菜,因而得名“大菜”,於自身共勉。 擴展閱讀 c#基礎系列1---深入理解 值類型和引用類型 c#基礎系列2---深入理解 Str
深入理解JVM之GC演算法與垃圾收集器[轉]
概述 說起垃圾收集(Grabage Collection,GC),我們需要考慮GC需要完成的三件事情: 哪些記憶體需要回收? 什麼時候回收? 如何回收? 為什麼我們要求瞭解GC呢和記憶體分配呢?答案很簡單:當需要排查各種記憶體溢位、記憶體洩露問題時,當垃圾收整合為系
深入理解i++和++i的區別
首先: i++ : 先取i的值作為表示式的值,然後執行 i = i + 1 ++i : 先執行 i = i + 1,再取運算完之後的值作為表示式的值 舉例和解釋: 先來個熱身的簡單例子 public static void main(String[] args
【深度學習】深入理解優化器Optimizer演算法(BGD、SGD、MBGD、Momentum、NAG、Adagrad、Adadelta、RMSprop、Adam)
1.http://doc.okbase.net/guoyaohua/archive/284335.html 2.https://www.cnblogs.com/guoyaohua/p/8780548.html 原文地址(英文論文):https://www.cnblogs.c
【半轉載】深入理解get和post的區別和一些思考
前言 PHP有道很經典的面試題,請回答get和post的區別,在網上找了很多答案,看到了很多有意思的想法,現在我們一起由淺入深的探討一下~ 下面這個表格是某個講學視訊筆記的講解: get post
深入理解 Props 和 State
系列部落格: 用通俗的語言和塗鴉來解釋 React 術語 在上篇文章中,我們介紹了元件、props 和 state 。 props 和 state 的區別相當明顯,確定何時使用 props 和 state 似乎也很簡單。舉個例子,屋頂的顏色自然就是 prop ,因為顏色是屋頂
【web基礎】深入理解http和https的區別
在對比這兩者的區別之前咱們先來看一下http有哪些缺點 第一個缺點:http通訊使用明文可能會被竊聽。由於http本身不具備加密的功能,所以也無法做到對通訊整體(使用http協議通訊的請求和響應)的加密。也就是說http的報文使用的是明文傳送。 為什麼通訊不加密
第十五節:深入理解async和await的作用及各種適用場景和用法
一. 同步VS非同步 1. 同步 VS 非同步 VS 多執行緒 同步方法:呼叫時需要等待返回結果,才可以繼續往下執行業務 非同步方法:呼叫時無須等待返回結果,可以繼續往下執行業務 開啟新執行緒:在主執行緒之外開啟一個新的執行緒去執行業務 同步方法和非
深入理解ob_flush和flush的區別
ob_flush/flush在手冊中的描述, 都是重新整理輸出緩衝區, 並且還需要配套使用, 所以會導致很多人迷惑… 其實, 他們倆的操作物件不同, 有些情況下, flush根本不做什麼事情.. ob_*系列函式, 是操作PHP本身的輸出緩衝區. 所以, ob_flus
Django第八篇,深入理解檢視和url
目錄 URL 配置小技巧 簡化匯入函式的方式 在除錯模式下提供特殊的 URL 具名分組 示例請求: 捕獲的引數始終是字串 為檢視的引數指定預設值 錯誤處理 引入其他 URL 配置 捕獲的引
查詢--深入理解一致性雜湊演算法
注:本篇部落格只是講述了一致性雜湊的思想,我們會在之後講述分散式雜湊表以及一致性雜湊的一種實現(Chord演算法)。 什麼是一致性雜湊演算法? 引用自維基百科: 一致性雜湊是一種特殊的雜湊演算法。在使用一致雜湊演算法後,雜湊表槽位數(大小)的改變
深入理解“靜態”和static關鍵字
「深入理解」系列,本文介紹“靜態”的概念、在記憶體中的分佈和應用 關於“靜態”的誤解:重新認識static關鍵字 靜態(static)這個詞,翻譯過來就是“靜態的、靜止的”,至於為什麼叫
java執行順序之深入理解clinit和init
前言: 最近研究了深入理解JVM這本書中的知識,對java中各部分執行的順序有了比較深入的瞭解。首先我們得了解一下java中init和clinit的區別。 概念: 型別初始化方法<clinit>:JVM通過Classload進行型別載
深入理解IEnumerable和IQueryable兩介面的區別
無論是在ado.net EF或者是在其他的Linq使用中,我們經常會碰到兩個重要的靜態類Enumerable、Queryable,他們在System.Linq名稱空間下。那麼這兩個類是如何定義的,又是來做什麼用的呢?特別是Queryable類,它和EF的延遲載入技術有什麼聯絡