Java併發編程實戰——學習筆記(一)

一、線程安全性


在線程安全性中,最核心的概念是正確性,而正確性的含義是:某個類的行爲與其規範完全一致。這裏的規範可以粗略理解爲在各種限定條件下,類對象的結果與預期一致。在單線程中,正確性可以近似的定義爲“所見即所知(we know it when we see it)”。在大概明確了“安全性”的概念後,我們可以認爲線程安全性就是:當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼這個類就可以認爲是線程安全的。

當多個線程訪問某個類時,不管運行環境採用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類是線程安全的。

也可以將線程安全類認爲是一個在併發環境和單線程環境中都不會被破壞的類。如果某個類在單線程環境下都不是線程安全類,那麼它肯定不是線程安全類。下面是一個線程安全類的示例:

public class StatelessFactorizer implements Servlet{
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

這個StatelessFactorizer是無狀態的:它既不包含任何域,也不包含任何對其他類中域的引用。方法中的局部變量只能由正在執行的線程訪問。如果同時有多個線程在訪問StatelessFactorizer,那麼這些線程之間將不會互相影響,因爲線程之間並沒有共享狀態,就好像在訪問不同的實例。

由於線程訪問無狀態對象的行爲並不會影響其他線程中操作的正確性,因此無狀態對象是線程安全的,且無狀態對象一定是線程安全的。


二、原子性

什麼是原子性呢?其實原子性就是一個不可再分割的性質,不能再分成更細的粒度。

如果我們在剛剛的示例中增加一個狀態(既一個計數器),用來統計已處理請求數量,每處理一個請求就將這個值加1,程序如下所示:

public class StatelessFactorizer implements Servlet{
	private long count = 0;
	
	public long getCount(){return count;}
	
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}

在上面的程序示例中,咋一看沒問題,++count看起來像是一個操作,但是這個自增操作並非原子性的。因爲實際上,它包含了三個操作:“讀取-修改-寫入”的操作序列。每個操作都依賴於前面之前的狀態。如果此時有兩個線程A、B,如果A線程已經進行到了修改操作,此時如果B線程進行了讀取,那麼最終A、B線程寫入的值是一樣的,這樣就與預期結果偏差了1.

雖然在這裏看起來,結果偏離了一些可以接受,但是如果這個計數器的值被用來生成數值序列或唯一的對象標識符,那麼在多次調用中返回相同的值將導致嚴重的數據完整性問題。

在併發編程中,像這種由於不恰當的執行時序而出現不正確的結果是一種非常重要的情況,這種情況叫做“競態條件(Race Condition)”。


競態條件

當某個計算的正確性取決於多個線程的交替執行時序的時候,那麼就會發生競態條件。常見的競態條件類型是“先檢查後執行”操作,既通過一個可能失效的觀測結果來決定下一步的動作

舉個栗子:你和朋友約好一起去網吧開黑,你當了網吧的時候,發現你朋友不在,此時你可能選擇呆在網吧裏等他,也可能去他家找他,如果你去找他,那麼當你出了網吧以後,你在網吧的觀測結果(朋友不在)就可能失效了,因爲他可能在你去他家找他的路上已經到了網吧,而你卻去找他了。

這個栗子中,正確的結果是(你們在網吧會面),但是這個結果取決於事件發生的時序(既誰先到網吧並且等待對方的時長)。這種觀察結果的失效就是大多數競態條件的本質——基於一種可能失效的觀測結果來做出判斷或者執行某個計算。

再舉個栗子,假設有兩個線程A、B,A、B線程都用來判斷某個文件夾是否存在,不存在就創建它,假如當A線程發現文件夾不存在時,正打算創建文件夾,但是此時B線程已經完成了文件夾的創建,那麼此時A線程觀測的結果就已經失效了,但是A線程依舊根據這個已失效的觀測結果在進行下一步動作,這就可能會導致各種問題。

使用“先檢查後執行”的一種常見的情況就是延遲初始化。就比如在單例模式中有一種寫法如下:

public class LazyInitRace {
	private static LazyInitRace instance = null;
	
