Java 併發編程(二)對象的發佈逸出和線程封閉

對象的發佈與逸出

        “發佈(Publish)“一個對象是指使對象能夠在當前作用域之外的代碼中使用。可以通過 公有靜態變量非私有方法構造方法內隱含引用 三種方式。

        如果對象構造完成之前就發佈該對象,就會破壞線程安全性。當某個不應該發佈的對象被髮布時,這種情況就被稱爲逸出(Escape)。

下面我們首先來看看一個對象是如何逸出的。

        發佈對象最簡單的方法便是將對象的引用保存到一個共有的靜態變量中,以便任何類和線程都能看見對象,如下面代碼。

public static Set<String> mySet;

	public void initialize() {
		mySet = new HashSet<String>();
	}


        當發佈某個對象時,可能會間接地發佈其他對象。如果將一個 String 對象添加到集合 mySet 中,那麼同樣會發佈這個對象,因爲任何代碼都可以遍歷這個集合,並獲得對這個 String 對象的引用。同樣,如果從非私有方法中返回一個引用,那麼同樣會發佈返回的對象。如下面代碼 UnsafeStates 發佈了本應爲私有的狀態數組。

class UnsafeState {
	private String[] states = new String[] { "AK", "AL" };

	public String[] getStates() {
		return states;
	}
}


        如果按照上訴方法來發布 states,就會出問題,因爲任何調用者都能修改這個數組的內容。數組 states 已經溢出了它所在的作用域了,因爲這個本應是私有的變量已經被髮布了。當私有變量被髮布出去之後,這個類就無法知道”外部方法“會進行何種操作。

        無論其他的線程會對義發佈的引用執行何種操作,其實都不重要,因爲誤用該引用的風險始終存在。當hadoop某個對象逸出後,你必須假設有某個類或者線程可能會誤用該對象。這正是需要使用封裝的的最主要的原因:封裝能使得對正確性分析變得可能,並使減少無意中破壞設計約束條件的行爲。

        最後一種發佈對象或其內部狀態的機制就是發佈一個內部的類實例,如下面類 ThisEscape 所示。當 ThisEscape 發佈 EventListener 時,也隱含的發佈了 ThisEscape 實例本身,因爲在這個內部類的實例中包含了對 ThisEscape 實例的隱含對象。

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

安全的構造過程

        ThisEscape 中給出了逸出的一個特殊示例,即 this 引用在構造函數中逸出。內部 EventListener 實例發佈時,在外部封裝的 ThisEscape 實例也逸出了。當且僅當對象的構造函數返回時,對象才處於可預測的和一致的狀態。因此,當從對象的構造函數中發佈對象時,只是發佈了一個尚未構造完成的對象。即使發佈對象的語句位於構造函數的最後一行也是如此。如果 this 引用在構造過程中逸出,那麼這種對象就被認爲是不正確構造

        在構造過程中使 this 引用逸出的一個常見錯誤是,在構造函數中啓動一個線程。當對象在其構造函數中創建一個線程時,無論是顯示創建(通過將它傳給構造函數)還是隱式創建(由於 Thread 或 Runnable 是該對象的一個內部類), this 引用都會被新創建的線程共享。在對象尚未被創建完成之前,新的線程就可以看見它。在構造函數中創建線程並沒有錯誤,但最好不要立即啓動它,而是通過一個 start 或 initialize 方法來啓動。在構造函數中調用一個可改寫的實例方法時,同樣會導致 this 引用在構造過程中逸出。

        如果想在構造函數中註冊一個事件監聽器或啓動線程,那麼可以使用一個私有的構造函數和一個公共的工廠方法(Factory Method),從而避免不正確的構造過程,如下面的 SafeListener 。

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;
	}
}

線程封閉

        當訪問共享的可變數據時,通常需要使用同步。一種避免使用同步的方式就是不共享數據。如果僅在單線程內訪問數據,就不需要同步。這種技術被稱爲線程封閉(Thread Confinement),它是實現線程安全型的最簡單方式之一。當某個對象封閉在一個線程中時,這種用法將自動實現線程安全性,即使被封閉的對象本身不是線程安全的。

        線程封閉的一種常見的應用是 JDBC 的 Connection 對象。JDBC 規範並不要求 Connection 對象必須是線程安全的。在典型的服務器應用程序中,線程從連接池中獲得一個 Connection 對象,並且用該對象來處理請求,使用完之後再將對象返還給連接池。由於大多數請求(例如 Servlet 請求或 EJB 調用等)都是由單個線程採用同步的方式來處理,並且在 Connection 對象返回之前,連接池都不會將它分配給其它線程,因此,這種連接管理模式在處理請求時隱含的將 Connection 對象封閉在線程中。

        Java 語言及其核心庫提供了一些機制來幫助維持線程封閉性,例如局部變量和 ThreadLocal 類,即便如此,程序員仍然需要確保封閉在線程中的對象不會從線程中逸出。


