HashMap原理分析

  HashMap是一種Java開發過程當中使用頻率很是高的容器,本文將對HashMap底層存儲結構和源代碼進行解讀和分析,源代碼依據的JDK的版本是JDK7,小版本是80,JDK7中各個小版本的HashMap源代碼多是不一樣的,這一點要注意。java

  • 什麼是哈希?

  一般咱們說的哈希函數(英語:Hash function)又稱散列算法、散列函數,是一種從任何一種數據中建立小的數字「指紋」的方法。散列函數把消息或數據壓縮成【摘要】,使得數據量變小,將數據的格式固定下來。該函數將數據打亂混合,從新建立一個叫作散列值的指紋。散列值一般用一個短的隨機字母和數字組成的字符串來表明。算法

  哈希表是一種能實現關聯數組的抽象數據結構,能把不少【value】映射到不少【key】上。哈希函數的一個使用場景就是哈希表,哈希表被普遍用於快速搜索數據,它的時間複雜度是O(1)。編程

  哈希函數的構造方法包括:除留餘數法、隨機數法、平方取中法、摺疊法、直接定址法和數字分析法。這裏就再也不對哈希函數進行展開解讀了,有空會專門寫一篇介紹哈希函數的總結。數組

  有一種現象叫作哈希衝突,指的是,當不一樣的數據用同一哈希函數計算出的值相同的場景。好的哈希函數在輸入域中不多出現哈希衝突。在哈希表和數據處理中,不抑制衝突來區別數據,會使得數據記錄更難找到。解決哈希衝突的方法一般有下面幾種:安全

方法
方法描述
備註
線性探查法
當產生哈希衝突時,則去尋找下一個空位。從當前位置開始搜索,當搜索到最後一個位置時,再從哈希表表首開始依次搜索,直到搜索到空位爲止。只要哈希表足夠大,而且有空位確定能搜索到位置。
fi(key) = (f(key) + di) mod m,m表明哈希表的長度,di = m-1,
di的取值範圍能夠保證搜索完整個哈希表
平方探查法
當產生哈希衝突時,則去尋找下一個空位位置。從當前位置增長平方項,再對哈希表的長度取模。增長平方項的目的是不讓關鍵字集中在同一個區域,避免不一樣的關鍵字爭奪同一位置。該方法並不能搜索全部的位置,一般能搜索哈希表一半的位置,若是在一半的位置都沒有找到合適的空位,則表明此哈希表須要重建。
fi(key) = (f(key) + di) mod m,m表明哈希表的長度,di=1^2,-1^2,2^2,
-2^2....p^2,-p^2, p<=m/2
雙散列函數探查法
當產生哈希衝突時,則去尋找下一個空位。在當前的位置基礎上,增長一個由隨機函數產生的數值。
fi(key) =(f(key) + di) mod m,m表明哈希表的長度,di由一個隨機函數產生。
鏈地址法
基礎是哈希表,哈希表的每個元素均可能加掛一個鏈表,也便是同義詞存儲在同一個列表中。
鏈地址法是HashMap解決哈希衝突使用的方法之一。JDK7徹底使用此方法,
在JDK8中使用混合的方式解決哈希衝突,當同一個鏈表的元素大於8的時候,
自動轉化爲紅黑樹,也防止HashMap查詢元素時出現O(n)的可能。
再哈希法
同時準備多個哈希函數,當一個哈希函數得出的值出現衝突時,使用其餘的哈希函數,直到獲取到空位爲止。
優勢:不容易產生彙集,缺點時:增長了計算時間。
創建公共溢出區
取兩個哈希表,例如表a和表b,當出現表a的下標衝突時,把該元素都移動到表b中。
 
  • 哈希值和內存地址的關係

  

package java.lang;


public class Object {

    。。。
    
    public native int hashCode();

