1. 程式人生 > >HashMap原始碼深入解析

HashMap原始碼深入解析

HashMap是Java Colletion Framework的重要成員,HashMap是Map介面的常用實現類,在我們平常開發時會經常使用到Map,在我們面試的時候也會問到map的儲存原理,今天特地來總結一下;

建立HashMap

HashMap<String , Double> map = new HashMap<String , Double>(); 
使用HashMap那麼首先你得去建立一個HashMap,在建立的時候會發生什麼事情啦?讓我們跟著原始碼去看一下;
  public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
這裡會給一個預設的初始化容量值,這個值是
static final int DEFAULT_INITIAL_CAPACITY = 16;
那第一個引數是預設初始化容量值,第二個引數就是預設最大載入因子,好了,我們也看一下這個值是多少;
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

那麼這兩個數字,有什麼特殊的含義啦,這裡我們來理解總結一下;

HashMap實現了Map介面,它在初始化的時候會有一個預設的初始化容量值,根據版本不同這個值也有可能會不一樣。然後還會有一個初始化的預設最大載入因子,所謂最大載入因子是指,當map裡面的資料超過了這個界限的時候會自動去擴大容量;

說的通俗一點啊 比如說你要裝水 你首先找個一個桶 這個桶的容量就是載入容量,載入因子就是比如說你要控制在這個桶中的水要不超過水桶容量的多少,比如載入因子是0.75 那麼在裝水的時候這個桶最多能裝到3/4 處, 這麼已定義的話 你的桶就最多能裝水 = 桶的容量 * 載入因子
如果桶的容量是40 載入因子是0.75 那麼你的桶最多能裝40*0.75 = 30的水 
如果你要裝的水比30 多 那麼就該用大一點的桶;

再接著往下走可以看到

<pre name="code" class="java">public HashMap(int initialCapacity, float loadFactor) {  
    // 引數判斷,不合法丟擲執行時異常  
    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);  
  
    // Find a power of 2 >= initialCapacity  
    // 這裡需要注意一下  
    int capacity = 1;  
    while (capacity < initialCapacity)  
        capacity <<= 1;  
  
    // 設定載入因子  
    this.loadFactor = loadFactor;  
    // 設定下次擴容臨界值  
    threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);  
    // 初始化雜湊表  
    table = new Entry[capacity];  
    useAltHashing = sun.misc.VM.isBooted() &&  
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);  
    init();  
}  


threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
這段程式碼前面的都是一些校驗而已,不用管,主要從這段開始,預設16的話,乘以0.75就是11.52,然後用了Math.min方法得到最終結果為12,所以這裡我們Map的臨界值就為12;

可以看到,預設的平衡因子為0.75,這是權衡了時間複雜度與空間複雜度之後的最好取值(JDK說是最好的),過高的因子會降低儲存空間但是查詢(lookup,包括HashMap中的put與get方法)的時間就會增加。

這裡比較奇怪的是問題:容量必須為2的指數倍(預設為16),這是為什麼呢?解答這個問題,需要了解HashMap中雜湊函式的設計原理。

雜湊函式的設計原理

/**
  * Retrieve object hash code and applies a supplemental hash function to the
  * result hash, which defends against poor quality hash functions.  This is
  * critical because HashMap uses power-of-two length hash tables, that
  * otherwise encounter collisions for hashCodes that do not differ
  * in lower bits. Note: Null keys always map to hash 0, thus index 0.
  */
 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.
  */
 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);
 }

看到這麼多位操作,是不是覺得暈頭轉向了呢,還是搞清楚原理就行了,畢竟位操作速度是很快的,不能因為不好理解就不用了。

網上說這個問題的也比較多,我這裡根據自己的理解,儘量做到通俗易懂。

在雜湊表容量(也就是buckets或slots大小)為length的情況下,為了使每個key都能在衝突最小的情況下對映到[0,length)(注意是左閉右開區間)的索引(index)內,一般有兩種做法:

  1. 讓length為素數,然後用hashCode(key) mod length的方法得到索引

  2. 讓length為2的指數倍,然後用hashCode(key) & (length-1)的方法得到索引

