深入理解JVM - Java內存模型與線程 - 第十二章


 

Java內存模型

主內存與工作內存
Java內存模型主要目標:定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量(Variable)與Java編程中的變量略有區別,它包括實例變量/靜態字段和構成數組對象的元素,不包括局部變量和方法參數(線程私有)。為獲得較好的執行效能,Java內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交換,也沒有限制即時編譯器調整代碼執行順序這類權利。 

Java內存模型規定所有變量都存儲在主存(Main Memory)中(虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory),線程的工作內存保存了被線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取/賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主存來完成。

這里的主內存/工作內存與Java內存區域中的Java棧/堆/方法區並不是同一個層次的內存划分。如果兩者一定要勉強對應起來,那從變量/主內存/工作內存的定義來看,主內存主要對應於Java堆中對象的實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。從更低的層次來說,主存就是硬件的內存,而為獲取更好的運算速度,虛擬機及硬件系統可能會讓工作內存優先存儲於寄存器和高速緩存。

內存間交互操作
主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、從工作內存同步回主內存之類的實現細節,Java內存模型中定義了以下8種操作來完成:
Lock(鎖定):作用於主內存的變量,將主內存該變量標記成當前線程私有的,其他線程無法訪問它把一個變量標識為一條線程獨占的狀態。
Unlock(解鎖):作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,才能被其他線程鎖定。
Read(讀取):作用於主內存的變量,把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
Load(加載):作用於工作內存中的變量,把read操作從內存中得到的變量值放入工作內存的變量副本中。
Use(使用):作用於工作內存中的變量,把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
Assgin(賦值):作用於工作內存中的變量,把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
Store(存儲):作用於工作內存中的變量,把工作內存中一個變量的值傳遞到主內存中,以便隨后的write操作使用。
Write(寫入):作用於主內存中的變量,把store操作從工作內存中得到的變量的值放入主內存的變量中。
如果把一個變量從主內存復制到工作內存,按順序執行read和load操作;如果把變量從工作內存同步回主內存,按順序執行store和write操作。Java內存模型還規定在執行上述8種基本操作時必須滿足如下規則:
不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況。
不允許一個線程丟棄它的最近assign操作,即變量在工作內存中改變了之后必須把該變化同步回主內存。
不允許一個線程無原因的(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,就是對一個變量執行use和store之前必須先執行過了assign和load操作。
一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重復執行多次,多次執行lock后,只有執行相同次數的unlock操作,變量才會被解鎖。
如果對一個變量執行lock操作,僵屍清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)。

對於volatile型變量的特殊規則

關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制。
當一個變量被定義成volatile后,它將具備兩種特性:
第一是保證對所有線程的可見性,“可見性”指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。

關於volatile變量的可見性的誤解:“volatile變量對所有線程立即可見的,對volatile變量所有的寫操作都能立刻反映到其他線程中,換句話說,volatile變量在各個線程中是一致的,所以基於volatile變量的運算在並發下是安全的”。這句話的論據部分並沒有錯,但是其論據並不能得出“基於volatile變量的運算在並發下是安全的”這個結論。
volatile變量在各個線程中的工作內存中不存在一致性問題(在各個線程的工作內存中volatile變量也可以存在不一致的情況,但由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認為不存在不一致問題),但是Java里面的運算並非原子操作,導致volatile變量的運算在並發下一樣是不安全的。

package com.jvm.thread;

/**
 * volatile變量自增運算測試
 * @author xl69628
 *
 */
public class VolatileTest {
    public static volatile int race = 0;
    private static final int THREADS_COUNT = 20;
    
    public static void increase(){
        race++;
    }
    
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i = 0; i < 10000; i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        
        //等待所有累加線程都結束
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        
        System.out.println(race);
        //如果代碼正確並發,輸出結果為200000。但是每次運行都不會得到期望的結果。
    }
}

 

G:\javaee\workspace\Test\bin\com\jvm\thread>javap -c VolatileTest
警告: 二進制文件VolatileTest包含com.jvm.thread.VolatileTest
Compiled from "VolatileTest.java"
public class com.jvm.thread.VolatileTest {
  public static volatile int race;

  static {};
    Code:
       0: iconst_0
       1: putstatic     #13                 // Field race:I
       4: return

