HashMap源碼解讀——深入理解HashMap高效的原因


一、前言

  Java的容器是面試中的必考點,最近為了准備春招,我開始閱讀容器的源碼。今天研究了一下HashMap的源碼,頗有心得,所以寫篇博客分享一下HashMap的實現原理。內容主要包括HashMap的底層結構,hash函數的原理,以及HashMap的容量機制等內容。內容很多,但是這些內容彼此相輔相成,並不適合分開來敘述,所以將它們放在一起進行講解。相信大家看完這篇博客,將清楚的理解HashMap高效的秘訣。


二、解析

 2.1 什么是Hash

  Hash,一般翻譯做“散列”,也有直接音譯為“哈希”的,就是把任意長度的輸入,通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,所以不可能從散列值來唯一的確定輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數(此處引用其他博客)。簡單來說,就是將一個任意類型的數據,根據一定的算法,計算出一個標識它的int類型的整數,或者一個字符串,也就是hash

  注意:根據同一散列函數計算出的散列值如果不同,那么輸入值肯定也不同;但是,根據同一散列函數計算出的散列值如果相同,輸入值不一定相同。兩個不同的輸入值,根據同一散列函數計算出的散列值相同的現象叫做hash碰撞


 2.2 HashMap的底層結構

  我們首先來談一談HashMap的底層結構,即HashMap是如何保存數據的,若連這個都不清楚,那其余的也無從談起。HashMap的結構概括說來就是:數組 + 鏈表

  我們知道,HashMap中的元素都是Key - Value類型的,我們姑且將每一個元素稱為一個節點Node。在HashMap中,所有的Node都是存在一個數組中,而這個數組的聲明如下:

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;

  可以看到,這個數組table的類型是Node類型,其實就是我們說的Key - Value。那當我們進行put操作時,元素將如何存入這個數組中呢?這時候就要用到我們前面提到的Hash了。當我們往HashMap中存入一個元素時,HaspMap底層會調用hash函數,計算出元素keyhash值,然后再用這個hash值與HashMap的總容量進行求余,得到的余數就是這個元素在數組中存放的下標。

  既然如此,那就可能會出現hash碰撞的情況——即兩個不同的元素,根據以上方法求出的下標值卻相等。這要如何解決呢?HashMap的做法就是采用 數組+鏈表 的方式解決:在存儲元素的數組中,每個位置並不是存儲一個單獨的Node,而是存儲一個鏈表,而這個Node就是鏈表中的一個節點,當一個元素要放入數組的某個位置時,若這個位置已經有元素了,那就將這個元素接在最后一個元素的后面。如下圖所示,數組下標為1的位置有三個元素,它們共同形成一個鏈表。

  我們來看看HashMapNode的代碼,幫助我們理解數組+鏈表的結構。通過下面的代碼可以看到,NodeHashMap的一個內部類,他有四個成員變量:

  • hash:記錄節點的hash值;
  • key:記錄節點的key值;
  • value:記錄節點的value值;
  • next:記錄當前節點的下一個節點;

  而鏈表的結構,就是通過next成員變量來實現的。

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

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    
    //其余方法......
}

 2.3 HashMap的容量機制——高效秘訣

  理解了HashMap的底層結構之后,我們再來探索它高效的秘訣。我們知道,HashMap是優化了查找速度的一種集合,查詢效率極高。而在HashMap中,查詢一個元素的步驟如下:

  1. 首先通過向hash函數傳入需要查找的元素的key值,hash函數計算出key的hash值;
  2. hash值與總容量進行取模運算,計算出數組元素在數組中的下標;
  3. 根據數組下標獲得元素所在的鏈表;
  4. 從鏈表的第一個節往后依次比較key值;
  5. 找到key值相等的節點返回;

  以上步驟可以歸結為以下代碼(注意:以下代碼是我從源碼中抽取出來組合在一起的,實際上它們並不在一個方法中):

