散列表
在瞭解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). * 陣列閥值,儲存資料總數超過這個值就會進行擴容(注意不是陣列不為空的位置數而是儲存資料數超過閥值就會擴容) * @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. * 實際的負載因子 * @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 方法直接就獲取到了 hash 值,那麼我們就需要去看一下這個方法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; }
可以看到在執行擴容操作時,是先確定擴容後的陣列長度以及閥值,然後新建一個滿足該條件的陣列,再將原陣列中所關聯的所有資料全部新增關聯到新陣列中,將之前的資料轉移到新的陣列這個過程是非常消耗時間的,所以我們在最初建立容器物件時就要先確定要存放的資料量,儘量避免擴容。
其他一些問題
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是在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、引入紅黑樹的好處?
紅黑樹具有查詢效率高的特點,當連結串列過長時,因為連結串列查詢效率低,所以在資料量大的情況下,連結串列就會變得很長,那麼查詢效率就會很低,這時將連結串列轉成紅黑樹就會極大的提高查詢效率。
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 方法使用樂觀鎖去新增,也是為了保證執行緒安全 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 內部類,這個類代表的是 table 某個下標關聯的所有資料,因為 1.7 中使用的是 HashEntry 而不是 Node,所以 1.7 中的陣列是 HashEntry 陣列,同樣,因為 Segement類儲存的也是 HashEntry 陣列,所以它的屬性和外部 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 使用的是 Segment 物件屬性,每個 segment 物件儲存一行的資料,加鎖鎖住的也是一行資料;而 1.8 鎖住的是陣列下標的第一個資料,效率更高。1.7本質使用的是 ReentrantLock 鎖 + 自旋鎖,而1.8 使用的是 synchronized + CAS樂觀鎖。關於這兩種鎖的區別,由於篇幅限制,後面會另開一篇進行解釋。
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 被淘汰的原因。