標籤:runtime 程式 指令 相關資訊 知識庫 blog 順序 java程式員 location
Java記憶體模型即Java Memory Model,簡稱JMM。JMM定義了JAVA 虛擬機器(JVM)在電腦記憶體(RAM)中的工作方式。JVM是整個電腦虛擬模型,所以JMM是隸屬於JVM的。
如果我們要想深入瞭解Java並發編程,就要先理解好Java記憶體模型。Java記憶體模型定義了多線程之間共用變數的可見度以及如何在需要的時候對共用變數進行同步。原始的Java記憶體模型效率並不是很理想,因此Java1.5版本對其進行了重構,現在的Java8仍沿用了Java1.5的版本。
關於並發編程
在並發編程領域,有兩個關鍵問題:線程之間的通訊和同步。
線程之間的通訊
線程的通訊是指線程之間以何種機制來交換資訊。在命令式編程中,線程之間的通訊機制有兩種共用記憶體和訊息傳遞。
在共用記憶體的並行存取模型裡,線程之間共用程式的公用狀態,線程之間通過寫-讀記憶體中的公用狀態來隱式進行通訊,典型的共用記憶體通訊方式就是通過共用對象進行通訊。
在訊息傳遞的並行存取模型裡,線程之間沒有公用狀態,線程之間必須通過明確的發送訊息來顯式進行通訊,在java中典型的訊息傳遞方式就是wait()和notify()。
關於Java線程之間的通訊,可以輔助線程之間的通訊(thread signal)。
線程之間的同步
同步是指程式用於控制不同線程之間操作發生相對順序的機制。
在共用記憶體並行存取模型裡,同步是顯式進行的。程式員必須顯式指定某個方法或某段代碼需要線上程之間互斥執行。
在訊息傳遞的並行存取模型裡,由於訊息的發送必須在訊息的接收之前,因此同步是隱式進行的。
Java的並發採用的是共用記憶體模型
Java線程之間的通訊總是隱式進行,整個通訊過程對程式員完全透明。如果編寫多線程程式的Java程式員不理解隱式進行的線程之間通訊的工作機制,很可能會遇到各種奇怪的記憶體可見度問題。
Java記憶體模型
上面講到了Java線程之間的通訊採用的是過共用記憶體模型,這裡提到的共用記憶體模型指的就是Java記憶體模型(簡稱JMM),JMM決定一個線程對共用變數的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主記憶體之間的抽象關係:線程之間的共用變數儲存在主記憶體(main memory)中,每個線程都有一個私人的本地記憶體(local memory),本地記憶體中儲存了該線程以讀/寫共用變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了緩衝,寫緩衝區,寄存器以及其他的硬體和編譯器最佳化。
從來看,線程A與線程B之間如要通訊的話,必須要經曆下面2個步驟:
1. 首先,線程A把本地記憶體A中更新過的共用變數重新整理到主記憶體中去。2. 然後,線程B到主記憶體中去讀取線程A之前已更新過的共用變數。
下面通過來說明這兩個步驟:
如所示,本地記憶體A和B有主記憶體中共用變數x的副本。假設初始時,這三個記憶體中的x值都為0。線程A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當線程A和線程B需要通訊時,線程A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。隨後,線程B到主記憶體中去讀取線程A更新後的x值,此時線程B的本地記憶體的x值也變為了1。
從整體來看,這兩個步驟實質上是線程A在向線程B發送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個線程的本地記憶體之間的互動,來為java程式員提供記憶體可見度保證。
上面也說到了,Java記憶體模型只是一個抽象概念,那麼它在Java中具體是怎麼工作的呢?為了更好的理解上Java記憶體模型工作方式,下面就JVM對Java記憶體模型的實現、硬體記憶體模型及它們之間的橋接做詳細介紹。
JVM對Java記憶體模型的實現
在JVM內部,Java記憶體模型把記憶體分成了兩部分:線程棧區和堆區,展示了Java記憶體模型在JVM中的邏輯視圖:
JVM中啟動並執行每個線程都擁有自己的線程棧,線程棧包含了當前線程執行的方法調用相關資訊,我們也把它稱作調用棧。隨著代碼的不斷執行,調用棧會不斷變化。
線程棧還包含了當前方法的所有本地變數資訊。一個線程只能讀取自己的線程棧,也就是說,線程中的本地變數對其它線程是不可見的。即使兩個線程執行的是同一段代碼,它們也會各自在自己的線程棧中建立本地變數,因此,每個線程中的本地變數都會有自己的版本。
所有原始類型(boolean,byte,short,char,int,long,float,double)的本地變數都直接儲存線上程棧當中,對於它們的值各個線程之間都是獨立的。對於原始類型的本地變數,一個線程可以傳遞一個副本給另一個線程,當它們之間是無法共用的。
堆區包含了Java應用建立的所有對象資訊,不管對象是哪個線程建立的,其中的對象包括原始類型的封裝類(如Byte、Integer、Long等等)。不管對象是屬於一個成員變數還是方法中的本地變數,它都會被儲存在堆區。
展示了調用棧和本地變數都儲存在棧區,對象都儲存在堆區:
一個本地變數如果是原始類型,那麼它會被完全儲存到棧區。
一個本地變數也有可能是一個對象的引用,這種情況下,這個本地引用會被儲存到棧中,但是對象本身仍然儲存在堆區。
對於一個對象的成員方法,這些方法中包含本地變數,仍需要儲存在棧區,即使它們所屬的對象在堆區。
對於一個對象的成員變數,不管它是原始類型還是封裝類型,都會被儲存到堆區。
Static類型的變數以及類本身相關資訊都會隨著類本身儲存在堆區。
堆中的對象可以被多線程共用。如果一個線程獲得一個對象的應用,它便可訪問這個對象的成員變數。如果兩個線程同時調用了同一個對象的同一個方法,那麼這兩個線程便可同時訪問這個對象的成員變數,但是對於本地變數,每個線程都會拷貝一份到自己的線程棧中。
展示了上面描述的過程:
硬體記憶體架構
不管是什麼記憶體模型,最終還是運行在電腦硬體上的,所以我們有必要瞭解電腦硬體記憶體架構,就簡單描述了當代電腦硬體記憶體架構:
現代電腦一般都有2個以上CPU,而且每個CPU還有可能包含多個核心。因此,如果我們的應用是多線程的話,這些線程可能會在各個CPU核心中並行運行。
在CPU內部有一組CPU寄存器,也就是CPU的儲存器。CPU操作寄存器的速度要比操作電腦主存快的多。在主存和CPU寄存器之間還存在一個CPU緩衝,CPU操作CPU緩衝的速度快於主存但慢於CPU寄存器。某些CPU可能有多個緩衝層(一級緩衝和二級緩衝)。電腦的主存也稱作RAM,所有的CPU都能夠訪問主存,而且主存比上面提到的緩衝和寄存器大很多。
當一個CPU需要訪問主存時,會先讀取一部分主存資料到CPU緩衝,進而在讀取CPU緩衝到寄存器。當CPU需要寫資料到主存時,同樣會先flush寄存器到CPU緩衝,然後再在某些節點把快取資料flush到主存。
Java記憶體模型和硬體架構之間的橋接
正如上面講到的,Java記憶體模型和硬體記憶體架構並不一致。硬體記憶體架構中並沒有區分棧和堆,從硬體上看,不管是棧還是堆,大部分資料都會存到主存中,當然一部分棧和堆的資料也有可能會存到CPU寄存器中,如所示,Java記憶體模型和電腦硬體記憶體架構是一個交叉關係:
當對象和變數儲存到電腦的各個記憶體地區時,必然會面臨一些問題,其中最主要的兩個問題是:
1. 共用對象對各個線程的可見度2. 共用對象的競爭現象
共用對象的可見度
當多個線程同時操作同一個共用對象時,如果沒有合理的使用volatile和synchronization關鍵字,一個線程對共用對象的更新有可能導致其它線程不可見。
想象一下我們的共用Object Storage Service在主存,一個CPU中的線程讀取主存資料到CPU緩衝,然後對共用對象做了更改,但CPU緩衝中的更改後的對象還沒有flush到主存,此時線程對共用對象的更改對其它CPU中的線程是不可見的。最終就是每個線程最終都會拷貝共用對象,而且拷貝的對象位於不同的CPU緩衝中。
展示了上面描述的過程。左邊CPU中啟動並執行線程從主存中拷貝共用對象obj到它的CPU緩衝,把對象obj的count變數改為2。但這個變更對運行在右邊CPU中的線程不可見,因為這個更改還沒有flush到主存中:
要解決共用對象可見度這個問題,我們可以使用java volatile關鍵字。 Java’s volatile keyword. volatile 關鍵字可以保證變數會直接從主存讀取,而對變數的更新也會直接寫到主存。volatile原理是基於CPU記憶體屏障指令實現的,後面會講到。
競爭現象
如果多個線程共用一個對象,如果它們同時修改這個共用對象,這就產生了競爭現象。
如所示,線程A和線程B共用一個對象obj。假設線程A從主存讀取Obj.count變數到自己的CPU緩衝,同時,線程B也讀取了Obj.count變數到它的CPU緩衝,並且這兩個線程都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU緩衝中。
如果這兩個加1操作是串列執行的,那麼Obj.count變數便會在原始值上加2,最終主存中的Obj.count的值會是3。然而中兩個加1操作是並行的,不管是線程A還是線程B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,儘管一共有兩次加1操作。
要解決上面的問題我們可以使用java synchronized代碼塊。synchronized代碼塊可以保證同一個時刻只能有一個線程進入代碼競爭區,synchronized代碼塊也能保證代碼塊中所有變數都將會從主存中讀,當線程結束代碼塊時,對所有變數的更新將會flush到主存,不管這些變數是不是volatile類型的。
volatile和 synchronized區別
詳細請見 volatile和synchronized的區別
支撐Java記憶體模型的基礎原理指令重排序
在執行程式時,為了提高效能,編譯器和處理器會對指令做重排序。但是,JMM確保在不同的編譯器和不同的處理器平台之上,通過插入特定類型的Memory Barrier來禁止特定類型的編譯器重排序和處理器重排序,為上層提供一致的記憶體可見度保證。
- 編譯器最佳化重排序:編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序:如果不存l在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序:處理器使用緩衝和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。
資料依賴性
如果兩個操作訪問同一個變數,其中一個為寫操作,此時這兩個操作之間存在資料依賴性。
編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序,即不會重排序。
as-if-serial
不管怎麼重排序,單線程下的執行結果不能被改變,編譯器、runtime和處理器都必須遵守as-if-serial語義。
記憶體屏障(Memory Barrier )
上面講到了,通過記憶體屏障可以禁止特定類型處理器的重排序,從而讓程式按我們預想的流程去執行。記憶體屏障,又稱記憶體柵欄,是一個CPU指令,基本上它是一條這樣的指令:
- 保證特定操作的執行順序。
- 影響某些資料(或則是某條指令的執行結果)的記憶體可見度。
編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試最佳化效能。插入一條Memory Barrier會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的資料,因此,任何CPU上的線程都能讀取到這些資料的最新版本。
這和java有什麼關係?上面java記憶體模型中講到的volatile是基於Memory Barrier實現的。
如果一個變數是volatile修飾的,JMM會在寫入這個欄位之後插進一個Write-Barrier指令,並在讀這個欄位之前插入一個Read-Barrier指令。這意味著,如果寫入一個volatile變數,就可以保證:
- 一個線程寫入變數a後,任何線程訪問該變數都會拿到最新值。
- 在寫入變數a之前的寫入操作,其更新的資料對於其他線程也是可見的。因為Memory Barrier會刷出cache中的所有先前的寫入。
happens-before
從jdk5開始,java使用新的JSR-133記憶體模型,基於happens-before的概念來闡述操作之間的記憶體可見度。
在JMM中,如果一個操作的執行結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係,這個的兩個操作既可以在同一個線程,也可以在不同的兩個線程中。
與程式員密切相關的happens-before規則如下:
- 程式順序規則:一個線程中的每個操作,happens-before於該線程中任意的後續操作。
- 監視器鎖規則:對一個鎖的解鎖操作,happens-before於隨後對這個鎖的加鎖操作。
- volatile域規則:對一個volatile域的寫操作,happens-before於任意線程後續對這個volatile域的讀。
- 傳遞性規則:如果 A happens-before B,且 B happens-before C,那麼A happens-before C。
注意:兩個操作之間具有happens-before關係,並不意味前一個操作必須要在後一個操作之前執行!僅僅要求前一個操作的執行結果,對於後一個操作是可見的,且前一個操作按順序排在後一個操作之前。
全面理解Java記憶體模型