1. 程式人生 > >《Java併發程式設計實踐》筆記1——併發程式設計基礎

《Java併發程式設計實踐》筆記1——併發程式設計基礎

1.執行緒安全定義:

當多個執行緒訪問一個類時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,並且不需要額外的同步及在呼叫方程式碼不必做其他的協調,這個類的行為仍然是正確的,那麼這個類就被稱之為是執行緒安全的。簡言之對於執行緒安全類的例項進行順序或併發的一系列操作,都不會導致例項處於無效狀態。

只有物件狀態可變,且存在物件共享,才需要考慮執行緒安全。

可以通過下面的方法來確保執行緒安全:

(1).無狀態物件:

無狀態物件不包含域也沒有引用其他類的域,一次特定計算的瞬時狀態只會唯一存在本地變數中,這些本地變數儲存線上程的棧中,只有執行執行緒才能訪問,因此無狀態物件永遠是執行緒安全的。

(2).不可變物件:

不可變物件需要滿足下面條件:

A.物件本身是final(避免被子類化),所有域都是final型別。

B.不可變物件的狀態在建立後就不能再改變,每次對他們的改變都是產生了新的不可變物件的物件。

C.不可變物件能被正確地建立(在建立過程中沒有發生this引用逸出)

不可變物件是執行緒安全的,不需要任何同步或鎖的機制就可以保證安全地在多執行緒之間共享。

(3).原子變數:

介紹原子變數前首先介紹原子操作,假設有操作AB,如果從執行A的角度看,當其他執行緒執行B時,要麼全部執行完成,要麼一點都沒有執行,這樣AB互為原子操作,不滿足原子操作要求的操作被稱為複合操作,原子操作是執行緒安全的。

JDK

java.util.concurrent.atomic包中包括了原子變數類,這些類用來實現數字和物件引用的原子狀態轉換。

原子變數自身是執行緒安全的,但是如果一個不變約束涉及多個變數時,變數間不是彼此獨立的,無論這些變數是否是原子變數都不能確保執行緒安全,而需要在同一個原子操作中更新這些相互關聯的狀態變數才能確保執行緒安全。

(4).正確使用執行緒同步:

通過使用synchronized關鍵字獨佔鎖、顯式鎖、volatile等同步機制實現的相對執行緒安全。

2.多執行緒的競爭條件與資料競爭:

(1).競爭條件:

指多個執行緒或者程序在讀寫一個共享資料時結果依賴於它們指令執行的相對時序,即要想得到正確的結果,要依賴於幸運的時序。

最常見的競爭條件是“檢查再執行”,使用一個潛在的過期值作為決定下一步操作的依據。

(2).資料競爭:

指沒有使用同步來協調所有那些共享的非final域訪問的情況,一個執行緒寫入一個變數,可以被另一個執行緒讀取;一個執行緒讀取剛剛被另一個執行緒寫入的變數,如果兩個執行緒都沒有使用同步,則會處於資料競爭的風險中。

不是所有的競爭條件都是資料競爭,也不是所有的資料競爭都是競爭條件,但它們都會引起併發程式以不可預期的方式失敗。

3.內部鎖:

Java提供了強制原子性的內建鎖機制:synchronized塊。

一個synchronized塊有兩部分:鎖物件引用,以及該鎖保護的程式碼塊。

synchronized方法的鎖是該方法所在的物件本身,靜態的synchronized方法的鎖是從Class物件上獲取的鎖。內部鎖的特性如下:

(1).自動獲得和釋放:

每個java物件都可以隱式地扮演一個用於同步的鎖的角色,這些內建的鎖被稱為內部鎖或監視器鎖,執行執行緒進入synchronized塊之前自動獲得鎖,而無論是正常退出還是丟擲異常,執行緒都會自動釋放鎖。因此獲得內部鎖的唯一途徑是進入這個內部鎖保護的同步塊或方法。

(2).互斥性:

