1. 程式人生 > >讀書筆記 ---- 《深入理解Java虛擬機器》---- 第11篇:Java記憶體模型與執行緒

讀書筆記 ---- 《深入理解Java虛擬機器》---- 第11篇:Java記憶體模型與執行緒

上一篇:晚期(執行期)優化:https://blog.csdn.net/pcwl1206/article/details/84642835

目  錄:

1  概述

2  Java記憶體模型

2.1  主記憶體與工作記憶體

2.2  記憶體間互動操作

2.3  對於volatile型變數的特殊規則

2.4  對於long和double型變數的特殊規則

2.5  原子性、可見性和有序性

2.6  先行發生原則

3  Java與執行緒

3.1  執行緒的實現

3.2  Java執行緒排程

3.3  狀態轉換

4  總結


1  概述

衡量一個服務效能的高低好壞,每秒事務處理數(Transactions Per Second TPS)是最重要的指標之一,它代表一秒內服務端平均能響應的請求總數。

硬體的效率與一致性

處理器和記憶體不是同數量級,所以需要在中間建立中間層,也就是快取記憶體,這會引出快取一致性問題。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),有可能操作同一位置引起各自快取不一致,這時候需要約定協議在保證一致性,這類有MSI、MESI、MOSI、Synapse、Firefly、 Dragon Protocol。

為了使得處理器內部的運算單元儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行優化(Out-Of-Order Execution),指令重排序優化

2  Java記憶體模型

Java記憶體模型(Java  Memory  Model,JMM):遮蔽掉了各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致性的記憶體訪問效果。

2.1  主記憶體與工作記憶體

Java記憶體模型的主要目標定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處變數不同於java的變數。它包括了例項欄位、靜態欄位

構成陣列物件的元素,但是不包含區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不存在競爭問題。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中每個執行緒有自己的工作執行緒(Working Memory),儲存主記憶體副本拷貝和自己私有變數,不同執行緒不能訪問工作記憶體中的變數。執行緒間變數值的傳遞需要通過主記憶體來完成。

執行緒、主記憶體、工作記憶體互動關係圖如下所示:


2.2  記憶體間互動操作

關於主記憶體與工作記憶體之間的具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步主記憶體之類的實現細節,java記憶體模型中定義一下八種操作來完成:

1、lock(鎖定): 作用於主記憶體的變數。它把一個變數標誌為一個執行緒獨佔的狀態;

2、unlock(解鎖):作用於主記憶體的變數,它把處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定;

3、read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用;

4、load(載入): 作用於工作記憶體的變數,它把read操作從主記憶體中得到變數值放入工作記憶體的變數的副本中;

5、use(使用): 作用於工作記憶體的變數, 它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作;

6、assign(賦值): 作用於工作記憶體的變數。它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到需要給一個變數賦值的位元組碼時執行這個操作;

7、store(儲存):作用於工作記憶體的變數。它把一個工作記憶體中一個變數的值傳遞到主記憶體中,以便隨後的write操作使用;

8、write(寫入): 作用於主記憶體的變數。 它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

如果要把一個變數從工作記憶體複製到工作記憶體,那就要按順序執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要按順序執行store和write操作。

上訴8種基本操作必須滿足的規則:

1、不允許read和load、store和write操作之一單獨出現;

2、不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變之後必須把該變化同步回主記憶體;

3、不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中;

4、一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說就是對一個變數實施use和store操作之前,必須執行過了assign和load 操作;

5、一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock,變數才會被解鎖;

6、如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值;

7、如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定主的變數;

8、對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store和write操作)。


2.3  對於volatile型變數的特殊規則

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制

當一個變數定義為volatile後,它將具備兩種特性:

1、保證此變數對所有執行緒的可見性;

2、禁止指令重排序優化;

1、保證此變數對所有執行緒的可見性

這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。而普通變數不能做到這一點,普通變數的值線上程間傳遞均需要通過主記憶體來完成,例如:執行緒A修改了一個普通變數的值,然後向主記憶體進行回寫,另外一條執行緒B線上程A回寫完成了之後再從主記憶體進行讀取操作,新變數值才會對執行緒B可見。

