HashMap淺析

  • 前言

HashMap是基於哈希表的Map接口的實現,它對數組以及鏈表做了綜合考慮。在看Handler源碼的時候看到需要了解這方面的知識,於是乎就瞭解下順便寫個博客加深理解。本文只對JDK7的HashMap源碼進行分析,後續版本的紅黑樹先不考慮。

  • 相關知識

數組:採用一段連續的存儲單元來存儲數據。他的主要特點是:查找速度快,插入和刪除效率低,內存空間要求高,必須有足夠的連續內存空間。

鏈表:插入刪除速度快,內存利用率高。

Hash:翻譯成中文是“散列”的意思。把任意長度的輸入通過散列算法變換成固定長度的輸出,該輸出就是散列值。

Hash衝突:Key鍵值經過行哈希運算得到一個存儲地址,發現已經被其他的元素所佔據。這就是所謂的Hash衝突。

  • HashMap源碼解析

    1、介紹

    先看下HashMap的一張內部結構圖:

img

縱向是一個數組,數組的每一項都是一個鏈表。數組相當於藍牙電話列表的首字母,而鏈表相當於對應首字母的一組電話號碼。當然這邊的首字母是舉得一個例子而已,HashMap中對應的是key鍵值經過一定運算得出來的結果。這個結果既要保證數組不能太長以免造成空間的浪費,又要保證鏈表不能太長造成時間的浪費。