    。。。

}

  hashCode方法是Java中全部類共有的方法,是一個原生態方法,參考源代碼中的註釋數據結構

  This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the Java programming language。併發

  翻譯過來是:這一般經過將對象的內部地址轉換爲整數來實現,但Java編程語言不須要此實現技術。app

  也就是說java中的類不復寫Object中的hashCode方法的話,是調用本地系統的方法生成的一個整數值,hash值和內存地址有關係,可是它們並不相等。編程語言

 

  • 哈希表的性能

  常見的存儲結構有順序存儲(數組)、鏈式存儲(鏈表)、索引存儲以及散列存儲(哈希表),咱們介紹一下幾類常見存儲結構對於新增、刪除和查找的性能狀況。函數

  一、數組

  數組Java中最高效的數據物理存儲結構了,它採用一段連續的存儲單元存儲數據。對於指定的下標,查找元素的時間複雜度是O(1);對於指定的值,查找元素須要遍歷整個數組,逐一比較數組元素和給定值,因此時間複雜度是O(n),對於有序數組,能夠採起二分查等方式,可將時間複雜度提高爲O(logn)。通常的新增或者刪除操做,涉及到數組元素的挪動,時間複雜度是O(n)。

  二、鏈表

  一種鏈式存儲方式,不保證順序性,邏輯上相鄰的元素之間用指針所指定,它不是用一塊連續的內存存儲,邏輯上相連的物理位置不必定相鄰。對於新增和刪除操做,只處理節點的引用便可,時間複雜度是O(1);查找指定的節點,則須要循環整個鏈表,逐一比較節點的值和給定的值,時間複雜度是O(n)。

  三、哈希表

  相比上述兩種常見的數據結構,哈希表的性能優點比較明顯,在不考慮哈希碰撞(實際場景中,哈希碰撞比較少見)的狀況下,哈希表對於新增、刪除和查找操做,時間複雜度都爲O(1)。
  
  綜上,咱們能夠總結得出三種數據結構的性能性能比較圖
  
  新增 刪除 查找