“基於volatie變數的運算子在併發下是安全的”這個結論是錯誤的:Java裡面的運算子非原子操作,導致volatile變數的運算在併發下一樣是不安全的,下面進行案例演示:

public class VolatileTest {
 
	public static volatile int race = 0;
	public static void increase(){
		race++;
	}
	
	private static final int THREADS_COUNT =20;
	
	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() {
				public void run() {
					for (int i = 0; i < 10000; i++){
						increase();
					}
				}
			});
			threads[i].start();
		}
		// 等待所有累加執行緒都結束
		while (Thread.activeCount() > 1){
			Thread.yield();
			System.out.println(race);
		}
	}
}

這段程式碼發起了20個執行緒,每個執行緒對race變數進行10000次自增操作,如果這段程式碼能夠正確併發的話,最後輸出的結果應該是200000。但是實際執行結果都是一個小於200000的數字。很明顯這是併發引起的錯誤。看下面VolatileTest的位元組碼:

當getstatic指令把race的值取到運算元棧時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他執行緒可能已經把race的值加大了,而在運算元棧頂的值就變成了過期的資料,所以putstatic指令執行後就可能把較小race值同步回主記憶體中。

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

1、運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值;

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

2、禁止指令重排序優化

普通的變數僅僅會保證在該方法執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼執行順序一致。因為在一個執行緒的方法執行過程中無法感知到這點,這也就是Java記憶體模型中描述的所謂的“執行緒內表現為序列的語義”

下面通過一個案例來說明:重排序會干擾程式的併發執行。

Map configOptions
char[] configText;
//此變數必須定義為volatile
volatile boolean initialized = false;

//假設以下程式碼線上程A中執行
//模擬讀取配置資訊,當讀取完成後
//將initialized設定為true來通知其他執行緒配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions (configText, configOptions);
initialized = true;
 
//假設以下程式碼線上程B中執行
//等待initialized為true,代表執行緒A已經把配置資訊初始化完成
while (!initialized){
   sleep();
}
//使用執行緒A中初始化好配置資訊
doSomethingWithConfig();

如果定義initialized變數時沒有使用volatile修飾,就可能會由於指令重排序優化,導致位於執行緒A中的最後一句程式碼“initialized = true;”被提前執行,這樣線上程B中使用配置資訊的程式碼就可能出現錯誤,而volatile關鍵字則可以避免此類情況的發生。

指令重排序優化不會執行。volatile開銷小於鎖。

說明:volatile在後面的Java併發程式設計中再做具體講解,這裡先簡單瞭解下。


2.4  對於long和double型變數的特殊規則

Java記憶體模型要求lock、unlock、read、load、assign、use、store和write這個八個操作都具有原子性,但是對於64位的資料型別(long和double),在模型中特別定義了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機器實現選擇可以不保證64位資料型別的load、store、read和write這4個操作的原子性,這點就是所謂的long和double的非原子性協定

如果有多個執行緒共享一個並未宣告為volatile的long或double型別的變數,並且同時對它們進行讀取和修改操作,則有可能讀取一半資料是已經被其他執行緒修改過的。然而,在實際開發中,各種商用虛擬機器幾乎都選擇把64為資料的讀寫操作作為原子操作對待,所以不需要專門為long和double變數專門宣告為volatile。


2.5  原子性、可見性和有序性

Java記憶體模型是圍繞著在併發過程中如何處理原子性、可見性有序性這3個特徵來建立的。

1、原子性

原子性(Atomicity): 由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store、write。 

如果應用場景需要一個更大範圍的原子性保證,Java記憶體模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機器並未把lock和unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個位元組碼反映到Java程式碼中就是同步快—synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

2、可見性

可見性(Visibility): 一個執行緒改變了共享變數的值會立即重新整理到主記憶體中,其他執行緒會立即得知這個修改。

除了volatile之外,java還有兩個關鍵字能實現可見性,它們是synchronized和final。同步塊的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)”這條規則獲得的。而final關鍵字的可見性是指:被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把“this”的引用傳遞出去,那在其他執行緒中就能看見final欄位的值。

