對(duì)象共享:Java并發(fā)環(huán)境中的煩心事

相關(guān)文章: 多線程安全性:每個(gè)人都在談,但是不是每個(gè)人都談地清

并發(fā)的意義在于多線程協(xié)作完成某項(xiàng)任務(wù),而線程的協(xié)作就不可避免地需要共享數(shù)據(jù)。今天我們就來(lái)討論下如何發(fā)布和共享類對(duì)象,使其可以被多個(gè)線程安全地訪問。

之前,我們討論了同步操作在多線程安全中如何保證原子性,其實(shí)關(guān)鍵字synchronized不光實(shí)現(xiàn)了原子性,還實(shí)現(xiàn)內(nèi)存可見性(Memory Visibility)。也就是在同步的過程中,不僅要防止某個(gè)線程正在使用的狀態(tài)被另一個(gè)線程修改,還要保證一個(gè)線程修改了對(duì)象狀態(tài)之后,其他線程能獲得更新之后的狀態(tài)。

1. 內(nèi)存可見性

在單個(gè)線程環(huán)境中,對(duì)某個(gè)變量寫入值后,在沒有其他寫操作的情況下,讀取該變量的值總是相同;但是在多線程環(huán)境中情況并非如此,雖然難以接受且違反直觀,但是很多問題就是這樣發(fā)生的,這都是由于沒有使用同步機(jī)制保證可見性。

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            //內(nèi)部靜態(tài)類可以直接使用外部類的靜態(tài)域
            while (!ready){
                // 線程讓步,使當(dāng)前線程從執(zhí)行狀態(tài)(運(yùn)行狀態(tài))變?yōu)榭蓤?zhí)行態(tài)(就緒狀態(tài))。
                // 就是說當(dāng)一個(gè)線程使用了這個(gè)方法之后,它就會(huì)把自己CPU執(zhí)行的時(shí)間讓掉,
                // 讓自己或者其它的線程運(yùn)行。
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        //JVM可能對(duì)一些語(yǔ)句進(jìn)行重排序
        number = 42;
        ready = true;
    }
}

上面的期望的代碼結(jié)果是:因主線程執(zhí)行ready = true,匿名子線程退出循環(huán),打印number。但是很可能事與愿違:由于匿名線程和主線程并不是一個(gè)線程環(huán)境,雖然主線程中更新了ready變量的值,但是由于缺少同步機(jī)制,更新之后的值不一定對(duì)匿名子線程是可見的,匿名子線程很可能就由于使用了失效的數(shù)據(jù)而不能正常工作.

失效數(shù)據(jù)是由于Java的內(nèi)存機(jī)制導(dǎo)致的:在沒有同步機(jī)制的情況下,在多線程的環(huán)境中,每個(gè)進(jìn)程單獨(dú)使用保存在自己的線程環(huán)境中的變量拷貝。正因如此,當(dāng)多線程共享一個(gè)可變狀態(tài)時(shí),該狀態(tài)就會(huì)有多份拷貝,當(dāng)一個(gè)線程環(huán)境中的變量拷貝被修改了,并不會(huì)立刻就去更新其他線程中的變量拷貝。

有些情況下,上面的程序會(huì)輸出0,這是由于重排序的發(fā)生,也就是JVM根據(jù)優(yōu)化的需要調(diào)整“不相關(guān)”代碼的執(zhí)行順序。在主線程中,number = 42ready = true看似是不相關(guān)的,不相互依賴,所以可能被JVM在編譯時(shí)顛倒執(zhí)行順序,所以才會(huì)出現(xiàn)這個(gè)奇怪結(jié)果。

重排序和變量多拷貝可能看上去是一種奇怪的設(shè)計(jì),但是這樣做的目的是希望JVM能充分利用多核處理器強(qiáng)大的性能,Java內(nèi)存模型更為具體的內(nèi)容將會(huì)在未來(lái)的篇章中為大家詳細(xì)介紹。

1.1 加鎖和可見性

正像前文提到同步控制那樣,加鎖的含義也不僅僅局限于建立互斥性以保證原子性,還涉及到內(nèi)存可見性。為確保所有線程都能看到共享變量的最新值,所有對(duì)該變量執(zhí)行讀操作和寫操作的線程都必須在同一個(gè)鎖上同步。

1.2 Volatile變量

