HashMap 的數據結構
hashMap 初始的數據結構以下圖所示,內部維護一個數組,而後數組上維護一個單鏈表,有個形象的比喻就是想掛鉤同樣,數組腳標同樣的,一個一個的節點往下掛。html
咱們能夠看源碼來驗證下,HashMap 的數據結構是否是真的是像上面所說是數組加鏈表的形式:java
//此處略過其餘代碼,只截取出了hashMap的數組結構相關的數組與鏈表 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { private static final long serialVersionUID = 362498820763181265L; /* ---------------- Fields -------------- */ /** * 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.) */ //這個是hashMap內部維護的數組 transient Node<K,V>[] table; /** * Basic hash bin node, used for most entries. (See below for * TreeNode subclass, and in LinkedHashMap for its Entry subclass.) */ //這個是數組元素的節點類,next的屬性表示下一個節點,即數組的節點元素維護的下一個節點的元素,那不是就是鏈表嗎 static class Node<K,V> implements Map.Entry<K,V> { final int hash; //數組的腳標值,下面會詳細描述這個內容 final K key; //map的key V value; //map的value Node<K,V> next; //下一個節點 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
經過源碼可知,HashMap 的數據結構正如上文所述,是一個數組加鏈表的形式存儲數組,那麼數組的角標是怎麼計算的呢?若是是你來設計,你會怎麼去設計這個角標的計算方式呢?node
在沒看源碼以前,我作了一個猜測,就是數組的角標我猜測是按照下面的計算方式計算的:算法
-
既然是 HashMap,那確定有個 hashCodebootstrap
-
而後經過 key 值的 hashCode 與數組的長度取模數組
-
取模以後,數值同樣的,就往數組的節點上面往下掛安全
上面是個人猜測,可是 HashMap 的數組角標的實現真的是這樣嗎?咱們進入下一節去探究數據結構
hash 值的計算
既然要看腳標值的計算,那咱們確定要看 HashMap 的 put 方法,由於在 put 方法裏面確定要計算出腳標的值,而後才能把數據存放到數組裏面去嘛,因此咱們直接看 put 的源碼:多線程
/** * 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. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with <tt>key</tt>, or * <tt>null</tt> if there was no mapping for <tt>key</tt>. * (A <tt>null</tt> return can also indicate that the map * previously associated <tt>null</tt> with <tt>key</tt>.) */ //此處是HashMap的put方法的源碼,這個put方法又調了另外一個putVal的方法,咱們看一下putVal的方法 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; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else {
這裏咱們關注 ***tab[i] = newNode(hash, key, value, null);
這句代碼,前面咱們看到了tab就是數組,那說明這句代碼就是給節點賦值,那麼i
就是數組的角標那這個i
***是怎麼計算的呢?併發
看他上面的一句判斷***(p = tab[i = (n - 1) & hash]
即這個i
是經過(n - 1) & hash
計算出來的,n = tab.length
這個n
是數組的長度,就是說數組的角標是經過數組的長度-1與上這個hash
,這個跟咱們以前猜測的而後經過hashCode與數組的長度取模就不一致了,那這裏咱們先保留着這個問題,先看一下hash
的計算,從上面代碼中,能夠知道,hash
值是經過調用hash(key)
***方法調用獲得。
這裏我將計算 ***hash
***的方法,單獨抽離出來外面寫,以下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
key 就是 map 調用 put 方法,put 進來的 key 值,看上面這個方法,前面判空以後返回0的你們一眼就看明白了,主要關注後面的內容,***(h = key.hashCode()) ^ (h >>> 16)
這句代碼前半部分很明白,就是取key的hashCode值賦給h,(^
這個符號表示異或,>>>
***表示無符號右移),而後與h右移16位後的值進行異或操做。
爲何要這樣計算去計算 hash 呢?這樣計算 hash 又最終與數組的腳標有什麼聯繫呢?
下面我來畫張圖,來理順這一塊的計算,看下圖:
這樣作能夠實現 hashCode 的值,高低位更加均勻地混到一塊兒,結合上面數組腳標***(n - 1) & hash
的運算,因爲 HashMap 數組的大小老是 2^n,即 (2^n-1) 獲得的值轉化爲二進制,如: 000011十一、00011111 (捨棄前面高位)等,與 hash 的值進行與運算,這樣又保證每個腳標i
***值都能在數組的長度內。這裏可能有點難理解,舉個例子來講明一下。
就是 hashMap 的數組初始大小是 16,那 length-1 的值就爲 15,15 的二進制值是.... 0000 1111,此時上面hash 值 363766277 的二進制位 0001 0101 1010 1110 1010 0010 0000 0101,這兩個數進行與運算時,因爲 15 的前面高位都爲 0,因此進行與運算的值最終都不可能大於15,像這個例子,最終的值爲 0101 爲 9,這樣就保證了每個腳標***i
***值都能在數組的長度內。
那麼這裏就有一個疑問了,爲何不直接採用與數組長度取模的方式,直接取得腳標值,而是先去異或,再與運算去計算腳標值?
主要有兩個緣由:
1.用位運算,效率更高
2. hashCode 的高低位異或運算,讓高低位更加均勻的混合到一塊兒,可使得在 put 元素時,能夠減小哈希碰撞
減小哈希碰撞纔是最主要的緣由。那什麼是哈希碰撞呢?
咱們知道 HashMap 的數組結構不是數組加鏈表嗎?那數組跟鏈表有什麼特色?咱們都知道數組是查詢快、增刪慢,鏈表是查詢慢、增刪快。
這也很容易理解,鏈表嘛,只記錄着下一個節點的值,又沒有腳標,若是你這個鏈表很長(雖然在這裏最長不會超過8,後面會講到),你查找的一個元素恰好在最後一個,那不是在定位到數組腳標之後找到鏈表的第一個節點,而後往下一直遍歷查找到最後一個才找到咱們要的元素,這樣效率不就很慢了嗎,因此若是咱們直接對 hashCode 跟數組的長度進行取模,計算出的 hash 值可能會碰撞高,就會使得數組單個節點的鏈表很長很長,而這樣子 HashMap 的查詢效率就不好,而 hashCode 的高低位異或運算,可讓高低位更加均勻的混合到一塊兒,減小哈希碰撞,從而提升 HashMap 的查詢效率。
一句話總結,失敗的 hashCode 算法會致使 HashMap 的性能由數組降低爲鏈表,因此想要避免發生碰撞,就要提升 hashCode 結果的均勻性。
數組的擴容
數組的初始化長度
在上一節的時候,咱們講到了 HashMap 的長度老是 2^n 這句話,咱們怎麼知道呢,咱們能夠從源碼中找到這一設定,那麼咱們首先先看一下,HashMap 數組初始的默認大小是多少呢,源碼中有這一句代碼
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
可是,咱們不能光看這個常量值就說HashMap內數組的默認常量值就是 16 啊,咱們要繼續找到初始化的方法代碼,看他是否是初始值爲 16
/** * Initializes or doubles table size. If null, allocates in * accord with initial capacity target held in field threshold. * Otherwise, because we are using power-of-two expansion, the * elements from each bin must either stay at same index, or move * with a power of two offset in the new table. * * @return the table */ //上面的英文中說到,初始化或者翻倍數組的大小 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) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; //當oldTab即爲table數組的長度,當oldTab長度爲0時,將DEFAULT_INITIAL_CAPACITY賦值給newCap,newCap即爲數組的新長度 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); }
咱們能夠查詢下 resize()
方法的調用者,發現putVal()
方法裏調用了這個方法
截圖中的代碼已經很清楚了,就是當 table 數組的長度爲 null 或長度爲 0 時,調用初始化***resize()
方法,而後在resize()
方法中也作了判斷,當table數組的長度爲 0 時,將新數組的長度賦值爲DEFAULT_INITIAL_CAPACITY
***,
因此 HashMap 中數組的初始化長度就是 DEFAULT_INITIAL_CAPACITY
,等於***1 << 4
***,等於 16
數組擴容的閾值
上一節咱們知道數組的初始長度是 16,然而 16 的長度顯然不能知足咱們普通應用的開發,因此這裏就涉及到了數組的擴容。那要何時擴容,怎麼擴呢?
咱們知道,**鏈表的查詢效率確定比數組的查詢效率低,因此要提升 HashMap 的查詢效率,咱們確定要數據儘量多的往數組上存數據,而不是延長鏈表的長度。**那是否是存滿以後再作擴容呢?比方說數組初始化 16,等到存滿 16 的時候或者第 17 個進來的時候,開始擴容呢?
咱們能夠先分析一下,而後再來看源碼。當數組的元素都放滿了,而後這時候來擴容,擴容以後,數組元素的腳標值就得從新計算,即 rehash ,好比原來是計算hash用的數組長度 16,擴容以後數組長度變成了 32,這時候***(n - 1) & hash
計算腳標的值就不正確了,那你數組都存滿了,那不是數組的每一個元素都得從新計算腳標i
***值,因此這種作法是否是不合理?
因此這裏就有一個數組擴容的閾值,就是說,當數組的長度達到某個值或某個條件時,數組就開始擴容,而這裏的某個值或某個條件就是咱們所說的數組擴容的閾值。
那麼這個閾值具體是多少呢?下面咱們來探究源碼,既然要找到擴容的閾值,那咱們不外乎要從兩個方法入手去找,一個就是***put()
操做的時候,一個就是擴容resize()
的時候。由於我已經找過了,我就直接去put()
方法裏面找了,resize()
方法後面會細講,這裏就講put()
***方法。
//這裏put方法只調用了putVal方法,那咱們就直接看putVal方法 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } //我解釋下這個方法裏面,大概的操做 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //這一步以前分析過了,就是判斷數組爲null或長度爲0時,對數組進行擴容 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //這一步其實也很清楚了,就是根據hash值計算出數組的腳標,而後判斷數組的該腳標的元素是否爲空,爲空的話就把put進來的數據封裝成節點賦值進數組 else { //根據上面的兩個判斷,那麼走到這裏的代碼就是說,數組不爲空,並且put進來的key計算所得的腳標節點也不爲空,走這一塊邏輯(實際上這塊邏輯也跟擴容的閾值無關,只是單純的判斷而後加節點的操做,可是我仍是解釋下這裏的代碼) Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;//這裏的意思是說,若是hash值相同,key值也相同,那麼就說明此時put操做的元素在數組從存在,這覆蓋該節點 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //這裏是判斷節點類型是不是樹類型,爲何會是樹類型呢?不是說是HashMap是數組加鏈表嗎?後面的章節會詳細講到,這裏暫且跳過 else { //代碼走到這裏,就說明此時put進來的元素,對應的數組腳標是個鏈表 for (int binCount = 0; ; ++binCount) { //此處的代碼後面講鏈表時會細講,這裏暫且跳過 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //這裏判斷hash值與key值是否都相同,若是是即說明map中存在該key-value,此時跳出循環 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //此邏輯是判斷hash值與key值是否都相同跳出循環後,將新值覆蓋舊值,而後將舊值返回出去 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount;//hashMap內部維護的一個修改的次數,有興趣瞭解的話能夠看源碼裏面對這個屬性的翻譯 if (++size > threshold) resize();//擴容,在此以前的代碼,都是判斷以後進行添加覆蓋節點的操做,此處是插入新節點以後判斷是否擴容,因此這裏的條件就是咱們找了這麼久的擴容的閾值!!! afterNodeInsertion(evict); return null; }
走讀完上面的代碼,咱們能夠得知 if (++size > threshold)
,以下代碼可知 ***size
***實際上就是HashMap集合的鍵值對數,即長度,因此就是說,當 ***size
***的大小超過 ***threshold
***時,開始進行擴容,也即 **threshold
就是進行擴容的閾值。那麼這個閾值的大小是多少呢?
/** * The number of key-value mappings contained in this map. */ transient int size; /** * Returns the number of key-value mappings in this map. * * @return the number of key-value mappings in this map */ public int size() { return size; }
繼續走讀源碼,找到 ***resize()
***方法處,
/** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
當 HashMap 數組爲 null 或長度爲 0 時,初始化***threshold
的值,DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
,*DEFAULT_INITIAL_CAPACITY
爲數組的初始長度,DEFAULT_LOAD_FACTOR
**是閾值的計算因子,他的值是 0.75f,意思就是當 HashMap 的 size 超過數組長度的75%的時候,就進行擴容。
咱們能夠繼續走讀源碼來驗證是否數組長度超過 75% 就進行擴容,仍是上面那張圖的源碼,我把其中一段給抽離出來,以下:
if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //此處的意思是說,當數組的長度是大於0的時候,並且數組擴容一倍以後,小於默認配置的最大值時,而且大於初始化數組的長度,則執行if下面的代碼,那就是說,擴容以後若是沒超過最大值,就走這個邏輯 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //而這個邏輯的代碼意思,就是閾值threshold增大一倍(左移一位) newThr = oldThr << 1; // double threshold }
那麼,咱們就知道了,當數組擴容時,**threshold
的值也會增大一倍,那麼下一次擴容時,也是HashMap的 size 超過數組長度的 75% 的時候,就進行擴容。
擴容
HashMap 內數組的擴容是將數組的長度左移一位,在二進制運算中,左移一位實際上就是將數值擴大一倍。並且咱們也知道,擴容的源碼就是***resize()
這個方法,因此這一章節就來重點解讀resize()
***方法的源碼
/** * Initializes or doubles table size. If null, allocates in * accord with initial capacity target held in field threshold. * Otherwise, because we are using power-of-two expansion, the * elements from each bin must either stay at same index, or move * with a power of two offset in the new table. * * @return the table */ final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //oldTab就是擴容前數組對象 int oldCap = (oldTab == null) ? 0 : oldTab.length; //oldCap就是擴容前數組的長度 int oldThr = threshold; //oldThr就是擴容前的閾值 int newCap, newThr = 0; //聲明newCap-擴容後的數組長度,newThr-擴容後的閾值 if (oldCap > 0) { //這一部分邏輯其實上一節已經講過了,在這裏我就大體說一下,就是若是這是擴容前數組長度已經達到了默認配置的最大值時,那麼就不擴容了,直接返回原數組,不然,數組擴容一倍,閾值也增大一倍 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; //這個判斷不是正常建立Map集合走的邏輯,這裏能夠跳過這句代碼 else { // zero initial threshold signifies using defaults //這一步的代碼前面也解釋過了,就是當數組長度爲0,初始化數組長度與擴容的閾值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //將新的擴容後的閾值賦值給threshold 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; //根據hash值與新數組的長度進行與操做,獲取新數組的腳標值,將節點存儲到新數組 else if (e instanceof TreeNode) //若是節點是樹節點,走這個邏輯,後面講鏈表的紅黑樹的時候會作解釋,這裏先跳過 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { //因此這一部分的邏輯,就是若是節點是鏈表,走這裏 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) { //這個判斷是理解這整個鏈表遍歷的關鍵,這裏也涉及到了前面講到的2^n-1對應二進制是0111xxxx的內容,咱們知道數組的長度老是2^n,因此oldCap的值實際上就是1000xxxx,而後hash & oldCap的操做,就是判斷oldCap高位的1與對應hash那一位的值是不是1,若是是0走這個邏輯,若是是1走下面的else代碼 //這裏,前面聲明的4個變量loHead, loTail, hiHead, hiTail中,lo的指的是低位,hi的指的是高位,走完這個do裏面的邏輯,就是將oldCap高位的1與對應hash那一位的值是0的存到loTail這個鏈表中,高位是1的存到hiTail這個鏈表中!!!! 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存放到新數組的原腳標處 loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { //將上面遍歷以後高位的hiTail存放到新數組的擴容後的腳標處 hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
在上面的源碼解讀中,咱們可能會留有一個問題,就是爲何擴容後,對數組中的鏈表還要作 (e.hash & oldCap) == 0
的判斷?
實際上這部分邏輯是爲了提升HashMap的查詢性能,由於數組擴容後,節點要從新散列,那麼節點上面的鏈表固然也最好要作到均勻的分佈,減小單個數組節點上的鏈表長度,變相的提升了查詢性能。因此,源碼的邏輯是在擴容後將低位的 loTail 存放到新數組的原腳標處,高位的 hiTail 存放到新數組的擴容後的腳標處(jdk1.8新設計)
注:有同窗可能會糾結於,爲何代碼中高位的鏈表是直接 j + oldCap
的腳標,不須要從新計算hash與上新數組長度計算嗎?其實這是一個簡單的數學問題而已,你本身舉個例子計算一下就能夠明白,結果是同樣的
鏈表的「擴容」
前面的章節已經對 HashMap 數組的擴容及其從新散列的內容講完了,這一章節的內容來說一講鏈表的"擴容"。根據前面的內容,咱們瞭解到,若是鏈表的長度愈來愈長,HashMap 的查詢效率也會隨之下降。因此單純的對鏈表長度的增長,顯然是不可取的。
因此在 HashMap 中,對於鏈表實際上並無擴容操做。在本文開頭列出的 Node 節點的源碼中也能夠看到,內部並無維護一個size或者length的屬性,也沒有一個去獲取 length 或 size 相關的方法,因此本章節主要闡述的內容,是鏈表結構向樹狀結構的轉化。
####單鏈表-->紅黑樹
在前面「數組擴容的閾值」章節的時候,我曾解讀過 putVal 方法的代碼,在解讀過程當中,我跳過了兩次代碼邏輯,在這一章節我就來詳細的解讀這兩處邏輯
咱們先看 for 循環遍歷處的代碼,此處的遍歷的內容是 HashMap 是 put 操做節點是爲鏈表時的邏輯:首先這裏先判斷鏈表的next
節點是否爲空,爲空則將 put 操做的 key-value 封裝爲 node 對象,賦值給next
節點,而後下一步的判斷if (binCount >= TREEIFY_THRESHOLD - 1)
是這裏的關鍵,TREEIFY_THRESHOLD
這個是什麼呢?THRESHOLD
這個單詞是否是看着有點眼熟,在前面將數組擴容的閾值的時候,是否是用的這個單詞,那在這裏的TREEIFY_THRESHOLD
會不會就是鏈表結構轉樹狀結構的閾值呢?
經過上面這段代碼的上下文,咱們知道 binCount
就是鏈表的長度**(注意:這裏是從 0 開始的)**,而TREEIFY_THRESHOLD
看下面的源碼,默認值是 8,意思就是說當鏈表的長度,大於等於 8 時,就執行treeifyBin(tab, hash);
/** * 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. */ static final int TREEIFY_THRESHOLD = 8;
treeifyBin(tab, hash);
方法的內容是作什麼呢?
/** * Replaces all linked nodes in bin at index for given hash unless * table is too small, in which case resizes instead. */ //翻譯大概的意思就是,在給定hash的節點處替換節點類型,除非是數組的長度過小了,才進行resize操做 //總結就是說,並非鏈表的長度超過了默認的閾值8時,就必定轉樹狀結構,還要判斷數組的長度是否已經通過了擴容 final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; //這裏就是上面翻譯說的判斷,MIN_TREEIFY_CAPACITY的值是64,就是說若是你的數組沒有通過擴容操做的狀況下,若是鏈表長度已經超過8了,此時不轉樹狀結構,而是進行數組擴容,數組擴容時會從新散列,將鏈表的節點均勻的分佈,查詢效率對比轉樹狀結構也要好,不得不佩服設計者的設計。 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { //此處代碼就是找到給定的hash的節點,將此節點的鏈表轉爲紅黑樹,下面的代碼主要是數據結構代碼的內容,有興趣的同窗能夠本身解讀,因爲時間緣由,我就不解讀這部分轉紅黑樹的代碼了 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); } }
在上面的源碼解讀中,咱們知道,並非鏈表的長度超過了默認的閾值 8 時,就必定轉樹狀結構,還要判斷數組的長度是否已經通過了擴容,就是說若是你的數組沒有通過擴容操做的狀況下,若是鏈表長度已經超過 8 了,此時不轉樹狀結構,而是進行數組擴容,數組擴容時會從新散列,將鏈表的節點均勻的分佈,查詢效率對比轉樹狀結構也要好。
那麼在數組擴容後,鏈表長度也超過了 8,此時就進行轉紅黑樹的操做,那紅黑樹又是什麼呢?
咱們知道鏈表的查詢時間複雜度最壞的狀況有多是 O(n) ,當你想要找到節點恰好是在鏈表的最後一個時,你就必須得遍歷完鏈表中全部的節點才能找到你要的值,查找效率過低。而紅黑樹的本質實際上是一棵平衡二叉查找樹,平衡二叉查找樹的特色就是左子節點小於等於父節點,右子節點大於等於父節點,因此他的查詢時間複雜度是 O(Log2n) ,比鏈表的 O(n) 效率就要高不少了。
本章開頭說到的另外一處未解讀的putVal源碼,其實只是判斷是樹狀結構時,將節點按照紅黑樹的規則,put進樹中而已。
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
HashTable 的數據結構
在前面解讀的 HashMap 中,已經將HashMap的數據結構,還有put操做、擴容作了詳細的解讀,而其實 HashTable,只是在 HashMap 的基礎上,給各個操做都加上了 synchronized 關鍵字而已,這就是咱們常說的 HashTable 是線程安全的,而 HashMap 是線程不安全的,以下代碼。
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable { private transient Entry<?,?>[] table; //數組 private int threshold; //數組擴容閾值 private float loadFactor; //鏈表實體類 private static class Entry<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Entry<K,V> next; //put方法 public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } //remove方法 public synchronized V remove(Object key) { Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; //get方法 public synchronized V get(Object key) { Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) { if ((e.hash == hash) && e.key.equals(key)) { return (V)e.value; } } return null; }
**內部也是維護了一個數組與鏈表,而後在 put、get 等方法上都加上 synchronized 關鍵字,**那這樣就能確保 HashTable 在任何場景都是線程安全的嗎?
HashTable 是否在任何場景都是線程安全的?
這裏有幾個場景:
(1)若 key 不存在,則添加元素
(2)若 key 存在,則刪除元素
我在這裏畫張圖來描述下多線程環境下的這兩個場景
存在這樣問題的緣由是複合操做的場景下,HashTable不是線程安全的,由於 HashTable 只是保證單個方法操做是原子性的,但在不保證原子性的複合操做下,HashTable 也存在線程安全問題。
ConCurrentHashMap 的數據結構
咱們知道 HashTable 的性能比 HashMap 的差不少,由於 HashTable 在每一個操做方法上面都加了 synchronized 關鍵字,並且在複合場景下還存在線程安全問題,因此 HashTable 算是舊版本遺留下來的問題了,如今的實際開發中通常也不會去使用到 HashTable,可是在 jdk1.5 之後新增了 java.util.concurrent 包,在這個包下提供了不少線程安全又高性能的集合,其中就包含了本章的主角 ConCurrentHashMap
jdk1.7 分段鎖
在 jdk1.5 之後到 jdk1.7 ,ConCurrentHashMap 在解決多線程場景下的線程安全問題,採用的是分段鎖的技術。
咱們知道 HashTable 之因此性能低下,是由於其在 public 方法的實現上都加上了 synchronized 的關鍵字,即當任意一個 put 或 get 操做,都將整個 map 對象鎖住,只有等待持有鎖的線程操做結束,纔有機會得到鎖進行操做。
這裏有一種場景,在 Map 的數組 table 中,線程1對 table[0] 進行 put 操做,而此時有線程2想對 table[1] 進行 put 操做,實際上二者的 put 操做互不干涉,而在 HashTable 的實現下,線程2只能等待線程1操做完成以後才能執行。那麼,咱們是否能夠這樣實現,當線程1對 table[0] 進行 put 操做時,對 table[0] 下的鏈表進行加鎖,而操做 table[1] 時,對 table[1] 的鏈表進行加鎖,各自那各自的鎖,這樣線程1在操做 table[0] 時,線程2也能夠操做 table[1]。
分段鎖採用的就是這種思想,在ConCurrentHashMap中維護着Segment[]的數組,這種實現方式把本來 HashTable 粗粒度的鎖實現,拆分紅一段一段的Segment鎖。
//jdk1.7的ConcurrentHashMap的源碼 public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>, Serializable { /** * Mask value for indexing into segments. The upper bits of a * key's hash code are used to choose the segment. */ final int segmentMask; /** * Shift value for indexing within segments. */ final int segmentShift; /** * The segments, each of which is a specialized hash table. */ //Segment是繼承了可重入鎖的子類,因此在Segment的操做方法中,包含了tryLock、unLock等方法 final Segment<K,V>[] segments; /** * Segments are specialized versions of hash tables. This * subclasses from ReentrantLock opportunistically, just to * simplify some locking and avoid separate construction. */ static final class Segment<K,V> extends ReentrantLock implements Serializable { /** * The per-segment table. Elements are accessed via * entryAt/setEntryAt providing volatile semantics. */ transient volatile HashEntry<K,V>[] table; } }
簡單理解就是,ConcurrentHashMap 維護一個 Segment 數組,Segment 經過繼承 ReentrantLock 來進行加鎖,因此每次須要加鎖的操做鎖住的是一個 Segment,這樣只要保證每一個 Segment 是線程安全的,也就實現了全局的線程安全。
以下,是 ConcurrentHashMap 的各個構造方法,可是實際上只有 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)
該構造方法是真正完成初始化的方法,其餘的都是方法重載
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments int sshift = 0; int ssize = 1; // 計算並行級別 ssize,由於要保持並行級別是 2 的 n 次方 while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 咱們這裏先不要那麼燒腦,用默認值,concurrencyLevel 爲 16,sshift 爲 4 // 那麼計算出 segmentShift 爲 28,segmentMask 爲 15,後面會用到這兩個值 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // initialCapacity 是設置整個 map 初始的大小, // 這裏根據 initialCapacity 計算 Segment 數組中每一個位置能夠分到的大小 // 如 initialCapacity 爲 64,那麼每一個 Segment 或稱之爲"槽"能夠分到 4 個 int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; // 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,這個值也是有講究的,由於這樣的話,對於具體的槽上, // 插入一個元素不至於擴容,插入第二個的時候纔會擴容 int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; // 建立 Segment 數組, // 並建立數組的第一個元素 segment[0] Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; // 往數組寫入 segment[0] UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; } public ConcurrentHashMap(int initialCapacity, float loadFactor) { this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL); } public ConcurrentHashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); } public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); } public ConcurrentHashMap(Map<? extends K, ? extends V> m) { this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); putAll(m); }
concurrencyLevel:並行級別、併發數、Segment 數,怎麼翻譯不重要,理解它。默認是 16,也就是說 ConcurrentHashMap 有 16 個 Segments,因此理論上,這個時候,最多能夠同時支持 16 個線程併發寫,只要它們的操做分別分佈在不一樣的 Segment 上。這個值能夠在初始化的時候設置爲其餘值,可是一旦初始化之後,它是不能夠擴容的。
再具體到每一個 Segment 內部,其實每一個 Segment 很像前面介紹的 HashMap,不過它要保證線程安全,因此處理起來要麻煩些。
initialCapacity:初始容量,這個值指的是整個 ConcurrentHashMap 的初始容量,實際操做的時候須要平均分給每一個 Segment。
loadFactor:負載因子,以前咱們說了,Segment 數組不能夠擴容,因此這個負載因子是給每一個 Segment 內部使用的。
jdk1.8 的新特性:CAS
在 jdk1.8 如下版本的 ConcurrentHashMap 爲了保證線程安全又要提供高性能的狀況下,採用鎖分段的技術,而在java8中對於 ConcurrentHashMap 的實現又變成了另一種方式----CAS
CAS的全稱是compare and swap,直譯過來就是比較與替換。CAS的機制就至關於這種(非阻塞算法),CAS是由CPU硬件實現,因此執行至關快.CAS有三個操做參數:內存地址,指望值,要修改的新值,當指望值和內存當中的值進行比較不相等的時候,表示內存中的值已經被別線程改動過,這時候失敗返回,當相等的時候,將內存中的值改成新的值,並返回成功。
這裏也不去細講多線程、鎖、CAS這些內容,後續等有空再整理一篇文檔出來作詳細點的筆記,這裏只當作體會精神,理解思想便可。
下面的代碼是摘自網上一篇文章的對 java8 中 ConcurrentHashMap 的源碼分析,也是爲了方便本身後續當筆記學習來看。
public V put(K key, V value) { return putVal(key, value, false); } final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); // 獲得 hash 值 int hash = spread(key.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(); // 找該 hash 值對應的數組下標,獲得第一個節點 f else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 若是數組該位置爲空, // 用一次 CAS 操做將這個新值放入其中便可,這個 put 操做差很少就結束了,能夠拉到最後面了 // 若是 CAS 失敗,那就是有併發操做,進到下一個循環就行了 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } // hash 竟然能夠等於 MOVED,這個須要到後面才能看明白,不過從名字上也能猜到,確定是由於在擴容 else if ((fh = f.hash) == MOVED) // 幫助數據遷移,這個等到看完數據遷移部分的介紹後,再理解這個就很簡單了 tab = helpTransfer(tab, f); else { // 到這裏就是說,f 是該位置的頭結點,並且不爲空 V oldVal = null; // 獲取數組該位置的頭結點的監視器鎖 synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { // 頭結點的 hash 值大於 0,說明是鏈表 // 用於累加,記錄鏈表的長度 binCount = 1; // 遍歷鏈表 for (Node<K,V> e = f;; ++binCount) { K ek; // 若是發現了"相等"的 key,判斷是否要進行值覆蓋,而後也就能夠 break 了 if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } // 到了鏈表的最末端,將這個新值放到鏈表的最後面 Node<K,V> pred = e; if ((e = e.next) == null) { 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; } } } } // binCount != 0 說明上面在作鏈表操做 if (binCount != 0) { // 判斷是否要將鏈表轉換爲紅黑樹,臨界值和 HashMap 同樣,也是 8 if (binCount >= TREEIFY_THRESHOLD) // 這個方法和 HashMap 中稍微有一點點不一樣,那就是它不是必定會進行紅黑樹轉換, // 若是當前數組的長度小於 64,那麼會選擇進行數組擴容,而不是轉換爲紅黑樹 // 具體源碼咱們就不看了,擴容部分後面說 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } // addCount(1L, binCount); return null; }
對於 ConcurrentHashMap 的源碼解讀就到這裏,詳細的源碼解讀,能夠看這篇很牛的文章,我也是在寫本分析的狀況下,發現了Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析這篇文章,發現寫的比我還寫的詳細的多的多,因此若是以爲意猶未盡的同窗能夠去讀讀這篇源碼解讀。