https://blog.csdn.net/u014532901/article/details/78936283
HashMap工作原理
HashMap擴容
1 HashMap的擴容時機
2 HashMap的擴容過程
補充
1 容量必須是2的冪
2 rehash
References
1. HashMap工作原理
HashMap作爲優秀的Java集合框架中的一個重要的成員,在很多編程場景下爲我們所用。HashMap作爲數據結構散列表的一種實現,就其工作原理來講單獨列出一篇博客來講都是不過分的。由於本文主要是簡單總結其擴容機制,因此對於HashMap的實現原理僅做簡單的概述。
HashMap內部實現是一個桶數組,每個桶中存放着一個單鏈表的頭結點。其中每個結點存儲的是一個鍵值對整體(Entry),HashMap採用拉鍊法解決哈希衝突(關於哈希衝突後面會介紹)。
由於Java8對HashMap的某些地方進行了優化,以下的總結和源碼分析都是基於Java7。
示意圖如下:
HashMap提供兩個重要的基本操作,put(K, V)和get(K)。
當調用put操作時,HashMap計算鍵值K的哈希值,然後將其對應到HashMap的某一個桶(bucket)上;此時找到以這個桶爲頭結點的一個單鏈表,然後順序遍歷該單鏈表找到某個節點的Entry中的Key是等於給定的參數K;若找到,則將其的old V替換爲參數指定的V;否則直接在鏈表尾部插入一個新的Entry節點。
對於get(K)操作類似於put操作,HashMap通過計算鍵的哈希值,先找到對應的桶,然後遍歷桶存放的單鏈表通過比照Entry的鍵來找到對應的值。
以上就是HashMap的基本工作原理,但是問題總是比我們看到的要複雜。由於哈希是一種壓縮映射,換句話說就是每一個Entry節點無法對應到一個只屬於自己的桶,那麼必然會存在多個Entry共用一個桶,拉成一條鏈表的情況,這種情況叫做哈希衝突。當哈希衝突產生嚴重的情況,某一個桶後面掛着的鏈表就會特別長,我們知道查找最怕看見的就是順序查找,那幾乎就是無腦查找。
哈希衝突無法完全避免,因此爲了提高HashMap的性能,HashMap不得儘量緩解哈希衝突以縮短每個桶的外掛鏈表長度。
頻繁產生哈希衝突最重要的原因就像是要存儲的Entry太多,而桶不夠,這和供不應求的矛盾類似。因此,當HashMap中的存儲的Entry較多的時候,我們就要考慮增加桶的數量,這樣對於後續要存儲的Entry來講,就會大大緩解哈希衝突。
因此就涉及到HashMap的擴容,上面算是回答了爲什麼擴容,那麼什麼時候擴容?擴容多少?怎麼擴容?便是第二部分要總結的了。
2. HashMap擴容
2.1 HashMap的擴容時機
在使用HashMap的過程中,我們經常會遇到這樣一個帶參數的構造方法。
public HashMap(int initialCapacity, float loadFactor) ;
1
第一個參數:初始容量,指明初始的桶的個數;相當於桶數組的大小。
第二個參數:裝載因子,是一個0-1之間的係數,根據它來確定需要擴容的閾值,默認值是0.75。
現在開始通過源碼來尋找擴容的時機:
put(K, V)操作
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);//計算鍵的hash值
int i = indexFor(hash, table.length);//通過hash值對應到桶位置
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))) {//注意這裏的鍵的比較方式== 或者 equals()
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);//遍歷單鏈表完畢,沒有找到與鍵相對的Entry,需要新建一個Entry換句話說就是桶i是一個空桶;
return null;
}
既然找到一個空桶,那麼新建的Entry必然會是這個桶外掛單鏈表的第一個結點。通過addEntry,找到了擴容的時機。
/**
* Adds a new entry with the specified key, value and hash code to
* the specified bucket. It is the responsibility of this
* method to resize the table if appropriate.
*
* Subclass overrides this to alter the behavior of put method.
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//當size大於等於某一個閾值thresholdde時候且該桶並不是一個空桶;
/*這個這樣說明比較好理解:因爲size 已經大於等於閾值了,說明Entry數量較多,哈希衝突嚴重,那麼若該Entry對應的桶不是一個空桶,這個Entry的加入必然會把原來的鏈表拉得更長,因此需要擴容;若對應的桶是一個空桶,那麼此時沒有必要擴容。*/
resize(2 * table.length);//將容量擴容爲原來的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//擴容後的,該hash值對應的新的桶位置
}
createEntry(hash, key, value, bucketIndex);//在指定的桶位置上,創建一個新的Entry
}
/**
* Like addEntry except that this version is used when creating entries
* as part of Map construction or "pseudo-construction" (cloning,
* deserialization). This version needn't worry about resizing the table.
*
* Subclass overrides this to alter the behavior of HashMap(Map),
* clone, and readObject.
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);//鏈表的頭插法插入新建的Entry
size++;//更新size
}
上面有幾個重要成員變量:
size
threshold
/**
* 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
*/
int threshold;
/**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor;
由註釋可以知道:
size記錄的是map中包含的Entry的數量
而threshold記錄的是需要resize的閾值 且 threshold = loadFactor * capacity
capacity 其實就是桶的長度
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
1
因此現在總結出擴容的時機:
當map中包含的Entry的數量大於等於threshold = loadFactor * capacity的時候,且新建的Entry剛好落在一個非空的桶上,此刻觸發擴容機制,將其容量擴大爲2倍。(爲什麼2倍,而不是1.5倍,3倍,10倍;解釋見最後的補充)
當size大於等於threshold的時候,並不一定會觸發擴容機制,但是會很可能就觸發擴容機制,只要有一個新建的Entry出現哈希衝突,則立刻resize。
直到這裏我們回答了什麼時候擴容和擴容多少的問題,那麼下面回答如何擴容的問題。
2.2 HashMap的擴容過程
上面有一個很重要的方法,包含了幾乎屬於的擴容過程,這就是resize()
/**
* Rehashes the contents of this map into a new array with a
* larger capacity. This method is called automatically when the
* number of keys in this map reaches its threshold.
*
* If current capacity is MAXIMUM_CAPACITY, this method does not
* resize the map, but sets threshold to Integer.MAX_VALUE.
* This has the effect of preventing future calls.
*
* @param newCapacity the new capacity, MUST be a power of two;
* must be greater than current capacity unless current
* capacity is MAXIMUM_CAPACITY (in which case value
* is irrelevant).
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {//最大容量爲 1 << 30
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];//新建一個新表
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;//是否再hash
transfer(newTable, rehash);//完成舊錶到新表的轉移
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* Transfers all entries from current table to newTable.
*/
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;//引用next
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//找到新表的桶位置;原桶數組中的某個桶上的同一鏈表中的Entry此刻可能被分散到不同的桶中去了,有效的緩解了哈希衝突。
e.next = newTable[i];//頭插法插入新表中
newTable[i] = e;
e = next;
}
}
}
對於resize的過程,相對來講是比較簡單清晰易於理解的。舊桶數組中的某個桶的外掛單鏈表是通過頭插法插入新桶數組中的,並且原鏈表中的Entry結點並不一定仍然在新桶數組的同一鏈表。
示意圖如下:
這裏很容易就想到多線程情況下,隱約感覺這個transfer方法在多線程環境下會亂套。事實上也是這樣的,由於缺乏同步機制,當多個線程同時resize的時候,某個線程t所持有的引用next(參考上面代碼next指向原桶數組中某個桶外掛單鏈表的下一個需要轉移的Entry),可能已經被轉移到了新桶數組中,那麼最後該線程t實際上在對新的桶數組進行transfer操作。
如果有更多的線程出現這種情況,那很可能出現大量線程都在對新桶數組進行transfer,那麼就會出現多個線程對同一鏈表無限進行鏈表反轉的操作,極易造成死循環,數據丟失等等,因此HashMap不是線程安全的,考慮在多線程環境下使用併發工具包下的ConcurrentHashMap。
3. 補充
3.1 容量必須是2的冪
在resize(),爲什麼容量需要時2倍這樣擴張,而不是1.5倍,3倍,10倍,另外在HashMap中有如下的代碼:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
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);
// Find a power of 2 >= initialCapacity 找到一個大於等於初始容量的且是2的冪的數作爲實際容量
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
通過以上我們知道HashMap的容量必須是2的冪,那麼爲什麼要這麼設計呢?答案當然是爲了性能。在HashMap通過鍵的哈希值進行定位桶位置的時候,調用了一個indexFor(hash, table.length);方法。
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
可以看到這裏是將哈希值h與桶數組的length-1(實際上也是map的容量-1)進行了一個與操作得出了對應的桶的位置,h & (length-1)。
但是爲什麼不採用h % length這種計算方式呢?
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=4631373中提出Java的%、/操作比&慢10倍左右,因此採用&運算會提高性能。
通過限制length是一個2的冪數,h & (length-1)和h % length結果是一致的。這就是爲什麼要限制容量必須是一個2的冪的原因。
舉個簡單的例子說明這兩個操作的結果一致性:
假設有個hashcode是311,對應的二進制是(1 0011 0111)
length爲16,對應的二進制位(1 0000)
%操作:311 = 16*19 + 7;所以結果爲7,二進制位(0111);
&操作:(1 0011 0111) & (0111) = 0111 = 7, 二進制位(0111)
1 0011 0111 = (1 0011 0000) + (0111) = (1*2^4 + 1* 2^5 + 0*2^6 + 0*2^7 + 1*2^8 ) + 7 = 2^4*(1 + 2 + 0 + 0 + 16) + 7 = 16 * 19 + 7; 和%操作一致。
如果length是一個2的冪的數,那麼length-1就會變成一個mask, 它會將hashcode低位取出來,hashcode的低位實際就是餘數,和取餘操作相比,與操作會將性能提升很多。
3.2 rehash
通過上面的分析可以看出,不同的鍵的的hashcode僅僅只能通過低位來區分。高位的信息沒有被充分利用,舉個例子:
假設容量爲爲16, 二進制位(10000)。
key1的hashcode爲11111 10101,另一個key2的hashcode爲00000 10101,很明顯這兩個hashcode不是一樣的,甚至連相似性(例如海明距離)也是很遠的。但是直接進行&操作得出的桶位置是同一個桶,這直接就產生了哈希衝突。
由於鍵的hashCode是HashMap的使用者來設計的,主要也就是我們這羣程序員,由於設計一個良好的hashcode分佈,是比較困難的,因此會容易出現分佈質量差的hashcode分佈,極端情況就是:所有的hashCode低位全相等,而高位不相等,這大大加大了哈希衝突,降低了HashMap的性能。
爲了防止這種情況的出現,HashMap它使用一個supplemental hash function對鍵的hashCode再進行了一個supplemental hash ,將最終的hash值作爲鍵的hash值來進行桶的位置映射(也就是說JDK團隊在爲我們這羣程序員加性能保險Orz)。這個過程叫做再哈希(rehash)。
經過一個supplemental hash過程後,能保證海明距離爲常數的不同的hashcode有一個哈希衝突次數上界(裝載因子爲0.75的時候,大約是8次)。
參見下段代碼:
/**
* Retrieve object hash code and applies a supplemental hash function to the
* result hash, which defends against poor quality hash functions. This is
* critical because HashMap uses power-of-two length hash tables, that
* otherwise encounter collisions for hashCodes that do not differ
* in lower bits. Note: Null keys always map to hash 0, thus index 0.
*/
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
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);
}
4 References
http://www.javarticles.com/2012/11/hashmap-faq.html
http://blog.csdn.net/u014532901/article/details/78573740
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=4631373
---------------------
作者:Spground
來源:CSDN
原文:https://blog.csdn.net/u014532901/article/details/78936283
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!