OK!在介紹源碼之前先看下幾個重要的變量:

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

	/**
     * 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;

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    // Android-Note: We always use a load factor of 0.75 and ignore any explicitly
    // selected values.
    final float loadFactor = DEFAULT_LOAD_FACTOR;

size代表的是HashMap中size。

threshold爲當前的閾值,初始化的時候如果沒人設置那麼就是4。

loadFactor爲負載因子,代表了table的填充度有多少,默認是0.75。

2、構造器

接下來就是分析源碼了,對於HashMap可以從get、put以及構造這三方面入手。那麼先看下構造的代碼:

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and load factor.
 *
 * @param  initialCapacity the initial capacity
 * @param  loadFactor      the load factor
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
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);
    // Android-Note: We always use the default load factor of 0.75f.

    // This might appear wrong but it's just awkward design. We always call
    // inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
    // to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
    // the load factor).
    threshold = initialCapacity;
    init();
}

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and the default load factor (0.75).
 *
 * @param  initialCapacity the initial capacity.
 * @throws IllegalArgumentException if the initial capacity is negative.
 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

大概的意思是如果沒有參數那麼默認table的數組大小爲4,加載因子爲0.75。如果有參數,那麼賦值爲傳進來的參數。

3、put方法介紹

下面介紹下put方法,貼上代碼:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);//標註1
    }
    if (key == null)
        return putForNullKey(value);//標註2
    int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);//標註3
    int i = indexFor(hash, table.length);//標註4
    for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {//標註5
        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++;//標註8
    addEntry(hash, key, value, i);//標註7
    return null;
}

先看下標註1。當table爲空的時候會調用inflateTable方法:

private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    // Android-changed: Replace usage of Math.min() here because this method is
    // called from the <clinit> of runtime, at which point the native libraries
    // needed by Float.* might not be loaded.
    float thresholdFloat = capacity * loadFactor;
    if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
        thresholdFloat = MAXIMUM_CAPACITY + 1;
    }

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

首先他是計算獲取容量,容量的大小必須是2的n次方且大於toSize的數值,然後根據capacity * loadFactor得出閾值,也就是說當元素個數超過容量loadFactor倍的時候才進行擴容。最後是分配容量大小的內存給table。OK,那麼table的初始化完成了。

此處有兩個要重點理解的:1、爲什麼容量一定要是2的n次方。2、HashMapEntry結構包含了那些元素以及作用。

第一個問題接下來分析到indexFor的時候會解答。那麼看下HashMapEntry的變量:

final K key;
V value;
HashMapEntry<K,V> next;
int hash;

key和value就不用多說了,這個是鍵值對的基本參數。next用於建立鏈表,而hash用於存儲獲取到的hash值。

OK,看下put方法的標註2,如果鍵值爲空的情況下會調用putForNullKey方法。貼上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[0]的鏈表,如果存在key爲null的Entry那麼替換成新值。如果沒找到,那麼在table[0]位置添加該值(該操作由addEntry方法完成,後續介紹)。

回頭再看下put方法的標註3,他的功能是對hashcode進行二次哈希計算,目前只知道他的目的是爲了使哈希值分佈的更加均勻,具體怎麼計算的Mark一下有時間看看。

put方法的標註4是獲取hash值低位的索引號,先看看代碼。

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

我的理解是:1、比如說table有length的長度,比如說是16也就是0~15。OK,那麼有12個元素的hash值,那麼如何均勻地將它分佈在這些數組呢。HashMap應該就是先通過二次哈希計算使得這12個hash值從低位開始儘量地均勻分佈,也就是通過與運算能夠讓這12個值儘量的分散在table上。2、該方法也正回答了前面的疑問,長度如果是2的n次方,那麼對於indexFor的與運算更加的友好。

put方法的標註5,他是循環遍歷table數組中獲取到的索引處的鏈表,如果找出key相等的鍵值對那麼替換成新的值返回舊的值。

下面看下如何判斷key相等首先是e.hash == hash,因爲hash值不相等的話key一定不相等所以首先判斷下這個必要不充分條件,第二步纔是判斷((k = e.key) == key || key.equals(k))。由於equals判斷比hash更耗時間,所以這樣子更能提高效率。

put方法的標註6,modCount是記錄修改次數,他與線程安全有關。在後續的fail-fast策略會提到這個。

put方法的標註7,addEntry(hash, key, value, i)。也就是當在對應的鏈表中找不到的相同key的時候用來增加一個新的Entry。先看看源碼:

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

首先是判斷如果元素的數目大於閾值的時候,擴容成原來長度的兩倍並重新計算哈希值以及Index。這邊有個疑問:爲什麼要重新計算哈希值,哈希值難道和低位位數有關?Mark一下。接下來就是重新創建Entry了。總的來說就是如果滿足條件就先擴容,然後再創建鍵值對。

這裏有兩個地方需要理解:第一是resize方法,第二個是createEntry方法。

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

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

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

resize也就是根據新的長度創建newTable。具體方法在transfer中實現,遍歷所有的元素重新計算index放入對應新table的桶中。createEntry即增加一個新的Entry。這些操作都是從桶的頭部開始插入!put方法介紹完畢。

4、get方法介紹

下面看下get方法:

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

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

關鍵是getEntry方法,跟進去看看:

final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }

    int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
    for (HashMapEntry<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))))//標註1 
            return e;
    }
    return null;
}

這邊先通過key的哈希值獲取index,然後只需在對應的桶中遍歷尋找相同的值即可。標註1可以看到必須同時滿足hash值相同以及key相同才能返回e。第一個hash的條件即可屏蔽很多鍵值對,而相對於只用equal來判斷這樣子高效了很多。同時有個疑問:tabel數組的大小永遠會小於元素大小,那麼鏈表不是很難產生,那和鏈表的特性不是體現不出來?從get這個方法看出,或許HashMap可能重點是在判斷hash上面這樣子比直接equal效率高多了。

5、fail-fast(快速失敗)機制

fail-fast 機制,即快速失敗機制,是java集合(Collection)中的一種錯誤檢測機制。當在迭代的過程中該就有可能會發生fail-fast拋出ConcurrentModificationException異常。前面提到modCount變量,註釋可知該變量用於迭代時觸發fail-fast機制。

/**
 * The number of times this HashMap has been structurally modified
 * Structural modifications are those that change the number of mappings in
 * the HashMap or otherwise modify its internal structure (e.g.,
 * rehash).  This field is used to make iterators on Collection-views of
 * the HashMap fail-fast.  (See ConcurrentModificationException).
 */
transient int modCount;

現在看下HashMap拋出異常的代碼:

private abstract class HashIterator<E> implements Iterator<E> {
	...

    HashIterator() {
        expectedModCount = modCount;
        ...
    }

    final Entry<K,V> nextEntry() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        ...
    }

    ...
}

在HashIterator方法在構造的時候賦值一下modCount,然後調用nextEntry的時候判斷是否在迭代的過程中被修改了。如果被修改了,那麼就報異常。分爲兩種情況:

1、單線程環境下

while(iterator.hasNext()) {
      if (i == 3) list.remove(3);
      System.out.println(iterator.next());
      i ++;
}

在遍歷的過程中刪除一個元素,那麼就會報出這個異常。

2、多線程條件下,由於HashMap不是線程安全的。所以比如A線程正在迭代的過程中,B線程修改了modCount值。那麼就會報異常。至於modCount修飾符從volatile 變爲transient不是很清楚。Mark一下!

  • 結語

1、與其他集合的差異性可以總結一下

2、Hash表的底層算法原理可以去了解

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章