2020大廠面試必備——HashMap1.7和1.8源碼解析
1.HashMap
(1)數據結構
在JDK1.7中,HashMap中的數據結構是數組+單鏈表的組合;在JDK1.8中的HashMap存儲結構是由數組、鏈表、紅黑樹這三種數據結構形成。
(2)JDK1.7中HashMap源碼分析
(2.1)首先看一張圖片:
這張圖片非常清晰直觀地表示了HashMap底層的數據結構,即數組+鏈表。
(2.2)實現原理
成員變量:
/** 初始容量,默認16 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 最大初始容量,2^30 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 負載因子,默認0.75,負載因子越小,hash衝突機率越低 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 初始化一個Entry的空數組 */
static final Entry<?,?>[] EMPTY_TABLE = {};
/** 將初始化好的空數組賦值給table,table數組是HashMap實際存儲數據的地方,並不在EMPTY_TABLE數組中 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/** HashMap實際存儲的元素個數 */
transient int size;
/** 臨界值(HashMap 實際能存儲的大小),公式爲(threshold = capacity * loadFactor) */
int threshold;
/** 負載因子 */
final float loadFactor;
/** HashMap的結構被修改的次數,用於迭代器 */
transient int modCount;
構造方法:
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);
// 設置負載因子,臨界值此時爲容量大小,後面第一次put時由inflateTable(int toSize)方法計算設置
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
put方法:
public V put(K key, V value) {
// 如果table引用指向成員變量EMPTY_TABLE,那麼初始化HashMap(設置容量、臨界值,新的Entry數組引用)
if (table == EMPTY_TABLE) {//如果當前數組爲空
inflateTable(threshold);//延遲加載
}
// 若“key爲null”,則將該鍵值對添加到table[0]處,遍歷該鏈表,如果有key爲null,則將value替換。沒有就創建新Entry對象放在鏈表表頭
// 所以table[0]的位置上,永遠最多存儲1個Entry對象,形成不了鏈表。key爲null的Entry存在這裏
if (key == null)
return putForNullKey(value);
// 若“key不爲null”,則計算該key的哈希值
int hash = hash(key);
// 搜索指定hash值在對應table中的索引
int i = indexFor(hash, table.length);
// 循環遍歷table數組上位置爲i的鏈表,鏈表有Entry對象組成,用於判斷該位置上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))) {
// 如果這個key對應的鍵值對已經存在,就用新的value代替老的value,然後退出!
V oldValue = e.value;
e.value = value;
//調用value的回調函數,其實這個函數也爲空實現
e.recordAccess(this);
return oldValue;
}
}
// 修改次數+1
modCount++;
// table數組中沒有key對應的鍵值對,就將key-value添加到table[i]處
addEntry(hash, key, value, i);
return null;
}
/**
* Inflates the table.
*/
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//一個很重要的方法,返回一個比toSize大或相等的2的n次冪的整數
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
/**
* 將一個數換算成2的n次冪
* @param number
* @return
*/
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;
// 理解 Integer.highestOneBit((number - 1) << 1)
// 比如 number = 23,23 - 1 = 22,二進制是:10110
// 22 左移一位(右邊補1個0),結果是:101100
// Integer.highestOneBit() 函數的作用是取左邊最高一位,其餘位取0,
// 即:101100 -> 100000,換成十進制就是 32
}
要研究roundUpToPowerOf2方法,必須看看下面的方法,該方法返回一個比i小的或相等的2的整數次冪:
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
將i的二進制表示右移,並與i自身做異或運算,例如10的二進制表示爲0000 1010,10>>1的二進制表示爲0000 0101,再異或運算得到:0000 1111,此時i=0000 1111。再進行後面的運算即可。最後的結果爲8,方法驗證成功。然後結合Integer.highestOneBit((number - 1) << 1) ,就可以得到一個比number大或相等的2的n次冪的整數。
總之,初始化數組的時候,HashMap的容量總是一個2的整數次冪。
計算Hash值的方法
//用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置儘量分佈均勻
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {//這裏針對String優化了Hash函數,是否使用新的Hash函數和Hash因子有關
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
返回數組下標的方法:
//返回數組下標
static int indexFor(int h, int length) {
return h & (length-1);
}
h&(length-1)保證獲取的index一定在數組範圍內,舉個例子,默認容量16,length-1=15,h=18,轉換成二進制計算,最終計算出的index=2。有些版本的對於此處的計算會使用取模運算,也能保證index一定在數組範圍內,不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)。使用 h & (length-1)是爲了使得數組的下標肯定在[0,length-1]內。相當於求餘,效率高。
HashCode是通過Key算出來的,Key的HashCode直接決定了這個K-V對放在數組的哪個位置上。這個缺點還是有的,如果多個K-V對放在同一個位置,會導致數組對應的鏈表很長很長,會影響到get的效率。
將K-V對象放入到鏈表中:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生哈希衝突時進行擴容,新容量爲舊容量的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//擴容後重新計算插入的位置下標
}
//把元素放入HashMap的桶的對應位置
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++;//元素個數+1
}
這裏採用的是頭插法
擴容操作
//按新的容量擴容Hash表
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;//修改HashMap的底層數組
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改閥值
}
//將老的表中的數據拷貝到新的結構中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;//容量
for (Entry<K,V> e : table) { //遍歷所有桶
while(null != e) { //遍歷桶中所有元素(是一個鏈表)
Entry<K,V> next = e.next;
if (rehash) {//如果是重新Hash,則需要重新計算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//定位Hash桶
e.next = newTable[i];//元素連接到桶中,這裏相當於單鏈表的插入,總是插入在最前面
newTable[i] = e;//newTable[i]的值總是最新插入的值
e = next;//繼續下一個元素
}
}
}
JDK1.7的HashMap在多線程的擴容情況下(“transfer”方法)會出現問題,容易出現循環鏈表。
(3)JDK1.8中HashMap源碼分析及改進
節點類:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
成員變量
//默認初始容量,即數組長度爲16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量,2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//負載因子爲0.75,可以確保一個拉姆達爲0.5的泊松分佈,使得時間和空間複雜度最優
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//鏈表轉爲紅黑樹的閾值爲8,節點類從Node改爲TreeNode
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹轉爲鏈表的閾值爲6,節點類從TreeNode改爲Node
static final int UNTREEIFY_THRESHOLD = 6;
//Node[] table 數組長度轉換爲紅黑樹的閾值
static final int MIN_TREEIFY_CAPACITY = 64;
即使 hash 算法 和負載因子設計的再完美,也避免不了拉鍊過長的情況,一旦出現拉鍊過長,嚴重影響 HashMap 的性能,於是在 JDK1.8 中對數據結構做了進一步的優化,引入了紅黑樹。當鏈表長度太長(超過 TREEIFY_THRESHOLD = 8)時,當Node[] table 數組長度超過 64(MIN_TREEIFY_THRESHOLD = 64) 時,鏈表就轉化爲了紅黑樹,利用紅黑樹快速增刪改查的特點提高 HashMap 的性能。
構造方法:
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;
this.threshold = tableSizeFor(initialCapacity);
}
有幾個說法,與JDK1.7中的有所區別:
- 一般將數組中的每一個元素稱作桶(segment),桶中連的鏈表或者紅黑樹中的每一個元素成爲bin
- capacity: 源碼中沒有將它作爲屬性,但是爲了方便,引進了這個概念,是指HashMap中桶的數量。默認值爲16。擴容是按照原容量的2倍進行擴。如果在構造函數中指定了Map的大小,那麼進行put操作時,初始化後的容量爲離傳入值最近的2的整數冪,是通過tableSizeFor() 函數達到該目的。總之,容量都是2的冪。 設計成16的好處是可以使用按位與替代取模來提升hash的效率。
- loadFactor: 譯爲裝載因子。裝載因子用來衡量HashMap滿的程度。loadFactor的默認值爲0.75f。計算HashMap的實時裝載因子的方法爲:size/capacity,而不是佔用桶的數量去除以capacity。
- threshold: threshold表示當HashMap的size大於threshold時會執行resize操作。threshold = capacity*loadFactor
- DEFAULT_INITIAL_CAPACITY : 默認初始化容量 16。容量必須爲2的次方。默認的hashmap大小爲16.
- MAXIMUM_CAPACITY :最大的容量大小2^30
- DEFAULT_LOAD_FACTOR: 默認resize的因子。0.75,即實際數量超過總數DEFAULT_LOAD_FACTOR的數量即會發生resize動作。在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大於1。
- TREEIFY_THRESHOLD: 樹化閾值 8。當單個segment的容量超過閾值時,將鏈表轉化爲紅黑樹。
- UNTREEIFY_THRESHOLD :鏈表化閾值 6。當resize後或者刪除操作後單個segment的容量低於閾值時,將紅黑樹轉化爲鏈表。
- MIN_TREEIFY_CAPACITY :最小樹化容量 64。當桶中的bin被樹化時最小的hash表容量,低於該容量時不會樹化。
HashMap擴容及其樹化的具體過程
- 如果在創建 HashMap 實例時沒有給定capacity、loadFactor則默認值分別是16和0.75。
- 隨着put進HashMap的元素增加,不可避免會發生哈希衝突,當較多bin被映射到同一個桶時,會形成一個較長的鏈表;如果這個桶(鏈表)中bin的數量小於等於TREEIFY_THRESHOLD(8),顯然不會轉化成樹形結構存儲;
- 如果這個桶中bin的數量大於了 TREEIFY_THRESHOLD(8) ,但是capacity小於MIN_TREEIFY_CAPACITY (64)則依然使用鏈表結構進行存儲,此時會對HashMap進行擴容,擴容的目的是爲了縮短鏈表的長度;
- 如果capacity大於了MIN_TREEIFY_CAPACITY ,纔有資格進行樹化(當bin的個數大於8時)。
綜上所述,HashMap的樹化,要受到兩個因素的制約,一個是capacity(數組的長度,桶的數量),另一個是bin的個數(鏈表的長度,桶的容量)。
當capacity<64,bin<8時,桶維持鏈表的數據結構;
當capacity<64,bin>=8時,數組會擴容;
當capacity>=64,bin>=8時,鏈表會轉爲紅黑樹,當bin<=6時,紅黑樹會轉爲鏈表。
hash 值的計算
- 根據存入的key-value對中的key計算出對應的hash值,然後放入對應的桶中,所以好的hash值計算方法十分重要,可以大大避免哈希衝突。
- HashMap是以hash操作作爲散列依據。但是又與傳統的hash存在着少許的優化。其hash值是key的hashcode與其hashcode右移16位的異或結果。在put方法中,將取出的hash值與當前的hashmap容量-1進行與運算。得到的就是位桶的下標。那麼爲何需要使用key.hashCode() ^ h>>>16的方式來計算hash值呢。其實從微觀的角度來看,這種方法與直接去key的哈希值返回在功能實現上沒有差別。但是由於最終獲取下表是對二進制數組最後幾位的與操作。所以直接取hash值會丟失高位的數據,從而增大沖突引起的可能。由於hash值是32位的二進制數。將高位的16位於低位的16位進行異或操作,即可將高位的信息存儲到低位。因此該函數也叫做擾亂函數。目的就是減少衝突出現的可能性。而官方給出的測試報告也驗證了這一點。直接使用key的hash算法與擾亂函數的hash算法衝突概率相差10%左右。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
n = table.length;
index = (n-1) & hash;
根據以上可知,hashcode是一個32位的值,用高16位與低16位進行異或,原因在於求index是是用 (n-1) & hash ,如果hashmap的capcity很小的話,那麼對於兩個高位不同,低位相同的hashcode,可能最終會裝入同一個桶中。那麼會造成hash衝突,好的散列函數,應該儘量在計算hash時,把所有的位的信息都用上,這樣才能儘可能避免衝突。這就是爲什麼用高16位與低16位進行異或的原因。
爲什麼capcity是2的冪?
因爲 算index時用的是(n-1) & hash,這樣就能保證n -1是全爲1的二進制數,如果不全爲1的話,存在某一位爲0,那麼0,1與0與的結果都是0,這樣便有可能將兩個hash不同的值最終裝入同一個桶中,造成衝突。所以必須是2的冪。
在算index時,用位運算(n-1) & hash而不是模運算 hash % n的好處(在HashTable中依舊是取模運算)?
位運算消耗資源更少,更有效率
避免了hashcode爲負數的情況
put 操作
put 操作的主要流程如下:
①.判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容;
②.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向⑥,如果table[i]不爲空,轉向③;
③.判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向④,這裏的相同指的是hashCode以及equals;
④.判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;
⑤.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
⑥.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//初始化時,map中還沒有key-value
if ((tab = table) == null || (n = tab.length) == 0)
//利用resize生成對應的tab[]數組
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//當前桶無元素
tab[i] = newNode(hash, key, value, null);
else {//桶內有元素
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//桶內第一個元素的key等於待放入的key,用
e = p;
else if (p instanceof TreeNode)
//如果此時桶內已經樹化
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//桶內還是一個鏈表,則插入鏈尾(尾插)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//變成紅黑樹
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//檢查是否應該擴容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize 擴容操作
resize擴容操作主要用在兩處:
向一個空的HashMap中執行put操作時,會調用resize()進行初始化,要麼默認初始化,capacity爲16,要麼根據傳入的值進行初始化
put操作後,檢查到size已經超過threshold,那麼便會執行resize,進行擴容,如果此時capcity已經大於了最大值,那麼便把threshold置爲int最大值,否則,對capcity,threshold進行擴容操作。
發生了擴容操作,那麼必須Map中的所有的數進行再散列,重新裝入。
resize擴容操作主要用在兩處:
向一個空的HashMap中執行put操作時,會調用resize()進行初始化,要麼默認初始化,capacity爲16,要麼根據傳入的值進行初始化
put操作後,檢查到size已經超過threshold,那麼便會執行resize,進行擴容,如果此時capcity已經大於了最大值,那麼便把threshold置爲int最大值,否則,對capcity,threshold進行擴容操作。
發生了擴容操作,那麼必須Map中的所有的數進行再散列,重新裝入。
具體擴容圖如下:將一個原先capcity爲16的擴容成32的:
在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變(因爲任何數與0與都依舊是0),是1的話index變成“原索引+oldCap”。
例如:n爲table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
2.總結
jdk 7 與 jdk 8 中關於HashMap的對比
- 8時紅黑樹+鏈表+數組的形式,當桶內元素大於8時,便會樹化
hash值的計算方式不同 - 1.7 table在創建hashmap時分配空間,而1.8在put的時候分配,如果table爲空,則爲table分配空間。
- 在發生衝突,插入鏈中時,7是頭插法,8是尾插法。
- 在resize操作中,7需要重新進行index的計算,而8不需要,通過判斷相應的位是0還是1,要麼依舊是原index,要麼是oldCap + 原index