併發Bug之源有三,請睜大眼睛看清它們

寫在前面

  • 生活中你必定據說過——能者多勞
  • 做爲 Java 程序員,你必定聽過——這個功能請求慢,能加一層緩存或優化一下 SQL 嗎?
  • 看過中國古代神話故事的也必定聽過——天上一天,地上一年

一切設計來源於生活,上一章 學併發編程,透徹理解這三個核心是關鍵 中有講過,做爲"資本家",你要儘量的榨取 CPU,內存與 IO 的剩餘價值,但三者完成任務的速度相差很大,CPU > 內存 > IO分,CPU 是天,那內存就是地,內存是天,那 IO 就是地,那怎樣平衡三者,提高總體速度呢?java

  1. CPU 增長緩存,還不止一層緩存,平衡內存的慢
  2. CPU 能者多勞,經過分時複用,平衡 IO 的速度差別
  3. 優化編譯指令

上面的方式貌似解決了木桶短板問題,但同時這種解決方案也伴隨着產生新的可見性,原子性,和有序性的問題,且看git

三大問題

可見性

一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲可見性程序員

談到可見性,要先引出 JMM (Java Memory Model) 概念, 即 Java 內存模型,Java 內存模型規定,將全部的變量都存放在 主內存 中,當線程使用變量時,會把主內存裏面的變量 複製 到本身的工做空間或者叫做 私有內存 ,線程讀寫變量時操做的是本身工做內存中的變量。github

用 Git 的工做流程理解上面的描述就很簡單了,Git 遠程倉庫就是主內存,Git 本地倉庫就是本身的工做內存面試

文字描述有些抽象,咱們來圖解說明:算法

看這個場景:shell

  1. 主內存中有變量 x,初始值爲 0
  2. 線程 A 要將 x 加 1,先將 x=0 拷貝到本身的私有內存中,而後更新 x 的值
  3. 線程 A 將更新後的 x 值回刷到主內存的時間是不固定的
  4. 恰好在線程 A 沒有回刷 x 到主內存時,線程 B 一樣從主內存中讀取 x,此時爲 0,和線程 A 同樣的操做,最後期盼的 x=2 就會編程 x=1

這就是線程可見性的問題編程

JMM 是一個抽象的概念,在實際實現中,線程的工做內存是這樣的: 數組

爲了平衡內存/IO 短板,會在 CPU 上增長緩存,每一個核都只有本身的一級緩存,甚至有一個全部 CPU 都共享的二級緩存,就是上圖的樣子了,都說這麼設計是硬件同窗留給軟件同窗的一個坑,但可否跳過去這個坑也是衡量軟件同窗是否走向 Java 進階的關鍵指標吧......緩存

小提示

從上圖中你也能夠看出,在 Java 中,全部的實例域,靜態域和數組元素都存儲在堆內存中,堆內存在線程之間共享,這些在後續文章中都稱之爲「共享變量」,局部變量,方法定義參數和異常處理器參數不會在線程之間共享,因此他們不會有內存可見性的問題,也就不受內存模型的影響

一句話,要想解決多線程可見性問題,全部線程都必需要刷取主內存中的變量 怎麼解決可見性問題呢?Java 關鍵字 volatile 幫你搞定,後續章節會分析......

原子性

原子(atom)指化學反應不可再分的基本微粒,原子性操做你應該能感覺到其含義:

所謂原子操做是指不會被線程調度機制打斷的操做;這種操做一旦開始,就一直運行到結束,中間不會有任何 context switch

小品「鐘點工」有一句很是經典的臺詞,要把大象裝冰箱,總共分幾步?

來看一小段程序:

多線程狀況下能獲得咱們期盼的 count = 20000 的值嗎? 也許有同窗會認爲,線程調用的 counter 方法只有一個 count++ 操做,是單一操做,因此是原子性的,非也。在線程第一講中說過咱們不能用高級語言思惟來理解 CPU 的處理方式,count++ 轉換成 CPU 指令則須要三步,經過下面命令解析出彙編指令等信息:

javap -c UnsafeCounter 
複製代碼

截取 counter 方法的彙編指令來看:

解釋一下上面的指令, 16 : 獲取當前 count 值,而且放入棧頂 19 : 將常量 1 放入棧頂 20 : 將當前棧頂中兩個值相加,並把結果放入棧頂 21 : 把棧頂的結果再賦值給 count

因而可知,簡單的 count++ 不是一步操做,被轉換爲彙編後就不具有原子性了,就比如大象裝冰箱,其實要分三步:

第一步,把冰箱門打開;第二步,把大象放進去;第三步,把冰箱門帶上

結合 JMM 結構圖理解,說明一下爲何很可貴到 count=20000 的結果:

多線程計數器,如何保證多個操做的原子性呢?最粗暴的方式是在方法上加 synchronized 關鍵字,好比這樣:

