相關文章: 多線程安全性:每個人都在談,但是不是每個人都談地清
并發的意義在于多線程協作完成某項任務,而線程的協作就不可避免地需要共享資料。今天我們就來讨論下如何釋出和共享類對象,使其可以被多個線程安全地通路。
之前,我們讨論了同步操作在多線程安全中如何保證原子性,其實關鍵字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記憶體模型