3、有序性

有序性(Ordering):Java程式中天然的有序性可以總結為一句話:“如果在本執行緒內觀察,所有的操作都是有序的,如果在一個執行緒中觀察另一個執行緒,所有操作都是無序的”。前半句執行緒內表現為序列的語義,後半句是指指令重排序現象和工作記憶體與主記憶體同步延遲現象。

volatile關鍵字本身包含了禁止指令重排序的語義,而synchronized則是有一個變數在同一時刻只允許一條執行緒對其進行lock操作獲得有序性,這條規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

從上面的分析可以看出,使用synchronized關鍵字都可以實現原子性、可見性有序性。


2.6  先行發生原則

先行發生(happens-before)原則:判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們可以通過幾條規則一攬子地解決併發環境下兩個操作之間是否可能存在衝突的所有問題。

先行發生是Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、發生子訊息、呼叫了方法等等。

Java記憶體模型中“天然的”一些先行發現原則,也就是程式預設執行順序:

1、程式次序規則(Program Order Rule) : 在本執行緒內按程式程式碼順序執行(準確說是按照控制流順序);

2、管理鎖定原則(Monitor Lock Rule): 一個unlock操作先行發生於後面對同一個鎖的lock操作,這個必須強調的是同一個鎖,必須在unlock才能lock操作;

3、volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作;

4、執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作;

5、執行緒終止規則(Thread Termination Rule): 執行緒中所有操作都先行發生於對此執行緒的終止檢測;

6、執行緒中斷規則(Thread Interruption Rule): 對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生;

7、物件終結規則(Finalizer Rule): 一個物件初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始;

8、傳遞性(Transitivity): 如果操作A先行於發生於操作B,操作B先行發生於操作C、那就可以得出操作A先行於操作C的結論。

需要說明一點:時間上的先後順序與先行發生原則之間基本沒有太大關係,所以我們衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。


3  Java與執行緒

3.1  執行緒的實現

執行緒是比程序更輕量級的排程執行單位,執行緒的引入,可以把一個程序的資源分配和執行排程分開,各個執行緒既可以共享程序資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的基本單位)。

每個已經執行start()且還未結束的java.lang.Thread類的例項就代表了一個執行緒。

實現執行緒主要有3種方式:使用核心執行緒實現使用使用者執行緒實現使用使用者執行緒加輕量級程序混合實現

3.1.1  使用核心執行緒實現

核心執行緒(Kernel Thread, KLT)就是直接由作業系統核心(Kernel)支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器對執行緒進行排程,並且負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫做多執行緒核心。

程式一般不會直接去使用核心執行緒,而是去使用核心執行緒的一種高階介面—輕量級程序(Light Weight Process LWP),輕量級程序與核心執行緒之間1:1關係為一對一的執行緒模型

由於核心執行緒的支援,每個輕量級程序都成為一個獨立的排程單元,即使有一個輕量級程序在系統呼叫中阻塞了,也不會影響整個程序繼續工作。

但是輕量級程序也有侷限性:首先,由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。 而系統呼叫的代價相對較高,需要在使用者態(User Mode)和核心態(KernelMode)中來回切換。其次,每個輕量級程序都需要有一個核心執行緒的支援,因此輕量級程序要消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程序的數量有限

3.1.2  使用使用者執行緒實現

狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。使用者執行緒的建立、 同步、 銷燬和排程完全在使用者態中完成,不需要核心的幫助。如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也可以支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型。

使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理。 執行緒的建立、切換和排程都是需要考慮的問題,而且由於作業系統只把處理器資源分配到程序,那諸如“阻塞如何處理”、“多處理器系統中如何將執行緒對映到其他處理器上”這類問題解決起來將會異常困難,甚至不可能完成。Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄使用它。

3.1.3  使用使用者執行緒加輕量級程序混合實現