問題是解決了,若是 synchronized 是萬能良方,那麼也許併發就沒那麼多事了,能夠靠一個 synchronized 走天下了,事實並非這樣,synchronized 是獨佔鎖 (同一時間只能有一個線程能夠調用),沒有獲取鎖的線程會被阻塞;另外也會帶來不少線程切換的上下文開銷

因此 JDK 中就有了非阻塞 CAS (Compare and Swap) 算法實現的原子操做類 AtomicLong 等工具類,看過源碼的同窗也許會發現一個共同特色,全部原子類中都有下面這樣一段代碼:

private static final Unsafe unsafe = Unsafe.getUnsafe();
複製代碼

這個類是 JDK 的 rt.jar 包中的 Unsafe 類提供了 硬件級別 的原子性操做,類中的方法都是 native 修飾的,後面介紹原子類以前也會先說明這個類中的幾個方法,這裏先簡單介紹有個印象便可。

有同窗不理解我剛剛提到的線程上下文切換開銷很大是什麼意思,舉 2個例子你就懂了:

  • 你(CPU)在看兩本書(兩個線程),看第一本書很短期後要去看第二本書,看第二本書很短期後又回看第一本書,並要精確的記得看到第幾行,當初看到了什麼(CPU 記住線程級別的信息),當讓你 "同時" 看 10 本甚至更多,切換的開銷就很大了吧
  • 綜藝節目中有不少遊戲,讓你一邊數錢,又要一邊作其餘的事,最終保證多樣事情都作正確,大腦開銷大不大,你試試就知道了😊

有序性

生活中你問候他人「吃了嗎你?」和「你吃了嗎?」是一個意思,你寫的是下面程序:

a = 1;
b =  2;
System.out.println(a);
System.out.println(b);
複製代碼

編譯器優化後可能就變成了這樣:

b =  2;
a = 1;
System.out.println(a);
System.out.println(b);
複製代碼

這個狀況,編譯器調整了語句順序沒什麼影響,但編譯器 擅自 優化順序,就給咱們埋下了雷,好比應用雙重檢查方式實現的單例

一切又很完美是否是,非也,問題出如今 instance = new Singleton();,這 1 行代碼轉換成了 CPU 指令後又變成了 3 個,咱們理解 new 對象應該是這樣的:

  1. 分配一塊內存 M
  2. 在內存 M 上初始化 Singleton 對象
  3. 而後 M 的地址賦值給 instance 變量

但編譯器擅自優化後可能就變成了這樣:

  1. 分配一塊內存 M
  2. 而後將 M 的地址賦值給 instance 變量
  3. 在內存 M 上初始化 Singleton 對象

首先 new 對象分了三步,給 CPU 留下了切換線程的機會;另外,編譯器優化後的順序可能致使問題的發生,來看:

  1. 線程 A 先執行 getInstance 方法,當執行到指令 2 時,剛好發生了線程切換
  2. 線程 B 剛進入到 getInstance 方法,判斷 if 語句 instance 是否爲空
  3. 線程 A 已經將 M 的地址賦值給了 instance 變量,因此線程 B 認爲 instance 不爲空
  4. 線程 B 直接 return instance 變量
  5. CPU 切換回線程 A,線程 A 完成後續初始化內容

咱們仍是畫個圖說明一下:

若是線程 A 執行到第 2 步,線程切換,因爲線程 A 沒有把紅色箭頭執行徹底,線程 B 就會獲得一個未初始化徹底的對象,訪問 instance 成員變量的時候就可能發生 NPE,若是將變量 instance 用 volatile 或者 final 修飾(涉及到類的加載機制,可看我以前寫的文章: 雙親委派模型:大廠高頻面試題,輕鬆搞定),問題就解決了.

總結

你所看到的程序並不必定是編譯器優化/編譯後的 CPU 指令,大象裝冰箱是是個程序,但其隱含三個步驟,學習併發編程,你要按照 CPU 的思惟考慮問題,因此你須要深入理解 可見性/原子性/有序性 ,這是產生併發 Bug 的源頭

本節說明了三個問題,下面的文章也會逐個分析解決以上問題的辦法,以及相對優的方案,請持續關注,另外關於併發的測試代碼我都會按例上傳到 github,公衆號回覆「demo」——> concurrency 獲取更多內容

靈魂追問

  1. 爲何用 final 修飾的變量就是線程安全的了呢?
  2. 你會常常查看 CPU 彙編指令嗎?
  3. 若是讓你寫單例,你一般會採用哪一種實現?

提升效率工具

Material Theme UI

這是一款 IDEA 的主題插件,安裝後,選擇 Material Palenight 主題,同時做出以下設置

設置完後,你的 IDEA 就是下面這樣,引發極度溫馨

推薦閱讀


歡迎持續關注公衆號:「日拱一兵」

  • 前沿 Java 技術乾貨分享
  • 高效工具彙總 | 回覆「工具」
  • 面試問題分析與解答
  • 技術資料領取 | 回覆「資料」

以讀偵探小說思惟輕鬆趣味學習 Java 技術棧相關知識,本着將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......

相關文章
相關標籤/搜索