從根源揭祕HashMap的資料儲存過程

silencezwm發表於2017-12-04


型別 描述 用時
選題 silencezwm 0.1小時
寫作時間 2017年12月3日 5小時
審稿 silencezwm 0.5小時
校對上線 silencezwm 0.1小時

Tips:4個環節,共計約5.7小時的精心打磨完成上線。


在我們日常的開發過程中,HashMap的使用率還是非常高的。本文將首先對Map介面的基本屬性和方法做一個簡單的介紹,然後從HashMap的初始化、增加資料兩方面來進行探討。

通過本文的學習,你可以瞭解到:

一、Map介面的簡單介紹

二、HashMap的初始化過程

三、HashMap的增加資料過程


一、Map介面的簡單介紹

我們檢視Map原始碼,可知道Map是以key-value(鍵值對)形式存在的介面,由其衍生出來的介面和類也是相當多的,比如今天的主角HashMap,還有TreeMap、Hashtable、SortedMap等等。

其常用的方法以及描述如下:

方法 描述
V put(K key, V value) 往Map中存入一個鍵值對資料,並返回一個Value
void putAll (Map<? extends K, ? extends V> map) 往Map中存入一個Map資料
V remove (Object key) 根據key刪除該資料,並返回該Value
void clear () 清空Map現有資料
V get (Object key) 根據key查詢對應的Value
boolean isEmpty () 判斷Map是否為空
int size () 返回Map存有資料的個數
boolean containsKey (Object key) 判斷Map是否包含該key
boolean containsValue (Object value) 判斷Map是否包含該value

關於Map的更多介紹,可參閱Api文件


二、HashMap的初始化過程

首先我們來看下HashMap的繼承以及介面實現關係:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
複製程式碼

AbstractMap同樣也實現了Map介面。所以,HashMap擁有Map所有的特徵也是毋庸置疑的。並且HashMap的靜態內部類HashMapEntry<K,V>也實現了Map.Entry<K,V>介面,如下:

static class HashMapEntry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    HashMapEntry<K,V> next;
    int hash;

    HashMapEntry(int h, K k, V v, HashMapEntry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    
    ......
}
複製程式碼

HashMap的表中存放的每一個資料都是HashMapEntry<K,V>的一個物件,其包含key、value、指向下一個物件的引用物件next以及該key生成的雜湊碼值。

我們先來看看HashMap幾個重要的全域性變數

// HashMap的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 4;

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

// 在建構函式中沒有指定的載入因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// HashMap未初始化時的陣列空表
static final HashMapEntry<?,?>[] EMPTY_TABLE = {};

// 該反序列化陣列table在HashMap需要調整容量時使用,預設為空表
transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE;

// HashMap的大小
transient int size;

// 該值用於HashMap需要調整容量時使用
int threshold;

// 載入因子,預設為0.75f
final float loadFactor = DEFAULT_LOAD_FACTOR;

// 計數器
transient int modCount;
複製程式碼

HashMap的構造方法有:

方法 描述
HashMap() 得到一個新的空HashMap例項
HashMap(int capacity) 根據傳入的容量例項化空HashMap
HashMap(int capacity, float loadFactor) 根據傳入的容量、載入因子例項化空HashMap
HashMap(Map<? extends K, ? extends V> map) 傳入已有Map物件例項化新的HashMap

這裡就選擇第一個構造方法來探討,其程式碼如下:

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY) {
        initialCapacity = MAXIMUM_CAPACITY;
    } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
        initialCapacity = DEFAULT_INITIAL_CAPACITY;
    }

    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    
    threshold = initialCapacity;
    init();
}
複製程式碼

從預設的構造方法中可以看出,有 initialCapacity(初始容量) 和 loadFactor(載入因子) 這兩個引數。因為我們並沒有通過其他構造方法傳入這兩個引數,所以其就會使用預設值。

該構造方法使用流程圖表示如下:

構造方法流程圖

所以,整個初始化過程僅僅就是對引數的合理性進行判斷以及確定幾個變數的初始值。

三、HashMap的增加資料過程

既然我們有了HashMap的例項,那就可以往裡存放資料了,而其存放資料用到的方法是:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return =;
    int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
    int i = indexFor(hash, table.length);
    for (HashMapEntry<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++;
    addEntry(hash, key, value, i);
    return null;
}
複製程式碼

該put方法的整個流程解析如下:

1、表的初始化:我們剛在構造方法中,並沒有對table進行初始化,所以inflateTable方法會被執行;

private void inflateTable(int toSize) {
    int capacity = roundUpToPowerOf2(toSize);

    float thresholdFloat = capacity * loadFactor;
    if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
        thresholdFloat = MAXIMUM_CAPACITY + 1;
    }

    threshold = (int) thresholdFloat;
    table = new HashMapEntry[capacity];
}

private static int roundUpToPowerOf2(int number) {
    int rounded = number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            : (rounded = Integer.highestOneBit(number)) != 0
                ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
                : 1;

    return rounded;
}
複製程式碼

roundUpToPowerOf2方法的作用是用來返回大於等於最接近number的2的冪數,最後對table進行初始化。

2、根據key存放資料:這裡分 “key為null” 和 “key不為null” 兩種情況處理。

情況一:key為null

此種情況將會呼叫putForNullKey方法,

private V putForNullKey(V value) {
    for (HashMapEntry<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;
}
複製程式碼

首先對陣列table從頭到尾遍歷,當找到有key為null的地方,就將舊值替換為新值,並返回舊值。否則,計數器modCount加1,呼叫addEntry方法,並返回null。

情況二:key不為null

此種情況首先會根據indexFor(hash, table.length)生成的bucketIndex去table中查詢是否存在相同bucketIndex的value,如果有,就將舊值替換為新值,並返回舊值。否則,計數器modCount加1,呼叫addEntry方法,並返回null。

以上兩種情況最終都指向了addEntry方法,來看看其具體實現:

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

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

該方法中,首先判斷table是否需要擴容。如果需要擴容,則執行resize方法,傳入的引數為現有table長度的兩倍。

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

    HashMapEntry[] newTable = new HashMapEntry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製程式碼

resize方法中,如果表容量已經達到最大值,則直接返回Integer.MAX_VALUE。否則根據新的容量值建立新表,並執行資料遷移方法transfer。

void transfer(HashMapEntry[] newTable) {
    int newCapacity = newTable.length;
    for (HashMapEntry<K,V> e : table) {
        while(null != e) {
            HashMapEntry<K,V> next = e.next;
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
複製程式碼

transfer方法的作用就是將老表的資料全部遷移到新表中。

void createEntry(int hash, K key, V value, int bucketIndex) {
    HashMapEntry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
    size++;
}
複製程式碼

最後將資料新增到table的bucketIndex位置,並將size加1。

現在,用兩個小圖來表示put過程的兩種狀態,如下:

put方法情況一
put方法情況二

其中資料存放的位置bucketIndex是由 key 和 表的長度 共同決定的。在addEntry方法中計算得到:

bucketIndex = indexFor(hash, table.length);
複製程式碼

所以有可能會出現bucketIndex相同的情況,也稱之為bucketIndex碰撞,當碰撞發生時,相同bucketIndex的value會通過單鏈的形式連線在一起,此時HashMapEntry<K,V>中的next就會指向下一個元素。也就印證了以下這句話:

如果hashCode不同,equals一定為false;如果hashCode相同,equals不一定為true。


最後,預祝你學習愉快!

把文章分享出去吧



相關文章