Ad-hoc 線程封閉

        Ad-hoc線程封閉是指,維護線程封閉性的職責完全由程序實現來承擔。Ad-hoc線程封閉是非常脆弱的,因爲沒有任何一種語言特性,例如可見性修飾符或局部變量,能將對象封閉到目標線程上。事實上,對線程封閉對象(例如,GUI應用程序中的可視化組件或數據模型等)的引用通常保存在公有變量中。

        當決定使用線程封閉技術時,通常是因爲要將某個特定的子系統實現爲一個單線程子系統。在某些情況下,單線程子系統提供的簡便性要勝過Ad-hoc線程封閉技術的脆弱性。

        在volatile變量上存在一種特殊的線程封閉。只要你能確保只有單個線程對共享的volatile變量執行寫入操作,那麼就可以安全地在這些共享的volatile變量上執行“讀取-修改-寫入”的操作。在這種情況下,相當於將修改操作封閉在單個線程中以防止發生競態條件,並且volatile變量的可見性保證還確保了其他線程能看到最新的值。

        由於Ad-hoc線程封閉技術的脆弱性,因此在程序中儘量少用它,在可能的情況下,應該使用更強的線程封閉技術(例如,棧封閉或ThreadLocal類)。


棧封閉

        棧封閉是線程封閉的一種特例,在棧封閉中,只能通過局部變量才能訪問對象。正如封裝能使代碼更容易維持不變性條件那樣,同步變量也能使對象更易於封閉在線程中。

        對於基本類型的局部變量,例如下面 loadTheArk 方法的 numPairs ,無論如何都不會破壞棧封閉性。由於任何方法都不發獲得對基本類型的引用,因此 Java 語言的這種語義確保了基本類型的局部變量始終封閉在線程內。

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;
}

        在維持對象引用的棧封閉性時,程序員需要多做一些工作以確保被引用的對象不會逸出。在loadTheArk中實例化一個TreeSet對象,並將指向該對象的一個引用保存到animals中。此時,只有一個引用指向集合animals,這個引用被封閉在局部變量中,因此也被封閉在執行線程中。然而,如果發佈了對集合animals(或者該對象中的任何內部數據)的引用,那麼封閉性將被破壞,並導致對象animals的逸出。

        如果在線程內部(Within-Thread)上下文中使用非線程安全的對象,那麼該對象仍然是線程安全的。然而,要小心的是,只有編寫代碼的開發人員才知道哪些對象需要被封閉到執行線程中,以及被封閉的對象是否是線程安全的。如果沒有明確地說明這些需求,那麼後續的維護人員很容易錯誤地使對象逸出。


ThreadLocal 類

        維持線程封閉性的一種更規範方法是使用ThreadLocal,這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal提供了get與set等訪問接口或方法,這些方法爲每個使用該變量的線程都存有一份獨立的副本,因此get總是返回由當前執行線程在調用set時設置的最新值。

       ThreadLocal對象通常用於防止對可變的單實例變量(Singleton)或全局變量進行共享。例如,在單線程應用程序中可能會維持一個全局的數據庫連接,並在程序啓動時初始化這個連接對象,從而避免在調用每個方法時都要傳遞一個Connection對象。由於JDBC的連接對象不一定是線程安全的,因此,當多線程應用程序在沒有協同的情況下使用全局變量時,就不是線程安全的。通過將JDBC的連接保存到ThreadLocal對象中,每個線程都會擁有屬於自己的連接。

        當某個頻繁執行的操作需要一個臨時對象,例如一個緩衝區,而同時又希望避免在每次執行時都重新分配該臨時對象,就可以使用這項技術。例如,在Java 5.0之前,Integer.toString()方法使用ThreadLocal對象來保存一個12字節大小的緩衝區,用於對結果進行格式化,而不是使用共享的靜態緩衝區(這需要使用鎖機制)或者在每次調用時都分配一個新的緩衝區。

        當某個線程初次調用ThreadLocal.get方法時,就會調用initialValue來獲取初始值。從概念上看,你可以將ThreadLocal<T>視爲包含了Map< Thread,T>對象,其中保存了特定於該線程的值,但ThreadLocal的實現並非如此。這些特定於線程的值保存在Thread對象中,當線程終止後,這些值會作爲垃圾回收。

        假設你需要將一個單線程應用程序移植到多線程環境中,通過將共享的全局變量轉換爲ThreadLocal對象(如果全局變量的語義允許),可以維持線程安全性。然而,如果將應用程序範圍內的緩存轉換爲線程局部的緩存,就不會有太大作用。

        在實現應用程序框架時大量使用了ThreadLocal。例如,在EJB調用期間,J2EE容器需要將一個事務上下文(Transaction Context)與某個執行中的線程關聯起來。通過將事務上下文保存在靜態的ThreadLocal對象中,可以很容易地實現這個功能:當框架代碼需要判斷當前運行的是哪一個事務時,只需從這個ThreadLocal對象中讀取事務上下文。這種機制很方便,因爲它避免了在調用每個方法時都要傳遞執行上下文信息,然而這也將使用該機制的代碼與框架耦合在一起。

        開發人員經常濫用ThreadLocal,例如將所有全局變量都作爲ThreadLocal對象,或者作爲一種“隱藏”方法參數的手段。ThreadLocal變量類似於全局變量,它能降低代碼的可重用性,並在類之間引入隱含的耦合性,因此在使用時要格外小心。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章