HashTable用的是方法1,HashMap用的是方法2。

接著往下走會到

table = new Entry[capacity];
讓我們用Debug接著往下走看看;
 final K key;
        V value;
        Entry<K,V> next;
        int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
這是一個匿名內部類,裡面的屬性有兩個泛型的key和value,然後下一個entry類,然後一個hash值;


我們會創建出一個叫做table大小是16的Entry陣列。

put操作:

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
這裡會首先判斷一下我們的key值,如果key為空的話,會執行putForNullKey(value)方法,我們看一下這個方法是什麼意思;
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;
    }
就是說,獲取Entry的第一個元素table[0],並基於第一個元素的next屬性開始遍歷,直到找到key為null的Entry,將其value設定為新的value值。
如果沒有找到key為null的元素,則呼叫如上述程式碼的addEntry(0, null, value, 0);增加一個新的entry,原始碼如下
void addEntry(int hash, K key, V value, int bucketIndex) {  
    Entry<K,V> e = table[bucketIndex];  
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
        if (size++ >= threshold)  
            resize(2 * table.length);  
    }  

但是調式中發現一個很奇怪的問題,就是key如果為null的話並不會進入到這個方法裡面,這很奇怪。。跟蹤一下發現key的值會變成file:///C:/Program%20Files/Java/jdk1.7.0_13/jre/lib/ext/dnsns.jar,有誰知道原因的可以告訴我一下。

當然我們大多數的key都不會為null,所以接著我們的put方法接著往下走就是:

  int hash = hash(key);
key的hashcode()方法會被呼叫,然後計算hash值。hash值用來找到儲存Entry物件的陣列的索引。有時候hash函式可能寫的很不好,所以JDK的設計者添加了另一個叫做hash()的方法,它接收剛才計算的hash值作為引數。程式碼如下
final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }

        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);
    }

繼續往下走就到了int i = indexFor(hash, table.length);

indexFor(hash,table.length)用來計算在table陣列中儲存Entry物件的精確的索引。

下面就要進行我們的迭代連結串列並替換更新操作了

<pre name="code" class="java"> //這裡的迴圈是關鍵
    //當新增的key所對應的索引i,對應table[i]中已經有值時,進入迴圈體
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判斷是否存在本次插入的key,如果存在用本次的value替換之前oldValue,相當於update操作
        //並返回之前的oldValue
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    //如果本次新增key之前不存在於HashMap中,modCount加1,說明又新增了一個table值,並且把count++;
    modCount++;
    addEntry(hash, key, value, i); //這裡執行插入操作
    return null;
}


這裡有一個疑問,就是為什麼要返回一個null,這個null到底有什麼寓意,我估計可能其它繼承自MAP的方法會返回一些實際的值,所以這裡繼承了就返回一個無意義的null;我覺得這裡返回一個有意義的值是不是會好一點,比如我新增更新結構體了,就返回1,沒有更新結構體就返回0;

Get:

這裡來看一下Get操作的原始碼

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
  1. 對key進行null檢查。如果key是null,table[0]這個位置的元素將被返回。

  2. key的hashcode()方法被呼叫,然後計算hash值。

  3. indexFor(hash,table.length)用來計算要獲取的Entry物件在table陣列中的精確的位置,使用剛才計算的hash值。

  4. 在獲取了table陣列的索引之後,會迭代連結串列,呼叫equals()方法檢查key的相等性,如果equals()方法返回true,get方法返回Entry物件的value,否則,返回null。


總結一下:
  • HashMap有一個叫做Entry的內部類,它用來儲存key-value對。
  • 上面的Entry物件是儲存在一個叫做table的Entry陣列中。
  • table的索引在邏輯上叫做“桶”(bucket),它儲存了連結串列的第一個元素。
  • key的hashcode()方法用來找到Entry物件所在的桶。
  • 如果兩個key有相同的hash值,他們會被放在table陣列的同一個桶裡面。
  • key的equals()方法用來確保key的唯一性。
  • value物件的equals()和hashcode()方法根本一點用也沒有。