	public LazyInitRace getInstance(){
		if(instance == null){
			instance = new LazyInitRace();
		}
		return instance;
	}
}

這就是典型的延遲初始化,在單線程中這樣寫沒毛病,但是在多線程環境中,如果有A、B線程同時執行getInstance()方法,那麼結果可能符合預期,也可能會得到兩個不一樣的對象。因爲在A線程發現instace爲null時,B線程可能也同時發現instace爲null。

與大多數併發錯誤一樣,競態條件並不總是會產生錯誤,還需要某種不恰當的執行時序,但是如果發生問題,那麼可能導致很嚴重的問題。

在上面的示例中都包含了一組需要以原子方式執行(或者說不可分割)的操作。要避免競態條件問題,就必須在某個線程修改變量時,通過某種方式防止其他線程使用這個變量,從而確保其他線程只能在修改操作完成之前或之後讀取和修改狀態,而不是在修改過程中

在上面統計已處理請求數量的示例中,我們可以使用AtomicLong對象來替換long,因爲AtmoicLong類是線程安全類,所以可以保證示例也是示例安全的,但是在添加一個狀態變量時,是否還可以通過使用線程安全的對象來管理而類的狀態以維護其線程安全性呢?如下所示:

public class UnsafeCachingFactorizer implements Servlet {
	private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();

	private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

	public void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		if (i.equals(lastNumber.get())) {
			encodeIntoResponse(resp, lastFactors.get());
		} else {
			BigInteger[] factors = factor(i);
			lastNumber.set(i);
			lastFactors.set(factors);
			encodeIntoResponse(resp, factors);
		}
	}
}

在上述例子中,雖然兩個變量都是線程安全的,但是在service方法中依然存在競態條件,因爲在上述例子中,類的不變性條件已經被破壞了,只有確保了這個不變性條件不被破壞,纔是正確的。當不變性條件中涉及到了多個變量時,各個變量之間並不是彼此獨立的,而是某個變量的值會對其他變量的值產生約束。因此,當更新某一個變量時,需要在同一個原子操作中對其他變量同時進行更新。

在上述例子中,雖然set方法是原子操作,但是在set方法無法同時更新lastNumber和lastFactors。如果當一個線程執行了lastNumber.set()方法還沒執行下一個set方法時,如果此時有一個線程訪問service方法,那麼得到的結果就與我們所預期的不一致了。

所以,要保持狀態的一致性,就需要在單個原子操作中更新所有相關的狀態變量。

三、加鎖機制

3.1內置鎖

在Java中提供了一種內置的鎖機制來支持原子性:同步代碼塊(Synchronized Block)。同步代碼塊包含兩部分:一個是作爲鎖的對象引用,一個作爲由這個鎖保護的代碼塊。以關鍵字synchronized來修飾的方法是一種橫跨整個方法體的同步代碼塊,其中該同步代碼塊的鎖就是方法調用所在的對象(this).靜態的synchronized方法以Class對象爲作爲鎖。

每個Java對象都可以用做一個實現同步的鎖,這些鎖被稱爲內置鎖(Intrinsic Lock)或是監視器鎖(Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,並且在退出同步代碼塊時自動釋放鎖。

Java的內置鎖相當於一種互斥鎖,最多隻有一個線程能持有這種鎖。當線程A嘗試獲取線程B持有的鎖時,線程A必須等待或阻塞,知道線程B釋放了該鎖。如果線程B不釋放鎖,則線程A也將永遠等下去。任何一個執行同步代碼塊的線程,都不可能看到有其他線程正在執行由同一個鎖保護的同步代碼塊。

下面時應用了內置鎖的示例:

public class SynchronizedFactorizer implements Servlet {
	private BigInteger lastNumber;

	private BigInteger[] lastFactors;

	public synchronized void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		if (i.equals(lastNumber)) {
			encodeIntoResponse(resp, lastFactors.get());
		} else {
			BigInteger[] factors = factor(i);
			lastNumber = i;
			lastFactors = factors;
			encodeIntoResponse(resp, factors);
		}
	}
}

雖然使用synchrnoized關鍵字保證了結果的正確性,但是在同一時刻只有一個線程可以執行service方法,這就導致了服務的響應性非常低,併發性非常的糟糕,變成了一個性能問題,而不是線程安全問題。