Object get(Object key){
    // 1:獲取key的hash值
    int h = hash(key);
    
    // 2-3:從數組中獲取元素所在的鏈表
    int len = table.length;
    Node n = table[ h & (len - 1) ]; // 重點:這里使用 h&(len-1) 取代了 h%len
    
    // 4-5:遍歷鏈表n,並返回查找結果(代碼省略)
    ......
}

  上面的代碼只有一個地方可能讓人疑惑,那就是取模操作%被按位與運算&所取代。上面的代碼中,數組的中括號中本應該是h%len,但是大家去查閱源碼,會發現實際寫的是h & (len-1)。這是什么意思呢,其實在特殊情況下,這就是取模運算。下面我們就來講解一下滿足 h & (len-1) == h % len的特殊情況。

  這種特殊情況就是:一個數對2^n取模,等價於這個數對2^n - 1做與運算,即num % 2^n == num & (2^n -1)。我們舉個例子來說明這個公式的原理:假設上面的公式中,n==3,即我們要對2^3,也就是8取模,8轉換成二進制是1000,而2^3-1 == 7,轉換成二進制就是0111,然后與一個數做與運算,即num & (2^3 -1),結果將得到num轉換成二進制后的末尾三位。而我們看num / 8,實際上就是二進制的num向右移動三位,移掉掉的那三位就是num / 8的余數,即num % 8。而移掉的三位數,不正是我們通過num & (2^3 -1)獲得的嗎。比方說10 % 8 == 2,而10 & (7) = 1010 & 0111 == 0010 == 2。這個地方需要好好理解一下,如果實在不理解,那就記住這個結論。

  在HashMap中,保證了存儲元素的數組的大小一定是2^n,所以在內部,通過hash值與數組容量取余的操作,都用上面說的與運算取代了。這樣做的好處是,與運算直接操作內存,效率極高,而在HashMap中,獲取數組下標是一個非常頻繁的操作,無論是get還是put都要用上,所以這種優化對HashMap的查詢效率有很多的提升。在HashMap中,有兩個靜態變量,分別是默認初始容量最大容量,可以看到,它們都是都是2的n次方,而且沒有直接寫成數字,而是一個移位公式,如 1 << 4,就是為了提醒大家HashMap的容量機制。

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

  說到這里,可能有人會有疑問了:HashMap不是有構造器,可以指定初始容量嗎,如果我們指定一個不是2^n的容量,不就破壞了這種機制嗎?答案當然是不會的,我們雖然可以指定HashMap的初始容量,但是不代表它會直接使用我們指定的容量。當我們為HashMap指定一個初始容量時,它不會直接使用這個容量,而是計算出第一個大於等於這個容量的且滿足2^n的數,若這個數大於HashMap運行的最大值,則直接使用最大值。而且我們知道,Java中的大多數容器都有自動擴容機制,包括HashMap,而HashMap為了滿足容量一定是2^n,擴容時是在原來的基礎上乘2,因為2^n乘以2還是滿足2^n

  其實,使用位運算代替取模運算,除了性能之外,還有一個好處就是可以很好的解決負數的問題。因為我們知道,hashcode的結果是int類型,而int的取值范圍是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];這里面是包含負數的,我們知道,對於一個負數取模還是有些麻煩的。如果使用二進制的位運算的話就可以很好的避免這個問題。首先,不管hashcode的值是正數還是負數。length-1這個值一定是個正數。那么,他的二進制的第一位一定是0(有符號數用最高位作為符號位,“0”代表“+”,“1”代表“-”),這樣里兩個數做按位與運算之后,第一位一定是個0,也就是,得到的結果一定是個正數。(此段引用參考博客)


 2.4 解析hash方法

  接下來,我們再來看看HashMap源碼中的計算哈希值的hash函數是如何實現的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

  以上是JDK1.8hash函數的實現(其他版本的hash方法有所差異,但是原理是一樣的),簡介明了:方法接收一個參數,也就是Nodekey值,然后判斷key值是否為null,若為null,則hash值為0;否則調用keyhashCode方法獲取hash值,並將hash值右移16位后與原hash值做異或運算。這個方法還是很好理解的,除了一個地方,就是為什么要將hash值右移16位后做與運算呢,調用hashCode方法獲取的hash值不能直接用嗎?這么做的原因還是為了優化。

  我們如何定義一個hash算法的優劣?其中的一個重要因素就是盡量少發生hash碰撞。大家可以試想一下,HashMap最壞的情況是什么樣子:所有存入其中的元素,通過hash值計算出來的下標都是一樣的,都放在數組的同一個位置,組成一個鏈表。這樣的情況下,HashMap便完全失去了意義,和一個普通的鏈表又有什么區別。而好的hash函數,可以使碰撞發生的概率大大減少,讓元素在數組中分別均勻,從而提高查找效率。

  而源碼中的異或運算,實際上就是為了降低hash碰撞進行的擾動計算。為什么這么說呢,舉個簡單的例子:

