1. 程式人生 > >讀書筆記 ---- 《深入理解Java虛擬機器》---- 第12篇:執行緒安全與鎖優化

讀書筆記 ---- 《深入理解Java虛擬機器》---- 第12篇:執行緒安全與鎖優化

上一篇:Java記憶體模型與執行緒:https://blog.csdn.net/pcwl1206/article/details/84661639

目  錄:

1  Java語言中的執行緒安全 

1.1  不可變 

1.2  絕對執行緒安全

1.3  相對執行緒安全

1.4  執行緒相容

1.5  執行緒對立

2  執行緒安全的實現方法

2.1  互斥同步   --  悲觀鎖

2.2  非阻塞同步   --  樂觀鎖

2.3  無同步方案

3  鎖優化

3.1  自旋鎖和自適應自旋

3.2  鎖消除

3.3  鎖粗化

3.4  輕量級鎖

3.5  偏向鎖

4  總結


如何實現“高效併發”:首先保證併發的正確性,再在此基礎上實現高效。

執行緒安全的定義:

當多執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方法進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件就是執行緒安全的。

1  Java語言中的執行緒安全 

按照執行緒安全的“安全程度”由強至弱來排序,可以將Java語言中各種操作共享的資料分為以下5類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容執行緒對立

1.1  不可變 

不可變(Immutable)物件一定是執行緒安全的,無論是物件的方法實現還是方法的呼叫者,都不需要再採取任何的執行緒安全保障措施。

Java語言中,如果共享資料是一個基本資料型別,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。如果共享資料是一個物件,那就需要保證物件的行為不會對其狀態產生任何的影響,可以將其帶有狀態的變數都宣告為final,這樣在建構函式結束之後,它就是不可變的了。

常見的不可變類有:String、列舉類、java.lang.Number的部分子類,如Long和Double等數值包裝型別、BigInteger和BigDecimal等大資料型別。

1.2  絕對執行緒安全

在Java API中標註自己是執行緒安全的類,大多數都不是絕對的執行緒安全。例如java.util.Vector是一個執行緒安全的容器,即使它的add()、get()和size()這類方法都被synchronized修飾,也不意味著呼叫它的時候永遠都不再需要同步手段了。下面舉例說明:

import java.util.Vector;
 
public class Demo {
	
	private static Vector<Integer> vector = new Vector<Integer>();
	public static void main(String[] args) throws Throwable{
		while(true){
			for (int i = 0; i < 10; i++){
				vector.add(i);
			}
			
			Thread removeThread  = new Thread(new Runnable(){
				public void run() {
					for (int i = 0; i < vector.size(); i++){
						vector.remove(i);
					}
					
				}
				
			});
			Thread printThread = new Thread(new Runnable(){
				public void run() {
					for (int i = 0; i < vector.size(); i++){
						System.out.println(vector.get(i));
					}
					
				}
				
			});
			removeThread.start();
			printThread.start();
			// 不要同時產生過多的執行緒,否則會導致作業系統假死
			while (Thread.activeCount() > 20);
		}
	}
}

執行結果會丟擲:陣列下標越界錯誤。

如果一個執行緒恰好在錯誤的時間裡刪除了一個元素,導致序號i已經不再可用的話,再用i訪問陣列就會丟擲一個ArrayIndexOutOfBoundsException,如果要保證上面這段程式碼正常的執行下去,需要加入同步以保證Vector訪問的執行緒安全。

 
import java.util.Vector;
 
public class Demo {
 
	private static Vector<Integer> vector = new Vector<Integer>();
	public static void main(String[] args) throws Throwable{
		while(true){
			for (int i = 0; i < 10; i++){
				vector.add(i);
			}

			Thread removeThread = new Thread(new Runnable(){
				public void run() {
					synchronized (vector) {
						for (int i = 0; i < vector.size(); i++){
							vector.remove(i);
						}
					}
				}
			});

			Thread printThread = new Thread(new Runnable(){
				public void run() {
					synchronized (vector) {
						for (int i = 0; i < vector.size(); i++){
							System.out.println(vector.get(i));
						}
					}
				}
			});

			removeThread.start();
			printThread.start();
			// 不要同時產生過多的執行緒,否則會導致作業系統假死
			while (Thread.activeCount()>20);
		}
	}
}

1.3  相對執行緒安全

相對的執行緒安全需要保證這個物件單獨的操作是執行緒安全的,我們在呼叫的時候不需要做額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。比如上面的vector案例中的程式碼。

在Java語言中,大部分的執行緒安全類都屬於這種型別,例如:Vector、HashTable、Collections的synchronizedCollection()方法包裝的集合等。

1.4  執行緒相容

物件身不是執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用,例如:Vector和HashTable相對應的集合類ArrayList和HashMap等。

1.5  執行緒對立

不管採用什麼同步措施都不能保證執行緒安全。例如Thread類的suspend() 和resume()方法,但是已經被JDK廢棄了。


2  執行緒安全的實現方法

虛擬機器提供的同步和鎖機制線上程安全方面發揮了很大的作用。

2.1  互斥同步   --  悲觀鎖

互斥同步(Mutual  Exclusion & Synchronization)是常見的一種併發正確性保障手段。同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只能被一個執行緒使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)、和資訊量(Semaphore)都是主要的互斥實現方式。互斥是因,同步是果,互斥是方法,同步是目的。

最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯後,會在同步塊的前後分別形成monitorentermonitorexit這個兩個位元組碼指令,這個兩個位元組碼需要一個reference型別的引數來指明要鎖定和解鎖的物件,獲取物件的鎖,鎖計數器加1,反之減一,當計數器為0時,鎖就被釋放了。

