老爺子這代碼,看跪了!

這是why的第 99 篇原創文章java

你好呀,我是why哥。程序員

不是,這個照片不是我,標題說的老爺子就是這個哥們,這事得從前幾天提及。編程

前幾天,發如今一個大佬雲集的技術羣裏面,大佬們就 Happens-Before 關係和 as-if-serial 語義進行了激烈的討論。安全

而我一看時間,快到 23 點了,大佬們都這麼卷,那我必須得跟着捲進去,因而看了一下他們的聊天記錄。markdown

而我,做爲一隻菜雞,雖然沒有什麼參與感,可是感受大佬們說的都挺有道理的,力排衆議。併發

因此基本上,我全程就是這樣的:app

可是,當他們說着說着就聊到了《Java併發編程實戰》,我一下就支棱了起來。函數

這書我看過啊,並且這書就在我手邊呀,終於能夠插上話了。oop

仔細一看,他們說的是書中的 16.1.4 小節:優化

沒啥映像了,甚至連「藉助同步」這個詞都沒有搞明白啥意思。

因而我翻到這一小節,讀了起來。

因爲這小節篇幅不長,且除了 Happens-Before 關係這個基礎知識鋪墊外,沒有其餘的背景,因此我把這一小節截圖出來,給你們看看:

怎麼樣,你們看完以後什麼感受?

是否是甚至都沒有耐心看完,一種雲裏霧裏的感受?

說實話,我看的時候就是這個感受,每一個字都看得懂,可是連在一塊兒就不知道啥意思了。

因此,讀完以後的感受就是:

找源碼

可是不慌,文章裏面舉的例子是 FutureTask ,這玩意併發編程基礎之一,我熟啊。

因而決定去源碼裏面看看,可是並沒找到書中舉的 innerSet 或者 innerGet 的方法:

因爲我這裏是 JDK 8 的源碼了,而這本書的發佈時間是 2012 年 2 月:

因爲是譯本,原書寫做時間可能就更早了。

對比這 JDK 版本發佈時間線來看,若是是源碼,也是 JDK 8 以前的源碼了:

果真,一個大佬告訴我,JDK 6 裏面的源碼就是這樣寫的:

可是我以爲去研究 JDK 6 的收益不是很大呀。(主要仍是我懶得去下載)

因而,我仍是在 JDK 8 的源碼裏面,發現了一點點蛛絲馬跡。

終於搞懂了,什麼是「藉助同步」了。

並且不得不讚嘆 Doug Lea 老爺子的代碼,真的是:妙啊。

到底什麼是「藉助同步」呢?且聽我細細道來。

基礎鋪墊

爲了文章的順利進行,必須得進行一個基礎知識的鋪墊,那就是 Happens-Before 關係。

而 Happens-Before 關係的正式提出,就是 jsr 133 規範:

http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf

若是你不知道 jsr133 是啥,那麼能夠去這個連接裏面看看。

http://ifeve.com/jsr133/

在這裏面就有你們耳熟能詳的 Happens-Before 關係的正式描述,你們看到的全部的中文版翻譯的原文,就是這裏:

因爲這段話,特別是那六個小黑點後面的話過重要了,失之毫厘謬以千里,因此我不敢輕易按照以前的輕鬆風格大體翻譯。

因而我決定站在大佬的肩膀上,分別把《深刻理解Java虛擬機(第三版)》、《Java併發編程實戰》、《Java併發編程的藝術》這三本書中關於這部分的定義和描述搬運一下,你們對比着看。

若是對於該規則瞭然於心,能夠跳過本小節。

走起。

首先是《深刻理解Java虛擬機(第三版)》:

  • 程序次序規則(Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操做先行發生於書寫在後面的操做。注意,這裏說的是控制流順序而不是程序代碼順序,由於要考慮分支、循環等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操做先行發生於後面對同一個鎖的 lock操做。這裏必須強調的是「同一個鎖」,而「後面」是指時間上的前後。
  • volatile 變量規則(Volatile Variable Rule):對一個 volatile 變量的寫操做先行發生於後面對這個變量的讀操做,這裏的「後面」一樣是指時間上的前後。
  • 線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每個動做。
  • 線程終止規則(Thread Termination Rule):線程中的全部操做都先行發生於對此線程的終止檢測,咱們能夠經過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
  • 線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,能夠經過Thread:interrupted()方法檢測到是否有中斷髮生。
  • 對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):若是操做A先行發生於操做B,操做B先行發生於操做C,那就能夠得出操做A先行發生於操做C的結論。

