HashMap,你知道多少?

1、前言

HashMap在面試中是個火熱的話題,那麼你能應付自如嗎?下面拋出幾個問題看你是否知道,若是知道那麼本文對於你來講就不值一提了。
  • HashMap的內部數據結構是什麼?
  • HashMap擴容機制時什麼?何時擴容?
  • HashMap其長度有什麼特徵?爲何是這樣?
  • HashMap爲何線程不安全?併發的場景會出現什麼的狀況?
本文是基於JDK1.7.0_79版本進行研究的。

2、源碼解讀

一、類的繼承關係

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
其中繼承了AbstractMap抽象類,別小看了這個抽象類哦,它實現了Map接口的許多重要方法,大大減小了實現此接口的工做量。

二、屬性解析

2.一、capacity:容量

  • DEFAULT_INITIAL_CAPACITY:默認的初始容量-必須是2的冪。爲何呢?先留個疑問在這
/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  • MAXIMUM_CAPACITY:最大容量爲2^30。

2.2 threshold:閾值

/**
 * The next size value at which to resize (capacity * load factor).
 * @serial
 */
// If table == EMPTY_TABLE then this is the initial capacity at which the
// table will be created when inflated.
int threshold;
從上面註釋能夠看出, 它的值是由容量和加載因子決定的。

2.3 loadFactor:加載因子,默認爲0.75

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

2.4 size:鍵值對長度

/**
 * The number of key-value mappings contained in this map.
 */
transient int size;

2.5 modCount:修改內部結構的次數

transient int modCount;
上面五個屬性字段都很重要, 後面再分析體現其重要。
 

三、底層數據結構

static final Entry<?,?>[] EMPTY_TABLE = {};

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 * 這裏也強調擴容時,長度必須是2的指數次冪
 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry內部結構以下:
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}
經分析後其數據結構爲數組+鏈表的形式,展現圖以下:

四、重要函數

4.1 構造函數

總共有四個構造函數, 主要分析含有兩個參數的構造函數:
其實這個構造函數也主要是初始化加載因子和閾值。(可能1.7的其餘版本會有點不同,會在構造函數中初始化table)
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);

    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    // 供子類實現
    init();
}
 

4.2 put()函數

public V put(K key, V value) {
    // 1 若是table爲空則須要初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 2 若是key爲空,則單獨處理
    if (key == null)
        return putForNullKey(value);
     // 3 根據key獲取hash值   
    int hash = hash(key);
    // 4 根據hash值和長度求取索引值。
    int i = indexFor(hash, table.length);
    // 5 根據索引值獲取數組下的鏈表進行遍歷,判斷元素是否存在相同的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;
        }
    }

    // 6 若是不存在重複的key, 則須要建立新的Entry,而後添加至鏈表中。
    // 先將修改次數加一
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
  • 第一步:當table尚未初始化時,看下inflateTable()函數作了什麼操做。
private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    // 其中閾值=容量*加載因子,而後再初始化數組。
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}
  • 其中容量是根據toSize取第一個大於它的2的指數次冪的值, 以下,其中highestOneBit函數是返回其最高位的權值,用的最巧的就是(number - 1) << 1 其實就是取number的倍數, 但綜合使用卻能取得第一個大於等於該值的2的指數次冪。(用的牛逼)
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;
}
  • 接着看put函數的第二步:當key爲null時,會取數組下標爲0的位置進行鏈表遍歷,若是存在key=null,則替換值並返回。不然進入第六步(注意:索引值依然指定是0)。
private V putForNullKey(V value) {
    // 取數組下標爲0的鏈表
    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++;
    // 注意:索引值依然指定是0
    addEntry(0, null, value, 0);
    return null;
}
  • 第三步:根據key的hashCode求取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);
}
  • 第四步:根據hash值和底層數組的長度計算索引下標。由於數組的長度是2的冪,因此h & (length-1)運算其實就是h與(length-1)的取模運算。不得不服啊,將計算運用的如此高效。
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);
}
找個數驗證下:
  • 第五步是驗證是否有重複key,若是有則替換新值而後返回,源碼很詳細了就再也不作解釋了。
  • 第六步:是將值添加到entry數組中,詳細看下addEntry()函數。首先根據size和閾值判斷是否須要擴容(進行兩倍擴容),若是須要擴容則先擴容從新計算索引,則建立新的元素添加至數組
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 若是長度大於閾值,則須要進行擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 進行2倍擴容
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        // 擴容以後由於長度變化了,須要從新計算下索引值。
        bucketIndex = indexFor(hash, table.length);
    }
    // 而後進行添加元素
    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 往表頭插入
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
其中擴容機制resize()函數須要重點撈出來曬下: newCapacity = 2 * length,理論上會進行兩倍擴容但會根最大容量進行對比取最小, 建立新數組而後將就數組中的值拷貝至新數組(其中會從新計算索引下標),而後再賦值給table, 最後再從新計算閾值。
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);
}
  接着看transfer()函數,多注意這個函數中循環的內容
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 定一個next
            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;
        }
    }
}

  經過上面分析,其實put函數仍是簡單的,不是很繞。那麼能從其中找到開頭的第二和第三個問題的答案嗎?下面總結下順便回答下這兩個問題:javascript

