散列表
在了解hashmap之前,要先知道什么是散列表,因為hashmap就是在散列表結構基礎上改造而成的。散列表,也叫哈希表,是根據關鍵碼值(key value)而直接進行訪問的數據結構。也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散列表 。
散列表為什么存在?數組不行么?
散列表和數組一樣,是八大數據結構中的一種。數組特點是線性結構、順序存儲,也就是數組中的所有元素排序是連續的,在遍歷查找時效率非常高,但同時也因為這個特點導致了增刪操作效率低的缺點,因為是內存連續的,所以在刪除中間某個元素時,某一方的數據就需要全部移動確保元素是內存連續的(但是並不能說對數組執行增刪操作效率就一定低,當增刪的是兩邊的數據時就不需要移動其他數據了)。而另一個數據結構則和數組相反,它就是鏈表,鏈表的元素並不是連續排列,相鄰兩個元素是使用prev、next(雙鏈表結構,單鏈表只有next屬性)來表示上一個元素和下一個元素的位置,這種結構的好處就是增刪效率高,而修改、查找慢,原因是在增刪時只需要改變相鄰元素的屬性就可以了。那有沒有一種結構能結合這兩種結構的優點呢,這就是散列表。
散列表的特點
上面已經說過了,散列表是結合了數組和鏈表優點的結構,它查找和增刪效率都不算低,那么它是怎樣實現的呢?散列表其實就是將存儲的數據通過固定的算法(也就是哈希算法)進行計算得到某一個范圍的值,這個范圍的值就對應散列表的數組范圍(見上圖散列表結構,0-15就是數組部分),然后再將這個數據根據剛才計算得出的值找到對應的數組下標進行保存。
哈希沖突是什么?如何解決?缺點是什么?
我們通過哈希算法來計算找到我們要存儲的數組下標,但是數組的容量是有限的,數據越多越容易產生多個數據計算得出同一個結果的情況,這就產生了哈希沖突。而一個數組下標位置只能保存一個值,所以我們就需要去解決哈希沖突,解決哈希沖突主要有兩種方式。一種就是鏈地址法,這也是常用的方法,鏈地址法就是在數組后面以鏈表的形式添加數據,這也是HashMap處理哈希沖突的方式。第二種是開放定址法,核心思想就是讓發生沖突的數據分配到其他空閑的下標位置進行保存,其實現方式有線性探測法、二次探測法、偽隨機探測法等。哈希沖突帶來的問題就是它會使當前數組的利用率不高,因為鏈表查詢效率不高,所以當數據都集中在那幾個下標時查詢的效率就會很低。
HashMap
前面已經說過,hashmap 就是散列表的結構上得到的,可以說散列表是一個概念結構,而 hashmap 則是這個概念的實現。hashmap 在 JDK1.8 進行一次升級,引入了紅黑樹結構,同時將頭插法改成了尾插法,還有其他一些改動。接下來就從內部源碼入手來看1.7和1.8中 hashmap 的執行過程。
結構
1.7 內部使用 Entry 數組來保存要存儲的鍵值對,1.8 使用 Node 數組來保存要存儲的鍵值對,這個 Entry 類型和 Node 類型都是 hashmap 內部維護的一個內部類,這個數組存儲的是各個下標的第一個數據,如果沒有數據就是 null,其他數據都是通過 next 屬性進行串接的。
1.7 static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; ... 1.8 static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; ...
可以看出,在 1.7 中的 Entry 內部類 hash 就是普通int類型的屬性,而在1.8中改成了 final 類型的,因為每個對象的 hash 值都是唯一的,1.7中的hash屬性沒有使用final修飾可能會產生安全問題,所以在1,8中改成了 final 修飾的。
此外,hashmap 內部還有其他一些參數,主要看下 1.8 中的
/** * The default initial capacity - MUST be a power of two. 默認容量,指得是在創建 hashmap 時沒有指定容量默認的數組容量 */ 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. 最大容量,指得是hashmap能存儲元素的最大個數,2的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; /** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. 最小樹化值,指得是當該鏈表的長度達到8時就可能進行樹化 */ static final int TREEIFY_THRESHOLD = 8; // /** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. 最小鏈化值,指得是某個數組下標后面已經樹化后又發生元素減少而使得元素個數過少再次退化成鏈表,這里規定就是元素達到6就退化成鏈表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * The smallest table capacity for which bins may be treeified. * (Otherwise the table is resized if too many nodes in a bin.) * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts * between resizing and treeification thresholds. 最小樹化容量,在某條鏈表元素達到8后會判斷當前數組的 length 是否達到規定值,達到才會進行樹化,這里是64(這里比較的是數組的 length 而不是存儲的數據量) */ static final int MIN_TREEIFY_CAPACITY = 64; /** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.)
存儲頭元素(紅黑樹就是根節點)的數組 */ transient Node<K,V>[] table; /** * Holds cached entrySet(). Note that AbstractMap fields are used * for keySet() and values().
所有鍵值對數據的 Set 結構 */ transient Set<Map.Entry<K,V>> entrySet; /** * The number of key-value mappings contained in this map.
存儲數據的總量 */ transient int size; /** * The number of times this HashMap has been structurally modified * Structural modifications are those that change the number of mappings in * the HashMap or otherwise modify its internal structure (e.g., * rehash). This field is used to make iterators on Collection-views of * the HashMap fail-fast. (See ConcurrentModificationException).
相當於一個版本號,每次對數據修改,添加,刪除都會加1,在每次迭代遍歷內部元素時都會去檢查是否與 expectedModeCount 相等,因為HashMap內部的迭代都是使用內部的迭代器進行迭代的,且維護了母迭代器 HashIterator,
其他的內部迭代器都是繼承了這個類,而這個母迭代器內部就含有 expectedModeCount屬性,這個屬性會在迭代器初始化時被賦予 modCount 數值,所以如果在迭代過程發現 modCount 與 expectedModeCount 不同,那么說明
內部維護的數據被修改過(添加、刪除),那么這次迭代就是不安全的(並不是實時的數據),那么就會拋出異常。 */ transient int modCount; /** * The next size value at which to resize (capacity * load factor). * 數組閥值,存儲數據總數超過這個值就會進行擴容(注意不是數組不為空的位置數而是存儲數據數超過閥值就會擴容),默認是當前數組 length*0.75 * @serial */ // (The javadoc description is true upon serialization. // Additionally, if the table array has not been allocated, this // field holds the initial array capacity, or zero signifying // DEFAULT_INITIAL_CAPACITY.) int threshold; /** * The load factor for the hash table. * 實際的負載因子,默認是 0.75 * @serial */ final float loadFactor;
1.7中相關參數大致相同,感興趣可以自行去研究。
初始化
hashmap在1.7和1.8中默認容量都是16,如果指定了容量,那么容量就是指定的容量。需要注意的是,在1.7、1.8中如果在創建容器時沒有指定容量那么內部的數組都不會初始化,只會在第一次put操作時才會初始化。下面是1.8中的相關代碼。
// 構造函數 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // put 操作 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; ... }
擾動函數
如果要設計hashmap元素存儲,可能會設計成下面的方法
1、先獲取 key 對象的 hashcode 值 2、將 hashcode 值與(數組容量-1)進行並操作,得到 hash 值 3、根據 hash 值找到對應的數組下標進行存儲。
這種方法符合散列表元素存儲的定義,可以實現數據的存儲,但是卻有致命的缺陷,因為我們要存儲的 key 可以是各種對象,所以 key 的 hashcode 值可以是非常大的數據,最大可以達到 2147483647,又因為我們在計算 hash 值時使用的是並操作,所有數據會轉成二進制進行計算,因為數組容量一般都不會太大,所以面對着 hashcode 數據很大的值時,高位的數往往都不會參與運算,參與運算的只有那幾位,這就導致發生哈希沖突的概率增加,帶來了各種缺點,所以我們應該極力避免哈希沖突的發生。
hashmap 中使用擾動函數解決了這個問題,過程如下:
前面還是獲取 hashcode 值(也就是哈希碼),然后調用 hash 方法得到處理后的 hashcode 值,那么我們就需要去看一下這個方法1.8中的源碼:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
可以看到,它會將 hashcode 值的二進制向右移動 16 位再與原本的 hashcode 值進行異或操作,得到的值才作為哈希碼返回進行並操作得到哈希值,這樣計算會讓 hashcode 高位的數也參與運算,減少了哈希沖突發生的概率。 1.7中的實現也差不多,思想也是讓高位的數也參與運算,代碼如下
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); }
比較 1.8 與 1.7 擾動函數,可以看出 1.8 擾動函數更加簡便,運算效率也更高。
put過程
因為 1.8 引入了紅黑樹,所以着重以 1.8 源碼為例進行講解
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { /* * tab:臨時的 Node 數組, * p:要添加的數據將要存放數組下標位置的第一個數據 * n:原 Node 數據總數 * i:要存儲數據位置的數組下標 */ Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 如果內部的 table 數組為空,就執行初始化再賦值 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 如果該位置為空直接賦值 else { /* e:要存儲的數據最終的 node 結點 * * 判斷該位置的 hashcode 值, * 1、如果與要添加的數據 key 的 hashcode 值相等(意思是該數組下標位置只有一個值並且相等),就賦值給 e * 2、如果該位置是樹節點就獲取樹節點返回並賦值給 e * 3、上面兩種都不滿足,就進行遍歷,每次下一個 Node 都賦值給 e 。 * 1、如果當前位置 key 相等,就返回 * 2、如果到頭了,就直接直接后面補一個結點就行了。然后進行樹化判斷 * 該位置最終的數據 */ Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 如果第一個值就相等,直接賦值p 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) // 如果鏈表長度達到 8 treeifyBin(tab, hash); // 執行這個方法,這個方法下面再講解 break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果遍歷的當前數據 key 與要添加的數據 key 相等,就直接退出循環(e此時也是當前的位置) p = e; } } if (e != null) { // 非遍歷完還未找到 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; }
通過上面的源碼可以知道 put 方法的具體過程:
1、調用擾動函數 hash 去處理 key,
2、檢查內部的數組是否為空,如果為空初始化
3、根據擾動后的 hashcode 計算得到 hash 值尋找對應的數組下標,判斷該位置是否為空,如果為空就直接將要添加的值設置到數組該下標位置上
4、如果3情況都不滿足,則再進行下面判斷
1、如果數組該位置的key相等(先比較 hashcode 值是否相等,如果相等再調用 equals 方法比較,如果 equals 返回為 true 才說明兩個值相等。下面的 key 判斷都一樣),返回該 Node 值
2、如果該節點是樹節點,調用方法查找 key 值相等的節點返回
3、上面兩種情況都不滿足,說明是鏈表結構,就遍歷鏈表,檢查各個 key 值與要添加的 key 是否相等,相等就返回,不存在相等的就在最后面進行添加,然后判斷是否需要樹化(鏈表長度 >= 8,進行判斷。如果數組 length<64,擴容,否則樹化成紅黑樹)。
4、如果返回值不為空,也就是上面的1,2,3三種情況中不是鏈表且沒有值相等的那種情況,換句話說就是存在 key 相等的節點,那么就進行節點值的替換。
5、判讀是否需要擴容(大於閥值 threshold 就進行擴容,擴容為原來的2倍)。
這里關於是否需要樹化的方法 treeifyBin 還沒有分析。接下來就分析一下 這個方法的源碼,同樣還是以1.8為例。
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 如果 數組容量小於規定的最小樹化容量,也就是64,就執行擴容 resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { // 否則執行樹化操作 TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
從源碼中可以很清楚地看出:當數組容量小於64是不會進行擴容的,只有達到64才會進行樹化操作。這樣也是防止數據全部集中在某幾個下標使哈希表退化成鏈表。
擴容操作
hashmap另一個難點就是擴容,我們知道的是hashmap的擴容會將數組容量擴容為原來的兩倍,但是具體是怎樣實現的呢,還是以1.8的源碼為例
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; // 擴容前的數組長度 int oldThr = threshold; // 擴容前的數據閥值 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { // 如果數組長度達到能存儲的最大值,就將閥值改成 Integer 的最大值 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 數組擴容為原來的2倍,然后判斷擴容前的數組長度是否達到了默認的數組容量,達到再將閥值也擴容為原來的2倍 oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // 如果之前的數組容量 =0,之前的閥值 >0,就將初始容量置於閾值 newCap = oldThr; else { // 如果之前的數組容量 =0,之前的閥值 =0,就將初始容量置於閾值,閥值也設為初始閥值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 如果閥值等於0,再設置閥值 float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } /* * 下面就進行數據的重排列,創建一個新的數組,將原數組關聯的所有數據全部加入新數組中 */ threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
可以看到在執行擴容操作時,是先確定擴容后的數組長度以及閥值,然后新建一個滿足該條件的數組,再將原數組中所關聯的所有數據全部添加關聯到新數組中,將之前的數據轉移到新的數組這個過程是非常消耗時間的,所以我們在最初創建容器對象時就要先確定要存放的數據量,盡量避免擴容。並且如果沒有在創建實例時指定擴容因子,就使用默認的擴容因子 0.75。
其他一些問題
1、hashmap數組容量為什么是16?或者說容量為什么是2的冪次方?
其實這是為了防止添加數據時頻繁的發生哈希沖突。前面已經說過哈希沖突的危害,頻繁的哈希沖突會使哈希表退化成鏈表,造成查詢效率低。那為什么設計成2的冪次方就可以減少哈希沖突呢?通過上面的源碼分析,我們都知道在添加操作時計算數組下標需要調用內部的擾動函數然后進行並運算才能得到哈希值,然后將這個哈希值作為數組下標找到對應的位置。
那么這中間關鍵的運算就是並運算,並運算的特點是“全真且為真”(這是我們那邊高中邏輯判斷題目記得順口溜,不知道你們是什么O(∩_∩)O~),也就是進行並運算的兩個的二進制該位數都是1,最后的結果才是1,否則結果就是0,那么問題就來了,我想要最終的結果既可能是0,也可能是1,這樣才能使得最終的結果不同,起到減少哈希沖突的作用,這是前提,那應該怎么做呢?在進行並運算時,參與運算的兩個數有一個數是確定的,那就是(數組的容量-1)這個數,另外一個數是 key 的 hashcode 經過擾動函數處理后的數,那么就要求(數組容量-1)這個數的二進制數每位都是1,這樣當另一個數某位是1,結果是1,;某位是0,結果是0,這樣就減少了哈希沖突了。每一位都是1,那么四個1轉成十進制就是15,那么容量就是16,其他容量同理。
2、hashmap在1.7中是頭插法,為什么到了1.8就變成尾插法?
頭插法存在着嚴重的弊端,那就是在多線程下擴容操作時可能會形成環形鏈表。所以在1.8變成了尾插法。當然,hashmap本身就是線程不安全的容器,不安全指的是數據不安全,可能會造成數據丟失和讀取不正確,不能同步。所以這里是改變只是適當地減小了hashmap1.7中的缺點,在多線程下還是不能使用hashmap作為容器存儲數據。
3、hashmap1.7與1.8有什么區別?
1、1.7是頭插法,1.8是尾插法。更安全
2、1.8引入了紅黑樹
3、1.7中的數據結點是 Entry 類型,1.8是 Node 類型。
4、擴容檢查不一樣,1.7是在put操作開始時檢查;1.8是在添加數據后檢查是否需要擴容。1.8擴容已經分析了,下面看一下 1.7 中的相關代碼
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; } } // 上面就是一些判斷,如果有相等的 key 就直接替換,然后直接返回,否則執行下面的 modCount++; addEntry(hash, key, value, i); // 添加數據方法 return null; }
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { // 如果數據總量 size 達到閥值 threshold,就執行擴容 resize resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); // 真正的添加數據 }
4、使用自定義對象作為 key 時,為什么要重寫該對象類的 hashcode() 方法和 equals() 方法?
通過前面的源碼可以看出,在 put 方法時在檢查是否有 key 值相等的節點存在時,先比較的是他們的 hashcode 值(准確的來說是比較結果擾動函數處理后的 hashcode 值,可以看作就是比較 hashcode 值),然后再 equals 方法去比較。
首先要明白為什么要先判斷 hashcode ,再調用 equals 方法去比較,為什么不直接調用 equlas去比較。這是因為 hashcode 值本身就是一個“散列值”,它就是由對象的地址經過散列函數處理轉成一個數值而形成的,我們知道散列值是多個對象可能擁有同一個散列值,那么在進行判斷時就可以擁有更高的效率。換句話說就是 hashcode 方法效率比 equlas 方法高,但是另一方面因為兩個對象他們的 hashcode 值可能相等,所以還需要 equlas 方法去二次判斷。而 hashcode 不相等的就直接被 pass 。
然后就是為什么要重寫這兩個方法,首先要知道,我們使用 String ,Date,Integer這些類直接不用重寫,這是為什么。因為這些是內部已經重寫了這兩個方法,而我們自定義的類,它沒有重寫,所以它默認調用的就是基類 Object 的方法,而 Object 的這兩個方法都是和地址值有關的, hashcode 是地址值轉成的,equlas 是比較地址值。我們想要的比較是比較屬性值,所以沒有重寫就會導致兩個對象他們雖然屬性值相等,但是在比較時卻永遠不會相等。所以我們在使用自定義對象作為 key 時,需要去重寫它的 hashcode 方法和 equals 方法。
5、引入紅黑樹的好處?
紅黑樹具有查詢效率高的特點,當鏈表過長時,因為鏈表查詢效率低,所以在數據量大的情況下,鏈表就會變得很長,那么查詢效率就會很低,這時將鏈表轉成紅黑樹就會極大的提高查詢效率。
6、HashMap 知識點小結。
1、底層使用數組加鏈表結構,在1.8開始又引入了紅黑樹,這是為了防止在鏈表長度過長時造成鏈表數據的查詢效率降低。兼顧了查詢與增刪的效率。
2、沒有指定容量時數組初始容量是0,第一次 put 后會初始化為16。
3、key 和 value 都可以為 null 值。
4、擴容時機是容器存儲的數據量達到 0.75*數組容量 時就會發生擴容,變成原來的2倍,而樹化是在鏈表長度達到 8 且數組容量達到 64 才會發生樹化操作,否則如果只是鏈表長度達到 8 而數組容量沒有達到 64 只會擴容為原來的2倍。
5、HashMap 在計算哈希值前會先調用內部的擾動函數處理 hashcode 值,讓高位和低位進行運算,這是為了讓高位的數也能參與哈希值的計算,減小哈希沖突。
6、key 值最好是一個常量,如果是自定義對象,那么需要重寫 hashcode 與 equals 方法。
ConcurrentHashMap
HashMap 是線程不安全的,也就是在多線程下使用 HashMap 來保存數據數據是不安全的,可能會發生數據遺失,錯誤等問題。那么為了能在多線程情況下也能使用 HashMap,創建多個線程安全的容器,如 HashTable,ConcurrentHashMap ,但是廣泛使用的還是ConcurrentHashMap ,那么 HashTable 為什么會被淘汰?下面會對這個問題進行解答,首先我們先着重來看 ConcurrentHashMap 的結構優勢。它的結構和 HashMap 非常像,但是同時它卻是一個線程安全的容器。那么它是怎樣實現的呢?下面還是從源碼上來看看它的結構,put 過程。
結構
/* 1.7*/ static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; ... } /* 1.8 */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; ... }
可以看出,1.7 和 1.8 內部維護的節點類是差不多的。和 HashMap 一樣,1.8 的 ConcurrentHashMap 相比於 1.7 引入了紅黑樹,樹化條件還是鏈表長度達到 8 , 且數組 length >= MIN_TREEIFY_CAPACITY,也就是 64。
重要屬性
// 以下是標記幾個特殊的節點的hash值,都是負數 // ForwardingNode節點,表示該節點正在處於擴容工作,內部有個指針指向nextTable static final int MOVED = -1; // 紅黑樹的首節點,內部不存key、value,只是用來表示紅黑樹 static final int TREEBIN = -2; // ReservationNode保留節點, // 當hash桶為空時,充當首結點占位符,用來加鎖,在compute/computeIfAbsent使用 static final int RESERVED = -3; /** * The array of bins. Lazily initialized upon first insertion. * Size is always a power of two. Accessed directly by iterators. 存儲數據首位數據的數組 */ transient volatile Node<K,V>[] table; /** * The next table to use; non-null only while resizing. table 遷移時的臨時容器 */ private transient volatile Node<K,V>[] nextTable; /** * Table initialization and resizing control. When negative, the * table is being initialized or resized: -1 for initialization, * else -(1 + the number of active resizing threads). Otherwise, * when table is null, holds the initial table size to use upon * creation, or 0 for default. After initialization, holds the * next element count value upon which to resize the table. 這個參數對應 hashmap 中的 threshold,但是它的作用並不僅僅表示擴容的閥值。 當它為0時,就表示還沒有初始化, 當它為-1時,表示正在初始化 當它小於-1時,表示(1 +活動的調整大小線程數) 當它大於0時,表示發生擴容的閥值 */ private transient volatile int sizeCtl;
可以看到屬性基本都使用 volatile 去修飾,這樣每次去獲取這些屬性都是從主內存中獲取,而不是從各自線程的工作內存中獲取。保證了數據的可見性。
初始化
以 1.8 源碼為例
/** * Creates a new, empty map with the default initial table size (16). */ public ConcurrentHashMap() { } public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }
可以看出如果沒有指定初始容量,則不會進行任何操作,指定了容量,則會初始化 sizeCtl ,但是還是沒有初始化數組。
put 操作
先看一下1.8中的源碼:
/** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { // ConcurrentHashMap 保存的鍵值對 key 與 value 都不能為空,所以 key 或者 value 為空直接拋出異常 if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); // 調用擾動函數處理 hashcode 值 int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 如果數組為空進行初始化操作 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 找到對應的數組數據,判斷是否為空,如果為空就直接創建一個節點添加到數組該位置 if (casTabAt(tab, i, null, // 這里調用 casTabAt 方法使用樂觀鎖去添加,也是為了保證線程安全,因為外面嵌套了一個for循環,所以這里會一直嘗試去獲取鎖直到獲取到獲取結構改變 new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } /* * 如果該節點狀態是 MOVED 狀態,說明數組正在進行復制,也就是擴容操作中的數據復制階段, * 那么當前線程也會參與復制操作,以此來減小數據復制需要的時間 */ else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { // 這里使用 synchronized 鎖住數組第一個數 if (tabAt(tab, i) == f) { // 重復檢查,防止多線程下的數據錯誤 if (fh >= 0) { // 取出來的元素的hash值大於0,當轉換為樹之后,hash值為-2 binCount = 1; for (Node<K,V> e = f;; ++binCount) { // 遍歷鏈表 K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // 如果節點key相等就替換 oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { // 遍歷到頭了沒有 key 相等的節點,就在創建節點在最后面關聯 pred.next = new Node<K,V>(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { // 為樹節點,就以樹節點形式進行添加 Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) // 鏈表長度達到8,進行擴容或樹化操作 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 新增一個節點數量 return null; }
可以看出,過程如下:
1、判斷 key,value 是否為空,如果為空,拋出異常
2、調用擾動函數處理,下面開始多個條件判斷
3、判斷數組是否為空,為空初始化數組
4、計算找到對應的數組下標,進行判斷
1、如果該位置為空,直接使用CAS樂觀鎖進行添加,如果失敗則重新判斷。
2、如果該位置的 hash 值為 MOVED,說明正在進行數據復制,那么當前線程也參與數據復制
4、上面兩個條件都不滿足,則使用 synchronized 鎖住該下標的數,然后判斷
1、如果是鏈表節點,遍歷,如果存在 key 相等的就替換;不存在就在后面添加關聯節點;
2、如果是樹節點,就按樹節點方式添加。
5、檢查鏈表長度是否達到8,如果達到8,執行 treeifyBin 方法,擴容或者樹化。
6、增加節點數量
這里需要注意的是,相比於HashMap,ConcurrentHashMap這里在最后一步不會去判斷是否需要擴容了。這里的 treeifyBin 方法和 hashmap 基本一致,這里就不過多分析了。
那1.7 中有什么不同,在解答這個問題之前首先要說明在1.7中有一個 Segment 內部類,這個類內部的 HashEntry[] 類型的屬性存儲的是 table 某個下標關聯的所有數據,因為 1.7 中使用的是 HashEntry 而不是 Node,所以 1.7 中的數組是 HashEntry 數組,同樣,因為 Segment類存儲的也是 HashEntry 數組,所以 Segment 類的屬性和外部 ConcurrentHashMap 的屬性很像。
static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; transient volatile HashEntry<K,V>[] table; transient int count; transient int modCount; transient int threshold; final float loadFactor; Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf; this.threshold = threshold; this.table = tab; } ... }
從源碼可以看到,這個類是繼承了 ReentrantLock 的,所以在對這個類對象操作時,可以調用 lock 方法進行加鎖操作。
接下來就看一下1.7中的 put 過程:
@SuppressWarnings("unchecked") public V put(K key, V value) { Segment<K,V> s; if (value == null) // 如果 value 為空直接拋出異常 throw new NullPointerException(); int hash = hash(key); // 調用擾動函數 int j = (hash >>> segmentShift) & segmentMask; //計算 hash 值 if ((s = (Segment<K,V>)UNSAFE.getObject // 定位到對應的 Segment 對象,如果為空,初始化 (segments, (j << SSHIFT) + SBASE)) == null) // s = ensureSegment(j); return s.put(key, hash, value, false); // 正式執行添加方法 } final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : // 嘗試獲取鎖,如果成功,繼續執行后面代碼, scanAndLockForPut(key, hash, value); // 如果失敗,通過執行 scanAndLockForPut 來自旋重復嘗試 V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); // 獲取對應 segment 中的第一個數據 for (HashEntry<K,V> e = first;;) { // 循環判斷 if (e != null) { // 如果第一個數不為空,就遍歷判斷,存在 key 相等的就替換掉 value K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // 如果第一個數為空,或者上面沒有找到 key 相等的數。就將該數在后面進行添加,然后判斷是否需要擴容 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
可以看出在 1.7 中特地用 segment 類將原本的數組橫向切開,一個 segment 保存的是原本的數組的某一個下標位置所包含的鏈表數據的數組,用於鎖住各個數組下標對應的鏈表數據。在 put 時是調用 segment 繼承 ReentrantLock 類中的加鎖方法對這個對象進行加鎖,也就是它鎖住的是這個桶的數據。 其他和 1.8 中差不多,除了沒有樹形結構。
總結一下:
ConcurrentHashMap 1.7 和 1.8 的區別:
1、1.7 中沒有紅黑樹。1.8 引入了紅黑樹
2、1.7 相比於1.8增加了 Segment 類,用於表示數組單個下標所對應的鏈表數據所組成的數組,用於鎖住這個鏈表;而 1.8 鎖住的是數組下標的第一個數據,鎖的顆粒度減小,效率更高。1.7本質使用的是 ReentrantLock 鎖 + 自旋鎖,而1.8 使用的是 synchronized + CAS樂觀鎖。關於這兩種鎖的區別,可以查看 Lock 與 synchronized 區別。
HashMap 與 ConcurrentHashMap 的區別?
上面的源碼解析得比較清楚了,下面就拿 1.8 來舉例。首先,HashMap 不是一個線程安全的容器,ConcurrentHashMap是線程安全的。其次, HashMap 是在 put 操作的最后檢查是否需要擴容,而 ConcurrentHashMap 只會進行樹形化判斷,並不會單獨的進行擴容判斷。
HashTable 與 ConcurrentHashMap 的區別?
以1.8 的 ConcurrentHashMap為例,簡單的看一下的 hashtable 的 put 方法的源碼
public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } // Makes sure the key is not already in the hashtable. Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; @SuppressWarnings("unchecked") Entry<K,V> entry = (Entry<K,V>)tab[index]; for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } } addEntry(hash, key, value, index); return null; }
可以看到它是直接使用 synchronized 將整個方法鎖住,這樣在多線程下效率是非常低的,因為某些操作並不會觸及到線程安全,比如第三行的 value==null 的判斷。在其他線程執行這個方法時,當前線程只能干等着,而 ConcurrentHashMap 的 put 方法在一開始一直沒有加鎖,在判斷到數組下標為空時還是只用 CAS 去嘗試處理,直到確定需要遍歷時才對第一個數進行加鎖,所以 ConcurrentHashMap 的並發量遠大於 HashTable ,這也是為什么 HashTable 被淘汰的原因。