HashMap原理


HashMap內部是基於哈希表實現的鍵值對存儲,繼承 AbstractMap 並且實現了 Map 接口。

 HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。

當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓后找到bucket位置來儲存值對象。

當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然后返回值對象。

HashMap使用LinkedList來解決碰撞問題,當發生碰撞了,對象將會儲存在LinkedList的下一個節點中。 

當兩個不同的鍵對象的hashcode相同時會發生什么? 它們會儲存在同一個bucket位置的LinkedList中,鍵對象的equals()方法用來找到鍵值對。

一、什么是哈希表

  在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能

  數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)

  鏈表:是一種物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是通過鏈表中的指針鏈接次序實現的。對於鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)

  二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。

  哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。

 

  我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中)。

而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數組

比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。

        存儲位置 = f(關鍵字)

  其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:

  

  查找操作同理,先通過哈希函數計算出實際存儲地址,然后從數組中對應地址取出即可。

  哈希沖突

  然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那么哈希沖突如何解決呢?

a. 鏈地址法:將哈希表的每個單元作為鏈表的頭結點,所有哈希地址為 i 的元素構成一個同義詞鏈表。即發生沖突時就把該關鍵字鏈在以該單元為頭結點的鏈表的尾部。

b. 開放定址法:即發生沖突時,去尋找下一個空的哈希地址。只要哈希表足夠大,總能找到空的哈希地址。

c. 再哈希法:即發生沖突時,由其他的函數再計算一次哈希值。

d. 建立公共溢出區:將哈希表分為基本表和溢出表,發生沖突時,將沖突的元素放入溢出表

 

二、HashMap實現原理

以下是 HashMap 源碼里面的一些關鍵成員變量以及知識點。在后面的源碼解析中會遇到,所以我們有必要先了解下。

initialCapacity:初始容量。指的是 HashMap 集合初始化的時候自身的容量。可以在構造方法中指定;如果不指定的話,總容量默認值是 16 。需要注意的是初始容量必須是 2 的冪次方。

size:當前 HashMap 中已經存儲着的鍵值對數量,即 HashMap.size()

loadFactor:加載因子。所謂的加載因子就是 HashMap (當前的容量/總容量) 到達一定值的時候,HashMap 會實施擴容。加載因子也可以通過構造方法中指定,默認的值是 0.75 。舉個例子,假設有一個 HashMap 的初始容量為 16 ,那么擴容的閥值就是 0.75 * 16 = 12 。也就是說,在你打算存入第 13 個值的時候,HashMap 會先執行擴容。

threshold:擴容閥值。即 擴容閥值 = HashMap 總容量 * 加載因子。當前 HashMap 的容量大於或等於擴容閥值的時候就會去執行擴容。擴容的容量為當前 HashMap 總容量的兩倍。比如,當前 HashMap 的總容量為 16 ,那么擴容之后為 32 。

table:Entry 數組。我們都知道 HashMap 內部存儲 key/value 是通過 Entry 這個介質來實現的。而 table 就是 Entry 數組。

在 Java 1.7 中,HashMap 的實現方法是數組 + 鏈表的形式。上面的 table 就是數組,而數組中的每個元素,都是鏈表的第一個結點。即如下圖所示:

數組+鏈表的形式。

 

 HashMap的主干是一個Entry數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。

//HashMap的主干數組,可以看到就是一個Entry數組,初始值為空數組{},主干數組的長度一定是2的次冪,至於為什么這么做,后面會有詳細分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

 Entry是HashMap中的一個靜態內部類。代碼如下

復制代碼
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構
        int hash;//對key的hashcode值進行hash運算后得到的值,存儲在Entry,避免重復計算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 
復制代碼

 所以,HashMap的整體結構如下

  

簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的。

如果定位到的數組位置不含鏈表(當前entry的next指向null):那么對於查找,添加等操作很快,僅需一次尋址即可;

如果定位到的數組包含鏈表:對於添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。

所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。

 

HashMap有4個構造器,其他構造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數,會使用默認值

initialCapacity默認為16,loadFactory默認為0.75

我們看下其中一個

復制代碼
public HashMap(int initialCapacity, float loadFactor) {
     //此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(2
30
)
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        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;
      init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現 }
復制代碼

  從上面這段代碼我們可以看出,在常規構造器中,沒有為數組table分配內存空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組

  OK,接下來我們來看看put操作的實現吧

復制代碼
    public V put(K key, V value) {
        //如果table數組為空數組{},進行數組填充(為table分配實際內存空間),入參為threshold,此時threshold為initialCapacity 默認是1<<4(2
4
=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key為null,存儲位置為table[0]或table[0]的沖突鏈上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻
        int i = indexFor(hash, table.length);//獲取在table中的實際位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗
        addEntry(hash, key, value, i);//新增一個entry
        return null;
    }    
復制代碼

 先來看看inflateTable這個方法

復制代碼
private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
復制代碼

  inflateTable這個方法用於為主干數組table在內存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

復制代碼
 private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }
復制代碼

roundUpToPowerOf2中的這段處理使得數組長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數值.

hash函數

復制代碼
//這是一個神奇的函數,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置盡量分布均勻
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
復制代碼

以上hash函數計算出的值,通過indexFor進一步處理來獲取實際的存儲位置

復制代碼
  /**
     * 返回數組下標
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }
復制代碼

h&(length-1)保證獲取的index一定在數組范圍內,舉個例子,默認容量16,length-1=15,h=18,轉換成二進制計算為

        1  0  0  1  0
    &   0  1  1  1  1
    __________________
        0  0  0  1  0    = 2

  最終計算出的index=2。有些版本的對於此處的計算會使用 取模運算,也能保證index一定在數組范圍內,不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)

所以最終存儲位置的確定流程是這樣的:

再來看看addEntry的實現:

復制代碼
void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生哈希沖突時進行擴容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }
復制代碼

  通過以上代碼能夠得知,當發生哈希沖突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度為之前數組2倍的新的數組,然后將當前的Entry數組中的元素全部傳輸過去,擴容后的新數組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。

 

三、Java 1.8 中 HashMap 的不同

在 Java 1.8 中,如果鏈表的長度超過了 8 ,那么鏈表將轉化為紅黑樹;

發生 hash 碰撞時,Java 1.7 會在鏈表頭部插入,而 Java 1.8 會在鏈表尾部插入;

在 Java 1.8 中,Entry 被 Node 代替(換了一個馬甲)。

 

 


免責聲明!

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



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