hashMap源碼分析以及原理

前言

hashMap在平時工做和麪試中,經常使用到和問到,本文將從一下幾個方面進行記錄:
 html

  • 什麼是哈希表
  • HashMap實現原理
  • 爲什麼HashMap的數組長度必定是2的次冪?
1. 什麼是哈希表

在討論哈希表以前,咱們先大概瞭解下其餘數據結構在新增,查找等基礎操做執行性能面試

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

  線性鏈表:對於鏈表的新增,刪除等操做(在找到指定操做位置後),僅需處理結點間的引用便可,時間複雜度爲O(1),而查找操做須要遍歷鏈表逐一進行比對,複雜度爲O(n)安全

  二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操做,平均複雜度均爲O(logn)。數據結構

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

而咱們知道,數據的存儲結構只有兩種方式:順序存儲結構 和 鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面咱們提到過,在數組中根據下標查找某個元素,一次定位就能夠達到,哈希表利用了這種特性,哈希表的主幹就是數組。函數

好比咱們要新增或查找某個元素,咱們經過把當前元素的關鍵字 經過某個函數映射到數組中的某個位置,經過數組下標一次定位就可完成操做性能

存儲位置 = f(關鍵字)this

其中,這個函數f通常稱爲哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,好比咱們要在哈希表中執行插入操做:spa

clipboard.png
查找操做同理,先經過哈希函數計算出實際存儲地址,而後從數組中對應地址取出便可。

哈希衝突

  然而萬事無完美,若是兩個不一樣的元素,經過哈希函數得出的實際存儲地址相同怎麼辦?也就是說,當咱們對某個元素進行哈希運算,獲得一個存儲地址,而後要進行插入的時候,發現已經被其餘元素佔用了,其實這就是所謂的哈希衝突,也叫哈希碰撞。前面咱們提到過,哈希函數的設計相當重要,好的哈希函數會盡量地保證 計算簡單和散列地址分佈均勻,可是,咱們須要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證獲得的存儲地址絕對不發生衝突。那麼哈希衝突如何解決呢?哈希衝突的解決方案有多種:開放定址法(發生衝突,繼續尋找下一塊未被佔用的存儲地址),再散列函數法,鏈地址法,而HashMap便是採用了鏈地址法,也就是數組+鏈表的方式

HashMap實現原理

HashMap的主幹類是一個Enty數組(jdk 1.7),每一個Enty都包含有一個鍵值對(key-value)
咱們能夠看一下源碼:

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的總體結構以下

clipboard.png
簡單來講,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突而存在的,若是定位到的數組位置不含鏈表(當前entry的next指向null),那麼對於查找,添加等操做很快,僅需一次尋址便可;若是定位到的數組包含鏈表,對於添加操做,其時間複雜度爲O(n),首先遍歷鏈表,存在即覆蓋,不然新增;對於查找操做來說,仍需遍歷鏈表,而後經過key對象的equals方法逐一比對查找。因此,性能考慮,HashMap中的鏈表出現越少,性能纔會越好。
其餘幾個重要字段

//實際存儲的key-value鍵值對的個數
transient int size;
//閾值,當table == {}時,該值爲初始容量(初始容量默認爲16);當table被填充了,也就是爲table分配內存空間後,threshold通常爲 capacity*loadFactory。HashMap在進行擴容時須要參考threshold,後面會詳細談到
int threshold;
//負載因子,表明了table的填充度有多少,默認是0.75
final float loadFactor;
//用於快速失敗,因爲HashMap非線程安全,在對HashMap進行迭代時,若是期間其餘線程的參與致使HashMap的結構發生變化了(好比put,remove等操做),須要拋出異常ConcurrentModificationException
transient int modCount;

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

initialCapacity默認爲16,loadFactory默認爲0.75

咱們看下其中一個

public HashMap(int initialCapacity, float loadFactor) {
     //此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(230)
    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(24=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中有大量位運算)

因此最終存儲位置的肯定流程是這樣的:

clipboard.png

再來看看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倍,因此擴容相對來講是個耗資源的操做。

爲什麼HashMap的數組長度必定是2的次冪?

咱們來繼續看上面提到的resize方法

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

若是數組進行擴容,數組長度發生變化,而存儲位置 index = h&(length-1),index也可能會發生變化,須要從新計算index,咱們先來看看transfer這個方法

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
     //for循環中的代碼,逐個遍歷鏈表,從新計算索引位置,將老數組數據複製到新數組中去(數組不存儲實際數據,因此僅僅是拷貝引用而已)
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
          //將當前entry的next鏈指向新的索引位置,newTable[i]有可能爲空,有可能也是個entry鏈,若是是entry鏈,直接在鏈表頭部插入。
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

這個方法將老數組中的數據逐個鏈表地遍歷,扔到新的擴容後的數組中,咱們的數組索引位置的計算是經過 對key值的hashcode進行hash擾亂運算後,再經過和 length-1進行位運算獲得最終數組索引位置。

  hashMap的數組長度必定保持2的次冪,好比16的二進制表示爲 10000,那麼length-1就是15,二進制爲01111,同理擴容後的數組長度爲32,二進制表示爲100000,length-1爲31,二進制表示爲011111。從下圖能夠咱們也能看到這樣會保證低位全爲1,而擴容後只有一位差別,也就是多出了最左位的1,這樣在經過 h&(length-1)的時候,只要h對應的最左邊的那一個差別位爲0,就能保證獲得的新的數組索引和老數組索引一致(大大減小了以前已經散列良好的老數組的數據位置從新調換)

clipboard.png

還有,數組長度保持2的次冪,length-1的低位都爲1,會使得得到的數組索引index更加均勻,好比:

clipboard.png
 咱們看到,上面的&運算,高位是不會對結果產生影響的(hash函數採用各類位運算可能也是爲了使得低位更加散列),咱們只關注低位bit,若是低位所有爲1,那麼對於h低位部分來講,任何一位的變化都會對結果產生影響,也就是說,要獲得index=21這個存儲位置,h的低位只有這一種組合。這也是數組長度設計爲必須爲2的次冪的緣由。

clipboard.png

若是不是2的次冪,也就是低位不是全爲1此時,要使得index=21,h的低位部分再也不具備惟一性了,哈希衝突的概率會變的更大,同時,index對應的這個bit位不管如何不會等於1了,而對應的那些數組位置也就被白白浪費了。

get方法

public V get(Object key) {
         //若是key爲null,則直接去table[0]處去檢索便可。
            if (key == null)
                return getForNullKey();
            Entry<K,V> entry = getEntry(key);
            return null == entry ? null : entry.getValue();
     }

get方法經過key值返回對應value,若是key爲null,直接去table[0]處檢索。咱們再看一下getEntry這個方法

et方法經過key值返回對應value,若是key爲null,直接去table[0]處檢索。咱們再看一下getEntry這個方法

 能夠看出,get方法的實現相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置,找到對應位置table[i],再查看是否有鏈表,遍歷鏈表,經過key的equals方法比對查找對應的記錄。要注意的是,有人以爲上面在定位到數組位置以後而後遍歷鏈表的時候,e.hash == hash這個判斷不必,僅經過equals判斷就能夠。其實否則,試想一下,若是傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,若是僅僅用equals判斷多是相等的,但其hashCode和當前對象不一致,這種狀況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null,後面的例子會作出進一步解釋。

特別說明,本文參考自 :https://www.cnblogs.com/cheng...

相關文章
相關標籤/搜索