每日三道面試題,通往自由的道路10——JMM篇

太子爺哪吒發表於2021-07-03

茫茫人海千千萬萬,感謝這一秒你看到這裡。希望我的面試題系列能對你的有所幫助!共勉!

願你在未來的日子,保持熱愛,奔赴山海!

每日三道面試題,成就更好自我

今天我們還是繼續聊聊多執行緒的一些其他話題吧!

1. 你知道JVM記憶體模型嗎?

在Java的併發中採用的就是JVM記憶體共享模型即JMM(Java Memory Model),它其實是是JVM規範中所定義的一種記憶體模型,跟計算機的CPU快取記憶體模型類似,是基於CPU快取記憶體模型來建立的,Java記憶體模型是標準化的,遮蔽掉了底層不同計算機的區別。

那我們先來講下計算機的記憶體模型:

其實早期計算機中CPU和記憶體的速度是差不多的,但在現代計算機中,CPU的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝。

將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(CacheCoherence)。

在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(MainMemory)。

而我們可以開啟工作管理員,可以進入效能 --> CPU中可以看到L1快取、L2快取和L3快取。

可以看到我們CPU跟我們計算機之間互動的快取記憶體。一般的流程,就是計算機會先從硬碟從讀取資料到主記憶體中,又會從主記憶體讀取資料到快取記憶體中,而CPU讀取的資料就是快取記憶體中的數。

我們現在再來看看JMM:

JMM是定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數存在主記憶體(MainMemory)中,每個執行緒都有一個私有的本地記憶體(LocalMemory)即共享變數副本,本地記憶體中儲存了該執行緒以讀、寫共享變數的副本。本地記憶體是Java記憶體模型的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器等。

JMM模型圖:

我們可以發現在JMM模型中:

  1. 所有的共享變數都存在主記憶體中。
  2. 每個執行緒都儲存了一份該執行緒使用到的共享變數的副本。
  3. 執行緒A是無法直接訪問到執行緒B的本地記憶體的,只能訪問主記憶體。
  4. 執行緒對共享變數的所有操作都必須在自己的本地記憶體中進行,不能直接從主記憶體中讀取。
  5. 併發的三要素:可見性、原子性、有序性,而JMM就主要體現在這三方面。

注意 :因為執行緒之間無法相互訪問,而一旦某個執行緒將共享變數進行修改,而執行緒B是無法發現到這個更新值的,所以可能會出現可見性問題。而這裡的可見性問題就是一個執行緒對共享變數的修改,另一個執行緒能夠立刻看到,但此時無法看到更新後的記憶體,因為訪問的是自己的共享變數副本。

解決方案有

  1. 加鎖,加synchronized、Lock,儲存一個執行緒只能等另一個執行緒結束後才能再訪問變數。
  2. 對共享變數加上volatile關鍵字,保證了這個變數是可見的。

不錯呀!看來難不住你呀,那我們們繼續

2. 你知道重排序是什麼嗎?

重排序是指計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排。

首先我們來看看為什麼指令重排序可以提高效能?

每一個指令都會包含多個步驟,每個步驟可能使用不同的硬體,而現代處理器會設計為一個時鐘週期完成一條執行時間最長的指令,為什麼會這樣呢?

主要原理就是可以指令1還沒有執行完,就可以開始執行指令2,而不用等到指令1執行結束之後再執行指令2,這樣就大大提高了效率。

例如:每條指令拆分為五個階段:

想這樣如果是按順序序列執行指令,那可能相對比較慢,因為需要等待上一條指令完成後,才能等待下一步執行:

而如果發生指令重排序呢,實際上雖然不能縮短單條指令的執行時間,但是它變相地提高了指令的吞吐量,可以在一個時鐘週期內同時執行五條指令的不同階段。

我們來分析下程式碼的執行情況,並思考下:

a = b + c;

d = e - f ;

按原先的思路,會先載入b和c,再進行b+c操作賦值給a,接下來就會載入e和f,最後就是進行e-f操作賦值給d。

這裡有什麼優化的空間呢?我們在執行b+c操作賦值給a時,可能需要等待b和c載入結束,才能再進行一個求和操作,所以這裡可能出現了一個停頓等待時間,依次後面的程式碼也可能會出現停頓等待時間,這降低了計算機的執行效率。

為了去減少這個停頓等待時間,我們可以先載入e和f,然後再去b+c操作賦值給a,這樣做對程式(序列)是沒有影響的,但卻減少了停頓等待時間。既然b+c操作賦值給a需要停頓等待時間,那還不如去做一些有意義的事情。

總結:指令重排對於提高CPU處理效能十分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。

重排序的型別有以下幾種:

指令重排一般分為以下三種:

  • 編譯器優化重排

    編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 指令並行重排

    現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。

  • 記憶體系統重排

    由於處理器使用快取和讀寫快取衝區,這使得載入(load)和儲存(store)操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的資料同步存在時間差。

而在重排序中還需要一個概念的東西:as-if-serial

不管如何重排序,都必須保證程式碼在單執行緒下的執行正確,連單執行緒下都無法正確,更不用討論多執行緒併發的情況,所以就提出了一個as-if-serial的概念。

as-if-serial語義的意思是:

  • 不管怎麼重排序,程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

  • 為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。(強調一下,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮)。但是,如果操作之間不存在資料依賴關係,這些操作依然可能被編譯器和處理器重排序。

真的不錯呀,本來我也不是很懂,聽你這麼一講,瞬間恍然大悟呀。那還是最後問你最後一道:

3. happens-before是什麼,和as-if-serial有什麼區別

happens-before的概念:

一方面,程式設計師需要JMM提供一個強的記憶體模型來編寫程式碼;另一方面,編譯器和處理器希望JMM對它們的束縛越少越好,這樣它們就可以最可能多的做優化來提高效能,希望的是一個弱的記憶體模型。

JMM考慮了這兩種需求,並且找到了平衡點,對編譯器和處理器來說,只要不改變程式的執行結果(單執行緒程式和正確同步了的多執行緒程式),編譯器和處理器怎麼優化都行。

而對於程式設計師,JMM提供了happens-before規則(JSR-133規範),在JMM中,如果一個執行緒執行的結果需要對另一個操作進行可見,那麼這兩個操作直接必須存在happens-before關係。

JMM使用happens-before的概念來定製兩個操作之間的執行順序。這並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前 。

happens-before關係的定義如下:

  1. 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
  2. 兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼JMM也允許這樣的重排序。
  3. happens-before關係保證正確同步的多執行緒程式的執行結果不被重排序改變。

在Java中,有以下天然的Happens-Before規則:

  • 程式順序規則:一個執行緒中的每一個操作,happens-before於該執行緒中的任意後續操作。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  • 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  • start規則:如果執行緒A執行操作ThreadB.start()啟動執行緒B,那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作、
  • join規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
  • 執行緒中斷規則:對執行緒interrupt()方法的呼叫happens-before於被中斷執行緒的程式碼檢測到中斷事件的發生。

Happens-Before和as-if-serial的關係實質上是一回事。

  • as-if-serial語義保證單執行緒內重排序後的執行結果和程式程式碼本身應有的結果是一致的,happens-before關係保證正確同步的多執行緒程式的執行結果不被重排序改變。
  • as-if-serial語義和happens-before這麼做的目的,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。

小夥子不錯嘛!今天就到這裡,期待你明天的到來,希望能讓我繼續保持驚喜!

參考資料:重排序與happens-before

注: 如果文章有任何錯誤和建議,請各位大佬盡情留言!如果這篇文章對你也有所幫助,希望可愛親切的您給個三連關注下,非常感謝啦!

相關文章