接着是《Java併發編程實戰》:

  • 程序順序規則:若是程序中操做A在操做B以前,那麼在線程中A操做將在B操做以前執行。
  • 監視器鎖規則:在監視器鎖上的解鎖操做必須在同一個監視器鎖上的加鎖操做以前執行。
  • volatile 變量規則:對volatile 變量的寫入操做必須在對該變量的讀操做以前執行。
  • 線程啓動規則:在線程上對Thread.Start的調用必須在該線程中執行任何操做以前執行。
  • 線程結束規則:線程中的任何操做都必須在其餘線程檢測到該線程已經結束以前執行,或者從Thread.join中成功返回,或者在調用Thread.isAlive時返回 false.
  • 中斷規則:當一個線程在另外一個線程上調用interrupt時,必須在被中斷線程檢測到interrupt調用以前執行(經過拋出InterruptedException,或者調用isInterrupted和interrupted).
  • 終結器規則:對象的構造函數必須在啓動該對象的終結器以前執行完成。
  • 傳遞性:若是操做A在操做B以前執行,而且操做B在操做C以前執行,那麼操做A必須在操做C以前執行。

《Java併發編程的藝術》,在這本書裏面做者加了一個限定詞「與程序員密切相關的 happens-before規則以下」:

  • 程序順序規則:一個線程中的每一個操做,happens-before 於該線程中的任意後續操做。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖。
  • volatile 變量規則:對一個volatile域的寫,happens-before 於任意後續對這個 volatile 域的讀。
  • 傳遞性:若是 A happens-before B,且B happens-before C,那麼 A happens-before C。

也就是說:線程啓動規則、線程結束規則、中斷規則、對象終結規則其實對於開發來講是無感的,在這幾個規則裏面,咱們沒有什麼能夠搞事的空間。

當你把這三本書中對於同一件事情的描述對比着來看的時候,也許會稍微的印象深入一點吧。

本質上說的是一回事,只是描述略有不一樣而已。

另外,我以爲我須要補充一個我以爲很是重要的點,那就是在原文論文中多處出現的一個很是重要的單詞 action:

那麼啥是 action?

對於這個略顯模糊的定義,論文開篇的第五點提到了具體含義:

In this section we define in more detail some of the informal concepts we have presented.
在本節中,咱們將更詳細地定義一些咱們提出的非正式概念。

其中對論文中的七個概念進行了詳細描述,分別是:

  • Shared variables/Heap memory
  • Inter-thread Actions
  • Program Order
  • Intra-thread semantics
  • Synchronization Actions
  • Synchronization Order
  • Happens-Before and Synchronizes-With Edges

其中,我我的理解,happens-before 中的 action 主要是說下面這個三個概念:

線程間(inter-thread)動做、線程內(intra-thread)動做、同步動做(Synchronization Actions)。

加鎖、解鎖、對 volatile 變量的讀寫、啓動一個線程以及檢測線程是否結束這樣的操做,均爲同步動做。

而線程間(inter-thread)動做與線程內(intra-thread)動做是相對而言的。好比一個線程對於本地變量的讀寫,也就是棧上分配的變量的讀寫,是其餘線程沒法感知的,這是線程內動做。而線程間動做好比對於全局變量的讀寫,也就是堆裏面分配的變量,其餘線程是能夠感知的。

另外,你看 Inter-thread Actions 裏面我畫下劃線的地方,描述其實和同步動做相差無幾。我理解,其實線程間動做大多也就是同步動做。

因此你去看一本書,叫作《深刻理解Java虛擬機HotSpot》,這本書裏面對於happens-before 的描述就稍微有點不同了,開篇加的限定條件就是「全部同步動做...」:

1)全部同步動做(加鎖、解鎖、讀寫volatile變量、線程啓動、線程完成)的代碼順序與執行順序一致,同步動做的代碼順序也叫做同步順序。
1.1)同步動做中對於同一個monitor,解鎖發生在加鎖前面。
1.2)同一個volatile變量寫操做發生在讀操做前面。
1.3)線程啓動操做是該線程的第一個操做,不能有先於它的操做發生。
1.4)當T2線程發現T1線程已經完成或者鏈接到T1,T1的最後一個操做要先於T2 全部操做。
1.5)若是線程T1中斷線程T2,那麼T1中斷點要先於任何肯定T2被中斷的線程的 操做。
對變量寫入默認值的操做要先於線程的第一個操做;對象初始化完成操做要先 於finalize()方法的第一個操做。
2)若是a先於b發生,b先於c發生,那麼能夠肯定a先於c發生。
3)volatile的寫操做先於volatile的讀操做。

