最近在看《深入理解Java虛擬機器:JVM高階特性與最佳實踐》講到了執行緒相關的細節知識,裡面講述了關於java記憶體模型,也就是jsr 133定義的規範。

系統的看了jsr 133規範的前面幾個章節的內容,覺得受益匪淺。廢話不說,簡要的介紹一下java記憶體規範。

什麼是記憶體規範

在jsr-133中是這麼定義的

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. For the Java programming language, the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

也就是說一個記憶體模型描述了一個給定的程式和和它的執行路徑是否一個合法的執行路徑。對於java序言來說,記憶體模型通過考察在程式執行路徑中每一個讀操作,根據特定的規則,檢查寫操作對應的讀操作是否能是有效的。

java記憶體模型只是定義了一個規範,具體的實現可以是根據實際情況自由實現的。但是實現要滿足java記憶體模型定義的規範。

處理器和記憶體的互動

這個要感謝矽工業的發展,導致目前處理器的效能越來越強大。目前市場上基本上都是多核處理器。如何利用多核處理器執行程式的優勢,使得程式效能得到極大的提升,是目前來說最重要的。

目前所有的運算都是處理器來執行的,我們在大學的時候就學習過一個基本概念 程式 = 資料 + 演算法 ,那麼處理器負責計算,資料從哪裡獲取了?

資料可以存放在處理器暫存器裡面(目前x86處理都是基於暫存器架構的),處理器快取裡面,記憶體,磁碟,光碟機等。處理器訪問這些資料的速度從快到慢依次為:暫存器,處理器快取,記憶體,磁碟,光碟機。為了加快程式執行速度,資料離處理器越近越好。但是暫存器,處理器快取都是處理器私有資料,只有記憶體,磁碟,光碟機才是才是所有處理器都可以訪問的全域性資料(磁碟和光碟機我們這裡不討論,只討論記憶體)如果程式是多執行緒的,那麼不同的執行緒可能分配到不同的處理器來執行,這些處理器需要把資料從主記憶體載入到處理器快取和暫存器裡面才可以執行(這個大學作業系統概念裡面有介紹),資料執行完成之後,在把執行結果同步到主記憶體。如果這些資料是所有執行緒共享的,那麼就會發生同步問題。處理器需要解決何時同步主記憶體資料,以及處理執行結果何時同步到主記憶體,因為同一個處理器可能會先把資料放在處理器快取裡面,以便程式後續繼續對資料進行操作。所以對於記憶體資料,由於多處理器的情況,會變的很複雜。下面是一個例子:

初始值 a = b = 0

process1           process2

1:load a               5:load b

2:write a:2           6:add b:1

3:load b               7: load a

4:write b:1           8:write a:1

假設處理器1先載入記憶體變數a,寫入a的值為2,然後載入b,寫入b的值為1,同時 處理2先載入b,執行b+1,那麼b在處理器2的結果可能是1 可能是3。因為在load b之前,不知道處理器1是否已經吧b寫會到主記憶體。對於a來說,假設處理器1後於處理器2把a寫會到主記憶體,那麼a的值則為2。

而記憶體模型就是規定了一個規則,處理器如何同主記憶體同步資料的一個規則。

記憶體模型介紹

在介紹java記憶體模型之前,我們先看看兩個記憶體模型

Sequential Consistency Memory Model:連續一致性模型。這個模型定義了程式執行的順序和程式碼執行的順序是一致的。也就是說 如果兩個執行緒,一個執行緒T1對共享變數A進行寫操作,另外一個執行緒T2對A進行讀操作。如果執行緒T1在時間上先於T2執行,那麼T2就可以看見T1修改之後的值。

這個記憶體模型比較簡單,也比較直觀,比較符合現實世界的邏輯。但是這個模型定義比較嚴格,在多處理器併發執行程式的時候,會嚴重的影響程式的效能。因為每次對共享變數的修改都要立刻同步會主記憶體,不能把變數儲存到處理器暫存器裡面或者處理器快取裡面。導致頻繁的讀寫記憶體影響效能。