  public com.jvm.thread.VolatileTest();
    Code:
       0: aload_0
       1: invokespecial #18                 // Method java/lang/Object."<init>":
()V
       4: return

  public static void increase();
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #13                 // Field race:I
       8: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        20
       2: anewarray     #25                 // class java/lang/Thread
       5: astore_1
       6: iconst_0
       7: istore_2
       8: goto          37
      11: aload_1
      12: iload_2
      13: new           #25                 // class java/lang/Thread
      16: dup
      17: new           #27                 // class com/jvm/thread/VolatileTest
$1
      20: dup
      21: invokespecial #29                 // Method com/jvm/thread/VolatileTes
t$1."<init>":()V
      24: invokespecial #30                 // Method java/lang/Thread."<init>":
(Ljava/lang/Runnable;)V
      27: aastore
      28: aload_1
      29: iload_2
      30: aaload
      31: invokevirtual #33                 // Method java/lang/Thread.start:()V

      34: iinc          2, 1
      37: iload_2
      38: bipush        20
      40: if_icmplt     11
      43: goto          49
      46: invokestatic  #36                 // Method java/lang/Thread.yield:()V

      49: invokestatic  #39                 // Method java/lang/Thread.activeCou
nt:()I
      52: iconst_1
      53: if_icmpgt     46
      56: getstatic     #43                 // Field java/lang/System.out:Ljava/
io/PrintStream;
      59: getstatic     #13                 // Field race:I
      62: invokevirtual #49                 // Method java/io/PrintStream.printl
n:(I)V
      65: return
}

問題就出現在自增運算“race++”之中,用javap反編譯這段代碼,發現只有一行代碼的increase()方法在Class文件中由4條字節碼指令構成(return指令不是由race++產生的,這條指令可以不計算),從字節碼層面容易分析並發失敗的原因了:當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1, iadd這些指令時,其他線程可能已經把race的值加大了,而操作棧頂的值就變成了過期的數據,所以putstatic指令執行后就可能把較小的race值同步回主內存中。

客觀地說,此時使用字節碼來分析並發問題,仍然是不嚴謹的,因為即使編譯出來只有一條指令,也並不意味執行這條指令就是一個原子操作。一條字節碼指令在解釋執行時,解釋器將要運行許多行代碼才能實現它的語義,如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令,此處使用 -XX:+PrintAssembly 參數輸出反編譯來分析會更加嚴謹一些。

由於volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性:

1)運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。

2)變量不需要與其他的狀態變量共同參與不變約束。

使用volatile變量的第二個語義是禁止指令重排序優化,普通變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因為在一個線程的方法執行過程中無法感知到這點,這也就是Java內存模型中描述的所謂的“線程內表現為串行的語義”(WithinThread As-If-Serial Semantics)。

在眾多保障並發安全工具中選用volatile的意義:在某些情況下,volatile的同步機制的性能確實要優先於鎖(使用synchronized關鍵字或java.util.concurrent包里面的鎖),但是由於虛擬機對鎖實行的許多消除和優化,使得很難量化地認為volatile就會比synchronized快多少。volatile變量的讀操作的性能消耗與普通變量幾乎沒有差別,但寫操作可能會慢一些,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下volatile的總開銷仍然要比鎖低,我們在volatile與鎖之中選擇的唯一依據僅僅是volatile的語義能否滿足使用場景的需求。

java內存模型中對volatile變量定義的特殊規則。假定T表示一個線程,V和W分別表示volatile型變量,那么在進行read、load、use、assign、store和write操作時需要滿足如下規則:

1)只有當線程T對變量V執行的前一個動作為load時,T才能對V執行use;並且,只有T對V執行的后一個動作為use時,T才能對V執行load。T對V的use,可以認為是和T對V的load。read動作相關聯,必須連續一起出現(這條規則要求在工作內存中,每次使用V前都必須先從主內存刷新最新的值,用於保證能看見其他線程對V修改后的值)。

2)只有當T對V的前一個動作是assign時,T才能對V執行store;並且,只有當T對V執行的后一個動作是store時,T才能對V執行assign。T對V的assign可以認為和T對V的store、write相關聯,必須連續一起出現(這條規則要求在工作內存中,每次修改V后都必須立刻同步回主內存中,用於保證其他線程看到自己對V的修改)。