HashMap的容量:8  ->  轉換成二進制:1000

兩個要存如HashMap中的元素的hash值如下(下面兩個hash值只有最后4位完全匹配):
    1、 0010 1010 0111 1001 0010 0101
    2、 0101 1101 1111 0100 0111 0101

這兩個hash值與容量8取模后得到:
    1、0101
    2、0101

  可以看到,上面例子中的兩個hash值差別巨大,但是它們和容量8進行取模后的結果卻是一樣的,結果發生了hash碰撞。因為容量對於容量8來說,取模的做法是與8-1也就是7做按位與運算,而7轉換成二進制的結果是0111,也就是說,取模的結果實際上就是取hash值的后3位,而hash值的前29位無論怎樣,都不會影響結果。所以就是上面兩個hash值差異巨大,但是后三位相同,導致它們求出的下標是相同的。這種情況下,發生hash碰撞的幾率將會大大增加。所以,為了充分利用計算出的hash值的每一位,HashMap的源碼做出了一個優化,將計算出的hash值向右移動16位,讓后讓移動后的值與原hash值做與運算,計算出新的值。為什么是16位呢,因為int32位的,16位正好是32的一半。這樣,就充分利用了hash值的每一位,減少了hash碰撞的發生。


 2.5 JDK1.8對HashMap結構的優化——紅黑樹

  其實從JDK1.8開始,HashMap已經不再是簡單的數組+鏈表的存儲結構,而是做出了一個巨大的變動,在HashMap的數據存儲中引入了紅黑樹,變成了數組+鏈表+樹的結構。下面我們來簡單的談一談這種結構。

  首先我們還是要回歸之前談過的HashMap最壞情況的問題:HashMap中,所有的元素都在數組的同一個位置,在一條鏈表上。這時候,HashMap和一個鏈表基本上沒什么區別,之前的那些查詢優化也就沒效果了。這時候查詢一個元素的時間復雜度是多少?當然是和遍歷鏈表一樣——O(n)。當然,這只是極端的情況,正常情況下不會出現,但是大部分元素集中在少數幾條鏈表上這種情況還是很常見的,比如key是自定義類型,而程序員提供了不好的hashCode方法,得到的hash值經常發生碰撞。

  為了當發生以上情況時效率不至於太慢,JDK1.8改變了HashMap的存儲結構——HashMap中的某一條鏈表元素過多時,底層就會將其轉換為一棵紅黑樹。而紅黑樹的查詢時間復雜度為O(log n),相比於鏈表的O(n)來說要快上不少。在HashMap中有下面三個帶有默認值的靜態變量,用來控制樹化過程:

/**
 * 桶的樹化閾值:
 *     即 鏈表轉成紅黑樹的閾值,在存儲數據時,
 *     當鏈表長度 > 該值時,則將鏈表轉換成紅黑樹
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 桶的鏈表還原閾值:
 *     即 紅黑樹轉為鏈表的閾值,當在擴容(resize())時
 *     (此時HashMap的數據存儲位置會重新計算),在重新計算存儲位置后,
 *     當原有的紅黑樹內數量 < 6時,則將 紅黑樹轉換成鏈表
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 最小樹形化容量閾值:
 *     即 當哈希表中的總容量 > 該值時,才允許將鏈表轉換成紅黑樹,
 *     否則,當元素太多時,則直接擴容,而不是樹形化
 *     為了避免進行擴容、樹形化選擇的沖突,這個值不能小於 4 * TREEIFY_THRESHOLD
 */
static final int MIN_TREEIFY_CAPACITY = 64;

三、總結

  上面的內容對HashMap的底層存儲,效率優化機制做了一個較為詳細的介紹,相信看完之后會對HashMap有一個較為深入的理解。但是,這些只是HashMap的一部分,想要真正了解HashMap,還是要自己結合源碼,仔細的閱讀。希望我寫的這篇博客能夠對一些人有所幫助。


四、參考

  以上內容大部分參看下面兩篇博客后,根據自己的理解編寫:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM