HashMap源碼閱讀

HashMap是什麼想必你們都是知道的,平常開發中常用,並且常駐於筆試題目及面試中,那麼今天將從源碼的角度來深刻理解一下HashMap。java

PS:本文如下分析基於jdk1.7,1.8的改動會在文後總結。面試

1.什麼是HashMap?

HashMap是基於哈希表的Map接口實現,是一個key-value型的數據結構。他在性能良好的狀況下,存取的時間複雜度皆爲O(1).數組

要知道數組的獲取時間複雜度爲O(1),可是他的插入時間複雜度爲O(n).bash

那麼HashMap是怎麼作到的呢?數據結構

看一下HashMap的屬性:app

//內部數組的默認初始容量,做爲hashmap的初始容量,是2的4次方,2的n次方的做用是減小hash衝突
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

 //默認的最大容量
 static final int MAXIMUM_CAPACITY = 1 << 30;

 //默認負載因子,當容器使用率達到這個75%的時候就擴容
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

 /** *當數組表還沒擴容的時候,一個共享的空表對象 */
 static final Entry<?,?>[] EMPTY_TABLE = {};

 //內部數組表,用來裝entry,大小隻能是2的n次方。
 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

 //存儲的鍵值對的個數
 transient int size;

 /** * 擴容的臨界點,若是當前容量達到該值,則須要擴容了。 * 若是當前數組容量爲0時(空數組),則該值做爲初始化內部數組的初始容量 */
 int threshold;

 //由構造函數傳入的指定負載因子
 final float loadFactor;

 //Hash的修改次數
 transient int modCount;

 //threshold的最大值
 static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

 //計算hash值時候用,初始是0
 transient int hashSeed = 0;

 //含有全部entry節點的一個set集合
 private transient Set<Map.Entry<K,V>> entrySet = null;

 private static final long serialVersionUID = 362498820763181265L;
複製代碼

註釋已經比較完備,便再也不作過多的說明。函數

由裏面的 性能

能夠看出,HashMap的主體實際上是個數組,是Entry這個內部類的數組。學習

Entry內部類是啥呢?優化

這是Entry內部類的屬性,能夠看出這是個單鏈表的節點,由於它內部有指向下一個節點的next。

那麼就至關明瞭了,HashMap內部是一個數組,數組的每個節點是一個鏈表的頭結點,也就是拉鍊式。

2.HashMap具體是怎麼作到的

對於HashMap來講,平常使用的就是兩個方法,get(),put().

咱們首先看put.

public V put(K key, V value) {

    //判斷當前HashMap是否爲空,爲空則初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //判斷傳入的key是否爲null,爲null則放到table[0]的位置或者其鏈表上
    if (key == null)
        return putForNullKey(value);

     //計算key的hash值 
    int hash = hash(key);
    //計算key存放在數組中的下標
    int i = indexFor(hash, table.length);
    //遍歷該位置上的鏈表,若是存在key值和傳入的key值相等,則替換掉舊值
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        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++;
    //若是沒有這個值,則添加一個Entry
    addEntry(hash, key, value, i);
    return null;
}
/** * Offloaded version of put for null keys */
private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
     //判斷是否須要擴容,是的話進行擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    //新建一個Entry
    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    //將傳入的key-value放在鏈表的頭部,而且指向原鏈表的頭。
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

複製代碼

代碼中添加了一些註釋,大概是能夠看懂的,那麼這裏總結一下流程。

  1. 判斷當前hashMap是否爲空,爲空則初始化。
  2. 判斷傳入的key是否爲null,爲null的話直接放到數組的0位置或者0位置的鏈表上。
  3. key不爲空,計算key的hash值。
  4. 計算key在數組中應該存儲的下標
  5. 遍歷數組在該下標的鏈表,若是找到已經存在的key和傳入的key相等,則用新的value替換舊的value。
  6. 沒找到,則在數組的i位置添加一個Entry。
  7. 添加Entry時,先判斷是否須要擴容,須要的話擴容,不須要的話下一步。
  8. 建立一個Entry,建立的方法是將新傳入的key-value放在數組i位置的鏈表頭結點,而且指向原鏈表頭結點。

接下來是get()方法。

public V get(Object key) {
    //key爲null,則在數組0位置尋找值
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}

private V getForNullKey() {
    if (size == 0) {
        return null;
    }
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}

final Entry<K,V> getEntry(Object key) {
    //若是hashMap中存的值數量爲0,則返回null
    if (size == 0) {
        return null;
    }
    //計算key的hash值
    int hash = (key == null) ? 0 : hash(key);

    //用indexof函數算出數組下標
    //在該下標位置上的鏈表中遍歷,尋找與傳入key相等的key,不然返回null
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

複製代碼

一樣這裏總結一下流程:

  1. 判斷key==null,若是爲null,在數組0位置尋找。
  2. key!=null,判斷hashMap中存的值數量是否爲0,若是爲0直接返回null。
  3. 計算key的hash值。
  4. 計算key應該在數組中的下標。
  5. 遍歷Entry數組在該位置的鏈表,尋找與傳入key相等的key,並返回值,若是遍歷結束找不到,則返回null。

hash()方法和indexOf()方法

你們可能注意到了,在get()put()方法的實現中,都使用到了這兩個方法,那麼這裏看一下源碼:

//經過一系列複雜的計算拿到一個int類型的hash值
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);
}

/** * Returns index for hash code h. */
//將hash值和數組長度與,結果等同於hash%length,拿到數組下標
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);
}

複製代碼

這裏重點是:indexOf()方法,將hash值和數組長度,結果等同於hash%length,拿到數組下標。

結果等同於取模法,可是運算過程更加快速。這裏有一個重要的知識點,後續會說噢。

resize()方法

put()方法及其調用的方法中,當在數組上新添加一個節點時,會判斷當前是否須要擴容,怎麼判斷的呢?

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }
複製代碼

能夠看到,噹噹前已經存儲值得size大於閥值,則將數組擴容爲原來的兩倍。

閥值threshold怎麼計算呢?容量 * 負載因子。即 capacity * loadFactory

擴容的方法爲:

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];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    /** * Transfers all entries from current table to newTable. */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        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);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

複製代碼

新建一個容量爲原來兩倍的數組,而後將舊數組中的值,rehash以後從新放入新數組,以保證散列均勻。

rehash這個操做是比較費時間的,總的來講擴容操做就比較費時間,由於須要將舊的值移動到新的數組中,所以若是在使用前能預估數量,儘可能使用帶有參數的構造方法,指定初始容量,儘可能避免過多的擴容操做

remove()方法

差點忘記remove()方法了。。

public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

    /** * Removes and returns the entry associated with the specified key * in the HashMap. Returns null if the HashMap contains no mapping * for this key. */
    final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) {
            return null;
        }
        //計算hash
        int hash = (key == null) ? 0 : hash(key);
        //計算下標
        int i = indexFor(hash, table.length);
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;

        while (e != null) {
            Entry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }

        return e;
    }

複製代碼

具體的實現思路也是同樣的:首先計算hash繼而計算下標,而後遍歷數組在該位置的鏈表,找到該key-value而後將其移除掉。

3.HashMap的一些爲何?

3.1.爲何擴容的閥值在capacity * loadFactory?

首先了解一下

  1. capacity是指容量,數組最大的容量
  2. loadfactory是指負載因子,是形容當前數組裝的有多滿的一個值。默認爲0.75.也就是若是初始capacity爲16,那麼當不發生hash碰撞,也就是沒有用到鏈表結構時,寫入12個元素即會擴容了。
  3. 數組在性能上是比鏈表優秀的(在HashMap中,數組能夠存null,不用進行值的移位)。
  4. HashMap的數據結構,致使即便容量只有16,也能夠存儲32(還能夠更多)個值,只須要每一個位置上的鏈表多鏈幾個節點就行了。

所以能夠發現,HashMap的性能問題又來到了時間和空間的取捨上,當你不擴容,仍然能夠存儲,只是因爲鏈表的變長,性能降低。當你進行太多的擴容,hash碰撞減小,鏈表長度統一減小,性能提升了可是浪費的空間又多了。0.75這個值是開發者定義的一個對時間空間的折中值。

3.2.性能極限的狀況

當存入的值愈來愈多,卻不擴容,HashMap性能就會降低,那麼咱們極限一點。

HashMap的容量只有1,存入了100個值。由上面的分析可知,這時候HashMap退化成了單鏈表,存取得時間複雜度都是O(n)。

HashMap的容量爲16,存入一個值,在存入第二個值,當即擴容,這樣能夠儘可能的避免hash碰撞,避免產生鏈表,存取時間複雜度都爲O(1).

所以,當你對存取速度要求很高,能夠適當調低loadfactory,當你當前對速度無所謂,可是內存很小,但是調大loadfactory,固然大部分時候默認值0.75都是一個不錯的選擇。

loadfactory的值爲:0.75,2,4等數字都是合法值

3.3.爲何HashMap的容量永遠是2的次冪?

看過上面的代碼咱們能夠發現,HashMap的初始容量爲16,擴容爲原容量乘以2。

也就是說,HashMap的容量永遠是2的次冪,這是爲何呢?

想想哪裏使用到了容量這個參數呢?

在拿到key的hash值,計算當前key在數組中的下標的時候,運用了以下的方法進行計算:

真實的length爲16,咱們假設一個假的lengthWrong = 15;

同時咱們有兩個key,hash以後拿到的hash=8,和hash=9;

length - 1 二進制 8 & length - 1 9 & length- 1
15 1111 1000 & 1111 = 1000 = 8 1001 & 1111 = 1001 = 9
14 1110 1000 & 1110 = 1000 = 8 1001 & 1110 = 1000 = 8

能夠看到當長度爲15時,當h = 8,h =9 h & length - 1 拿到的結果同樣都爲8,也就是這兩個key都存在數組中下標爲8的鏈表上。這是爲何呢?

當length爲偶數時,length- 1位奇數,奇數的二進制最後一位必然爲1,而當length = 奇數時,length - 1位偶數,偶數的二進制最後一位爲0.

二進制與運算有以下規則:

1 & 任意 = 任意;
0 & 任意 = 0;
複製代碼

也就是說,當length = 16時,計算的下標能夠爲1-16任意數字,而當length=15時,計算的下標只能爲2,4,6,8 等等偶數,這樣就浪費了通常的存儲空間,同時還增大了hash碰撞的機率,使得HashMap的性能變差。

所以length必須爲偶數,而length爲2的次冪不只能保證爲偶數,還能夠實現h & length - 1 = h % length,可謂是一箭雙鵰了。666啊。

擴展(Java8 的hashMap有哪些改進?)

在3.2中提到,當極限狀況下HashMap會退化成鏈表,存取時間複雜度變爲O(n),這顯然是不能接受的,所以在java8中對這一點作了優化。

在java7中,存儲在數組上的是一個鏈表的頭結點,當哈希碰撞以後,不斷的增加鏈表的長度,這會致使性能降低。在java8中,引入了紅黑樹數據結構,當鏈表長度小於8時,仍然使用鏈表存儲,而當長度大於8時,會將鏈表轉化爲紅黑樹。同時,當樹的節點數小於6時,會從紅黑樹變成鏈表。

這樣改進以後,即便在性能最差的狀況下,hashMap的存取時間複雜仍爲O(logn).

而紅黑樹的具體實現,這裏再也不詳細敘述,這屬於數據結構的範圍了,在HashMap中展開不合適。





ChangeLog

2018-10-17 完成

以上皆爲我的所思所得,若有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文連接。

聯繫郵箱:huyanshi2580@gmail.com

更多學習筆記見我的博客------>呼延十

相關文章
相關標籤/搜索