數組 O(1) O(n) O(n)
鏈表 O(1) O(1) O(n)
哈希表 O(1) O(1) O(1)

   哈希表爲何具有如此高效的性能呢?

  上面咱們已經介紹了,數組是最高效的數據存儲物理結構,根據下標查找元素的時間複雜度是O(1)。哈希表的基礎就是數組,在不考慮哈希衝突的狀況下,經過一個特定的函數計算出要存儲的元素在數組中的下標,只需一步便可實現新增、刪除和查找操做。這個特定的函數就是哈希函數,哈希函數的好壞直接影響建立的哈希表的性能。一個優秀的哈希函數須要具有以下幾個特性:

  1. 正向快速:能根據算法邏輯,在有限時間內快速計算出哈希值。
  2. 逆向困難:給定哈希值,在有限時間內(極端困難)不可能推算出原始值。
  3. 輸入敏感:即使是原始值作了一個很是小的修改,僅僅從哈希值來看,修改很是明顯。
  4. 避免碰撞:理論上兩個不一樣的原始值會根據同一個哈希函數計算出相同的哈希值,優秀的哈希算法要儘量避免哈希碰撞的出現。

  HashMap採用鏈地址法解決哈希衝突,極端狀況下hashMap的查找元素的時間複雜度是O(n),也便是採用一個返回固定值的哈希算法,這樣不一樣的元素返回的哈希值是同樣的,在某一個固定位置上,引入一個鏈表,存儲全部的元素。

  • HashMap的內部結構

  HashMap內部結構是由數組和鏈表組成,如圖:

  

  • HashMap的關鍵參數

  size        hashmap中的kv組合的總數量,拿上圖舉例,size = 4(數組元素)+4(鏈表節點) = 9。

  capacity  容量,hashmap中數組的長度,也稱做桶的數量,默認值是DEFAULT_INITIAL_CAPACITY=16。拿上圖舉例,capacity=10。

  loadFactor 裝載因子,默認是0.75,此數值能夠衡量hashmap滿的程度。

  threshold   擴容閥指,threshold = capacity * loadFactor ,當hashmap的size大於或者等於 threshold 時,hashmap將進行擴容。

  MAXIMUM_CAPACITY HashMap的最大容量,1 << 30 = 230

  • HashMap的構造函數

  HashMap有4個構造函數,下面這個函數是其餘三個函數的基礎。  

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        //容量小於0,拋出異常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //最大容量是2的30次冪
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //裝載因子參數大於零
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        //HashMap中這是一個空方法,LinkedHashMap則有邏輯
        init();
    }             

  從構造方法中能夠看出,HashMap並未在new的時候就初始化數組,初始化數組是在put方法中進行的。

  • HashMap的put方法
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            //第一次存儲數據時,進行數組的擴容
            inflateTable(threshold);
        }
        if (key == null)
            //k=null時,放置在數組的第一個位置
            return putForNullKey(value);
        //計算key的hashcode
        int hash = hash(key);
        //哈希表的祕密之所在,根據hashcode,計算此key在table中的存儲位置
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //若是key是同樣,則覆蓋原的值
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                //鉤子函數,hashmap並未實現
                e.recordAccess(this);
                return oldValue;
            }
        }
        //迭代hashmap時,fast-fail依據此值是否拋出ConcurrentModificationException異常
        modCount++;
        //新增元素
        addEntry(hash, key, value, i);
        return null;
    }    

  HashMap非線程安全,就是說的這個方法未加鎖。當覆蓋原值的時候,會把原值返回;當是新增一個元素時,則返回null。

  咱們接下來看一些inflateTable函數

    private void inflateTable(int toSize) {
        // capacity大於等於toSize,而且是2的n次冪
        int capacity = roundUpToPowerOf2(toSize);
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //初始化數組
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

  roundUpToPowerOf2函數的目的就是找到一個是2的次冪,而且是大於toSize,最接近toSize的正整數。它是怎麼作到的呢?

    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        //number非負數
        //而且最大是2的30次冪
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

  就是經過Integer.highestOneBit函數作到的,此方法的用意就是取正整數二進制的左邊最高位的數字,而後再用這個數字的右邊所有補0組成一個新的二進制數,這個二進制數的就是Integer.highestOneBit的結果。

  例如number=15,

  第一步:(15-1) << 1 = 14 * 2= 28 

  第二步:28的二進制表示是 00011100

  第三步:取 00011100,右邊補0,組成新的二進制數 00010000

  則Integer.highestOneBit((15-1) << 1) = 16,也便是大於15,而且最接近15的2的n次冪的整數是16。

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

  此版本的HashMap哈希函數應用了大量的位運算,目的就是使得hashcode很是分散。

  良好的哈希算法結合indexFor方法,使得存儲的元素能均勻的分散在數組中。由於能直接索引到數組的下標值,因此HashMap的平均時間複雜度O(1)。

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

   此方法至關於對h取模,可是經過位運算比取模運算效率更高。

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //數量大於等於閥值,而且發生了哈希碰撞
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //擴容hash表
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            //從新計算hash表的索引
            bucketIndex = indexFor(hash, table.length);
        }
        //建立Entry,若是此數組位置上已經有數據了,則在此位置上產生鏈表。最新的元素存儲在數組中,最新生成的Entry的next指向原來的Entry。
        createEntry(hash, key, value, bucketIndex);    
    }
  • 爲何HashMap桶的數量必定是2的n次冪?

  上面介紹了HashMap第一次初始化數組的時候,經過roundUpToPowerOf2函數計算出數組的大小是2的n次冪,在addEntry的時候運行resize函數,將數組擴容到 2*table.length,這就決定了數組的大小必定是2的n次冪。

  這樣的設計的目的是減小哈希碰撞,使得要存儲的元素能均勻的分佈在數組中。下面咱們經過比較來證實這樣設計的好處。

  咱們假設要存儲的元素的哈希值是[0,1,2...9]這10個數,當table.length = 16時,經過idnexFor函數計算出的索引值以下圖所示:

  

  咱們能夠看到,要存儲的元素的存儲下標值很是均勻,而且沒有產生任何哈希碰撞,此哈希表的時間複雜度是O(1)。下面咱們把table.length=15,示意圖以下:

  

  總共發生了5次碰撞,造成了5個鏈表,而且形成了table數組的空間浪費。

  • HashMap的get方法
    public V get(Object key) {
        if (key == null)
            //key是null的時候,直接在數組第一個位置取
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        //取key的hashcode
        int hash = (key == null) ? 0 : hash(key);
        //根據key的hashcode,定位索引下標
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            //首先哈希值必須相等
            //其次要不內存地址同樣,要不就是equals
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
  • 總結HashMap的知識點
  1. HashMap的初始話數組是16,最大容量capacity是230 ,默認裝載因子是0.75。
  2. HashMap解決哈希衝突的方法是鏈地址法,物理存儲結構是數組+鏈表。
  3. 此版本的HashMap是在put方法後才進行數組初始化的,數組的長度一直是2的n次冪。這樣設計的好處是減小哈希衝突,使存儲元素均勻的分佈在HashMap的數組中。
  4. 擴容閥值threshold=capacity*loadFactor,當HashMap的size大於等於threshold,而且產生了哈希衝突時會進行擴容。擴容到原來的2倍容量,擴容很是耗費性能。
  5. HashMap是非線程安全的,併發可能會誘發產生死循環。要線程安全的話可使用HashTable。
相關文章
相關標籤/搜索