散列表(Hash Table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構。這種數據結構在不考慮哈希碰撞的條件下,有前着O(1)的時間複雜度,因此效率很是高。Java中HashMap底層就使用了哈希表,因此一般咱們認爲HashMap的時間複雜度也是O(1)。html
在JDK中,HashMap底層是由數組實現,該數組即爲哈希表(由HashCode決定索引位置)。在不存在哈希碰撞的條件下,哈希表的性能最優,但在實際代碼實現中不能不考慮這個問題。因此在HashMap中,每一個Bucket(哈希表中的節點)都是一個鏈表(JDK1.8中當鏈表元素超過8個時,會將鏈表轉換爲紅黑樹),當發生哈希碰撞時,該元素將被添加到鏈表的末端。因爲鏈表中的時間複雜度是O(n),因此當Bucket所在鏈表過長時,會影響HashMap性能。java
JDK自1.6之後(之前的代碼沒讀過)的版本HashMap的實現本質上沒有太大差異(核心結構都是哈希表),這裏以JDK1.7版本爲例講解,下面是HashMap的核心方法源碼解讀。數組
下面是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; threshold = initialCapacity; init(); }
方法只是初始化了一些屬性,this.loadFactor
是擴容因子,即當實際使用容量比總容量爲該因子時,將發生擴容。threshold
表示擴容閾值,由總容量乘以擴容因子計算得出,但在構造方法中,直接使用初始容量表示。在無參的構造方法中,初始容量爲16,擴容因子爲0.75。多線程
判斷集合是否爲空的方法,實際只是內部維護了一個計數器,若是計數器爲0即爲空,不然非空。併發
public boolean isEmpty() { return size == 0; }
同理集合實際大小也是由該計數器表示,該計數器將在添加、移除元素時被維護。app
public int size() { return size; }
put方法是HashMap最核心方法之一,其代碼實現複雜之處也在於此。函數
public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); 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; } } modCount++; addEntry(hash, key, value, i); return null; }
代碼中先判斷哈希表是否爲空,空則執行擴容(初始化哈希表的過程實際使用的擴容的邏輯,因此構造方法中用擴容閾值來表示初始容量,減小一個全局變量)。 初始化哈希表後,會判斷鍵是否爲空,空鍵不會使用哈希函數來計算哈希和索引,而是直接遍歷哈希表找到空鍵所在Bucket,將元素加入該Bucket(該Bucket也是一個鏈表,但最多隻能有一個元素,這就是HashMap中最多隻能一個空鍵的緣由)。 若是鍵不爲空,則計算它的哈希碼,並根據哈希碼找到其對應哈希表的索引。找到的Bucket是一個鏈表,遍歷該鏈表,若是鍵已存在,則更新找到的節點值,將舊值返回,方法結束,若是沒有找到該鍵,則做爲一個新的節點加入鏈表末端。 上面解讀中遺留了幾個細節沒講,下面一一解讀:性能
inflateTable(threshold);
邏輯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); }
實際邏輯很簡單,經過roundUpToPowerOf2
方法保證擴容容量值必定是恰好大於等於傳入容量值的2的整數次冪(關於這點的緣由,後面會解釋),而後根據擴容後的容量計算擴容後的擴容閾值threshold
,最後從新構造哈希表table
(最後一行根據容量生成哈希種子的邏輯不影響主邏輯,這裏略過)。實際上這個方法只在哈希表空時執行,只是一個初始化的方法,後續在添加新元素的過程當中觸發擴容是由其它方法實現。優化
private V putForNullKey(V value) { 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++; addEntry(0, null, value, 0); return null; }
由源碼能夠看出只是簡單的遍歷哈希表,找到空鍵對應的Bucket(沒有就新增一個),更新找到節點值,返回舊值。
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); } 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); }
生成哈希碼的過程略過(我會告訴你是由於實現得太過複雜,我也不是很懂?),重點講一下根據哈希碼計算索引的邏輯。代碼只有一行h & (length-1)
,這行代碼實際上保證了返回值在[0 ~ 哈希表長度 - 1]這個區間內,若是不用位運算,它的等效(注意:這裏是說等效,非等價,二者的返回值未必是相同的,但效果是一致的)實現爲:h % length
,但位運算的性能更好,因此使用了這種寫法,另外使用位運算還有一個緣由,後面解釋爲何HashMap的容量必定是2的整數次冪裏會講到,這裏先略過。 計算出索引後,直接從哈希表中獲得Bucket(由於是經過下標查找,因此時間複雜度是O(1)),Bucket是一個鏈表,遍歷這個鏈表找到對應的節點(鍵相同),更新節點值,返回舊值,若是未找到說明是一個新的節點,經過addEntry(hash, key, value, i);
添加一個節點到鏈表末端。
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); } 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++; }
實際上添加一個新節點很容易,只須要構造一個Entry節點,添加到鏈表末端便可,但這裏須要考慮的時,若是實際容量到達擴容閾值時,須要觸發擴容邏輯。 首先經過resize(2 * table.length);
將哈希表擴容爲原來的兩倍,而後從新計算哈希碼和索引,最後經過createEntry(hash, key, value, bucketIndex);
建立一個新節點,將其加入鏈表末端。 這裏重點須要介紹一下resize
方法(區別於inflateTable
方法,這個方法會被屢次調用)
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); } 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) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
使用擴容後的容量構造一個新的哈希表,將原哈希表中的數據複製到新表中,再將新表賦值給類的table
屬性,從而完成擴容。
查找一個元素,在put
方法中已經體現了查找過程,先計算哈希碼,再計算索引,找到Bucket後遍歷鏈表,根據鍵找到目標元素便可
public V get(Object key) { 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; } 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; }
刪除元素,先找到元素,將其從對應鏈表中刪除便可,此時size
計數器會遞減
public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
清空集合,只須要將哈希表(數組)全部元素置空,並將計數器歸零便可
public void clear() { modCount++; Arrays.fill(table, null); size = 0; }
前面講過,哈希表的時間複雜度爲O(1),但前提是沒有發生哈希碰撞的狀況下。若是一個類在重寫hashCode方法時直接返回了一個常數(下次不能偷懶了~),就會形成存入集合的全部元素的哈希碼相同,也就是哈希碰撞,那麼HashMap的存儲結構就會退化爲一個鏈表結構(只有一個Bucket),而鏈表的時間複雜度爲O(n),所以一個好的hashCode實現,能夠提高其在HashMap或者HashSet等數據結構中的性能的,而只返回一個常數是萬不可取的。
以前提到過個問題,這時就要解釋一下indexFor
這個方法了。h & (length-1)
使用了按位與運算,而該運算的特色是,參與計算的兩個相同位都爲1時輸出1,不然輸出0。參考一下下面的示例(假設當前容量爲8):
# 8 - 1 == 7 轉換二進制爲 00000111 7 & 1 == 00000111 & 00000001 == 00000001 == 1 7 & 2 == 00000111 & 00000010 == 00000010 == 2 7 & 11 == 00000111 & 00001011 == 00000011 == 3
首先第一點,解釋一下該方法是怎樣保證返回索引必定在[0, length - 1]這個區間內。根據按位與運算的特色,(length - 1)中爲1的項,在與任何數作按位與計算時纔有可能爲1,而(length - 1)中爲0的項與任何數作按位與計算必定返回0,因此整個運算返回的最大值只能是(length - 1),而最小值是0,因此保證了[0, length - 1]這個區間範圍。 HashMap實際容量必定是2的整數次冪也和這裏的按位與運算有關。若是length是2的整數次冪(那麼length的二進制必定是...1000...0
的形式),那麼(length - 1)的二進制必定是...0001111...1
形式(這點讀者自行去驗證一下)。而在作按位與計算時,1纔是會引發值變化的項,假設length爲8,那麼length-1的二進制就是00000111
,任意數與其作按位與計算能夠獲得[0000, 0111]即[0, 7]全部項,而若是length不是2的整數次冥,那麼length-1必須中間會有0(空項)存在,這意味着這些位置沒法表示,從而形成哈希表中存在空洞(空間浪費),實際可用空間減小,那麼哈希碰撞的概率就更大,即浪費空間,也影響效率。
# 下面是一組length不爲2的整數次冪時,length - 1的二進制值 length = 10, length - 1 == 9 == 00001001 length = 13, length - 1 == 12 == 00001100 length = 15, length - 1 == 14 == 00001110 ... ...
因此只有length值爲2的整數次冪時,length - 1纔會是...1111...1
的結構,作按位與計算才能表示全部值。
JDK1.8中對HashMap作了大量優化,代碼細節調整很是多,但代碼結構基本一致,也仍然使用哈希表,因此這裏再也不展開, 有興趣的自行閱讀。JDK1.8中對HashMap作的最大調整是哈希表中單項(Bucket)再也不徹底使用鏈表結構了,當鏈表長度超過8時,將被轉換爲紅黑樹,而紅黑樹相比與鏈表有着更好的查詢性能。
具體分析過程略,這裏只說結論: 由於在HashMap中添加元素時,可能發生擴容,擴容過程當中會將遍歷原來的鏈表數據,複製到新哈希表過,在多線程的狀況下能夠多個線程同時觸發擴容,那麼在這個過程當中頗有可能形成鏈表變成循環鏈表或者空表,致使數據get的時候死循環或者數據丟失的狀況。 因此多線程(併發)場景下推薦使用ConcurrentHashMap,後面的文章中會介紹該集合類。
參考資料:
HashMap在實際開發中用得很是多,其代碼實現所涉及到的知識點也比較多,並且應用普遍,因此它的源碼仍是頗有必要讀一讀的。本文主要介紹的是JDK1.7的源碼,JDK1.8作了至關多的調整,後續有時間會深刻閱讀一下JDK1.8的源碼,尤爲是關於紅黑樹這一塊。
編寫本文除了閱讀源碼之外,了參考了其它博主的文章(其實都比我寫得好,不會畫圖是硬傷):