加鎖當(dāng)然是多線程安全的完備方法,但是有的時(shí)候只需要確保少數(shù)狀態(tài)變量的可見性即可,使用加鎖機(jī)制未免有些大材小用,因此Java語(yǔ)言提供一種稍弱的同步機(jī)制——Volatile變量。當(dāng)變量被聲明為Volatile類型后,在編譯時(shí)和運(yùn)行時(shí),JVM都會(huì)注意到這是一個(gè)共享變量,既不會(huì)在編譯時(shí)對(duì)該變量的操作進(jìn)行重排序,也不會(huì)緩存該變量到其他線程不可見的地方,保證所有線程都能讀取到該變量的最新狀態(tài)。

訪問Volatile變量時(shí)并沒使用加鎖操作,不會(huì)阻塞線程的運(yùn)行,所以性能遠(yuǎn)遠(yuǎn)優(yōu)于同步代碼塊和上鎖機(jī)制,只比訪問正常變量略高,不過這是犧牲原子性為代價(jià)的。

加鎖機(jī)制可以確保可見性、原子性和不可重排序性,但是Volatile變量只能確保可見性不可重排序性。

使用Volatile變量時(shí)需要謹(jǐn)慎,一定要確保以下所有條件:

  1. 對(duì)當(dāng)前變量的寫操作,不依賴變量的當(dāng)前值(比如++操作就不符合要求),或者確保只有一個(gè)進(jìn)程更新該變量狀態(tài);
  2. 該變量不會(huì)和其他變量一起納入不變性條件中;
  3. 訪問該變量不需要加鎖;

實(shí)際使用中,Volatile變量多使用在會(huì)發(fā)生狀態(tài)翻轉(zhuǎn)的標(biāo)志位上。

2. 發(fā)布與逸出

對(duì)象的可見性是保證對(duì)象的最新狀態(tài)被共享,同時(shí)我們還應(yīng)該注意防止不應(yīng)該被共享的對(duì)象被暴露在多線程環(huán)境中。

發(fā)布對(duì)象意味著該對(duì)象能在當(dāng)前作用域之外的代碼中被使用,比如,將類內(nèi)部的對(duì)象傳給其他類使用,或者一個(gè)非私有方法返回了該對(duì)象的引用等等。Java中強(qiáng)調(diào)類的封裝性就是希望能合理的發(fā)布對(duì)象,保護(hù)類的內(nèi)部信息。發(fā)布類內(nèi)部狀態(tài),在多線程的環(huán)境下可能問題不大,但是在并發(fā)環(huán)境中卻用可能嚴(yán)重地破壞多線程安全。

某個(gè)不該發(fā)布的對(duì)象被發(fā)布了,這種情況被稱為逸出.
我們來(lái)一起看看幾種逸出的例子:

class UnsafeStates {
    private String[] states = new String[]{
        "AK", "AL" /*...*/
    };

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

上面的例子中,雖然states是私有變量,但是其被共有方法所暴露,數(shù)組中的元素都可以被任意修改,這就是一種逸出的情況。

當(dāng)一個(gè)對(duì)象被發(fā)布時(shí),該對(duì)象的非私有域中的所有引用都會(huì)被發(fā)布,即間接發(fā)布。

有一種逸出是比較隱蔽的,就是This逸出:

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

內(nèi)部的匿名類是隱私持有外部類的this引用的,這就無(wú)意中將this發(fā)布給內(nèi)部類,如果內(nèi)部類再被發(fā)布,則外部類就可能逸出,無(wú)意間造成內(nèi)存泄漏和多線程安全問題。

具體來(lái)說,只有當(dāng)構(gòu)造器執(zhí)行結(jié)束后,this對(duì)象完成初始化后才能發(fā)布,否者就是一種不正確的構(gòu)造,存在多線程安全隱患。

解決這個(gè)問題最常見的方法就是工廠模式

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

上例中,外部類的構(gòu)造器被設(shè)置為私有的,其他類執(zhí)行外部類的公有靜態(tài)方法在構(gòu)造器執(zhí)行完畢之后才返回對(duì)象的引用,避免了this對(duì)象的逸出問題。

相對(duì)而言,對(duì)象安全發(fā)布的問題比可見性問題更容易被忽視,接下來(lái)就討論下如何才能安全發(fā)布對(duì)象。

3. 線程封閉

對(duì)象的發(fā)布既然是個(gè)頭疼的問題,所以我們應(yīng)該避免泛濫地發(fā)布對(duì)象,最簡(jiǎn)單的方式就是盡可能把對(duì)象的使用范圍都控制在單線程環(huán)境中,也就是線程封閉。

常見的線程封閉方法有:

