天天看點

對象的共享對象的共享

對象的共享

在上一篇線程安全的總結中,說到了要想編寫正确的并發程式,關鍵在于:在通路共享的可變狀态時需要進行正确的管理。本章的總結将介紹如何共享和釋出對象,進而使他們能夠安全地由多個線程同時通路

可見性

通常,我們無法確定執行讀操作的線程能适時地看到其他線程寫入的值,有時候甚至是根本不可能的事情,為了確定多個線程之間對記憶體寫入操作的可見性,必須使用同步機制

下面我們來看一個例子

public class NoVisibility {

    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) {

        new ReaderThread().start();

        number = 10;
        ready = true;
    }
}
           

上面的這個例子中,主線程和度線程都将通路共享變量ready和number

  • 主線程啟動讀線程,然後将number設為10,并将ready設為true
  • 讀線程一直都循環直到發現ready的值變為true,然後輸出number的值
  • 雖然看起來上面的代碼會輸出10,但事實上很可能輸出0,或者根本無法終止

這是因為在代碼中沒有使用足夠的同步機制,是以無法保證主線程寫入的ready的值和number的值對于度線程來說是可見的

NoVisibility可能會持續循環下去,因為讀線程可能永遠都看不到ready的值,一種更奇怪的現象是,NoVisibility可能會輸出0,因為讀線程可能看到寫入ready的值,但是卻沒有看到之後寫入的number的值,這種現象叫做 "重排序"

隻要在某個線程中無法檢測到重排序的情況(及時在其他線程中可以很明顯地看到該線程中的重排序),那麼就無法確定線程中的操作将按照程式中指定的順序來執行

當主線程首先寫入number,然後在沒有同步的情況下寫入ready,那麼讀線程看到的順序可能與寫入的順序完全相反

指令重排

在沒有同步的情況下,編譯器、處理器以及運作時都可能對操作的執行順序進行一些意想不到的調整,在缺乏足夠同步的多線程程式中,要想對記憶體操作的執行順序進行判斷,幾乎無法得到正确的結論

失效資料

NoVisibility展示了在缺乏同步的程式中可能産生錯誤結果的一種情況:失效資料

在NoVisibility中,當讀線程檢視ready變量的時候,可能會得到一個已經失效的值。除非每次通路這個變量的時候都進行同步,否則很可能獲得該變量的一個是小智。更糟糕的是,失效值可能不會同時出現:一個線程可能獲得某個變量的最新值,而獲得另一個變量的失效值,再來看一個例子

public class MutableInteger {

    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

}
           

上述程式也是不正确的,因為get和set方法都是在沒有同步的情況下通路的value,與其他問題比起來,失效值問題更容易出現:如果當某個線程調用了set方法,那麼另一個正在調用get方法的線程可能看到更新後的value,也可能看不到

public class SynchronizedInteger {

    private int value;

    public synchronized int getValue() {
        return value;
    }

    public synchronized void setValue(int value) {
        this.value = value;
    }

}
           

改進後的程式對get/set方法使用synchronized修飾并且對指派和取操作進行上鎖,僅僅對set一個方法上鎖都是不夠的,調用get的線程仍然可能看見失效值

非原子的64位操作

當線程在沒有同步的情況下讀取變量時,可能獲得到一個失效值,但至少這個值是由之前某個線程設定的值,而不是一個随機值。這種安全性保證也被稱為最低安全性

最低安全性适用于絕大多數變量,但是存在一個例外:非volatile修飾的64位數值變量(double和long),Java記憶體模型要求,變量的讀取操作和寫入操作都必須是原子操作,但是對于非volatile類型的long和double變量,JVM允許将64位的讀操作或者寫操作分解為兩個32位的操作

當讀取一個非volatile類型的long變量時,如果對該變量的讀操作和寫操作位于兩個不同的線程中執行,那麼很可能會讀取到某個值的高32位和另一個值的低32位,除非使用關鍵字volatile來聲明它們,或者用鎖保護起來

加鎖與可見性

内置鎖可以用于確定某個線程以一種可預測的方式來檢視另一個線程的執行結果,如圖

對象的共享對象的共享

同步的可見性保證

當線程A執行某個同步代碼塊的時候,線程B随後進入由同一個鎖保護的同步代碼塊,在這種情況下可以保證,在鎖被釋放之前,A看到的變量值在B獲得鎖後同樣可以由B看到,換句話說:當線程B執行由鎖保護的同步代碼塊時,可以看到線程A之前在同一個同步代碼塊中的所有操作結果。如果沒有同步,那麼就無法實作上述保證

