最近在復習准備一些面試,偶爾會抽些零碎時間逛一下之前關注的公眾號,看看有沒有哪些被自己遺漏的地方,或者是一些能補充知識的文章,比如前幾天看到一篇講MySQL插入100W條數據要花多久的文章,點進去看到了久違的 PreparedStatement,順便復習了一下,原來數據庫不僅能識別純的SQL還可以識別執行計划,PreparedStatement 利用了連接池的緩存機制將SQL轉成執行計划保存起來,通過參數占位符化(用?占位)的方式將純SQL轉換成可以重用的執行計划,同一個執行計划可以對參數相同但值不同的SQL進行復用,從而降低了數據庫方面編譯SQL的開銷而提高了性能。后面又想到如果用 Mybatis 框架的話,它內部有沒有類似的機制來沖用執行計划呢?
雖然 Mybatis 也是一個輕量級 ORM 框架,大部分時間只要在 xml 里處理 SQL,但還是因為不夠熟,之前也一直沒實戰過這個框架,就沒去深究它的這一層原理。
而本文現在要討論的則是 Java 開發者應該(可能?或許?)都非常熟悉的一個 API,它是 ConcurrentHashMap。源於昨天看到了一篇《為什么 ConcurrentHashMap 讀操作不加鎖》,先說說文章里面總結的比較好的一些點:
1)volatile 的作用:
a. volatile 修飾的變量的操作都直接與主內存打交道,JMM 的工作內存在 volatile 面前無效
b. volatile 變量的更新,JVM 會向所有CPU發出一條禁用高級緩存的指令,迫使 CPU 對變量的讀寫都是直接操作主內存
c. volatile 語義中有禁止指令重排序的作用,這在分析程序運行次序時是一條重要的規則
2)與其他並發工具hashtable、用Collections.synchronizedMap()包裝的hashmap進行的性能比較中優勝,因為 get 不加鎖
3)對 CHM 內部數組成員 table 的 volatile 分析:用來保證擴容(包含初始化)時 table 對其他線程可見
4)分析了 Node 節點的 val、next 字段的 volatile,對這些字段在“更新”操作的分析還算正確
然后就是我覺得的這篇對 CHM 分析不足的地方,或者說是對 Java 並發分析沒有考慮周到的地方。以下是我的個人觀點。
首先,在 Java 中分析並發,避不可及的會談到幾個概念:happens-before,JMM,volatile,鎖,CAS。為什么要把 happens-before 放在最前面???因為它真的是可以將 happens-before、JMM、volatile 三者聯系起來的一個主導者。其次它也可以和 鎖 直接聯系成為另一種並發情況的分析依據。可以說,happens-before 是在討論 Java 並發分析快要進入鑽牛角尖階段時的大殺器。為什么?因為現代 CPU 動輒每秒上百億次的運算速度,就算是 1毫秒內可以執行的指令數量也是千萬級別,在並發環境下到底誰先執行誰后執行是一個很容易鑽牛角尖的點。
而 Java 在 JSR-133 使用 happens-before 概念來定義兩個操作之間執行順序(周志明《深入理解 Java 虛擬機 第二版》中有對 happens-before、JMM、volatile 較詳細的講解)。
貼幾個本文會用到的 happens-before 關系:
(1)程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意后續操作。
(2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨后對這個鎖的加鎖。
(3)volatile變量規則:對一個volatile域的寫,happens-before於任意后續對這個volatile域的讀。
(4)傳遞性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
第(3)點的意思是,volatile 域被寫了之后,如果后面有讀操作的話,所有的讀都能看到這次更新。即使 volatile 變量初始化后沒有任何寫,那么之后所有的讀都能讀到初始化的值(可以不是 0),而不是 JVM 申明變量時給的“零值”。 第(1)點在沒有指令重排序的情況下是成立的,在有 volatile 的情況下這一點能夠保證。第(2)點說的是重量級鎖 synchronized,相信都會分析了。第(4)也很容易理解,而且通常是 happens-before 分析並發套路中的重要一環。
先給一個 happens-before 分析並發的例子:
假設線程 A 執行 writer()方法之后,線程 B 執行 reader() 方法。根據 happens-before 規則,這個過程建立的 happens-before 關系可以分為3類:
1)根據程序次序規則,1 happens-before 2;3 happens-before 4。
2)根據 volatile 規則,2 happens-before 3。
3)根據happens-before的傳遞性規則,1 happens-before 4。
上述happens-before關系的圖形化表現形式如下圖:
在上面這個例子中,分析時假設了線程 A writer() 方法執行完后 線程 B reader() 方法才執行,在分析 CHM get() 方法時這個粒度可以更小一些,可以具體到某幾行代碼上。讓我們來看一下 CHM 是如何保證 get() 方法中節點的可見性的吧。
使用 happens-before 來分析 ConcurrentHashMap get() 為什么不加鎖:
我們假設 CHM 初始 size 是 16(默認),並且 table 沒有被初始化,那么因為 table 被 volatile 修飾,當它被初始化到 16個節點都是 Null 的 Node[] 數組上后,后續線程能看到 table 初始化后的數組。table 的初始化使用 CAS(sizeCtl) 字段來控制只有一條線程執行,這是 table 在初始化階段的線程安全以及可見性保證。
在 table 初始化后,假設有一條線程調用 put(),N 條線程調用 get(),有哪些線程能看到(get到) put 進去的 val?上兩段代碼,然后像上面那個例子一樣來分析CHM的並發 put、get。
get() 代碼段:
1 if ((tab = table) != null && (n = tab.length) > 0 && 2 (e = tabAt(tab, (n - 1) & h)) != null) { // 1
put() 代碼段:
1 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { 2 if (casTabAt(tab, i, null, 3 new Node<K,V>(hash, key, value, null))) // 2 4 break; // no lock when adding to empty bin 5 }
代碼段后面有兩個標注 // 1、// 2。要讓線程 get() 能看到 new Node() 的值,必須要有 2 happens-before 1 關系,也就是說 new Node() 要對線程可見必須在 2 happens-before 1 的前提下,即當調用 put() 線程的代碼執行了標注 2 位置的代碼之后后續線程執行 get() 才能看到 Node 從 null 變為 new Node() 的值;如果 1 happens-before 2,get 線程就不能看見 new Node()。在 Node 節點變得可見之后,線程調用 get() 讀取 volatile val 則會直接讀取主內存的值,因此可以 get 到。
注意到標注 2 代碼下面有一行注釋“ no lock when adding to empty bin ”,意思是 table[i] 從 null 變為 new Node() 不需要加鎖。
PS:這里注釋的意思感覺是默認了 CAS 也能保證可見性,也就是它操作的是主內存。不僅是這里,挺多用了 CAS 的代碼都默認了其直接操作主內存來保證可見性的效果。之前 level 較低,沒有考慮到這層,應該就是這么實現的不過后面找資料把這個窟窿補上吧。
綜上:在 table 剛剛被初始化的階段,所有 Node 節點都還是 null,只有當 put 代碼將 new Node() 設置到對應數組位置上時該數組位置的 Node 節點才對后面執行 get() 的線程可見,在此之前進行 get() 的線程只能看到 null 節點。
而當 table 數組上的節點被初始化了之后,后面的操作再訪問 Node 的 val 和 next 時,由於 volatile 的作用保證了 get 訪問 val 和 next 節點的可見性,在分享的文章中也有較對應的描述,本文就不再贅述了。
小結
在分析 Java 並發時,需要以 happens-before 原則為基准,結合 JMM、volatile、鎖、CAS 等機制來分析程序運行時代碼在什么時候才具有“可見性”。