重入鎖(ReentrantLock)來實現同步。程式碼寫法上有點區別,一個表現為API層面的互斥鎖(lock())和unlock()方法配合try/finally語句塊來完成,一個表現為原生語法層面的互斥鎖,不過ReentrantLock比synchronized增加了一些高階功能,主要有以下:等待可中斷、可實現公平鎖,以及鎖可以繫結多個條件。

互斥同步最主要的問題是進行執行緒阻塞和喚醒所帶類的效能問題,因此這種同步也稱為阻塞同步。從處理問題的角度來說,互斥同步屬於一種悲觀的併發策略,總認為只要不去做正確的同步措施(如:加鎖),那就肯定會出現問題,無論共享資料是否真的會出現競爭,它都會進行加鎖、使用者核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要喚醒等操作。

2.2  非阻塞同步   --  樂觀鎖

先進行操作,如果沒有其他執行緒競爭用共享資料,那操作就成功了;如果有共享資料被爭用,產生了衝突,那就再採取其他的補償措施,最常見的補償措施就是不斷地重試,直到成功為止。這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步。

硬體需要保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類的指令常有:

  • 測試並設定(Test-and-Set)
  • 獲取並增加(Fetch-and-Increment)
  • 交換(Swap)
  • 比較並交換(Compare-and-Swap   CAS)
  • 載入連結/條件儲存(Load-Linked/Store-Conditional   LL/SC)

CAS:

CAS需要3個運算元,分別是記憶體位置V、舊的預期值A和新值B。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,上訴的過程是一個原子操作。

import java.util.concurrent.atomic.AtomicInteger;
 
public class AtomicTest {
 
	public static AtomicInteger race = new AtomicInteger(0);
	
	public static void increase(){
		race.incrementAndGet();
		System.out.println(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);
    }
}

執行結果:200000

CAS會出現ABA問題,雖然一執行緒訪問前是A,訪問後還是A,但是在此期間可能有執行緒改為B,然後又改成了A, 為了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”。

2.3  無同步方案

不需要進行同步操作。

2.3.1  可重入程式碼

在程式碼執行過程中可以中斷它,轉而去執行另一段程式碼,而控制權返回後,原來的程式不會出現任何錯誤。

可重入程式碼共同的特徵:不依賴儲存在堆上的資料和公用的系統資源、用到的狀態量都由引數中傳入、不呼叫非可重入的方法。

判斷程式碼是否具有可重入性的原則:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的資料,就都能返回相同的結果,那它就滿足可重入性的要求,當然也是執行緒安全的。

2.3.2  執行緒本地儲存

一段程式碼中所需要的資料必須與其他程式碼共享,但是這些共享資料的程式碼在同一個執行緒中,則無需同步也可以保證執行緒安全。

比如:生產者—消費者模式、Web互動模式(一個請求對應一個伺服器執行緒)。

可以通過java.lang.ThreadLocal類來實現執行緒本地的儲存功能。


3  鎖優化

鎖優化技術:適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)。

3.1  自旋鎖和自適應自旋

如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需要讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖

-XX:+UseSpinning 引數開啟,自旋就是一直在執行(等待執行緒釋放鎖,這樣避免喚醒和休眠的效能損耗),只是在空轉,自旋次數的預設是10次,如果自旋10次沒有等待執行緒釋放鎖就掛起執行緒,使用者使用引數-XX:PreBlockSpin設定自旋次數。自適應自旋根據近期自旋是否獲得過鎖的情況自適應的進行自旋,避免鎖佔用的時間很長,自旋等待的時間太長,白白浪費處理器資源。

3.2  鎖消除

鎖消除指的是虛擬機器即時編譯在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。

鎖消除的主要判定依據來源於逃逸分析的資料支援,如果判斷一段程式碼中,堆上的所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把它們當作棧上資料對待,認為它們是執行緒私有的,同步加鎖自然無須進行。

3.3  鎖粗化

在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制的儘量小,只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在競爭,那等待鎖的執行緒也能儘快拿到鎖。

大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。則可以把鎖的同步範圍擴充套件(粗化)到整個操作序列的外部。

3.4  輕量級鎖

輕量級鎖的本意是:在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

相對傳統同步(重量級鎖)來說,它是通過物件頭部有標記資訊(Mark Word) 來標記當前的鎖情況,主要分為五種:未鎖定(01)、輕量級鎖(00)、重量級鎖(10)、GC標記(11) 、可偏向(01)

類似於:剛開始是道德約束(對應輕量級鎖)、如果道德約束不行,就法律約束(重量級鎖),剛開始獲取這個物件資源採用輕量級鎖,當有執行緒來競爭,而當前執行緒佔用,這時候就改變標記為10(重量級鎖),你必須等著我執行完才能執行。【--引用自:https://blog.csdn.net/m0_37355951/article/details/77750182

3.5  偏向鎖

偏向鎖的目的是:消除資料在無競爭的情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

偏向鎖的“偏”字就是說:這個鎖會偏向第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他執行緒獲取,則持有偏向鎖的執行緒將應選不需要再進行同步。

啟用偏向鎖-XX:+UseBiaseLocking,就是第一個執行緒獲取執行,如果有執行緒來競爭,就取消標記,改為輕量級鎖,一直到重量級鎖。


4  總結

本文的主要內容:

1、執行緒安全所涉及的概念和分類;

2、同步實現的方式及虛擬機器的底層運作原理;

3、虛擬機器為了實現高效併發所採用的一系列鎖優化措施。


上一篇:Java記憶體模型與執行緒:https://blog.csdn.net/pcwl1206/article/details/84661639