并發的意義在于多線程協作完成某項任務,而線程的協作就不可避免地需要共享數據。今天我們就來討論下如何發布和共享類對象,使其可以被多個線程安全地訪問。
之前,我們討論了同步操作在多線程安全中如何保證原子性,其實關鍵字synchronized不光實現了原子性,還實現內存可見性(Memory Visibility)。也就是在同步的過程中,不僅要防止某個線程正在使用的狀態被另一個線程修改,還要保證一個線程修改了對象狀態之后,其他線程能獲得更新之后的狀態。
1. 內存可見性
在單個線程環境中,對某個變量寫入值后,在沒有其他寫操作的情況下,讀取該變量的值總是相同;但是在多線程環境中情況并非如此,雖然難以接受且違反直觀,但是很多問題就是這樣發生的,這都是由于沒有使用同步機制保證可見性。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
//內部靜態類可以直接使用外部類的靜態域
while (!ready){
// 線程讓步,使當前線程從執行狀態(運行狀態)變為可執行態(就緒狀態)。
// 就是說當一個線程使用了這個方法之后,它就會把自己CPU執行的時間讓掉,
// 讓自己或者其它的線程運行。
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
//JVM可能對一些語句進行重排序
number = 42;
ready = true;
}
}
上面的期望的代碼結果是:因主線程執行ready = true
,匿名子線程退出循環,打印number。但是很可能事與愿違:由于匿名線程和主線程并不是一個線程環境,雖然主線程中更新了ready變量的值,但是由于缺少同步機制,更新之后的值不一定對匿名子線程是可見的,匿名子線程很可能就由于使用了失效的數據而不能正常工作.
失效數據是由于Java的內存機制導致的:在沒有同步機制的情況下,在多線程的環境中,每個進程單獨使用保存在自己的線程環境中的變量拷貝。正因如此,當多線程共享一個可變狀態時,該狀態就會有多份拷貝,當一個線程環境中的變量拷貝被修改了,并不會立刻就去更新其他線程中的變量拷貝。
有些情況下,上面的程序會輸出0,這是由于重排序的發生,也就是JVM根據優化的需要調整“不相關”代碼的執行順序。在主線程中,number = 42
和ready = true
看似是不相關的,不相互依賴,所以可能被JVM在編譯時顛倒執行順序,所以才會出現這個奇怪結果。
重排序和變量多拷貝可能看上去是一種奇怪的設計,但是這樣做的目的是希望JVM能充分利用多核處理器強大的性能,Java內存模型更為具體的內容將會在未來的篇章中為大家詳細介紹。
1.1 加鎖和可見性
正像前文提到同步控制那樣,加鎖的含義也不僅僅局限于建立互斥性以保證原子性,還涉及到內存可見性。為確保所有線程都能看到共享變量的最新值,所有對該變量執行讀操作和寫操作的線程都必須在同一個鎖上同步。
1.2 Volatile變量
加鎖當然是多線程安全的完備方法,但是有的時候只需要確保少數狀態變量的可見性即可,使用加鎖機制未免有些大材小用,因此Java語言提供一種稍弱的同步機制——Volatile變量。當變量被聲明為Volatile類型后,在編譯時和運行時,JVM都會注意到這是一個共享變量,既不會在編譯時對該變量的操作進行重排序,也不會緩存該變量到其他線程不可見的地方,保證所有線程都能讀取到該變量的最新狀態。
訪問Volatile變量時并沒使用加鎖操作,不會阻塞線程的運行,所以性能遠遠優于同步代碼塊和上鎖機制,只比訪問正常變量略高,不過這是犧牲原子性為代價的。
加鎖機制可以確保可見性、原子性和不可重排序性,但是Volatile變量只能確保可見性和不可重排序性。
使用Volatile變量時需要謹慎,一定要確保以下所有條件:
- 對當前變量的寫操作,不依賴變量的當前值(比如++操作就不符合要求),或者確保只有一個進程更新該變量狀態;
- 該變量不會和其他變量一起納入不變性條件中;
- 訪問該變量不需要加鎖;
實際使用中,Volatile變量多使用在會發生狀態翻轉的標志位上。
2. 發布與逸出
對象的可見性是保證對象的最新狀態被共享,同時我們還應該注意防止不應該被共享的對象被暴露在多線程環境中。
發布對象意味著該對象能在當前作用域之外的代碼中被使用,比如,將類內部的對象傳給其他類使用,或者一個非私有方法返回了該對象的引用等等。Java中強調類的封裝性就是希望能合理的發布對象,保護類的內部信息。發布類內部狀態,在多線程的環境下可能問題不大,但是在并發環境中卻用可能嚴重地破壞多線程安全。
某個不該發布的對象被發布了,這種情況被稱為逸出.
我們來一起看看幾種逸出的例子:
class UnsafeStates {
private String[] states = new String[]{
"AK", "AL" /*...*/
};
public String[] getStates() {
return states;
}
}
上面的例子中,雖然states
是私有變量,但是其被共有方法所暴露,數組中的元素都可以被任意修改,這就是一種逸出的情況。
當一個對象被發布時,該對象的非私有域中的所有引用都會被發布,即間接發布。
有一種逸出是比較隱蔽的,就是This逸出:
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
}
內部的匿名類是隱私持有外部類的this引用的,這就無意中將this發布給內部類,如果內部類再被發布,則外部類就可能逸出,無意間造成內存泄漏和多線程安全問題。
具體來說,只有當構造器執行結束后,this對象完成初始化后才能發布,否者就是一種不正確的構造,存在多線程安全隱患。
解決這個問題最常見的方法就是工廠模式:
public class SafeListener {
private final EventListener listener;
private 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;
}
}
上例中,外部類的構造器被設置為私有的,其他類執行外部類的公有靜態方法在構造器執行完畢之后才返回對象的引用,避免了this對象的逸出問題。
相對而言,對象安全發布的問題比可見性問題更容易被忽視,接下來就討論下如何才能安全發布對象。
3. 線程封閉
對象的發布既然是個頭疼的問題,所以我們應該避免泛濫地發布對象,最簡單的方式就是盡可能把對象的使用范圍都控制在單線程環境中,也就是線程封閉。
常見的線程封閉方法有:
- Ad-hoc線程封閉,也就是維護線程封閉性的責任完全由編程承擔,這種方法是不推薦的;
- 局部變量封閉,很多人容易忽視一點,局部變量的固有屬性之一就是封閉在執行線程內,無法被外界引用,所以盡量使用局部變量可以減少逸出的發生;
- ThreadLocal,這是一種更為規范的方法,該類將把進程中的某個值和保存值的對象關聯起來,并提供get和set方法,保證get方法獲得的值都是當前進程調用set方法設置的最新值。
需要說明的是,看起來是ThreadLocal類似于一種 Map<Thread, T>對象,來保存特定于線程的值,但實際上這些值** **,其生命周期和Thread對象一致,一旦線程終止后,線程對象中的值都會被回收。
ThreadLoacl在JDBC和J2EE容器中有著大量的應用。比如,在JDBC中,ThreadLoacl用來保證每個線程只能有一個數據庫連接,再如在J2EE中,用以保存線程的上下文,方便線程切換等。
4. 不變性
如果一定要將發布對象,那么不可變的對象是首選,因為其一定是多線程安全的,可以放心地被用來數據共享。這是因為不變的對象的狀態只有一種狀態,并且該狀態由其構造器控制。
對象不可變要求滿足以下條件:
- 該對象是正確創建的,沒有this逸出問題;
- 該對象的所有狀態在創建之后不能修改,也就是其set方法應該為私有的,或者該域直接是final的。
下面這個類就是不可變的:
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
}
《Effective Java》建議在類設計時應該盡可能減少可變的域:除非必須,域都應該是私有域;除非可變,域都應該是final域。
5. 安全發布
要安全地發布一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式安全地發布:
- 在靜態初始化函數中初始化一個對象的引用(態初始化函數由JVM在初始化階段執行,JVM為其提供同步機制);
- 將對象的引用保存在Volatile域或AtomicReference對象中;
- 將對象的引用保存在某個正確構造對象的final域中;
- 將對象的引用保存到一個由鎖保護的域中;
- 將對象的引用保存到線程安全容器中;
6. 總結
在討論過可見性和安全發布之后,我們來總結下安全共享對象的策略:
- 線程封閉:線程封閉的對象只能由一個線程擁有,對象封閉在線程中,并且只能由該線程修改。
- 只讀共享:共享不可變的只讀對象,只要保證可見性即可,可以不需要額外的同步操作。
- 線程安全共享:線程安全的對象在其內部封裝同步機制,多線程通過公有接口訪問數據;對象發布的內部狀態必須是安全發布的,且可變的狀態需要鎖來保護;對象的引用和對象的狀態都是可見的。
后續預告:Java內存模型