Happens-Before Memory Model : 先行發生模型。這個模型理解起來就比較困難。先介紹一個現行發生關係 (Happens-Before Relationship

  如果有兩個操作A和B存在A Happens-Before B,那麼操作A對變數的修改對操作B來說是可見的。這個現行並不是程式碼執行時間上的先後關係,而是保證執行結果是順序的。看下面例子來說明現行發生

A,B為共享變數,r1,r2為區域性變數
 
初始 A=B=0
 
Thread1   | Thread2
1: r2=A   | 3: r1=B
2: B=2    4: A=2

  憑藉直觀感覺,執行緒1先執行 r2=A,則r2=0 ,然後賦值B=1,執行緒2執行r1=B,由於執行緒1修改了B的值為1,所以r1=1。但是在現行發生記憶體模型裡面,有可能最終結果為r1 = r2 = 2。為什麼會這樣,因為編譯器或者多處理器可能對指令進行亂序執行,執行緒1 從程式碼流上面看是先執行r2 = A,B = 1,但是處理器執行的時候會先執行 B = 2 ,在執行 r2 = A,執行緒2 可能先執行 A = 2 ,在執行r1 = B,這樣可能 會導致 r1 = r2 = 2。

那我們先看看先行發生關係的規則

  • 1 在同一個執行緒裡面,按照程式碼執行的順序(也就是程式碼語義的順序),前一個操作先於後面一個操作發生
  • 2 對一個monitor物件的解鎖操作先於後續對同一個monitor物件的鎖操作
  • 3 對volatile欄位的寫操作先於後面的對此欄位的讀操作
  • 4 對執行緒的start操作(呼叫執行緒物件的start()方法)先於這個執行緒的其他任何操作
  • 5 一個執行緒中所有的操作先於其他任何執行緒在此執行緒上呼叫 join()方法
  • 6 如果A操作優先於B,B操作優先於C,那麼A操作優先於C

解釋一下以上幾個先行發生規則的含義

規則1應該比較好理解,因為比較適合人正常的思維。比如在同一個執行緒t裡面,程式碼的順序如下:

thread 1
共享變數A、B
區域性變數r1、r2
 
程式碼順序
1: A =1
2: r1 = A
3: B = 2
4: r2 = B
 
執行結果 就是 A=1 ,B=2 ,r1=1 ,r2=2

因為以上是在同一個執行緒裡面,按照規則1 也就是按照程式碼順序,A = 1 先行發生 r1 =A ,那麼r1 = 1

再看規則2,下面是jsr133的例子

按照規則2,由於unlock操作先於發生於lock操作,所以X=1對執行緒2裡面就是可見的,所以r2 = 1

在分析以下,看這個例子,由於unlock操作先於lock操作,所以執行緒x=1對於執行緒2不一定是可見(不一定是現行發生的),所以r2的值不一定是1,有可能是x賦值為1之前的那個狀態值(假設x初始值為0,那麼此時r2的值可能為0)

對於規則3,我們可以稍微修改一下我們說明的第一個例子

A,B為共享變數,並且B是valotile型別的
r1,r2為區域性變數
 
初始 A=B=0
 
Thread1   | Thread2
1: r2=A   | 3: r1=B
2: B=2    4: A=2
 
那麼r1 = 2, r2可能為0或者2

 因為對於volatile型別的變數B,執行緒1對B的更新馬上執行緒2就是可見的,所以r1的值就是確定的。由於A是非valotile型別的,所以值不確定。

規則4,5,6這裡就不解釋了,知道規則就可以了。

可以從以上的看出,先行發生的規則有很大的靈活性,編譯器可以對指令進行重新排序,以便滿足處理器效能的需要。只要重新排序之後的結果,在單一執行緒裡面執行結果是可見的(也就是在同一個執行緒裡面滿足先行發生原則1就可以了)。

java記憶體模型是建立在先行發生的記憶體模型之上的,並且再此基礎上,增強了一些。因為現行發生是一個弱約束的記憶體模型,在多執行緒競爭訪問共享資料的時候,會導致不可預期的結果。有一些是java記憶體模型可以接受的,有一些是java記憶體模型不可以接受的。具體細節這裡面就不詳細說明了。這裡只說明關於java新的記憶體模型重要點。

final欄位的語義

在java裡面,如果一個類定義了一個final屬性,那麼這個屬性在初始化之後就不可以在改變。一般認為final欄位是不變的。在java記憶體模型裡面,對final有一個特殊的處理。如果一個類C定義了一個非static的final屬性A,以及非static final屬性B,在C的構造器裡面對A,B進行初始化,如果一個執行緒T1建立了類C的一個物件co,同一時刻執行緒T2訪問co物件的A和B屬性,如果t2獲取到已經構造完成的co物件,那麼屬性A的值是可以確定的,屬性B的值可能還未初始化,

下面一段程式碼演示了這個情況

public class FinalVarClass {
 
    public final int a ;
    public int b = 0;
   
    static FinalVarClass co;
   
    public FinalVarClass(){
        a = 1;
        b = 1;
    }
   
    //執行緒1建立FinalVarClass物件 co
    public static void create(){
        if(co == null){
            co = new FinalVarClass();
        }
    }
   
    //執行緒2訪問co物件的a,b屬性
    public static void vistor(){
        if(co != null){
            System.out.println(co.a);//這裡返回的一定是1,a一定初始化完成
            System.out.println(co.b);//這裡返回的可能是0,因為b還未初始化完成
        }
    }
}

為什麼會發生這種情況,原因可能是處理器對建立物件的指令進行重新排序。正常情況下,物件建立語句co = new FinalVarClass()並不是原子的,簡單來說,可以分為幾個步驟,1 分配記憶體空間 2 建立空的物件 3 初始化空的物件 4 把初始化完成的物件引用指向 co ,由於這幾個步驟處理器可能併發執行,比如3,4 併發執行,所以在create操作完成之後,co不一定馬上初始化完成,所以在vistor方法的時候,b的值可能還未初始化。但是如果是final欄位,必須保證在對應返回引用之前初始化完成。

volatile語義

對於volatile欄位,在現行發生規則裡面已經介紹過,對volatile變數的寫操作先於對變數的讀操作。也就是說任何對volatile變數的修改,都可以在其他執行緒裡面反應出來。

在 java 垃圾回收整理一文中,描述了jvm執行時刻記憶體的分配。其中有一個記憶體區域是jvm虛擬機器棧,每一個執行緒執行時都有一個執行緒棧,

執行緒棧儲存了執行緒執行時候變數值資訊。當執行緒訪問某一個物件時候值的時候,首先通過物件的引用找到對應在堆記憶體的變數的值,然後把堆記憶體

變數的具體值load到執行緒本地記憶體中,建立一個變數副本,之後執行緒就不再和物件在堆記憶體變數值有任何關係,而是直接修改副本變數的值,

在修改完之後的某一個時刻(執行緒退出之前),自動把執行緒變數副本的值回寫到物件在堆中變數。這樣在堆中的物件的值就產生變化了。下面一幅圖

描述這寫互動

read and load 從主存複製變數到當前工作記憶體 use and assign  執行程式碼,改變共享變數值 store and write 用工作記憶體資料重新整理主存相關內容

其中use and assign 可以多次出現

但是這一些操作並不是原子性,也就是 在read load之後,如果主記憶體count變數發生修改之後,執行緒工作記憶體中的值由於已經載入,不會產生對應的變化,所以計算出來的結果會和預期不一樣

對於volatile修飾的變數,jvm虛擬機器只是保證從主記憶體載入到執行緒工作記憶體的值是最新的

例如假如執行緒1,執行緒2 在進行read,load 操作中,發現主記憶體中count的值都是5,那麼都會載入這個最新的值

線上程1堆count進行修改之後,會write到主記憶體中,主記憶體中的count變數就會變為6

執行緒2由於已經進行read,load操作,在進行運算之後,也會更新主記憶體count的變數值為6

導致兩個執行緒及時用volatile關鍵字修改之後,還是會存在併發的情況。

volatile在java新的記憶體規範裡面還加強了新的語義。在老的記憶體規範裡面,volatile變數與非volatile變數的順序是可以重新排序的。舉個例子

public class VolatileClass {
 
    int              x = 0;
    volatile boolean v = false;
 
    //執行緒1write
    public void writer() {
        x = 42;
        v = true;
    }
    //執行緒2 read
    public void reader() {
        if (v == true) {
            System.out.println(x);//結果可能為0,可能為2
        }
    }
}

 執行緒1先呼叫writer方法,對x和v進行寫操作,執行緒reader判斷,如果v=true,則列印x。在老的記憶體規範裡面,可能對v和x賦值順序發生改變,導致v的寫操作先行於x的寫操作執行,同時另外一個執行緒判斷v的結果,由於v的寫操作先行於v的讀操作,所以if(v==true)返回真,於是程式執行列印x,此時x不一定先行與System.out.println指令之前。所以顯示的結果可能為0,不一定為2

但是java新的記憶體模型jsr133修正了這個問題,對於volatile語義的變數,自動進行lock 和 unlock操作包圍對變數volatile的讀寫操作。那麼以上語句的順序可以表示為

thread1              thread2
1 :write x=1        5:lock(m)
 
2 :lock(m)          6:read v
 
3 :write v=true     7:unlock(m)
 
4 :unlock            8 :if(v==true)
 
                     9: System.out.print(x)

 由於unlock操作先於lock操作,所以x寫操作5先於發生x的讀操作9

以上只是jsr規範中一些小結行的內容,由於jsr133規範定義了很多術語以及很多推論,上述只是簡單的介紹了一些比較重要的內容,具體細節可以參考jsr規範的public view :http://today.java.net/pub/a/today/2004/04/13/JSR133.html

http://www.cnblogs.com/aigongsi/archive/2012/04/26/2470296.html