阿里面試,一面就倒在了Java內存模型上

目錄

什麼是Java內存模型

可見性問題

原子性問題

指令重排序帶來的問題

敲黑板

總結

擴展


最近金三銀四跳槽找工作的高峯期,我也湊熱鬧準備出去看看機會,結果就寫了簡歷,一邊投一邊複習準備試雖然還沒開始就結束了,傷心歸傷心,失落就失落,該覆盤我們還是要的撒?

短短半個小時,我覺得回答最不好的可能就是面試官問:談談你對Java內存模型的理解?

這個問題吧,問的比較泛,可能是想考察我到底有沒有真的理解掌握了,如果你最近也在面試,那麼很大機率也是會問到這個問題,畢竟搞Java的,沒有不問併發的。而Java內存模型是一個人併發水平的一種體現。 原因是當併發程序出現問題時,需要一行一行的審視代碼,這個時候,只有掌握Java內存模型,才能慧眼如炬的發現問題。

什麼是Java內存模型

在學習Java內存模型之前,我們先思考下,Java內存模型主要是爲了用來做什麼?爲什麼要定義它?帶着這個問題我們接着往下看。

在併發編程中有一句話你要牢牢記住,那就是: 可見性、有序性、原子性 這三者是所有併發bug的源頭。

可見性問題

多核時代,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對於線程 B 而言就不具備可見性了。這個就屬於硬件程序員給軟件程序員挖的“坑”。

原子性問題

早期的操作系統基於進程來調度 CPU,不同進程間是不共享內存空間的,所以進程要做任務切換就要切換內存映射地址,而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了。現代的操作系統都基於更輕量的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。

Java 併發程序都是基於多線程的,自然也會涉及到任務切換,也許你想不到,任務切換竟然也是併發編程裏詭異 Bug 的源頭之一。任務切換的時機大多數是在時間片結束的時候,我們現在基本都使用高級語言編程,高級語言裏一條語句往往需要多條 CPU 指令完成,例如上面代碼中的count += 1,至少需要三條 CPU 指令。

  1. 指令 1:首先,需要把變量 count 從內存加載到 CPU 的寄存器;
  2. 指令 2:之後,在寄存器中執行 +1 操作;
  3. 指令 3:最後,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。

操作系統做任務切換,可以發生在任何一條 CPU 指令執行完,是的,是 CPU 指令,而不是高級語言裏的一條語句。對於上面的三條指令來說,我們假設 count=0,如果線程 A 在指令 1 執行完後做線程切換,線程 A 和線程 B 按照下圖的序列執行,那麼我們會發現兩個線程都執行了 count+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。

這裏你可能有一點不好理解,那就是 count+=1,一般來說我們就覺得這行代碼是一個整體,就算 CPU 切換也是在這行代碼之前或者之後,其實這裏犯了想當然的毛病了。 一個或多個操作在CPU執行過程中不被中斷的特性成爲原子性。 CPU 保證的原子性是指的CPU指令層面, 而並不能保證我們高級語言的一行操作符的原子性,所以我們就需要在很多場景實現操作符層面的原子性。

指令重排序帶來的問題

編譯器爲了優化性能,有時候會改變程序中語句的先後順序,例如程序中:“a=6;b=7;”編譯器優化後可能變成“b=7;a=6;”,在這個例子中,編譯器調整了語句的順序,但是不影響程序的最終結果。不過有時候編譯器及解釋器的優化可能導致意想不到的 Bug。

一個經典的案例就是利用雙重檢查創建單例對象,例如下面的代碼:在獲取實例 getInstance() 的方法中,我們首先判斷 instance 是否爲空,如果爲空,則鎖定 Singleton.class 並再次檢查 instance 是否爲空,如果還爲空則創建 Singleton 的一個實例。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null ,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。這看上去一切都很完美,無懈可擊,但實際上這個 getInstance() 方法並不完美。問題出在哪裏呢?出在 new 操作上,我們以爲的 new 操作應該是:

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

是實際上優化後的執行路徑卻是這樣的:

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

優化後會導致什麼問題呢?我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。

實際上爲了提高性能,編譯器和處理器在運行時都會對指令做重排序。可以分爲以下三類:

  1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序:

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會導致多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序。

敲黑板

ok,經過上面的介紹,你肯定已經知道了,導致可見性的原因是緩存,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,但是這樣問題雖然解決了,我們程序的性能可就堪憂了。

合理的方案應該是按需禁用緩存以及編譯優化。那麼,如何做到“按需禁用”呢?對於併發程序,何時禁用緩存以及編譯優化只有程序員知道,這裏你應該也明瞭了。所謂“按需禁用”只需要提供給程序員按需禁用緩存和編譯優化的方法即可。 這裏就該我們的主角出場了,Java內存模型

Java 內存模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來說,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及六項 Happens-Before 規則。解決了併發編程中的可見性以及有序性問題。

Java 內存模型裏面,最晦澀的部分就是 Happens-Before 規則了,這裏我們不對具體的規則進行論述,後續我們會專門的文章來學習 Happens-Before 的定義和規則,敬請期待。

一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程序員來說,happens-before規則簡單易懂,它避免Java程序員爲了理解JMM提供的內存可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法。關係如下圖所示:

總結

今天主要學習併發編程的源頭:可見性、有序性、原子性,以及現代處理器爲什麼會出現這三種問題。而Java內存模型就是針對這三個問題中的可見性和有序性定義了一系列規範,在JVM層面提供了按需禁用緩存和編譯優化的方法。具體包括 volatilesynchronizedfinal 三個關鍵字,以及六項 Happens-Before 規則,這些具體的實現手段我們後續文章會繼續學習。

綜上,Java 內存模型主要分爲兩部分,一部分面向你我這種編寫併發程序的應用開發人員,另一部分是面向 JVM 的實現人員的,我們可以重點關注前者,也就是和編寫併發程序相關的部分,這部分內容的核心就是 Happens-Before 規則。

相信通過本文的閱讀,你已經對JMM有了深刻的理解,建議你在認爲自己理解之後多和他人探討或者講給你的朋友,如果你能以自己的話語給別人講明白,那麼說明你真的學會了。

擴展

面試官:JMM爲什麼不保證對64位的long型和double型變量的寫操作具有原子性?

對這個問題感興的小夥伴歡迎關注小黑的公衆號,回覆 “0”查看解答哦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章