Java集合框架之 Java HashMap 原始碼解析
繼上一篇文章Java集合框架綜述後,今天正式開始分析具體集合類的程式碼,首先以既熟悉又陌生的HashMap開始。
簽名(signature)
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
可以看到HashMap
繼承了
- 標記介面Cloneable,用於表明
HashMap
物件會重寫java.lang.Object#clone()
方法,HashMap實現的是淺拷貝(shallow copy)。 - 標記介面Serializable,用於表明
HashMap
物件可以被序列化
比較有意思的是,HashMap
同時繼承了抽象類AbstractMap
與介面Map
,因為抽象類AbstractMap
的簽名為
public abstract class AbstractMap<K,V> implements Map<K,V>
Stack Overfloooow上解釋到:
在語法層面繼承介面
Map
是多餘的,這麼做僅僅是為了讓閱讀程式碼的人明確知道HashMap
是屬於Map
體系的,起到了文件的作用
AbstractMap
相當於個輔助類,Map
的一些操作這裡面已經提供了預設實現,後面具體的子類如果沒有特殊行為,可直接使用AbstractMap
提供的實現。
Cloneable介面
<code>It's evil, don't use it. </code>
Cloneable
這個介面設計的非常不好,最致命的一點是它裡面竟然沒有clone
方法,也就是說我們自己寫的類完全可以實現這個介面的同時不重寫clone
方法。
關於Cloneable
的不足,大家可以去看看《Effective Java》一書的作者給出的理由,在所給連結的文章裡,Josh Bloch也會講如何實現深拷貝比較好,我這裡就不在贅述了。
Map介面
在Eclipse中的outline皮膚可以看到Map
介面裡麵包含以下成員方法與內部類:
在上篇文章講了Map
雖然並不是Collection
,但是它提供了三種“集合視角”(collection views),與下面三個方法一一對應:
Set<K> keySet()
,提供key的集合視角Collection<V> values()
,提供value的集合視角Set<Map.Entry<K, V>> entrySet()
,提供key-value序對的集合視角,這裡用內部類Map.Entry
表示序對
AbstractMap抽象類
AbstractMap
對Map
中的方法提供了一個基本實現,減少了實現Map
介面的工作量。
舉例來說:
如果要實現個不可變(unmodifiable)的map,那麼只需繼承
AbstractMap
,然後實現其entrySet
方法,這個方法返回的set不支援add與remove,同時這個set的迭代器(iterator)不支援remove操作即可。相反,如果要實現個可變(modifiable)的map,首先繼承
AbstractMap
,然後重寫(override)AbstractMap
的put方法,同時實現entrySet
所返回set的迭代器的remove方法即可。
設計理念(design concept)
雜湊表(hash table)
HashMap
是一種基於雜湊表(hash table)實現的map,雜湊表(也叫關聯陣列)一種通用的資料結構,大多數的現代語言都原生支援,其概念也比較簡單:key經過hash函式作用後得到一個槽(buckets或slots)的索引(index),槽中儲存著我們想要獲取的值
,如下圖所示
所以利用雜湊表這種資料結構實現具體類時,需要:
- 設計個好的hash函式,使衝突儘可能的減少
- 其次是需要解決發生衝突後如何處理。
後面會重點介紹HashMap
是如何解決這兩個問題的。
HashMap的一些特點
- 執行緒非安全,並且允許key與value都為null值,
HashTable
與之相反,為執行緒安全,key與value都不允許null值。 - 不保證其內部元素的順序,而且隨著時間的推移,同一元素的位置也可能改變(resize的情況)
- put、get操作的時間複雜度為O(1)。
- 遍歷其集合視角的時間複雜度與其容量(capacity,槽的個數)和現有元素的大小(entry的個數)成正比,所以如果遍歷的效能要求很高,不要把capactiy設定的過高或把平衡因子(load factor,當entry數大於capacity*loadFactor時,會進行resize,reside會導致key進行rehash)設定的過低。
- 由於HashMap是執行緒非安全的,這也就是意味著如果多個執行緒同時對一hashmap的集合試圖做迭代時有結構的上改變(新增、刪除entry,只改變entry的value的值不算結構改變),那麼會報ConcurrentModificationException,專業術語叫
fail-fast
,儘早報錯對於多執行緒程式來說是很有必要的。 Map m = Collections.synchronizedMap(new HashMap(...));
通過這種方式可以得到一個執行緒安全的map。
原始碼剖析
首先從建構函式開始講,HashMap
遵循集合框架的約束,提供了一個引數為空的建構函式與有一個引數且引數型別為Map的建構函式。除此之外,還提供了兩個建構函式,用於設定HashMap
的容量(capacity)與平衡因子(loadFactor)。
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(); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); }
從程式碼上可以看到,容量與平衡因子都有個預設值,並且容量有個最大值
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;
可以看到,預設的平衡因子為0.75,這是權衡了時間複雜度與空間複雜度之後的最好取值(JDK說是最好的),過高的因子會降低儲存空間但是查詢(lookup,包括HashMap中的put與get方法)的時間就會增加。
這裡比較奇怪的是問題:容量必須為2的指數倍(預設為16),這是為什麼呢?解答這個問題,需要了解HashMap中雜湊函式的設計原理。
雜湊函式的設計原理
/** * 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 = 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); } /** * Returns index for hash code h. */ 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); }
看到這麼多位操作,是不是覺得暈頭轉向了呢,還是搞清楚原理就行了,畢竟位操作速度是很快的,不能因為不好理解就不用了。
網上說這個問題的也比較多,我這裡根據自己的理解,儘量做到通俗易懂。
在雜湊表容量(也就是buckets或slots大小)為length的情況下,為了使每個key都能在衝突最小的情況下對映到[0,length)
(注意是左閉右開區間)的索引(index)內,一般有兩種做法:
- 讓length為素數,然後用
hashCode(key) mod length
的方法得到索引 - 讓length為2的指數倍,然後用
hashCode(key) & (length-1)
的方法得到索引
HashTable用的是方法1,HashMap
用的是方法2。
因為本篇主題講的是HashMap,所以關於方法1為什麼要用素數,我這裡不想過多介紹,大家可以看這裡。
重點說說方法2的情況,方法2其實也比較好理解:
因為length為2的指數倍,所以
length-1
所對應的二進位制位都為1,然後在與hashCode(key)
做與運算,即可得到[0,length)
內的索引
但是這裡有個問題,如果hashCode(key)
的大於length
的值,而且hashCode(key)
的二進位制位的低位變化不大,那麼衝突就會很多,舉個例子:
Java中物件的雜湊值都32位整數,而HashMap預設大小為16,那麼有兩個物件那麼的雜湊值分別為:
0xABAB0000
與0xBABA0000
,它們的後幾位都是一樣,那麼與16異或後得到結果應該也是一樣的,也就是產生了衝突。
造成衝突的原因關鍵在於16限制了只能用低位來計算,高位直接捨棄了,所以我們需要額外的雜湊函式而不只是簡單的物件的hashCode
方法了。
具體來說,就是HashMap中hash
函式乾的事了
首先有個隨機的hashSeed,來降低衝突發生的機率
然後如果是字串,用了
sun.misc.Hashing.stringHash32((String) k);
來獲取索引值最後,通過一系列無符號右移操作,來把高位與低位進行異或操作,來降低衝突發生的機率
右移的偏移量20,12,7,4是怎麼來的呢?因為Java中物件的雜湊值都是32位的,所以這幾個數應該就是把高位與低位做異或運算,至於這幾個數是如何選取的,就不清楚了,網上搜了半天也沒統一且讓人信服的說法,大家可以參考下面幾個連結:
- http://stackoverflow.com/questions/7922019/openjdks-rehashing-mechanism/7922219#7922219
- http://stackoverflow.com/questions/9335169/understanding-strange-java-hash-function/9336103#9336103
- http://stackoverflow.com/questions/14453163/can-anybody-explain-how-java-design-hashmaps-hash-function/14479945#14479945
HashMap.Entry
HashMap中存放的是HashMap.Entry物件,它繼承自Map.Entry,其比較重要的是建構函式
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } // setter, getter, equals, toString 方法省略 public final int hashCode() { //用key的hash值與上value的hash值作為Entry的hash值 return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } /** * This method is invoked whenever the value in an entry is * overwritten by an invocation of put(k,v) for a key k that's already * in the HashMap. */ void recordAccess(HashMap<K,V> m) { } /** * This method is invoked whenever the entry is * removed from the table. */ void recordRemoval(HashMap<K,V> m) { } }
可以看到,Entry實現了單向連結串列的功能,用next
成員變數來級連起來。
介紹完Entry物件,下面要說一個比較重要的成員變數
/** * The table, resized as necessary. Length MUST Always be a power of two. */ //HashMap內部維護了一個為陣列型別的Entry變數table,用來儲存新增進來的Entry物件 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
你也許會疑問,Entry不是單向連結串列嘛,怎麼這裡又需要個陣列型別的table呢?
我翻了下之前的演算法書,其實這是解決衝突的一個方式:鏈地址法(開雜湊法),效果如下:
鏈地址法的視覺化
網上找到個很好的網站,用來視覺化各種常見的演算法,很棒。瞬間覺得國外大學比國內的強不知多少倍。
下面的連結可以模仿雜湊表採用鏈地址法解決衝突,大家可以自己去玩玩
get操作
get操作相比put操作簡單,所以先介紹get操作
public V get(Object key) { //單獨處理key為null的情況 if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } private V getForNullKey() { if (size == 0) { return null; } //key為null的Entry用於放在table[0]中,但是在table[0]衝突鏈中的Entry的key不一定為null //所以需要遍歷衝突鏈,查詢key是否存在 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); //首先定位到索引在table中的位置 //然後遍歷衝突鏈,查詢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; }
put操作(含update操作)
因為put操作有可能需要對HashMap進行resize,所以實現略複雜些
private void inflateTable(int toSize) { //輔助函式,用於填充HashMap到指定的capacity // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize); //threshold為resize的閾值,超過後HashMap會進行resize,內容的entry會進行rehash threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); table = new Entry[capacity]; initHashSeedAsNeeded(capacity); } /** * 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. */ 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); //這裡的迴圈是關鍵 //當新增的key所對應的索引i,對應table[i]中已經有值時,進入迴圈體 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //判斷是否存在本次插入的key,如果存在用本次的value替換之前oldValue,相當於update操作 //並返回之前的oldValue if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //如果本次新增key之前不存在於HashMap中,modCount加1,說明結構改變了 modCount++; addEntry(hash, key, value, i); return null; } void addEntry(int hash, K key, V value, int bucketIndex) { //如果增加一個元素會後,HashMap的大小超過閾值,需要resize if ((size >= threshold) && (null != table[bucketIndex])) { //增加的幅度是之前的1倍 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) { //首先得到該索引處的衝突鏈Entries,有可能為null,不為null Entry<K,V> e = table[bucketIndex]; //然後把新的Entry新增到衝突鏈的開頭,也就是說,後插入的反而在前面(第一次還真沒看明白) //需要注意的是table[bucketIndex]本身並不儲存節點資訊, //它就相當於是單向連結串列的頭指標,資料都存放在衝突鏈中。 table[bucketIndex] = new Entry<>(hash, key, value, e); size++; } //下面看看HashMap是如何進行resize,廬山真面目就要揭曉了 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]; //initHashSeedAsNeeded(newCapacity)的返回值決定了是否需要重新計算Entry的hash值 transfer(newTable, initHashSeedAsNeeded(newCapacity)); 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; //遍歷當前的table,將裡面的元素新增到新的newTable中 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]; //最後這兩句用了與put放過相同的技巧 //將後插入的反而在前面 newTable[i] = e; e = next; } } } /** * Initialize the hashing mask value. We defer initialization until we * really need it. */ final boolean initHashSeedAsNeeded(int capacity) { boolean currentAltHashing = hashSeed != 0; boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); //這裡說明了,在hashSeed不為0或滿足useAltHash時,會重算Entry的hash值 //至於useAltHashing的作用可以參考下面的連結 // http://stackoverflow.com/questions/29918624/what-is-the-use-of-holder-class-in-hashmap boolean switching = currentAltHashing ^ useAltHashing; if (switching) { hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0; } return switching; }
remove操作
public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); //可以看到刪除的key如果存在,就返回其所對應的value 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物件,相當於兩個指標,為的是防治衝突鏈發生斷裂的情況 //這裡的思路就是一般的單向連結串列的刪除思路 Entry<K,V> prev = table[i]; Entry<K,V> e = prev; //當table[i]中存在衝突鏈時,開始遍歷裡面的元素 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) //當衝突鏈只有一個Entry時 table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
到現在為止,HashMap的增刪改查都介紹完了。
一般而言,認為HashMap的這四種操作時間複雜度為O(1),因為它hash函式性質較好,保證了衝突發生的機率較小。
HashMap的序列化
介紹到這裡,基本上算是把HashMap中一些核心的點講完了,但還有個比較嚴重的問題:儲存Entry的table陣列為transient的,也就是說在進行序列化時,並不會包含該成員,這是為什麼呢?
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
為了解答這個問題,我們需要明確下面事實:
- Object.hashCode方法對於一個類的兩個例項返回的是不同的雜湊值
我們可以試想下面的場景:
我們在機器A上算出物件A的雜湊值與索引,然後把它插入到HashMap中,然後把該HashMap序列化後,在機器B上重新算物件的雜湊值與索引,這與機器A上算出的是不一樣的,所以我們在機器B上get物件A時,會得到錯誤的結果。
所以說,當序列化一個HashMap物件時,儲存Entry的table是不需要序列化進來的,因為它在另一臺機器上是錯誤的。
因為這個原因,HashMap重現了writeObject
與readObject
方法
private void writeObject(java.io.ObjectOutputStream s) throws IOException { // Write out the threshold, loadfactor, and any hidden stuff s.defaultWriteObject(); // Write out number of buckets if (table==EMPTY_TABLE) { s.writeInt(roundUpToPowerOf2(threshold)); } else { s.writeInt(table.length); } // Write out size (number of Mappings) s.writeInt(size); // Write out keys and values (alternating) if (size > 0) { for(Map.Entry<K,V> e : entrySet0()) { s.writeObject(e.getKey()); s.writeObject(e.getValue()); } } } private static final long serialVersionUID = 362498820763181265L; private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) { throw new InvalidObjectException("Illegal load factor: " + loadFactor); } // set other fields that need values table = (Entry<K,V>[]) EMPTY_TABLE; // Read in number of buckets s.readInt(); // ignored. // Read number of mappings int mappings = s.readInt(); if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); // capacity chosen by number of mappings and desired load (if >= 0.25) int capacity = (int) Math.min( mappings * Math.min(1 / loadFactor, 4.0f), // we have limits... HashMap.MAXIMUM_CAPACITY); // allocate the bucket array; if (mappings > 0) { inflateTable(capacity); } else { threshold = capacity; } init(); // Give subclass a chance to do its thing. // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { K key = (K) s.readObject(); V value = (V) s.readObject(); putForCreate(key, value); } } private void putForCreate(K key, V value) { int hash = null == key ? 0 : hash(key); int i = indexFor(hash, table.length); /** * Look for preexisting entry for key. This will never happen for * clone or deserialize. It will only happen for construction if the * input Map is a sorted map whose ordering is inconsistent w/ equals. */ for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { e.value = value; return; } } createEntry(hash, key, value, i); }
簡單來說,在序列化時,針對Entry的key與value分別單獨序列化,當反序列化時,再單獨處理即可。
總結
在總結完HashMap後,發現這裡面一些核心的東西,像雜湊表的衝突解決,都是演算法課上學到,不過由於“年代久遠”,已經忘得差不多了,我覺得忘
- 一方面是由於時間久不用
- 另一方面是由於本身沒理解好
平時多去思考,這樣在遇到一些效能問題時也好排查。
還有一點就是我們在分析某些具體類或方法時,不要花太多時間一些細枝末節的邊界條件上,這樣很得不償失,倒不是說這麼邊界條件不重要,程式的bug往往就是邊界條件沒考慮周全導致的。
只是說我們可以在理解了這個類或方法的總體思路後,再來分析這些邊界條件。
如果一開始就分析,那真是丈二和尚——摸不著頭腦了,隨著對它工作原理的加深,才有可能理解這些邊界條件的場景。
相關文章
- Java集合之HashMap原始碼解析JavaHashMap原始碼
- Java集合(6)之 HashMap 原始碼解析JavaHashMap原始碼
- Java集合框架原始碼剖析:HashSet 和 HashMapJava框架原始碼HashMap
- Java 集合框架 HashSet 和 HashMap 原始碼剖析Java框架HashMap原始碼
- 死磕 java集合之HashMap原始碼分析JavaHashMap原始碼
- Java集合:HashMap原始碼剖析JavaHashMap原始碼
- Java——HashMap原始碼解析JavaHashMap原始碼
- Java集合之Hashtable原始碼解析Java原始碼
- Java集合之ArrayList原始碼解析Java原始碼
- 【Java集合原始碼剖析】HashMap原始碼剖析Java原始碼HashMap
- java集合原始碼分析(六):HashMapJava原始碼HashMap
- 【Java集合原始碼剖析】Java集合框架Java原始碼框架
- Java集合之LinkedList原始碼解析Java原始碼
- Java集合之HashMapJavaHashMap
- Java集合原始碼學習(5)HashMapJava原始碼HashMap
- java基礎型別原始碼解析之HashMapJava型別原始碼HashMap
- java集合框架--HashMap--putJava框架HashMap
- HashMap原始碼解析(java1.8.0)HashMap原始碼Java
- Java集合系列之---HashMapJavaHashMap
- Java集合系列之HashMapJavaHashMap
- Java類集框架 —— HashMap原始碼分析Java框架HashMap原始碼
- 集合框架原始碼學習之HashMap(JDK1.8)框架原始碼HashMapJDK
- 【集合框架】JDK1.8原始碼分析之HashMap(一)框架JDK原始碼HashMap
- java併發之hashmap原始碼JavaHashMap原始碼
- Java 集合框架------ArrayList原始碼分析Java框架原始碼
- Java集合框架原始碼剖析:ArrayDequeJava框架原始碼
- Java 集合框架 ArrayList 原始碼剖析Java框架原始碼
- Java 集合Hashtable原始碼深入解析Java原始碼
- Java集合Stack原始碼深入解析Java原始碼
- JAVA集合:LinkedList原始碼解析Java原始碼
- Java集合類:AbstractCollection原始碼解析Java原始碼
- Java HashMap 原始碼逐行解析(JDK1.8)JavaHashMap原始碼JDK
- JAVA集合:HashMap深度解析(版本對比)JavaHashMap
- Java集合:HashMapJavaHashMap
- Java 集合系列:Vector原始碼深入解析Java原始碼
- Java集合詳解(三):HashMap原理解析JavaHashMap
- Java:HashMap原始碼分析JavaHashMap原始碼
- 搞懂 Java HashMap 原始碼JavaHashMap原始碼