HashMap(Java 7)的實現原理
一、HashMap的定義和建構函式
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
HashMap繼承自AbstractMap,AbstractMap是Map介面的骨幹實現,AbstractMap中實現了Map中最重要最常用和方法,這樣HashMap繼承AbstractMap就不需要實現Map的所有方法,讓HashMap減少了大量的工作。
而在這裡仍然實現Map結構,沒有什麼作用,應該是為了讓map的層次結構更加清晰
HashMap的成員變數
int DEFAULT_INITIAL_CAPACITY = 16:預設的初始容量為16
int MAXIMUM_CAPACITY = 1 << 30:最大的容量為 2 ^ 30
float DEFAULT_LOAD_FACTOR = 0.75f:預設的載入因子為 0.75f
Entry< K,V>[] table:Entry型別的陣列,HashMap用這個來維護內部的資料結構,它的長度由容量決定
int size:HashMap的大小
int threshold:HashMap的極限容量,擴容臨界點(容量和載入因子的乘積)
謹記這些成員變數,在HashMap內部經常看到
HashMap的四個建構函式
public HashMap():構造一個具有預設初始容量 (16) 和預設載入因子 (0.75) 的空 HashMap
public HashMap(int initialCapacity):構造一個帶指定初始容量和預設載入因子 (0.75) 的空 HashMap
public HashMap(int initialCapacity, float loadFactor):構造一個帶指定初始容量和載入因子的空 HashMap
public HashMap(Map< ? extends K, ? extends V> m):構造一個對映關係與指定 Map 相同的新 HashMap
這裡有兩個很重要的引數:initialCapacity(初始容量)、loadFactor(載入因子),看看JDK中的解釋:
HashMap 的例項有兩個引數影響其效能:初始容量 和載入因子。
容量 :是雜湊表中桶的數量,初始容量只是雜湊表在建立時的容量,實際上就是Entry< K,V>[] table的容量
載入因子 :是雜湊表在其容量自動增加之前可以達到多滿的一種尺度。它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。系統預設負載因子為0.75,一般情況下我們是無需修改的。
當雜湊表中的條目數超出了載入因子與當前容量的乘積時,則要對該雜湊表進行 rehash 操作(即重建內部資料結構),從而雜湊表將具有大約兩倍的桶數。
二、HashMap的資料結構
我們知道在Java中最常用的兩種結構是陣列和模擬指標(引用),幾乎所有的資料結構都可以利用這兩種來組合實現,HashMap也是如此。實際上HashMap是一個“連結串列雜湊”,如下是它資料結構:
從上圖我們可以看出HashMap底層實現還是陣列,只是陣列的每一項都是一條鏈。其中引數initialCapacity就代表了該陣列的長度。下面為HashMap建構函式的原始碼:
public HashMap(int initialCapacity, float loadFactor) {
//容量不能小於0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//容量不能超出最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//載入因子不能<=0 或者 為非數字
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//計算出大於初始容量的最小 2的n次方作為雜湊表table的長度,下面會說明為什麼要這樣
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
//設定HashMap的容量極限,當HashMap的容量達到該極限時就會進行擴容操作
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//建立Entry陣列
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
可以看到,這個建構函式主要做的事情就是:
1. 對傳入的 容量 和 載入因子進行判斷處理
2. 設定HashMap的容量極限
3. 計算出大於初始容量的最小 2的n次方作為雜湊表table的長度,然後用該長度建立Entry陣列(table),這個是最核心的
可以發現,一個HashMap對應一個Entry陣列,來看看Entry這個元素的內部結構:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
Entry是HashMap的一個內部類,它也是維護著一個key-value對映關係,除了key和value,還有next引用(該引用指向當前table位置的連結串列),hash值(用來確定每一個Entry連結串列在table中位置)
三、HashMap的儲存實現put(K,V)
public V put(K key, V value) {
//如果key為空的情況
if (key == null)
return putForNullKey(value);
//計算key的hash值
int hash = hash(key);
//計算該hash值在table中的下標
int i = indexFor(hash, table.length);
//對table[i]存放的連結串列進行遍歷
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判斷該條鏈上是否有hash值相同的(key相同)
//若存在相同,則直接覆蓋value,返回舊value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次數+1
modCount++;
//把當前key,value新增到table[i]的連結串列中
addEntry(hash, key, value, i);
return null;
}
從上面的過程中,我們起碼可以發現兩點:
1. 如果為null,則呼叫putForNullKey:這就是為什麼HashMap可以用null作為鍵的原因,來看看HashMap是如何處理null鍵的:
private V putForNullKey(V value) {
//查詢連結串列中是否有null鍵
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++;
//如果鏈中查詢不到,則把該null鍵插入
addEntry(0, null, value, 0);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
//這一步就是對null的處理,如果key為null,hash值為0,也就是會插入到雜湊表的表頭table[0]的位置
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
2. 如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回:這就是為什麼HashMap不能有兩個相同的key的原因
對於hash操作,最重要也是最困難的就是如何通過確定hash的位置,我們來看看HashMap的做法:
首先求得key的hash值:hash(key)
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();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
這是一個數學計算,可以不用深入,關鍵是下面這裡:
計算該hash值在table中的下標
static int indexFor(int h, int length) {
return h & (length-1);
}
對於HashMap的table而言,資料分佈需要均勻(最好每項都只有一個元素,這樣就可以直接找到),不能太緊也不能太鬆,太緊會導致查詢速度慢,太鬆則浪費空間。計算hash值後,怎麼才能保證table元素分佈均與呢?我們會想到取模,但是由於取模的消耗較大,而HashMap是通過&運算子(按位與操作)來實現的:h & (length-1)
在建構函式中存在:capacity <<= 1,這樣做總是能夠保證HashMap的底層陣列長度為2的n次方。當length為2的n次方時,h&(length - 1)就相當於對length取模,而且速度比直接取模快得多,這是HashMap在速度上的一個優化。至於為什麼是2的n次方下面解釋。
我們回到indexFor方法,該方法僅有一條語句:h&(length - 1),這句話除了上面的取模運算外還有一個非常重要的責任:均勻分佈table資料和充分利用空間。
這裡我們假設length為16(2^n)和15,h為5、6、7。
當length-1 = 14時,6和7的結果一樣,這樣表示他們在table儲存的位置是相同的,也就是產生了碰撞,6、7就會在一個位置形成連結串列,這樣就會導致查詢速度降低詳細地看看當length-1 = 14 時的情況:
可以看到,這樣發生發生的碰撞是非常多的,1,3,5,7,9,11,13都沒有存放資料,空間減少,進一步增加碰撞機率,這樣就會導致查詢速度慢,
分析一下:當length-1 = 14時,二進位制的最後一位是0,在&操作時,一個為0,無論另一個為1還是0,最終&操作結果都是0,這就造成了結果的二進位制的最後一位都是0,這就導致了所有資料都儲存在2的倍數位上,所以說,所以說當length = 2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得資料在table陣列中分佈較均勻,查詢速度也較快。
然後我們來看看計算了hash值,並用該hash值來求得雜湊表中的索引值之後,如何把該key-value插入到該索引的連結串列中:
呼叫 addEntry(hash, key, value, i) 方法:
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果size大於極限容量,將要進行重建內部資料結構操作,之後的容量是原來的兩倍,並且重新設定hash值和hash值在table中的索引值
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//真正建立Entry節點的操作
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++;
}
首先取得bucketIndex位置的Entry頭結點,並建立新節點,把該新節點插入到連結串列中的頭部,該新節點的next指標指向原來的頭結點
這裡有兩點需要注意:
一、鏈的產生
這是一個非常優雅的設計。系統總是將新的Entry物件新增到bucketIndex處。如果bucketIndex處已經有了物件,那麼新新增的Entry物件將指向原有的Entry物件,形成一條Entry鏈,但是若bucketIndex處沒有Entry物件,也就是e==null,那麼新新增的Entry物件指向null,也就不會產生Entry鏈了。
二、擴容問題
還記得HashMap中的一個變數嗎,threshold,這是容器的容量極限,還有一個變數size,這是指HashMap中鍵值對的數量,也就是node的數量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什麼時候發生擴容?
當不斷新增key-value,size大於了容量極限threshold時,會發生擴容
如何擴容?
擴容發生在resize方法中,也就是擴大陣列(桶)的數量,如何擴容參考:http://blog.csdn.net/jeffleo/article/details/63684953
我們重新來理一下儲存的步驟:
1. 傳入key和value,判斷key是否為null,如果為null,則呼叫putForNullKey,以null作為key儲存到雜湊表中;
2. 然後計算key的hash值,根據hash值搜尋在雜湊表table中的索引位置,若當前索引位置不為null,則對該位置的Entry連結串列進行遍歷,如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回,結束;
3. 否則呼叫addEntry,用key-value建立一個新的節點,並把該節點插入到該索引對應的連結串列的頭部
四、HashMap的讀取實現get(key,value)
public V get(Object key) {
//如果key為null,求null鍵
if (key == null)
return getForNullKey();
// 用該key求得entry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
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;
}
讀取的步驟比較簡單,呼叫hash(key)求得key的hash值,然後呼叫indexFor(hash)求得hash值對應的table的索引位置,然後遍歷索引位置的連結串列,如果存在key,則把key對應的Entry返回,否則返回null
五、HashMap鍵的遍歷,keySet()
HashMap遍歷的核心程式碼如下:
private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // next entry to return
int expectedModCount; // For fast-fail
int index; // current slot
Entry<K,V> current; // current entry
//當呼叫keySet().iterator()時,呼叫此程式碼
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
//從雜湊表陣列從上到下,查詢第一個不為null的節點,並把next引用指向該節點
while (index < t.length && (next = t[index++]) == null)
;
}
}
public final boolean hasNext() {
return next != null;
}
//當呼叫next時,會呼叫此程式碼
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
//如果當前節點的下一個節點為null,從節點處罰往下查詢雜湊表,找到第一個不為null的節點
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
public void remove() {
if (current == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Object k = current.key;
current = null;
HashMap.this.removeEntryForKey(k);
expectedModCount = modCount;
}
}
從這裡可以看出,HashMap遍歷時,按雜湊表的每一個索引的連結串列從上往下遍歷,由於HashMap的儲存規則,最晚新增的節點都有可能在第一個索引的連結串列中,這就造成了HashMap的遍歷時無序的。
原文連結:http://blog.csdn.net/jeffleo/article/details/54946424