內部鎖在java中扮演了互斥鎖的角色,即至多隻有一個執行緒可以擁有鎖,沒有獲取到鎖的執行緒只能等待或阻塞直到鎖被釋放,因此同步塊可以執行緒安全地原子執行。

(3).可重入性:

可重入是指對於同一個執行緒,它可以重新獲得已有它佔用的鎖。

可重入性意味著鎖的請求是基於”每執行緒”而不是基於”每呼叫”,它是通過為鎖關聯一個請求計數器和一個佔有它的執行緒來實現。

可重入性方便了鎖行為的封裝,簡化了面向物件併發程式碼的開發,可以防止類繼承引起的死鎖,例子如下:

public class Widget {
	public synchronized void doSomething(){
		......
	}
}

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

子類LoggingWidget覆蓋了父類Widgetsynchronized型別的doSomething方法,並呼叫了父類的中的同步方法,因此子類LoggingWidget和父類Widget在呼叫doSomething方法之前都會先獲取Widget的鎖,若內部鎖沒有可重入性,則super.doSomething的呼叫就會因為無法獲得鎖而被死鎖。

4.記憶體可見性:

可見性是關於在哪些情況下,一個執行緒執行的結果對另一個執行緒是可見的問題。

在單執行緒的情況下,程式按順序執行保證記憶體可見性,但是在多執行緒環境下,為了優化效能,在沒有同步的情況下,java儲存模型允許編譯器進行指令重排序,指令重排序的結果是程式指令的執行順序是不確定的,結果會導致記憶體可見性問題。

記憶體可見性不僅避免一個執行緒修改其他執行緒正式使用的物件狀態,而且還保證一個執行緒修改了物件的狀態之後,其他的執行緒能夠真正看到改變,可以通過如下方式保證多執行緒的記憶體可見性:

(1).鎖:

鎖不僅僅是關於同步與互斥的的,也是關於記憶體可見性的,為了保證所有執行緒都能看到共享的、可變變數的最新值,讀取和寫入執行緒必須使用公共的鎖進行同步,鎖確保釋放鎖之前對共享資料做出的更改對於隨後獲得該鎖的另一個執行緒是可見的

(2).volatile變數:

volatile是一種弱形式的同步, volatile確保對一個變數的更新以可預見的方式告知其他執行緒。當一個域被宣告為volatile型別後,編譯器與執行時會監視這個變數:它是共享的,而且對它的操作不會與其他記憶體操作一起被重排序,volatile變數不會快取在暫存器或者快取在其他處理器隱藏的地方,因此讀一個volatile型別的變數時,總是返回由某一執行緒所寫入的最新值。

出於簡易性或可伸縮性的考慮,您可能傾向於使用 volatile 變數而不是鎖。當使用 volatile 變數而非鎖時,某些習慣用法更加易於編碼和閱讀。此外,volatile 變數不會像鎖那樣造成執行緒阻塞,因此也很少造成可伸縮性問題。在某些情況下,如果讀操作遠遠大於寫操作,volatile 變數還可以提供優於鎖的效能優勢,但是請注意volatile只保證可見性,不保證原子性。

只有滿足如下條件才能使用volatile 變數:

A. 對變數的寫操作不依賴於當前值。

B. 該變數沒有包含在具有其他變數的不變式中。

5.釋出和逸出:

(1).釋出:

釋出一個物件是指使該物件能夠被當前範圍之外的程式碼所使用,例如將一個引用儲存到其他程式碼可以訪問的地方;在一個非私有的方法中返回該引用;將該物件傳遞到其他類的方法中等。

最常見的釋出方式是將物件的引用儲存到公共靜態域中,例如:

public static Set<Secrte> knownSecrets;
public void initialize(){
	knowSecrets = new HashSet<Secret>();
}

(2).逸出:

逸出是指一個物件在尚未準備好時就將它釋出。

