1. 程式人生 > >併發Bug之源有三,請睜大眼睛看清它們

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

寫在前面

  • 生活中你一定聽說過——能者多勞
  • 作為 Java 程式設計師,你一定聽過——這個功能請求慢,能加一層快取或優化一下 SQL 嗎?
  • 看過中國古代神話故事的也一定聽過——天上一天,地上一年

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

  1. CPU 增加快取,還不止一層快取,平衡記憶體的慢
  2. CPU 能者多勞,通過分時複用,平衡 IO 的速度差異
  3. 優化編譯指令

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

三大問題

可見性

一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,我們稱為可見性

談到可見性,要先引出 JMM (Java Memory Model) 概念, 即 Java 記憶體模型,Java 記憶體模型規定,將所有的變數都存放在 主記憶體 中,當執行緒使用變數時,會把主記憶體裡面的變數 複製 到自己的工作空間或者叫作 私有記憶體 ,執行緒讀寫變數時操作的是自己工作記憶體中的變數。

用 Git 的工作流程理解上面的描述就很簡單了,Git 遠端倉庫就是主記憶體,Git 本地倉庫就是自己的工作記憶體

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

看這個場景:

  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 本甚至更多,切換的開銷就很大了吧
  • 綜藝節目中有很多遊戲,讓你一邊數錢,又要一邊做其他的事,最終保證多樣事情都做正確,大腦開銷大不大,你試試就知道了