一.HashMap概述
HashMap是基于哈希表的Map接口的非同步實(shí)現(xiàn)。此實(shí)現(xiàn)提供所有可選的映射操作,并允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恒久不變。
二.HashMap的數(shù)據(jù)結(jié)構(gòu)
在討論哈希表之前,我們先了解下兩種最基礎(chǔ)的數(shù)據(jù)結(jié)構(gòu):數(shù)組和鏈表。
數(shù)組:存儲(chǔ)空間連續(xù),可以通過下標(biāo)索引直接查找到指定位置的元素,因此賦值或查找效率高。但每次插入或刪除元素,就要大量地移動(dòng)元素,插入刪除元素的效率低。
鏈表:存儲(chǔ)空間不連續(xù),大數(shù)據(jù)量下對(duì)元素的訪問效率很低,需要遍歷鏈表進(jìn)行查找,但是增刪元素很快。
接下來,我們?cè)倏聪翲ashMap中使用的數(shù)據(jù)結(jié)構(gòu):哈希表,即數(shù)組+鏈表的結(jié)構(gòu)。比如我們要插入或查找某個(gè)元素,先通過哈希函數(shù)計(jì)算出當(dāng)前元素在數(shù)組中的存儲(chǔ)地址,再通過數(shù)組下標(biāo)一次定位即可完成操作。然而,如果兩個(gè)不同的元素通過哈希函數(shù)得出的存儲(chǔ)地址相同怎么辦?其實(shí)這就是所謂的哈希沖突,也叫哈希碰撞。哈希沖突的解決方案有多種,HashMap采用了鏈地址法,即數(shù)組+鏈表的方式。如下圖所示,數(shù)組中的每個(gè)元素都是鏈表的頭節(jié)點(diǎn),指向一個(gè)鏈表。存儲(chǔ)的元素被Hash后,得到數(shù)組下標(biāo),再把元素放在數(shù)組下標(biāo)元素指向的鏈表上。
三.HashMap的實(shí)現(xiàn)原理
1.HashMap類的內(nèi)部結(jié)構(gòu)
在理解HashMap的實(shí)現(xiàn)原理之前,我們先了解下HashMap的幾個(gè)字段。
table是一個(gè)Entry數(shù)組,是HashMap的主干數(shù)組,Entry是HashMap的基本組成單元。
size是HashMap中實(shí)際存儲(chǔ)的key-value鍵值對(duì)的數(shù)量。
threshold為閾值,是HashMap可以容納的鍵值對(duì)的最大個(gè)數(shù),如果超過這個(gè)數(shù)目HashMap就要resize(擴(kuò)容),擴(kuò)容后的HashMap容量是之前容量的兩倍。threshold一般為capacity*loadFactor。
loadFactor是裝載因子,默認(rèn)值是0.75。
modCount主要用來記錄HashMap內(nèi)部結(jié)構(gòu)發(fā)生變化的次數(shù),主要用于迭代的快速失敗。強(qiáng)調(diào)一點(diǎn),內(nèi)部結(jié)構(gòu)發(fā)生變化指的是結(jié)構(gòu)發(fā)生變化,例如put新鍵值對(duì),但是某個(gè)key對(duì)應(yīng)的value值被覆蓋不屬于結(jié)構(gòu)變化。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //Entry數(shù)組,HashMap的主干數(shù)組
transient int size; //實(shí)際存儲(chǔ)的key-value鍵值對(duì)的個(gè)數(shù)
int threshold; //閾值,HashMap可以容納的鍵值對(duì)的最大個(gè)數(shù),一般為capacity*loadFactor
final float loadFactor; //裝載因子
transient int modCount; //用來記錄HashMap內(nèi)部結(jié)構(gòu)發(fā)生變化的次數(shù),主要用于迭代的快速失敗
Entry是HashMap中的一個(gè)靜態(tài)內(nèi)部類,每個(gè)Entry都包含一個(gè)key-value鍵值對(duì),代碼如下:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; //存儲(chǔ)指向下一個(gè)Entry的引用
int hash; //對(duì)key的hashcode值進(jìn)行hash運(yùn)算后得到的值,存儲(chǔ)在Entry中,避免重復(fù)計(jì)算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
2.HashMap的構(gòu)造方法
HashMap有4個(gè)構(gòu)造器,其他構(gòu)造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個(gè)參數(shù),會(huì)使用默認(rèn)值:initialCapacity默認(rèn)為16,loadFactory默認(rèn)為0.75。我們看下其中一個(gè)構(gòu)造器,從下面這段代碼可以看出,在常規(guī)構(gòu)造器中,沒有為table數(shù)組分配內(nèi)存空間(有一個(gè)入?yún)橹付∕ap的構(gòu)造器例外),而是在執(zhí)行put操作時(shí)才真正構(gòu)建table數(shù)組。
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(); //init方法在HashMap中并沒有實(shí)際實(shí)現(xiàn)
}
3.HashMap的put方法
接下來,我們來看看put方法的實(shí)現(xiàn)。
public V put(K key, V value) {
// 如果table數(shù)組為空數(shù)組,則調(diào)用inflateTable方法對(duì)數(shù)組進(jìn)行填充(為table分配實(shí)際內(nèi)存空間),入?yún)閠hreshold,此時(shí)threshold為initialCapacity,默認(rèn)值為16
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key為null,存儲(chǔ)位置為table[0]
if (key == null)
return putForNullKey(value);
int hash = hash(key); //對(duì)key的hashcode進(jìn)一步計(jì)算,確保散列均勻
int i = indexFor(hash, table.length); //獲取在table中的存儲(chǔ)位置,即桶的位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果key已存在,執(zhí)行覆蓋操作,用新value替換舊value,并返回舊value
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++; //保證并發(fā)訪問時(shí),若HashMap內(nèi)部結(jié)構(gòu)發(fā)生變化,快速響應(yīng)失敗
addEntry(hash, key, value, i); //新增一個(gè)Entry
return null;
}
先來看看inflateTable這個(gè)方法,inflateTable這個(gè)方法用于為主干數(shù)組table在內(nèi)存中分配存儲(chǔ)空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大于或等于toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize); //capacity一定是2的次冪
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
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;
}
再來看看hash函數(shù):
//這個(gè)hash函數(shù)用了很多的異或,移位等運(yùn)算,對(duì)key的hashcode進(jìn)一步進(jìn)行計(jì)算以及二進(jìn)制位的調(diào)整等來保證最終獲取的存儲(chǔ)位置盡量分布均勻
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
以上hash函數(shù)計(jì)算出的值,通過indexFor方法進(jìn)一步處理來獲取實(shí)際的存儲(chǔ)位置。indexFor方法非常巧妙,它通過h&(length-1)來獲取在數(shù)組中的存儲(chǔ)位置。而HashMap的table數(shù)組的長(zhǎng)度總是2的次冪,當(dāng)length總是2的次冪時(shí),h&(length-1)運(yùn)算等價(jià)于對(duì)length取模,也就是h%length,但是&比%具有更高的效率。
//返回?cái)?shù)組下標(biāo)
static int indexFor(int h, int length) {
return h & (length-1);
}
因此,獲取一個(gè)key在table數(shù)組中的存儲(chǔ)位置的流程是這樣的:取key的hashcode值、計(jì)算hash值、位運(yùn)算獲取桶下標(biāo)。
再來看看addEntry的實(shí)現(xiàn),通過以下代碼可以得知,當(dāng)發(fā)生哈希沖突并且size大于閾值的時(shí)候,需要調(diào)用resize方法對(duì)Entry數(shù)組進(jìn)行擴(kuò)容。擴(kuò)容時(shí),需要新建一個(gè)長(zhǎng)度為之前數(shù)組2倍的新的數(shù)組,然后將當(dāng)前的Entry數(shù)組中的元素全部轉(zhuǎn)移過去,擴(kuò)容后的新數(shù)組長(zhǎng)度為之前的2倍,所以擴(kuò)容相對(duì)來說是個(gè)耗資源的操作。后面再詳細(xì)關(guān)注下resize方法的實(shí)現(xiàn)原理。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
最后,來看下createEntry方法的實(shí)現(xiàn),我們通過具體的例子來看插入元素的過程。例如,第一個(gè)鍵值對(duì)A進(jìn)來,通過計(jì)算其key的hash得到index=0,因此Entry[0] = A。之后又進(jìn)來一個(gè)鍵值對(duì)B,通過計(jì)算其index也等于0,現(xiàn)在怎么辦?HashMap會(huì)這樣做:B.next = A,Entry[0] = B。如果又進(jìn)來C,index也等于0,那么C.next = B,Entry[0] = C。可以發(fā)現(xiàn),由于采用頭插法,Entry[0]對(duì)應(yīng)的鏈表存儲(chǔ)的節(jié)點(diǎn)為:C->B->A,即鏈表中節(jié)點(diǎn)的順序與插入的順序是相反的。
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++;
}
4.HashMap的resize方法
上文已經(jīng)提到,當(dāng)發(fā)生哈希沖突并且size大于閾值的時(shí)候,需要調(diào)用resize方法對(duì)Entry數(shù)組進(jìn)行擴(kuò)容。HashMap的resize方法的實(shí)現(xiàn)原理及擴(kuò)容帶來的死循環(huán)問題,可以看我之前寫的一篇文章:深入淺出HashMap擴(kuò)容死循環(huán)問題。
5.HashMap的get方法
我們來看下get方法的實(shí)現(xiàn),get方法通過key值返回對(duì)應(yīng)value,如果key為null,則直接去table[0]處檢索。如果key不為null,則調(diào)用getEntry方法獲取對(duì)應(yīng)的節(jié)點(diǎn)。可以發(fā)現(xiàn),get方法的實(shí)現(xiàn)相對(duì)簡(jiǎn)單,通過三步:取key的hashcode值、計(jì)算hash值、位運(yùn)算獲取桶下標(biāo),定位到table[i]后,再遍歷鏈表,通過e.hash == hash和key的equals方法來對(duì)比查找相同的key即可。
public V get(Object key) {
//如果key為null,則直接去table[0]處檢索即可
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//根據(jù)key的hashcode值計(jì)算hash值
int hash = (key == null) ? 0 : hash(key);
//通過indexFor方法獲取桶索引,然后遍歷鏈表,通過equals方法對(duì)比找出對(duì)應(yīng)節(jié)點(diǎn)
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;
}
四.為什么重寫equals一定要重寫hashCode
這里可以看我之前寫的一篇文章:為什么重寫equals一定要重寫hashCode。
五.總結(jié)
簡(jiǎn)單來說,HashMap由數(shù)組+鏈表組成的,數(shù)組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的。根據(jù)一個(gè)key定位數(shù)組的存儲(chǔ)位置分為三步:取key的hashcode值、計(jì)算hash值、位運(yùn)算獲取桶下標(biāo)。如果定位到的數(shù)組位置不含鏈表(當(dāng)前entry的next指向null),那么對(duì)于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數(shù)組位置包含鏈表,對(duì)于put操作,其時(shí)間復(fù)雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增,如果發(fā)生哈希沖突并且size大于閾值,還需要調(diào)用resize方法對(duì)Entry數(shù)組進(jìn)行擴(kuò)容;對(duì)于get操作,仍需遍歷鏈表,然后通過key對(duì)象的equals方法逐一對(duì)比查找。