物件逸出會導致物件的內部狀態被暴露,可能危及到封裝性,使程式難以維持穩定;若釋出尚未構造完成的物件,可能危及執行緒安全問題。

最常見的逸出是this引用在構造時逸出,導致this引用逸出的常見錯誤有:

A.在建構函式中啟動執行緒:

當物件在建構函式中顯式還是隱式建立執行緒時,this引用幾乎總是被新執行緒共享,於是新的執行緒在所屬物件完成構造之前就能看見它。

避免建構函式中啟動執行緒引起的this引用逸出的方法是不要在建構函式中啟動新執行緒,取而代之的是在其他初始化或啟動方法中啟動物件擁有的執行緒。

B.在構造方法中呼叫可覆蓋的例項方法:

在構造方法中呼叫那些既不是private也不是final的可被子類覆蓋的例項方法時,同樣導致this引用逸出。

避免此類錯誤的方法時千萬不要在父類構造方法中呼叫被子類覆蓋的方法。

C.在構造方法中建立內部類:

在構造方法中建立內部類例項時,內部類的例項包含了對封裝例項的隱含引用,可能導致隱式this逸出。例子如下:

public class ThisEscape {
	public ThisEscape(EventSource source) {
		source.registerListener(new EventListener() {
			public void onEvent(Event e) {
				doSomething(e);
			}
		});
	}
}

上述例子中的this逸出可以使用工廠方法來避免,例子如下:

public class SafeListener {
	private final EventListener listener;

	public SafeListener(){
		listener = new EventListener(){
			public void onEvent(Event e){
				doSomething(e);
			}
		);
	}

	public static SafeListener newInstance(EventSource source) {
		SafeListener safe = new SafeListener();
		source.registerListener(safe.listener);
		return safe;
	}
}

6.執行緒封閉:

訪問共享的、可變的資料要求使用同步。執行緒封閉是一種將資料僅在單執行緒中訪問而不共享,不需要任何同步的最簡單的實現執行緒安全的方式。

當物件(無論本身是否執行緒安全)封閉在一個執行緒中,會自動成為執行緒安全,執行緒封閉的常用做法有:

(1).棧限制:

棧限制是執行緒封閉的一種特例,只能通過本地變數才可以觸及物件,本地變數使物件限制在執行執行緒中,存在於執行執行緒棧,其他執行緒無法訪問這個棧,從而確保執行緒安全。棧限制的例子如下:

public int loadTheArk(Collection<Animal> candidates){
	SortedSet<Animal> animals;
	int numPairs = 0;
	Animal candidate = null;

	//animals被限制在本地方法棧中
	animals = new TreeSet<Animal>(new SpeciesGenderComparator());
	animals.addAll(candidates);
	for(Animal a : animals){
		if(candidate == null || !candidate.isPotentialMate(a)){
			candidate = a;
		}else{
			ark.load(new AnimalPair(candidate, a));
			++numPairs;
			candidate = null;
		}
	}
	return numPairs;
}

注意上面的棧限制例子中animals不能逸出,否則就會破壞限制性。

(2).ThreadLocal

ThreadLocal執行緒本地變數是一種規範化的維護執行緒限制的方式,它允許將每個執行緒與持有數值的物件關聯在一起,為每個使用它的執行緒維護一份單獨的拷貝。ThreadLocal提供了setget訪問器,get總是返回由當前執行緒通過set設定的最新值。

ThreadLocal執行緒本地變數通過用於防止在基於可變的單例(singleton)或全域性變數的設計中,出現不正確的共享。ThreadLocal執行緒本地變數的例子如下:

private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
	public Connection initialValue(){
		return DriverManager.getConnection(DB_URL);
	}
};
public static Connection getConnection(){
	return connectionHolder.get();
}

7.向已有的執行緒安全類新增功能:

如果JDK或者第三方類庫提供的執行緒安全類只能滿足我們大部分的要求,即不能完全滿足要求時就需要對其進行新增新操作,這個看似簡單的問題往往會引起執行緒安全問題。