3.2 重入

當某個線程請求一個由其他線程持有的鎖是,發出請求的線程就會被阻塞,但是,由於內置鎖是可重入的,即如果某個線程試圖獲得一個已經由它自己持有的鎖時,那麼這個請求就會成功。"重入"意味着獲取鎖的操作粒度是“線程”而不是“調用”。重入的一種實現方法就是,爲每一個鎖關聯一個計數值和一個所有者線程。當計數值爲0時,就認爲這個鎖是沒有被任何線程持有。當線程請求一個未被持有的鎖時,JVM記下鎖的持有者,並且將獲取計數值置爲1。如果同一個線程再次獲取這個鎖,計數值將遞增,而當線程退出同步代碼塊時,計數值會相應的遞減。當計數值爲0時,這個鎖將被釋放。

下面是一個重入的例子:

public class Widget{
    public synchronized void doSomething(){
        System.out.println(toString() + ": calling doSomething");
    }
}
public class LoggingWidget extends Widget{
    public synchronzied void doSomething(){
           System.out.println(toString() + ": calling doSomething");
           super.doSomething();
    }
}

在上述例子中,LoggingWidget繼承了Widget並改寫了父類,並且都是用了synchronized關鍵字修飾doSomething方法,如果子類對象在調用doSomething方法時。如果沒有可重入鎖,那麼這段代碼就會產生死鎖。因爲每個doSomething方法在執行前都會獲得Widget上的鎖,如果內置鎖是不可重入的,那麼在調用super.doSomething時就無法獲得Widget上的鎖,因爲這個鎖已經被持有了,從而線程將永遠停頓下去,等待一個永遠也無法獲得的鎖。注意:在這裏synchronized關鍵字修飾的是方法體,也就是說它鎖住的是對象本身(this),所以當第一次進入doSomething方法時,鎖住的是LoggingWidget對象,而在調用super.doSomething時,並沒有新建一個父類對象,鎖的對象還是this.

四、用鎖來保護狀態

對於可能被多個線程同時訪問的可變狀態變量,在訪問它時都需要持有同一個鎖,在這種情況下,我們稱狀態變量是由這個鎖保護的。對象的內置鎖與其狀態之間沒有內在的關聯,對象的域並不一定要通過內置鎖類保護。當獲取與對象關聯的鎖時,並不能阻止其他線程訪問該對象,某個線程在獲得對象的鎖之後,只能阻止其他線程獲得同一個鎖,每個對象都有一個內置鎖。

每個共享的和可變的變量都應該只由一個鎖來保護。一種常見的加鎖約定是,將所有可變狀態都封裝在對象內部,並通過對象的內置鎖對所有訪問可變狀態的代碼路勁進行同步,使得在該對象上不會發生併發訪問。但是,如果在添加新的方法或代碼路徑時忘記了使用同步,那麼這種加鎖協議會很容易被破壞。

我們應該知道的是,並非所有數據都需要鎖的保護,只有被多個線程同時訪問的可變數據才需要通過鎖來保護。當某個變量由鎖來保護時,意味着每次訪問這個變量時都需要首先獲得這個鎖,這樣就確保在同一時刻只有一個一個線程可以訪問這個變量。當類的不變性條件涉及多個狀態變量時,那麼還有另外一個需求:在不變性條件中的每個變量都必須由同一個鎖來保護。

雖然同步可以避免競態條件問題,但並不意味着可以在每個方法聲明時都是用關鍵字synchronized.如果將程序中存在過多的同步方法,可能會導致活躍性問題或性能問題。

我們應該儘量將不影響共享狀態且執行時間較長的操作從同步代碼塊中分離出去,確保同步代碼塊中儘量只存在原子性的操作。

在使用鎖時,應該清楚代碼塊中實現的功能,以及在執行該代碼塊時是否需要很長的時間,當執行時間較長的計算或者可能無法快速完成的操作時(例如,網絡I/O或控制檯I/O),一定不要持有鎖!!!

發佈了34 篇原創文章 · 獲贊 31 · 訪問量 5035
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章