原本,我還想舉出《Java編程思想》裏面關於 happens-before 的描述的。

結果,我翻完了書中關於併發的部分,結果它:

沒,有,寫!

好吧,我想有可能這本神書寫於 2004 年 jsr133 發佈以前?

結果,它的英文版發佈時間是在 2006 年,也就是做者故意沒寫的,他只是在 21.11.1 章節裏面提到了《Java Concurreny in Practice》:

而《Java Concurreny in Practice》就是咱們前面說的《Java併發編程實戰》。

做爲在 Java 界享有如此盛譽的一本書,竟然沒有提到 happens-before,略微有點遺憾。

可是轉念一想,這書的江湖地位雖然很高,可是定位實際上是入門級的,沒提到這塊的知識也算是比較正常。

另外,一個有意思的地方是這樣的:

在《深刻理解Java虛擬機(第三版)》裏面把 Monitor 翻譯爲了「管程」,另外兩本翻譯過來都是「監視器」。

那麼「管程」究竟是個什麼東西呢?

害,原來是一回事啊。

在 Java 裏面的 synchronized 就是管程的一種實現。

FutureTask in JDK 8

前面鋪墊了這麼多,你們應該還沒忘記我這篇主要想要分享的東西吧?

那就是「藉助同步」這個東西在 FutureTask 裏面的應用。

這是 JDK 8 裏面的 FutureTask 源碼截圖,重點關注我框起來的兩個部分。

  • state 是有 volatile 修飾的。
  • outcome 變量後面跟的註釋。

着重關注這句註釋:

non-volatile, protected by state reads/writes

你想,outcome 裏面封裝的是一個 FutureTask 的返回,這個返回多是一個正常的返回,也多是任務裏面的一個異常。

舉一個最簡單,也是最多見的應用場景:主線經過 submit 方式把任務提交到線程池裏面去了,而這個返回值就是 FutureTask:

接下來你會怎麼操做?

是否是在主線程裏面調用 FutureTask 的 get 方法獲取這個任務的返回值?

如今的狀況就是:線程池裏面的線程對 outcome 進行寫入,主線程調用 get 方法對 outcome 進行讀取?

這個場景下,咱們的常規操做是否是得在 outcome 上加一個 volatile,保證可見性?

那麼爲何這裏沒有加 volatile 呢?

你先本身咂摸咂摸。

接下來,要描述的全部東西都是圍繞着這個話題展開的。

來,走起。

首先,縱觀全局,outcome 變量的寫入操做,只有這兩個地方:

set 和 setException,而這兩個地方的邏輯和原理實際上是一致的。因此我就只分析 set 方法了。

接下來看看 outcome 變量的讀取操做,只有這個地方,也就是 get 方法:

須要說明的是 java.util.concurrent.FutureTask#get(long, java.util.concurrent.TimeUnit) 方法和 get 方法原理一致,也就不作過多解讀了。

因而咱們把目光彙集到了這三個方法上:

get 方法不是調用了 report 方法嘛,咱們把這兩個方法合併一下:

這裏沒毛病吧?

接着,咱們其實只關心 outcome 何時返回,其餘的對於我來講都是干擾項,因此咱們把上面的 get 變成僞代碼:

當 s 爲 NORMAL 的時候,返回 outcome,這僞代碼也沒毛病吧?

下面,咱們再看一下 set 方法:

其中第二行的含義是利用 CAS 操做把狀態從 NEW 修改成 COMPLETING 狀態,CAS 成功以後在進入 if 代碼段裏面。

而後在通過第三行代碼,即 outcome=v 以後,狀態就修改成了 NORMAL。

其實你看,從 NEW 到 NORMAL,中間這個的 COMPLETING 狀態,其實咱們能夠說是轉瞬即逝。

甚至,好像沒啥用似的?

那麼爲了推理的順利進行,我決定使用反證法,假設咱們不須要這個 COMPLETING 狀態,那麼咱們的 set 方法就變成了這個樣子:

通過簡化以後,這就是最終 set 的僞代碼:

因而咱們把 get/set 的僞代碼放在一塊兒:

到這裏,終於終於全部的鋪墊都完成了。

歡迎你們來到解密環節。

首先,若是標號爲 ④ 的地方,讀到的值是 NORMAL,那麼說明標號爲 ③ 的地方必定已經執行過了。

爲何?

由於 s 是被 volatile 修飾的,根據 happens-before 關係:

volatile 變量規則:對volatile 變量的寫入操做必須在對該變量的讀操做以前執行。

因此,咱們能夠得出標號爲 ③ 的代碼先於標號爲 ④ 的代碼執行。

而又根據程序次序規則,即:

在一個線程內,按照控制流順序,書寫在前面的操做先行發生於書寫在後面的操做。注意,這裏說的是控制流順序而不是程序代碼順序,由於要考慮分支、循環等結構。

能夠得出 ② happens-before ③ happens-before ④ happens-before ⑤

又根據傳遞性規則,即:

若是操做A先行發生於操做B,操做B先行發生於操做C,那就能夠得出操做A先行發生於操做C的結論。

能夠得出 ② happens-before ⑤。

而 ② 就是對 outcome 變量的寫入,⑤ 是對 outcome 變量的讀取。

雖然被寫入,被讀取的變量沒有加 volatile,可是它經過被 volatile 修飾的 s 變量,藉助了 s 變量的 happens-before 關係,完成了同步的操做。

即:寫入,先於讀取。

這就是「藉助同步」。

有沒有品到一點點味道了?

別急,我這反證法呢,還沒聊到 COMPLETING 狀態呢,咱們繼續分析。

回過頭去看 set 方法的僞代碼,標號爲 ① 的地方我還沒說呢。

雖然標號爲 ① 的地方和標號爲 ③ 的地方都是對 volatile 變量的操做,可是它們之間不是線程安全的,這個點咱們能達成一致吧?

因此,這個地方咱們得用 CAS 來保證線程安全。

因而程序變成了這樣:

這樣,線程安全的問題被解決了。可是其餘的問題也就隨之而來了。

第一個問題是程序的含義發生了變化:

從「outcome 賦值完成後,s 才變爲 NORMAL」,變成了「s 變成 NORMAL 後,纔開始賦值」。

可是,這個問題不在我本文的討論範圍內,並且最後這個問題也會被解決,因此咱們看另一個問題,纔是我想要討論的問題。

什麼問題呢?

那就是 outcome 的「藉助同步」策略失敗了。

由於若是咱們經過這樣的方式去解決線程安全的問題,把 CAS 操做拆開看,程序就有點像是這樣的:

根據 happens-before 關係,咱們只能推斷出:

② happens-before ④ happens-before ⑤,和 ③ 沒有扯上關係。

因此,咱們不能得出 ③ happens-before ⑤,因此藉助不了同步了。

這種時候,若是是咱們碰到了怎麼辦呢?

很簡單嘛,給 outcome 加上 volatile 就好了,哪裏還須要這麼多奇奇怪怪的推理。

可是 Doug Lea 畢竟是 Doug Lea,加 volatile 多 low 啊,老爺子準備「藉助同步」。

前面咱們分析了,這樣是能夠藉助同步的,可是不能保證線程安全:

protected void set(V v) {
    if (s==NEW) {
        outcome = v;
        s=NORMAL;
    }
}
複製代碼

那麼,咱們是否是能夠搞成這樣:

protected void set(V v) {
    if (s==NEW) {
        s=COMPLETING;
        outcome = v;
        s=NORMAL;
    }
}
複製代碼

COMPLETING 也是對 s 變量的寫入呀,這樣 outcome 又能「藉助同步」了。

用 CAS 優化一下就是這樣:

protected void set(V v) {
    if (compareAndSet(s, NEW, COMPLETING)){
        outcome = v;
        s=NORMAL;
    }
}
複製代碼

引入一個轉瞬即逝的 COMPLETING 狀態,就可讓 outcome 變量不加 volatile,也能創建起 happens-before 關係,就能達到「藉助同步」的目的。

看起來其貌不揚、無關緊要的 COMPLETING 狀態,居然是一個基於代碼優化得出的一個深思熟慮的產物。

不得不說,老爺子這代碼:

真的是「騷」啊,學不來,學不來。

另外,關於 FutureTask 以前我也寫過一篇文章,描述的是其另一個 BUG:

Doug Lea在J.U.C包裏面寫的BUG又被網友發現了。

在這篇文章裏面提到了:

老爺子說他「故意這樣寫的」,這背後是否是還包含着「藉助同步」的這個背景呢?

不得而知,可是我彷彿有了一絲「夢幻聯動」的感受。

好了,本次的文章就分享到這裏了。

恭喜你,又學到了一個這輩子基本上不會用到的知識點。

再見。

最後說一句

才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,能夠在留言區提出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

相關文章
相關標籤/搜索