現在我們可以進一步了解為什麼在通路某個共享且可變的變量時要求所有的線程在同一個鎖上進行同步,就是為了確定某個線程寫入該變量的值對于其他線程來說都是可見的

volatile變量

Java提供了一種稍弱的同步機制,即volatile變量,用來確定将變量的更新操作通知到其他線程

  • 當把變量聲明為volatile類型之後,編譯器與運作時都會注意到這個變量是共享的,是以不會将該變量上的操作與其它記憶體操作一起重排序
  • volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,是以在讀取volatile類型的變量時總會傳回最新寫入的值

我們可以對比一下上面的SynchronizedInteger類,在通路volatile變量時時不會執行加鎖操作,是以也就不會使執行線程阻塞,是以volatile變量是一種比synchronized關鍵字更輕量級的同步機制。

volatile變量對可見性的影響比volatile變量本身更為重要

當線程A首先寫入一個volatile變量并且線程B随後讀取該變量時,在寫入volatile變量之前對A可見的所有變量的值,在B讀取了volatile變量之後,對B也是可見的。是以,從記憶體可見性的角度來看,寫入volatile變量相當于退出同步代碼塊,而讀取volatile變量就相當于進入同步代碼塊

volatile變量的正确使用方式包括:

  • 確定它們自身狀态的可見性
  • 確定它們所引用對象的狀态的可見性
  • 以及辨別一些重要的程式生命周期事件的發生(例如初始化或關閉)

下面我們再來看一個檢查某個狀态标記以判斷是否退出循環的例子

volatile boolean asleep;
……
    while (!asleep) {
        countSomeSheep();
    }
           

在這個示例中,線程試圖通過類似于數綿羊的傳統方法來進入休眠狀态。為了是這個示例能夠正确運作,asleep必須設定為volatile變量,否則,當asleep被另一個線程修改的時候,執行判斷的線程卻發現不了。同樣我們也能用鎖來確定asleep更新操作的可見性,但是這相對于volatile就顯得複雜一些

volatile的弊端

雖然volatile變量很友善,但是也同樣存在一些局限性。volatile變量通常用作某個操作完成、發生中斷或者狀态的标志,就例如上面數綿羊的例子,但是使用的時候要格外小心,例如,volatile并保證不了(count++)的原子性,除非能保證隻有一個線程對變量執行寫操作

當且僅當滿足以下所有條件的時候,才應該使用volatile變量:

  • 對變量的寫操作不依賴變量的目前值,或者你能確定隻有單個線程更新變量的值
  • 該變量不會與其他狀态變量一起納入不變性條件中
  • 在通路變量時不需要加鎖

釋出與逸出

"釋出(Publish)"一個對象的意思是指,是對象能夠在目前作用域之外的代碼中使用。例如,将一個指向該對象的引用儲存在其他代碼可以通路的地方,或者在某一個非私有的方法中傳回該引用,或者将引用傳遞到其他類的方法中

當某個不應該釋出的對象被釋出時,這種情況就被稱為逸出(Escape)

接下來通過一個例子我們來了解一下一個對象是如何逸出的

public static Set<Secret> knownSecrets;

public void initialize() {
    knownSecrects = new HashSet<Secret>();
}
           

釋出對象的最簡單方法就是将對象的引用儲存在一個公有的靜态變量中,以便任何類和線程都能看到這個對象,就如上面的程式清單一樣:在initialize方法中執行個體化一個新的HashSet對象,并且講對象的引用儲存到knownSecrets中以釋出該對象

當釋出某個對象時,可能會間接地釋出其他對象,如果将一個Secret對象添加進集合knownSecrets中,那麼同樣會釋出這個對象,因為任何代碼都可以周遊這個集合,并獲得對這個新Secret對象的引用

再來看一個例子,該例釋出了本應為私有的狀态數組

public class UnsafeStates {

    private String[] status = new String[] {
            "TEST1", "TEST2", ......
    };
    
    public String[] getStatus() {
        return status;
    }
}
           

如果按照上述方式來釋出status,就會出現問題,因為任何調用者都能修改這個數組的内容。在這個示例中,數組status已經逸出了它所在的作用域,因為這個本應是私有的變量已經被釋出了

當釋出一個對象的時候,在該對象的非私有域中引用的所有對象同樣會被釋出。一般來說1,如果一個已經釋出的對象能夠通過非私有的變量的引用和方法的調用到達其他對象,那麼這些對象也都會被釋出