  1. Ad-hoc線程封閉,也就是維護(hù)線程封閉性的責(zé)任完全由編程承擔(dān),這種方法是不推薦的;
  2. 局部變量封閉,很多人容易忽視一點(diǎn),局部變量的固有屬性之一就是封閉在執(zhí)行線程內(nèi),無(wú)法被外界引用,所以盡量使用局部變量可以減少逸出的發(fā)生;
  3. ThreadLocal,這是一種更為規(guī)范的方法,該類將把進(jìn)程中的某個(gè)值和保存值的對(duì)象關(guān)聯(lián)起來(lái),并提供get和set方法,保證get方法獲得的值都是當(dāng)前進(jìn)程調(diào)用set方法設(shè)置的最新值。

需要說明的是,看起來(lái)是ThreadLocal類似于一種 Map<Thread, T>對(duì)象,來(lái)保存特定于線程的值,但實(shí)際上這些值** **,其生命周期和Thread對(duì)象一致,一旦線程終止后,線程對(duì)象中的值都會(huì)被回收。

ThreadLoacl在JDBC和J2EE容器中有著大量的應(yīng)用。比如,在JDBC中,ThreadLoacl用來(lái)保證每個(gè)線程只能有一個(gè)數(shù)據(jù)庫(kù)連接,再如在J2EE中,用以保存線程的上下文,方便線程切換等。

4. 不變性

如果一定要將發(fā)布對(duì)象,那么不可變的對(duì)象是首選,因?yàn)槠湟欢ㄊ嵌嗑€程安全的,可以放心地被用來(lái)數(shù)據(jù)共享。這是因?yàn)椴蛔兊膶?duì)象的狀態(tài)只有一種狀態(tài),并且該狀態(tài)由其構(gòu)造器控制。

對(duì)象不可變要求滿足以下條件:

  1. 該對(duì)象是正確創(chuàng)建的,沒有this逸出問題;
  2. 該對(duì)象的所有狀態(tài)在創(chuàng)建之后不能修改,也就是其set方法應(yīng)該為私有的,或者該域直接是final的。

下面這個(gè)類就是不可變的:

@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》建議在類設(shè)計(jì)時(shí)應(yīng)該盡可能減少可變的域:除非必須,域都應(yīng)該是私有域;除非可變,域都應(yīng)該是final域。

5. 安全發(fā)布

要安全地發(fā)布一個(gè)對(duì)象,對(duì)象的引用以及對(duì)象的狀態(tài)必須同時(shí)對(duì)其他線程可見。一個(gè)正確構(gòu)造的對(duì)象可以通過以下方式安全地發(fā)布:

  1. 在靜態(tài)初始化函數(shù)中初始化一個(gè)對(duì)象的引用(態(tài)初始化函數(shù)由JVM在初始化階段執(zhí)行,JVM為其提供同步機(jī)制);
  2. 將對(duì)象的引用保存在Volatile域或AtomicReference對(duì)象中;
  3. 將對(duì)象的引用保存在某個(gè)正確構(gòu)造對(duì)象的final域中;
  4. 將對(duì)象的引用保存到一個(gè)由鎖保護(hù)的域中;
  5. 將對(duì)象的引用保存到線程安全容器中;

6. 總結(jié)

在討論過可見性和安全發(fā)布之后,我們來(lái)總結(jié)下安全共享對(duì)象的策略:

  1. 線程封閉:線程封閉的對(duì)象只能由一個(gè)線程擁有,對(duì)象封閉在線程中,并且只能由該線程修改。
  2. 只讀共享:共享不可變的只讀對(duì)象,只要保證可見性即可,可以不需要額外的同步操作。
  3. 線程安全共享:線程安全的對(duì)象在其內(nèi)部封裝同步機(jī)制,多線程通過公有接口訪問數(shù)據(jù);對(duì)象發(fā)布的內(nèi)部狀態(tài)必須是安全發(fā)布的,且可變的狀態(tài)需要鎖來(lái)保護(hù);對(duì)象的引用和對(duì)象的狀態(tài)都是可見的。

后續(xù)預(yù)告:Java內(nèi)存模型

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容