以向一個執行緒安全的List新增一個原子的缺少即加入操作為例,有如下方法:

(1).修改原始類:

新增一個新原子操作最安全的方式就是修改原始類,在原始類中新增新操作,但是軟體設計的開放閉合原則以及有可能無法得到原始碼(或無法自由修改原始碼)等問題決定修改原始類是最不可能的。即便可以修改原始類,也要保證理解原有的同步策略,維持原有的設計。

(2).擴充套件原始類:

如果原始類在設計上是可擴充套件的(沒有聲名為final,即允許繼承),則擴充套件原始類並新增新方法,例子如下:

public class BetterVector<E> extends Vector<E> {
	public synchronized boolean putIfAbsent(E x){
		boolean absent = !contains(x);
		if(absent){
			add(x);
		}
		return absent;
	}
}

擴充套件原始的執行緒安全類要特別小心,雖然做法非常直觀,但是一定要明白原始的執行緒安全類的同步策略,新擴充套件的類要使用和原始類相同的鎖來控制對基本類狀態的併發訪問,這種訪問有很大的侷限性,如果原始類使用的是內部私有鎖同步策略或者沒有告知使用者同步策略,則該方式是不能支援的。

Vector是使用內部鎖來控制併發訪問的,因此上面程式碼中BetterVector也使用內部鎖控制併發訪問是可以維持Vector同步策略。

(3).客戶端加鎖:

對於一個由Collections.synchronizedList封裝的ArrayList,客戶端不知道同步封裝工廠方法返回的List物件的同步策略的時候,前面所介紹的擴充套件原始類方案就無法支援,這時我們就需要將新增功能新增在客戶端,並在客戶端進行加鎖同步。

客戶端加鎖要非常小心,不注意就會發生錯誤,下面例子演示一個常見的錯誤:

public class ListHelper<E> {
	public List<E> list = Collections.synchronizedList(new ArrayList<E>());

	public synchronized boolean putIfAbsent(E x) {
		poolean absent = !list.contains(x);
		if (absent) {
			list.add(x);
		}
		return absent;
	}
}

上述程式碼錯誤在於使用了與執行緒同步List不同的鎖,上面程式碼的putIfAbsent方式使用的是ListHelper物件的內部鎖,而執行緒同步List的其他原子操作肯定用的不是ListHelper物件內部鎖,因此putIfAbsent對於List的其他操作而言並不是原子化的,上述程式碼是很多人經常不小心犯的錯誤。

避免上述的錯誤的辦法是讓客戶端的putIfAbsent方法所使用的鎖與List的其他操作所使用的鎖是同一個鎖,正確的程式碼如下:

public class ListHelper<E> {
	public List<E> list = Collections.synchronizedList(new ArrayList<E>());

	public boolean putIfAbsent(E x) {
		synchronized (list) {
			poolean absent = !list.contains(x);
			if (absent) {
				list.add(x);
			}
			return absent;
		}
	}
}

(4).組合:

面向物件有兩種常用的擴充套件方式:繼承(is-a)和組合(has-a),設計原則也經常推薦優先使用組合,除非情況非常合適,否則儘量少使用繼承。

組合對於向現有執行緒安全類新增新功能時同樣適合,通過新增一層額外的鎖層,組合物件將操作委託給底層List例項,無論底層List例項是否實現執行緒安全,組合物件的putIfAbsent方法都可以保證操作的原子性,例子如下:

public class ImprovedList<T> implements List<T>{
	private final List<T> list;
	public ImprovedList<T>(List<T> list){
		this.list = list;
	}
	public synchronized boolean putIfAbsent(T x){
		boolean absent= !list.contains(x);
		if(absent){
			list.add(x);
		}
		return absent;
	}
	public synchronized void clear(){
		list.clear();
	}
	......
}