將核心執行緒與使用者執行緒一起使用的實現方式。在這種混合實現下,既存在使用者執行緒,也存在輕量級程序。 使用者執行緒還是完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統提供支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心提供的執行緒排程功能及處理器對映,並且使用者執行緒的系統呼叫要通過輕量級執行緒來完成,大大降低了整個程序被完全阻塞的風險。

在這種混合模式中,使用者執行緒與輕量級程序的數量比是不定的,即為N:M的關係。

3.1.4  Java執行緒的實現

目前的JDK版本中,作業系統支援怎樣的執行緒模型取決於Java虛擬機器的執行緒是怎樣對映。執行緒模型只對執行緒的併發規模和操作成本產生影響,對Java程式的編碼和執行過程來說,這些差異都是透明的。

對於Sun  JDK來說,它的Windows和Linux版都是一對一的執行緒模型實現的,一條Java執行緒就對映到一條輕量級程序之中。


3.2  Java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要的排程方式有兩種:協同式執行緒排程(Cooperative Threads-Scheduling)和搶佔式執行緒排程(Preemptive ThreadsScheduling)。

協同式排程:

執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上。

優點:實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以沒有什麼執行緒同步的問題。

缺點:執行緒執行時間不可控制,甚至如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。

搶佔式排程:

執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,執行緒本身是沒有什麼辦法的)。

在這種實現執行緒排程的方式下,執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程序阻塞的問題。Java使用的執行緒排程方式就是搶佔式排程。

Java語言一共設定了10個級別的執行緒優先順序(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在兩個執行緒同時處於Ready狀態時,優先順序越高的執行緒越容易被系統選擇執行。Java的執行緒是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,即系統執行緒優先順序跟Java執行緒的優先順序一般對不上。


3.3  狀態轉換

該節內容轉自:執行緒狀態

執行緒共包括以下5種狀態。

1. 新建狀態(New) : 執行緒物件被建立後,就進入了新建狀態。例如,Thread thread = new Thread()。

2. 就緒狀態(Runnable): 也被稱為“可執行狀態”。執行緒物件被建立後,其它執行緒呼叫了該物件的start()方法,從而來啟動該執行緒。例如,thread.start()。處於就緒狀態的執行緒,隨時可能被CPU排程執行。

3. 執行狀態(Running) : 執行緒獲取CPU許可權進行執行。需要注意的是,執行緒只能從就緒狀態進入到執行狀態。

4. 阻塞狀態(Blocked)  : 阻塞狀態是執行緒因為某種原因放棄CPU使用權,暫時停止執行。直到執行緒進入就緒狀態,才有機會轉到執行狀態。阻塞的情況分三種:
    (1) 等待阻塞 -- 通過呼叫執行緒的wait()方法,讓執行緒等待某工作的完成。
    (2) 同步阻塞 -- 執行緒在獲取synchronized同步鎖失敗(因為鎖被其它執行緒所佔用),它會進入同步阻塞狀態。
    (3) 其他阻塞 -- 通過呼叫執行緒的sleep()或join()或發出了I/O請求時,執行緒會進入到阻塞狀態。當sleep()狀態超時、join()等待執行緒終止或者超時、或者I/O處理完畢時,執行緒重新轉入就緒狀態。

5. 死亡狀態(Dead)    : 執行緒執行完了或者因異常退出了run()方法,該執行緒結束生命週期。

這5種狀態涉及到的內容包括Object類, Thread和synchronized關鍵字。

1、Object類,定義了wait(), notify(), notifyAll()等休眠/喚醒函式。
2、Thread類,定義了一些列的執行緒操作函式。例如,sleep()休眠函式, interrupt()中斷函式, getName()獲取執行緒名稱等。
3、synchronized,是關鍵字;它區分為synchronized程式碼塊和synchronized方法。synchronized的作用是讓執行緒獲取物件的同步鎖。


4  總結

本文首先介紹了:

1、Java記憶體模型的結構及操作;

2、原子性、可見性、有序性在Java記憶體模型中的體現;

3、先行發生原則的規則及使用;

4、執行緒在Java語言中是如何實現的。


上一篇:晚期(執行期)優化:https://blog.csdn.net/pcwl1206/article/details/84642835