一、前言
HashMap在面試中是個火熱的話題,那么你能應付自如嗎?下面拋出幾個問題看你是否知道,如果知道那么本文對於你來說就不值一提了。
- HashMap的內部數據結構是什么?
- HashMap擴容機制時什么?什么時候擴容?
- HashMap其長度有什么特征?為什么是這樣?
- HashMap為什么線程不安全?並發的場景會出現什么的情況?
本文是基於JDK1.7.0_79版本進行研究的。
二、源碼解讀
1、類的繼承關系
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
其中繼承了AbstractMap抽象類,別小看了這個抽象類哦,它實現了Map接口的許多重要方法,大大減少了實現此接口的工作量。
2、屬性解析
2.1、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;
上面五個屬性字段都很重要, 后面再分析體現其重要。
3、底層數據結構
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、重要函數
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函數還是簡單的,不是很繞。那么能從其中找到開頭的第二和第三個問題的答案嗎?下面總結下順便回答下這兩個問題:
1、數組長度不管是初始化還是擴容時,都始終保持是2的指數次冪。為什么呢?下面我的分析:
- 能使元素均勻分布,增大空間利用率。put值時需要根據key的hash值與長度進行取模運算得到索引下標,如果是2的冪,那么length一定是偶數,則length-1一定是奇數,那么它對應的二進制的最后一位一定是1,所以它能保證h&(length-1)既能到奇數也能得到偶數,這樣保證了散列的均勻性。相反如果不是2的冪,那么length-1可能是偶數,這樣h&(length-1)得到的都是偶數,就會浪費一半的空間了。
- 運算效率高效。位運算比%運算高效。
2、
重復key的值會被新值替換,允許key為空且統一放在下標為0的鏈表上。
3、當size大於等於閾值(容量*加載因子)時,會進行擴容。擴容機制是:擴容量為原來數組長度的兩倍,根據擴容量創建新數組然后進行數組拷貝,新元素落位需要重新計算索引下標。擴容后,閾值需要重新計算,需要插入的元素落位的索引下標也需要重新計算。
4、擴容很耗時,而擴容的次數主要取決於加載因子的值,因為它決定這擴容的次數。下面講下它的取值的重要性:
- 加載因子越小,優點:存儲的沖突機會減少;缺點:擴容次數越多(消耗性能就越大)、同時浪費空間較大(很多空間還沒用,就開始擴容了)
- 加載因子越大,有點:擴容次數較少,空間利用率高;缺點:沖突幾率就變大了、鏈表(后面介紹)長度會變長,查找的效率降低。
5、擴容時會重新計算索引下標。也就是所謂的rehash過程。
6、插入元素都是表頭插入,而不是鏈表尾插入。
4.3、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放在哪)。
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; }
三、並發場景中使用HashMap會怎么樣?
1、肯定不能保證數據的安全性,因為內部方法沒有一個是線程安全的。
2、有時會出現死鎖情況。為什么呢?下面列個場景簡單分析下:
- 假設當前容量為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帶有環形鏈表,所以程序將會進入死循環!(上面圖形均忽略四個元素和要插入元素的規划)
四、怎樣合理使用HashMap?
- 1、創建HashMap時,指定足夠大的容量,減少擴容次數。最好為:需要存的實際個數/除以加載因子。可以使用guava包中的Maps.newHashMapWithExpectedSize()方法。
為什么要這樣指定大小呢? 再去上面回顧下擴容時機吧
- 2、不要在並發場景中使用HashMap,如硬要使用通過Collections工具類創建線程安全的map,如:Collections.synchronizedMap(new HashMap<String, Object>());