3)假定動作A是T對V實施的use或assign動作,假定動作F是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對V的read或write動作;類似的,假定動作B是T對W實施的use或assign動作,假定動作G是和動作B相關聯的load或store動作,假定動作Q是和動作G相應的對W的read或write動作。如果A先於B,那么P先於Q(這條規則要求volatile修飾的變量不會被指令的重排序優化,保證代碼的執行順序與程序的順序相同)。

對long和double型變量的特殊規則

允許虛擬機將沒有被volatile修飾的64位數據類型(long和double)的讀取操作划分為兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性,就點就是long和double的非原子協定(Nonatomic Treatment of double and long Variables)。

如果多個線程共享一個為聲明為volatile的long或double類型變量,並同時對他們進行讀取和修改操作,那么有些線程可能會讀取到一個即非原值,也不是其他線程修改值得代表了“半個變量”的數值。

不過這種讀取帶“半個變量”的情況非常罕見(在目前商用虛擬機中不會出現),因為Java內存模型雖然允許虛擬機不把long和double變量的讀寫實現成原子操作,但允許虛擬機選擇把這些操作實現為具有原子性的操作,而且還“強烈建議”虛擬機這樣實現。

原子性、可見性和有序性

原子性(Atomicity):由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write,我們大致可以認為基本數據類型的訪問具備原子性(long和double例外)。

如果應用場景需要一個更大范圍的原子性保證,Java內存模型還提供了lock和unlock操作來滿足需求,盡管虛擬機未把lock和unlock操作直接開放給用戶,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反應到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性

可見性(Visibility):指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。

除了volatile,Java還有兩個關鍵字能實現可見性,synchronized和final同步塊的可見性是由“對一個變量執行unlock操作之前,必須把此變量同步回主內存中(執行store和write操作)”這條規則獲得的,而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化了一半”的對象),那么其他線程中就能看見final字段的值。

//變量i與j都具備可見性,它們無須同步就能被其他線程正確訪問
    public static final int i;
    public final int j;
    
    static{
        i = 0;
        //do something
    }
    
    {
        //也可以選擇在構造函數中初始化
        j = 0;
        //do something
    }

有序性(Ordering):Java程序中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,所有的操作都是無序的。前半句是指“線程內表現為串行的語義”(Within-Thread As-if-Serial Semantics),后半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。

Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一時刻只允許一條線程對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

先行發生原則

先行發生是Java內存模型中定義的兩項操作之間的偏序關系,如果操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值/發送了消息/調用了方法等。

    i = 1;//在線程A中執行
    j = i;//在線程B中執行
    i = 2;//在線程C中執行
    //A先於B,但是C與B沒有先行關系,B存在讀取過期數據風險,不具備多線程安全性

下面是Java內存模型下一些“天然的”先行發生關系,無須任何同步器協助就已經存在,可直接在編碼中使用。如果兩個操作之間的關系不在此列,並且無法從下列規則推倒出來,它們就沒有順序性的保障,虛擬機可以對它們進行隨意地重排序。

1)程序次序規則(Program Order Rule):在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在后面的操作。准確地來說應該是控制流順序而不是程序代碼順序,因為要考慮分支/循環結構。

2)管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面對同一鎖的lock操作。這里必須強調的是同一鎖,而“后面”是指時間上的先后順序。

3)volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於后面對這個變量的讀操作,這里的“后面”是指時間上的先后順序。

4)線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。

5)線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束/Thread.isAlive()的返回值等手段檢測到縣城已經終止執行。

6)線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷發生。

7)對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。

8)傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那么操作A先行發生於操作C。

 時間上的先后順序與先行發生原則之間基本沒有太大的關系,所以我們衡量並發安全問題時不要受時間順序的干擾,一切必須以先行發生原則為准。

Java與線程

並發不一定依賴多線程,但是Java里面談論並發,大多數與線程脫不開關系。

線程的實現

主流操作系統都提供了線程實現,Java語言則提供了在不同硬件和操作系統平台對線程的同一處理,每個java.lang.Thread類的實例就代表了一個線程。Thread類與大部分Java API有着顯著的差別,它的所有關鍵方法都被聲明為Native。在Java API中一個Native方法可能就意味着這個方法沒有使用或無法使用平台無關的手段實現。正因為這個原因,我們這里的“線程的實現”而不是“Java線程的實現”。

