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

 

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值同步回主內存中。jvm

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

因爲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):已終止線程的線程狀態,線程已經結束執行。

相關文章
相關標籤/搜索