一、數組長度不論是初始化仍是擴容時,都始終保持是2的指數次冪。爲何呢?下面個人分析:
  • 能使元素均勻分佈,增大空間利用率。put值時須要根據key的hash值與長度進行取模運算獲得索引下標,若是是2的冪,那麼length必定是偶數,則length-1必定是奇數,那麼它對應的二進制的最後一位必定是1,因此它能保證h&(length-1)既能到奇數也能獲得偶數,這樣保證了散列的均勻性。相反若是不是2的冪,那麼length-1多是偶數,這樣h&(length-1)獲得的都是偶數,就會浪費一半的空間了。
  • 運算效率高效。位運算比%運算高效。
二、 重複key的值會被新值替換,容許key爲空且統一放在下標爲0的鏈表上。
三、size大於等於閾值(容量*加載因子)時,會進行擴容。擴容機制是:擴容量爲原來數組長度的兩倍,根據擴容量建立新數組而後進行數組拷貝,新元素落位須要從新計算索引下標。擴容後,閾值須要從新計算,須要插入的元素落位的索引下標也須要從新計算。
四、擴容很耗時,而擴容的次數主要取決於加載因子的值,由於它決定這擴容的次數。下面講下它的取值的重要性:
  • 加載因子越小,優勢:存儲的衝突機會減小;缺點:擴容次數越多(消耗性能就越大)、同時浪費空間較大(不少空間還沒用,就開始擴容了)
  • 加載因子越大,有點:擴容次數較少,空間利用率高;缺點:衝突概率就變大了、鏈表(後面介紹)長度會變長,查找的效率下降。
五、擴容時會從新計算索引下標。也就是所謂的rehash過程
六、插入元素都是表頭插入,而不是鏈表尾插入。
 

4.三、get()函數

知道了put方法的原理,那麼get方法就很簡單了。
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

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

第一步:若是key爲空,則直接從table[0]所對應的鏈表中查找(應該還記得put的時候爲null的key放在哪)。java

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;
    }
第二步:若是key不爲空,則根據key獲取hash值,而後再根據hash和length-1取模獲得索引,而後再遍歷索引對應的鏈表,存在與key相等的則返回。
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        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;
    }

3、併發場景中使用HashMap會怎麼樣?

一、確定不能保證數據的安全性,由於內部方法沒有一個是線程安全的。

二、有時會出現死鎖狀況。爲何呢?下面列個場景簡單分析下:

  • 假設當前容量爲4, 有三個元素(a, b, c)都在table[2]下的鏈表中,另外一個元素(d)在table[3]下。如圖

  • 假設此時有A,B兩個線程都要往map中put一個元素則都須要擴容,當遍歷到table[2]時,假設線程B先進入循環體的第一步:e 指向a, next指向b, 如圖:
Entry<K,V> next = e.next;

  • 此時線程B讓出時間片,讓A線程一直執行完擴容操做,最終落位一樣也是落位到table[2],其鏈表元素已經倒序了。如圖:
  • A線程讓出時間片,B線程操做:接着循環繼續執行,執行到循環末尾的時候,table[2] 指向a, 同時 e 和 next 都是指向b,如圖:
// 同理落位到2
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
// 指向a
newTable[i] = e;
e = next;

  • 接着第二輪循環, e = b, next = a, 進行第二輪循環後的結果是e = next 且 table[2] 指向b元素,b元素再指向a元素,如圖:

  • 接着第三輪循環, e = a, a的下個元素爲null, 因此next = null,可是當執行到下面這步就改變形式了,e.next 又指向了b,此時a和b已經出現了環形。由於next = null,因此終止了循環。
e.next = newTable[i];

 
  • 此時,問題尚未直接產生。當調用get()函數查找一個不存在的Key,而這個Key的Hash結果剛好等於3的時候,因爲位置3帶有環形鏈表,因此程序將會進入死循環!(上面圖形均忽略四個元素和要插入元素的規劃)

4、怎樣合理使用HashMap?

  • 一、建立HashMap時,指定足夠大的容量,減小擴容次數。最好爲:須要存的實際個數/除以加載因子。可使用guava包中的Maps.newHashMapWithExpectedSize()方法。
爲何要這樣指定大小呢? 再去上面回顧下擴容時機吧
  • 二、不要在併發場景中使用HashMap,如硬要使用經過Collections工具類建立線程安全的map,如:Collections.synchronizedMap(new HashMap<String, Object>());
相關文章
相關標籤/搜索