實現線程主要三種方式:

1. 使用內核線程實現

內核線程(Kernel Thread, KLT)就是直接由操作系統內核(Kernel,下稱內核)支持的線程,這種線程由內核來完成線程切換,內核通過操縱調度器(Scheduler)對線程進行調度,並負責將線程的任務映射到各個處理器上。每個內核線程都可以看作是內核的一個分身,這樣操作系統就有能力同時處理多件事情,支持多線程的內核就叫多線程內核(Multi-Thread Kernel)。

程序一般不會直接去使用內核線程,而是去使用內核線程的一種高級接口——輕量級進程(Light Weight Process, LWP),輕量級進程就是我們通常意義上所講的線程,由於每個輕量級進程都由一個內核線程支持,因此只有先支持內核線程,才能有輕量級進程。這種輕量級進程與內核線程之間1:1的關系稱為一對一的線程模型。

輕量級進程的局限性:由於是基於內核線程實現的,所以各種進程操作,如創建/析構及同步,都需要進行系統調用。而系統調用的代價相對較高,需要在用戶態(User Mode)和內核態(Kernel Mode)中來回切換;每個輕量級進程都需要有一個內核線程的支持,因此輕量級進程需要消耗一定的內核資源(如內核線程的棧空間),因此一個系統支持輕量級進程是有限的。

 

2. 使用用戶線程實現

狹義上的用戶線程指的是完全建立在用戶空間的線程庫上,系統內核不能感知到線程存在的實現。用戶線程的建立/同步/銷毀和調度完全在用戶態完成,不需要內核的幫助。如果程序實現得當,這種線程不需要切換到內核態,因此操作快速且低消耗,也可以支持規模更大的線程數量,部分高性能數據庫中的多線程就是由用戶線程實現的。這種進程與用戶線程之間1:N的關系稱為一對多的線程模型。

3. 使用用戶線程加輕量級進程混合實現

既存在用戶線程,也存在輕量級進程。

 

Java線程調度

線程調度是指系統為線程分配處理器使用權的過程。主要調度方式兩種:

使用協同調度的多線程系統,線程執行時間由線程本身控制,線程把自己的工作執行完后,要主動通知系統切換到另外一個線程上去。優點:實現簡單。缺點:執行時間不可控制。

使用搶占調用的多線程系統,每個線程由系統分配執行時間,線程的切換不由線程本身決定。Java使用的就是這種線程調度方式。

Java提供10個級別的線程優先級設置,不過,線程優先級不靠譜,因為Java線程是被映射到系統的原生線程上實現的,所以線程調度最終還是由操作系統決定。

狀態轉換

Java語言定義了5種進程狀態,在任意一個時間點,一個線程只能有且只有其中一種狀態:

新建(New):創建尚未啟動的線程處於這種狀態。

運行(Runable):包括操作系統線程狀態中的Running和Ready,處於此狀態的線程可能正在運行,也可能等待着CPU為它分配執行時間。

無限期等待(Waiting):處於這種狀態的線程不會被分配CPU執行時間,它們要等待其他線程顯示地喚醒。以下方法會讓線程陷入無限期的等待狀態:

沒有設置Timeout參數的Object.wait()方法。

沒有設置Timeout參數的Thread.join()方法。

LockSupport.park()方法。

限期等待(Timed Waiting):處於這種狀態的線程也不會被分配CPU執行時間,不過無須等待被其他線程顯示地喚醒,在一定時間后由系統自動喚醒。以下方法會讓線程陷入限期的等待狀態:

Thread.sleep()方法。

設置了Timeout參數的Object.wait()方法。

設置了Timeout參數的Thread.join()方法。

LockSupport.parkNanos()方法。

LockSupport.parkUntil()方法。

阻塞(Blocked):線程被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待獲取一個排它鎖,這個事件將在另外一個線程放棄這個鎖的時候發生;“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程序進入等待進入同步塊區域的時候,線程將進入這種狀態。

結束(Terminated):已終止線程的線程狀態,線程已經結束執行。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM