HashMap 怎么 hash?又如何 map?
2019獨角獸企業重金招聘Python工程師標準>>>
HashMap?是 Java 中 Map 的一個實現類,它是一個雙列結構(數據+鏈表),這樣的結構使得它的查詢和插入效率都很高。HashMap 允許 null 鍵和值,它的鍵唯一,元素的存儲無序,并且它是線程不安全的。
由于 HashMap 的這些特性,它在 Java 中被廣泛地使用,下面我們就基于 Java 8 分析一下?HashMap 的源碼。
雙列結構:數組+鏈表
首先 HashMap 是一個雙列結構,它是一個散列表,存儲方式是鍵值對。 它繼承了 AbstractMap,實現了 Map<K,V> Cloneable Serializable 接口。
HashMap 的雙列結構是數組 Node[]+鏈表,我們知道數組的查詢很快,但是修改很慢,因為數組定長,所以添加或者減少元素都會導致數組擴容。而鏈表結構恰恰相反,它的查詢慢,因為沒有索引,需要遍歷鏈表查詢。但是它的修改很快,不需要擴容,只需要在首或者尾部添加即可。HashMap 正是應用了這兩種數據結構,以此來保證它的查詢和修改都有很高的效率。
HashMap 在調用 put() 方法存儲元素的時候,會根據 key 的 hash 值來計算它的索引,這個索引有什么用呢?HashMap 使用這個索引來將這個鍵值對儲存到對應的數組位置,比如如果計算出來的索引是 n,則它將存儲在 Node[n] 這個位置。
HashMap 在計算索引的時候盡量保證它的離散,但還是會有不同的 key 計算出來的索引是一樣的,那么第二次?put 的時候,key 就會產生沖突。HashMap 用鏈表的結構解決這個問題,當 HashMap 發現當前的索引下已經有不為 null 的 Node 存在時,會在這個 Node 后面添加新元素,同一索引下的元素就組成了鏈表結構,Node 和 Node 之間如何聯系可以看下面 Node 類的源碼分析。
先了解一下 HashMap 里數組的幾個參數:
DEFAULT_INITIAL_CAPACITY,默認初始長度,16:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16MAXIMUM_CAPACITY,最大長度,2^30:
static final int MAXIMUM_CAPACITY = 1 << 30;DEFAULT_LOAD_FACTOR,默認加載因子,0.75:
static final float DEFAULT_LOAD_FACTOR = 0.75f; final float loadFactor;threshold,閾值,擴容的臨界值(capacity * load factor)
int threshold;再看看 HashMap 構造函數
// 初始長度 加載因子 public HashMap(int initialCapacity, float loadFactor) public HashMap(int initialCapacity)// 無參構造 public HashMap() // 初始化一個Map public HashMap(Map<? extends K, ? extends V> m)下邊是非常重要的一個內部類 Node ,它實現了 Map.Entry,Node 是 HashMap 中的基本元素,每個鍵值對都儲存在一個 Node 對象里, Node 類有四個成員變量:hash?key 的哈希值、鍵值對 key 與 value,以及 next 指針。next 也是 Node 類型,這個 Node 指向的是鏈表下一個鍵值對,這也就是前文提到的 hash 沖突時 HashMap 的處理辦法。
Node 類內部實現了 Map.Entry 接口中的?getKey()、getValue() 等方法,所以在遍歷 Map 的時候我們可以用 Map.entrySet() 。
static class Node<K,V> implements Map.Entry<K,V> {final int hash; // 哈希值final K key;V value;// 鏈表結構, 這里的next將指向鏈表的下一個Node鍵值對Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) {...}public final K getKey() { return key; }public final V getValue() { return value; }}HashMap put() 流程
put() 方法
put() 主要是將 key 和 value 保存到 Node 數組中,HashMap 根據 key 的 hash 值來確定它的索引,源碼里 put 方法將調用內部的 putVal() 方法。
public V put(K key, V value) {return putVal(hash(key), key, value, false, true); }HashMap 在 put 鍵值對的時候會調用 hash() 方法計算 key 的 hash 值,hash() 方法會調用 Object 的 native 方法 hashCode() 并且將計算之后的 hash 值高低位做異或運算,以增加 hash 的復雜度。(Java 里一個 int 類型占 4 個字節,一個字節是 8 bit,所以下面源碼中的 h 與 h 右移 16 位就相當于高低位異或)
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } //key.hashCode() 是Object類的native方法, 實現是將內部地址轉換成一個integer, 但是并不是由Java實現的 public native int hashCode();putVal() 方法
這部分是主要 put 的邏輯
如果以上條件都不成立,表明 tab[i] 上有其它?key 元素存在,并且沒有轉成紅黑樹結構,這時只需調用 tab[i].next 來遍歷此鏈表,找到鏈表的尾然后將元素存到當前鏈表的尾部。
transient Node<K,V>[] table; final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 根據當前的map size計算容量大小capacity, 主要實現是在resize()中計算capacity,需要擴容的時候, 長度左移一位(二倍)if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 這里就是經常說的桶結構了, 看過HashMap介紹的都知道它的內部有不同的桶, 這個桶實際上就是一個鏈表結構// 在這個地方, HashMap先判斷key所屬的桶是否存在。 (n - 1) & hash 相當于計算桶的序號, 根據桶序號來找到對應的桶// 這里的table 是HashMap的數組, 數組為空就新建一個數組 newNode(hash, key, value, null)if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {//數組不為空, 先判斷key是否存在, 存在 就覆蓋valueNode<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;// 如果此鏈表是紅黑樹結構(TreeNode)else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {// 循環當前鏈表, 找出p.next為空的位置就是鏈表的末端, 添加上for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) { p.next = newNode(hash, key, value, null);// 這里會判斷這個鏈表是否需要轉換為紅黑樹鏈表if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)// put之后,如果元素個數大于當前的數組容量了,進行數組擴容resize();afterNodeInsertion(evict);return null; }HashMap 的 get()
get() 方法會調用 getNode() 方法,這是 get() 的核心,getNode() 方法的兩個參數分別是 hash 值和 key。
public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;}這里重點來看 getNode() 方法,前面講到過,HashMap 是通過 key 生成的 hash 值來存儲到數組的對應索引上,HashMap 在 get 的時候也是用這種方式來查找元素的。
數組擴容時再哈希(re-hash)的理解
前面提到,當 HashMap 在 put 元素的時候,HashMap 會調用 resize() 方法來重新計算數組容量,數組擴容之后,數組長度發生變化。我們知道 HashMap 是根據 key 的 hash 和數組長度計算元素位置的,那當數組長度發生變化時,如果不重新計算元素的位置,當我們 get 元素的時候就找不到正確的元素了,所以 HashMap 在擴容的同時也重新對數組元素進行了計算。
這時還有一個問題,re-hash 的時候同一個桶(bucket)上的鏈表會重新排列還是鏈表仍然在同一桶上。先考慮一下當它擴容的時候同一個桶上的元素再與新數組長度做與運算 & 時,可能計算出來的數組索引不同。假如數組長度是 16,擴容后的數組長度將是 32。
下邊用二進制說明這個問題:
最終的結果是 0000 1111,和用 oldLen 計算的結果一樣,其實看上式可以發現真正能改變索引值的是 hash 第 5 位(從右向左)上的值,也就是 length 的最高非零位,所以,同一個鏈表上的元素在擴容后,它們的索引只有兩種可能,一種就是保持原位(最高非零位是 0),另一種就是 length+ 原索引 i (第五位是 1,結果就相等于 25+原索引 i,也就是 length+i)。
下邊所示的 HashMap 源碼中就是用這個思路來 re-hash 一個桶上的鏈表,e.hash & oldCap == 0 判斷 hash 對應 length 的最高非 0 位是否是 1,是 1 則把元素存在原索引,否則將元素存在 length+原索引的位置。HashMap 定義了四個 Node 對象,lo 開頭的是低位的鏈表(原索引),hi 開頭的是高位的鏈表(length+原索引,所以相當于是新 length 的高位)。
Node<K,V> loHead = null, loTail = null; // 低位索引(原索引)上的元素 Node<K,V> hiHead = null, hiTail = null; // 高位索引(新索引)上的元素 Node<K,V> next; do {next = e.next;// 判斷是否需要放到新的索引上if ((e.hash & oldCap) == 0) {// 最高非零位與操作結果是0,擴容后元素索引不發生變化if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}else {// 需要將元素放到新的索引上if (hiTail == null)hiHead = e;elsehiTail.next = e;hiTail = e;} } while ((e = next) != null); if (loTail != null) {loTail.next = null;// 這部分的鏈表索引沒有發生變化,將鏈表放到原索引上newTab[j] = loHead; } if (hiTail != null) {hiTail.next = null;// 這部分的鏈表索引發生變化,將鏈表放到新索引上newTab[j + oldCap] = hiHead; }HashMap 與 HashTable
另外對比一下?HashMap 與 HashTable:
- HashMap 是線程不安全的,HashTable 線程安全,因為它在 get、put 方法上加了 synchronized 關鍵字。
- HashMap 和 HashTable 的 hash 值是不一樣的,所在的桶的計算方式也不一樣。HashMap 的桶是通過 & 運算符來實現?(tab.length - 1) & hash,而 HashTable 是通過取余計算,速度更慢(hash & 0x7FFFFFFF) % tab.length?(當 tab.length = 2^n 時,因為 HashMap 的數組長度正好都是 2^n,所以兩者是等價的)
- HashTable 的 synchronized 是方法級別的,也就是它是在 put() 方法上加的,這也就是說任何一個 put 操作都會使用同一個鎖,而實際上不同索引上的元素之間彼此操作不會受到影響;ConcurrentHashMap?相當于是 HashTable 的升級,它也是線程安全的,而且只有在同一個桶上加鎖,也就是說只有在多個線程操作同一個數組索引的時候才加鎖,極大提高了效率。
總結
- HashMap 底層是數組+鏈表結構,數組長度默認是 16,當元素的個數大于數組長度×0.75 時,數組會擴容。
- HashMap 是散列表,它根據 key 的 hash 值來找到對應的數組索引來儲存, 發生 hash 碰撞的時候(計算出來的 hash 值相等) HashMap 將采用拉鏈式來儲存元素,也就是我們所說的單向鏈表結構。
- 在 Java7 中,如果 hash 碰撞,導致拉鏈過長,查詢的性能會下降, 所以在 Java8 中添加紅黑樹結構,當一個桶的長度超過 8 時,將其轉為紅黑樹鏈表,如果小于 6,又重新轉換為普通鏈表。
- re-hash?再哈希問題:HashMap 擴容的時候會重新計算每一個元素的索引,重新計算之后的索引只有兩種可能,要么等于原索引要么等于原索引加上原數組長度。
- 由上一條可知,每次擴容,整個 hash table 都需要重新計算索引,非常耗時,所以在日常使用中一定要注意這個問題。
參考文檔
- Why is the bucket size 16 by default in HashMap??
- 集合源碼解析之 HashMap(基于 Java8)?
- Java HashMap 工作原理?
- HashMap 源碼詳細分析(JDK1.8)
作者介紹
樊騰飛,有開源精神,樂于分享,希望通過寫博客認識更多志同道合的人。個人博客:rollsbean.com
本文系作者投稿文章。歡迎投稿。
投稿內容要求
- 互聯網技術相關,包括但不限于開發語言、網絡、數據庫、架構、運維、前端、DevOps(DevXXX)、AI、區塊鏈、存儲、移動、安全、技術團隊管理等內容。
- 文章不需要首發,可以是已經在開源中國博客或網上其它平臺發布過的。但是鼓勵首發,首發內容被收錄可能性較大。
- 如果你是記錄某一次解決了某一個問題(這在博客中占絕大比例),那么需要將問題的前因后果描述清楚,最直接的就是結合圖文等方式將問題復現,同時完整地說明解決思路與最終成功的方案。
- 如果你是分析某一技術理論知識,請從定義、應用場景、實際案例、關鍵技術細節、觀點等方面,對其進行較為全面地介紹。
- 如果你是以實際案例分享自己或者公司對諸如某一架構模型、通用技術、編程語言、運維工具的實踐,那么請將事件相關背景、具體技術細節、演進過程、思考、應用效果等方面描述清楚。
- 其它未盡 case 具體情況具體分析,不虛的,文章投過來試試先,比如我們并不拒絕就某個熱點事件對其進行的報導、深入解析。
投稿方式
- 以 Word 或者 Markdown 文檔的形式將稿件投遞到?oscbianji@oschina.cn?郵箱
重要說明
- 作者需要擁有所投文章的所有權,不能將別人的文章拿過來投遞。
- 投遞的文章需要經過審核,如果開源中國編輯覺得需要的話,將與作者一起進一步完善文章,意在使文章更佳、傳播更廣。
- 文章版權歸作者所有,開源中國獲得文章的傳播權,可在開源中國各個平臺進行文章傳播,同時保留文章原始出處和作者信息,可在官方博客中標原創標簽。
轉載于:https://my.oschina.net/editorial-story/blog/2396106
總結
以上是生活随笔為你收集整理的HashMap 怎么 hash?又如何 map?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 解决 'config.h' file n
- 下一篇: IOS安卓常见问题