【本人秃顶程序员】深入理解Java——ConcurrentHashMap源码的分析(JDK1.8)
←←←←←←←←←←←← 快!點關注
一、前提
在閱讀這篇博客之前,希望你對HashMap已經(jīng)是有所理解的,如果你對java的cas操作也是有一定了解的,因為在這個類中大量使用到了cas相關的操作來保證線程安全的。
二、概述
ConcurrentHashMap這個類在java.lang.current包中,這個包中的類都是線程安全的。ConcurrentHashMap底層存儲數(shù)據(jù)的結構與1.8的HashMap是一樣的,都是數(shù)組+鏈表(或紅黑樹)的結構。在日常的開發(fā)中,我們最長用到的鍵值對存儲結構的是HashMap,但是我們知道,這個類是非線程安全的,在高并發(fā)的場景下,在進行put操作的時候有可能進入死循環(huán)從而使服務器的cpu使用率達到100%;sun公司因此也給出了與之對應的線程安全的類。在jdk1.5以前,使用的是HashTable,這個類為了保證線程安全,在每個類中都添加了synchronized關鍵字,而想而知在高并發(fā)的情景下相率是非常低下的。為了解決HashTable效率低下的問題,官網(wǎng)在jdk1.5后推出了ConcurrentHashMap來替代飽受詬病的HashTable。jdk1.5后ConcurrentHashMap使用了分段鎖的技術。在整個數(shù)組中被分為多個segment,每次get,put,remove操作時就鎖住目標元素所在的segment中,因此segment與segment之前是可以并發(fā)操作的,上述就是jdk1.5后實現(xiàn)線程安全的大致思想。但是,從描述中可以看出一個問題,就是如果出現(xiàn)比較機端的情況,所有的數(shù)據(jù)都集中在一個segment中的話,在并發(fā)的情況下相當于鎖住了全表,這種情況下其實是和HashTable的效率出不多的,但總體來說相較于HashTable,效率還是有了很大的提升。jdk1.8后,ConcurrentHashMap摒棄了segment的思想,轉而使用cas+synchronized組合的方式來實現(xiàn)并發(fā)下的線程安全的,這種實現(xiàn)方式比1.5的效率又有了比較大的提升。那么,它是如何整體提升效率的呢?見下文分析吧!
三、重要成員變量
1. ziseCtr:在多個方法中出現(xiàn)過這個變量,該變量主要是用來控制數(shù)組的初始化和擴容的,默認值為0,可以概括一下4種狀態(tài):
- a、sizeCtr=0:默認值;
- b、sizeCtr=-1:表示Map正在初始化中;
- c、sizeCtr=-N:表示正在有N-1個線程進行擴容操作;
- d、sizeCtr>0: 未初始化則表示初始化Map的大小,已初始化則表示下次進行擴容操作的閾值;
2. table:用于存儲鏈表或紅黑數(shù)的數(shù)組,初始值為null,在第一次進行put操作的時候進行初始化,默認值為16; 3. nextTable:在擴容時新生成的數(shù)組,其大小為當前table的2倍,用于存放table轉移過來的值; 4. Node:該類存儲數(shù)據(jù)的核心,以key-value形式來存儲; 5. ForwardingNode:這是一個特殊Node節(jié)點,僅在進行擴容時用作占位符,表示當前位置已被移動或者為null,該node節(jié)點的hash值為-1;
四、put操作
先把源碼擺上來:
/** Implementation for put and putIfAbsent */ final V putVal(K key, V value, Boolean onlyIfAbsent) {//key和value不能為空if (key == null || value == null) throw new NullPointerException();//通過key來計算獲得hash值int hash = spread(key.hashCode());//用于計算數(shù)組位置上存放的node的節(jié)點數(shù)量//在put完成后會對這個參數(shù)判斷是否需要轉換成紅黑樹或鏈表int binCount = 0;//使用自旋的方式放入數(shù)據(jù)//這個過程是非阻塞的,放入失敗會一直循環(huán)嘗試,直至成功for (Node<K,V>[] tab = table;;) {Node<K,V> f;int n, i, fh;//第一次put操作,對數(shù)組進行初始化,實現(xiàn)懶加載if (tab == null || (n = tab.length) == 0)//初始化tab = initTable();//數(shù)組已初始化完成后//使用cas來獲取插入元素所在的數(shù)組的下標的位置,該位置為空的話就直接放進去 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))break;// no lock when adding to empty bin}//hash=-1,表明該位置正在進行擴容操作,讓當前線程也幫助該位置上的擴容,并發(fā)擴容提高擴容的速度 else if ((fh = f.hash) == MOVED)//幫助擴容tab = helpTransfer(tab, f);//插入到該位置已有數(shù)據(jù)的節(jié)點上,即用hash沖突//在這里為保證線程安全,會對當前數(shù)組位置上的第一個節(jié)點進行加鎖,因此其他位置上//仍然可以進行插入,這里就是jdk1.8相較于之前版本使用segment作為鎖性能要高效的地方 else {V oldVal = null;synchronized (f) {//再一次判斷f節(jié)點是否為第一個節(jié)點,防止其他線程已修改f節(jié)點if (tabAt(tab, i) == f) {//為鏈表if (fh >= 0) {binCount = 1;//將節(jié)點放入鏈表中for (Node<K,V> e = f;; ++binCount) {K ek;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;//將節(jié)點插入紅黑樹中if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,value)) != null) {oldVal = p.val;if (!onlyIfAbsent)p.val = value;}}}}//插入成功后判斷插入數(shù)據(jù)所在位置上的節(jié)點數(shù)量,//如果數(shù)量達到了轉化紅黑樹的閾值,則進行轉換if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD)//由鏈表轉換成紅黑樹treeifyBin(tab, i);if (oldVal != null)return oldVal;break;}}}//使用cas統(tǒng)計數(shù)量增加1,同時判斷是否滿足擴容需求,進行擴容addCount(1L, binCount);return null; } 復制代碼在代碼上寫注釋可能看得不是很清晰,那么我就使用文字再來描述一下插入數(shù)據(jù)的整個流程:
所以,put操作流程可以簡單的概括為上面的六個步驟,其中一些具體的操作會在下面進行詳細的說明,不過,值得注意的是:
- ConcurrentHashMap不可以存儲key或value為null的數(shù)據(jù),有別于HashMap;
- ConcurrentHashMap使用了懶加載的方式初始化數(shù)據(jù),把table的初始化放在第一次put數(shù)據(jù)的時候,而不是在new的時候;
- 擴容時是支持并發(fā)擴容,這將有助于減少擴容的時間,因為每次擴容都需要對每個節(jié)點進行重hash,從一個table轉移到新的table中,這個過程會耗費大量的時間和cpu資源。
- 插入數(shù)據(jù)操作鎖住的是表頭,這是并發(fā)效率高于jdk1.7的地方;
Ⅰ、hash計算的spread方法
/*** Spreads (XORs) higher bits of hash to lower and also forces top* bit to 0. Because the table uses power-of-two masking, sets of* hashes that vary only in bits above the current mask will* always collide. (Among known examples are sets of Float keys* holding consecutive whole numbers in small tables.) So we* apply a transform that spreads the impact of higher bits* downward. There is a tradeoff between speed, utility, and* quality of bit-spreading. Because many common sets of hashes* are already reasonably distributed (so don't benefit from* spreading), and because we use trees to handle large sets of* collisions in bins, we just XOR some shifted bits in the* cheapest possible way to reduce systematic lossage, as well as* to incorporate impact of the highest bits that would otherwise* never be used in index calculations because of table bounds.*/ static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS; } 復制代碼從源碼中可以看到,jdk1.8計算hash的方法是先獲取到key的hashCode,然后對hashCode進行高16位和低16位異或運算,然后再與 0x7fffffff 進行與運算。高低位異或運算可以保證haahCode的每一位都可以參與運算,從而使運算的結果更加均勻的分布在不同的區(qū)域,在計算table位置時可以減少沖突,提高效率,我們知道Map在put操作時大部分性能都耗費在解決hash沖突上面。得出運算結果后再和 0x7fffffff 與運算,其目的是保證每次運算結果都是一個正數(shù)。對于java位運算不了解的同學,建議百度自行了解相關內(nèi)容。
Ⅱ、java內(nèi)存模型和cas操作
這里我只是簡單的說一下java的內(nèi)存模型和cas,因為這篇文章的主角的ConcurrentHashMap。
java內(nèi)存模型:在java中線程之間的通訊是通過共享內(nèi)存(即我們在變成時聲明的成員變量或叫全局變量)的來實現(xiàn)的。Java內(nèi)存模型中規(guī)定了所有的變量都存儲在主內(nèi)存中,每條線程還有自己的工作內(nèi)存(可以與前面將的處理器的高速緩存類比),線程的工作內(nèi)存中保存了該線程使用到的變量到主內(nèi)存副本拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存中的變量。不同線程之間無法直接訪問對方工作內(nèi)存中的變量,線程間變量值的傳遞均需要在主內(nèi)存來完成,線程、主內(nèi)存和工作內(nèi)存的交互關系如下圖所示,和上圖很類似。
舉一個非常簡單的例子,就是我們常用的i++的操作,這個操作看起來只有一行,然而在編譯器中這一行代碼會被編譯成3條指令,分別是讀取、更新和寫入,所以i++并不是一個原子操作,在多線程環(huán)境中是有問題了。其原因在于(我們假設當前 i 的值為1)當一條線程向主內(nèi)存中讀取數(shù)據(jù)時,還沒來得及把更新后的值刷新到主內(nèi)存中,另一個線程就已經(jīng)開始向主內(nèi)存中讀取了數(shù)據(jù),而此時內(nèi)存中的值仍然為1,兩個線程執(zhí)行+1操作后得到的結果都為2,然后將結果刷新到主內(nèi)存中,整個i++操作結果,最終得到的結果為2,但是我們預想的結果應該是3,這就出現(xiàn)了線程安全的問題了。
cas: cas的全名稱是Compare And Swap 即比較交換。cas算法在不需要加鎖的情況也可以保證多線程安全。核心思想是: cas中有三個變量,要更新的變量V,預期值E和新值N,首先先讀取V的值,然后進行相關的操作,操作完成后再向主存中讀取一次取值為E,當且僅當V == E時才將N賦值給V,否則再走一遍上訴的流程,直至更新成功為止。就拿上面的i++的操作來做說明,假設當前i=1,兩個線程同時對i進行+1的操作,線程A中V = 1,E = 1,N = 2;線程B中 V = 1,E = 1,N = 2;假設線程A先執(zhí)行完整個操作,此時線程A發(fā)現(xiàn) V = E = 1,所以線程A將N的值賦值給V,那么此時i的值就變成了 2 ;線程B隨后也完成了操作,向主存中讀取i的值,此時E = 2,V = 1,V != E,發(fā)現(xiàn)兩個并不相等,說明i已經(jīng)被其他線程修改了,因此不執(zhí)行更新操作,而是從新讀取V的值V = 2 ,執(zhí)行+1后N = 3,完成后再讀取主存中i的值,因為此時沒有其他線程修改i的值了,所以E = 2,V = E = 2,兩個值相等,因此執(zhí)行賦值操作,將N的值賦值給i,最終得到的結果為3。在整過過程中始終沒有使用到鎖,卻實現(xiàn)的線程的安全性。
從上面的過程知道,cas會面臨著兩個問題,一個是當線程一直更新不成功的話,那么這個線程就一直處于死循環(huán)中,這樣會非常耗費cpu的資源;另一種是ABA的問題,即對i =1進行+1操作后,再-1,那么此時i的值仍為1,而另外一個線程獲取的E的值也是1,認為其他線程沒有修改過i,然后進行的更新操作,事實上已經(jīng)有其他線程修改過了這個值了,這個就是 A ---> B ---> A 的問題;
Ⅲ、獲取table對應的索引元素的位置
通過(n-1)& hash 的算法來獲得對應的table的下標的位置,如果對于這條公式不是很理解的同學可以到: jdk1.8源碼分析-hashMap 博客中了解。
tabAt(Node<K,V>[] tab, int i): 這個方法使用了java提供的原子操作的類來操作的,sun.misc.Unsafe.getObjectVolatile 的方法來保證每次線程都能獲取到最新的值;
casTabAt(Node<K,V>[] tab, int i,Node<K,V> c, Node<K,V> v): 這個方法是通過cas的方式來獲取i位置的元素;
Ⅳ、擴容
-
如果新增節(jié)點之后,所在的鏈表的元素個數(shù)大于等于8,則會調(diào)用treeifyBin把鏈表轉換為紅黑樹。在轉換結構時,若tab的長度小于MIN_TREEIFY_CAPACITY,默認值為64,則會將數(shù)組長度擴大到原來的兩倍,并觸發(fā)transfer,重新調(diào)整節(jié)點位置。(只有當tab.length >= 64, ConcurrentHashMap才會使用紅黑樹。)
-
新增節(jié)點后,addCount統(tǒng)計tab中的節(jié)點個數(shù)大于閾值(sizeCtl),會觸發(fā)transfer,重新調(diào)整節(jié)點位置。
五、get操作
get操作中沒有使用到同步的操作,所以相對來說比較簡單一點。通過key的hashCode計算獲得相應的位置,然后在遍歷該位置上的元素,找到需要的元素,然后返回,如果沒有則返回null:
/*** Returns the value to which the specified key is mapped,* or {@code null} if this map contains no mapping for the key.** <p>More formally, if this map contains a mapping from a key* {@code k} to a value {@code v} such that {@code key.equals(k)},* then this method returns {@code v}; otherwise it returns* {@code null}. (There can be at most one such mapping.)** @throws NullPointerException if the specified key is null*/ public V get(Object key) {Node<K,V>[] tab;Node<K,V> e, p;int n, eh;K ek;int h = spread(key.hashCode());if ((tab = table) != null && (n = tab.length) > 0 &&(e = tabAt(tab, (n - 1) & h)) != null) {if ((eh = e.hash) == h) {if ((ek = e.key) == key || (ek != null && key.equals(ek)))return e.val;} else if (eh < 0)return (p = e.find(h, key)) != null ? p.val : null;while ((e = e.next) != null) {if (e.hash == h &&((ek = e.key) == key || (ek != null && key.equals(ek))))return e.val;}}return null; } 復制代碼轉載于:https://juejin.im/post/5cc06d33e51d456e403772e5
總結
以上是生活随笔為你收集整理的【本人秃顶程序员】深入理解Java——ConcurrentHashMap源码的分析(JDK1.8)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在Ubuntu 16.04.3 LTS
- 下一篇: Vue 2.x 文件夹目录