1. 程式人生 > >【Java並發編程實戰-閱讀筆記】02-對象的共享

【Java並發編程實戰-閱讀筆記】02-對象的共享

mut 表現 普通 ola 過程 ger hashset 操作 unlock

編寫正確的並發程序需要在訪問可變狀態的時候進行正確的管理。前面說了如何通過同步避免多個線程在同一個時刻訪問相同的數據,本章介紹如何共享和發布對象,才能讓對象安全地被多個線程同時訪問。 synchronized只是實現了原子性和臨界區。我們還希望某個線程修改對象狀態後,其他線程能夠立刻看到狀態的變化。

3.1 可見性

一般情況下,我們無法保證執行讀操作的線程能夠立刻看到其他線程寫入的值,比如下面的例子:
public class NoVisibility {
    private static boolean ready;
    private
static int number; public static class ReaderThread extends Thread { public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) throws Exception {
new ReaderThread().start(); number = 42; ready = true; System.out.println("賦值結束"); } }
上面的代碼雖然看起來沒有問題,運行起來似乎也正確。但是,會存在如下可能性: 1、線程輸出了0。(未測試出來)因為CPU會對指令編碼進行重排序,導致“ready=true”先執行,“member=42”後執行。 2、死循環。雖然靜態變量是公共的,子線程可能永遠看不到主線程修改後的值。(因為子線程看到的是線程自身緩存的值,如果沒有一個適當的觸發機制讓線程內的緩存重新觸發更新,那麽盡管主線程修改了靜態變量,子線程仍然看不到修改後的值) 經過修改後的代碼,就能出現死循環的情況:
public
class NoVisibility { private static boolean ready; private static int number; public static class ReaderThread extends Thread { public void run() { int i = 0; while (!ready) { i++; /* 把下面這句print代碼放開,就能觸發內存更新,線程才能讀取新的ready的值。 */ // System.out.println("--進入循環體-"); /*通知系統放棄執行該線程,轉交其他線程,自己可能會由運行態-->可運行態 */ // Thread.yield(); } System.out.println(number + "," + i); } } public static void main(String[] args) throws Exception { new ReaderThread().start(); Thread.sleep(5); // TimeUnit.MILLISECONDS.sleep(10); number = 42; ready = true; System.out.println("賦值結束"); } }

一、失效數據

上面由於ready沒有及時獲取主線程更新到靜態變量的值,還是用了之前的值做判斷,稱之為失效數據,這也是缺乏同步的表現。 這種失效數據可能會導致意料之外的異常、被破壞的數據結構、不精確的計算以及無限循環等。
@NotThreadSafe
public class MutableInteger(){
    private int value;
    public int get(){return value;}
    public void set(int value){this.value = value;}
}
上面這個代碼,看起來沒問題,但是不是線程安全的。如果某個線程調用了set,另一個線程正好在調用get,雖然set要比get早一點點(甚至1納秒),但是卻不能保證get的值是新值還是舊值。 這裏如果只對set同步是不行的,還要對get進行同步。
@ThreadSafe
public class MutableInteger(){
    private int value;
    public synchronized int get(){return value;}
    public synchronized void set(int value){this.value = value;}
}

二、非原子的64位操作

一般情況下,就算是失效數據,至少也是曾經有效的,並不是一個隨機的值。這個安全性保證稱之為最低安全性。 有一種特殊的情況不是最低安全性。普通的64位數值變量,比如double或者long,在Java內存模型裏面,讀和寫都是非原子操作。因為JVM會將64位讀寫操作拆分為兩個32位的操作。因此,在並發讀寫的時候,可能會讀的到某個高32位的值和另一個低32位的值。這個就是一個奇怪的值。

三、加鎖與可見性

內置鎖能夠保證一個線程可以正確查看到另一個線程的執行結果。也就是說,對於某個鎖M。線程A在unlock M之前的所有操作,在B線程 lock M的時候,都能夠看到前一個同步代碼塊的操作的結果。 加鎖不僅僅在於互斥,而且還包括內存可見性。因此,為了確保所有的線程都能看到共享變量的最新紙,所有執行讀操作和寫操作的線程都必須在同一個鎖上同步。

四、Volatile變量

Volatile是一種稍微弱的同步機制,就是解決內存可見性的。通過這個volatile,可以確保將變量的更新操作通知到其他線程。把變量聲明為volatile類型之後,編譯器和運行時都會註意到這個變量是共享的: (1)就不會把該變量上的操作和其他內存操作一起進行重排序; (2)volatile變量不會被緩存再寄存器或者其他處理器不可見的地方。 因此,讀取volatile變量的時候,永遠都會返回最新的值。上面的對象定義中,可以改成“private volatile int value;”這樣,既不會使線程阻塞,也能夠保證內存可見性。所以,volatile是輕量級的同步機制。(目前大多數處理器架構,讀取volatile變量的開銷,比讀取非volatile變量的開銷稍微高一點) 從內存可見性來看,寫入volatile變量,相當於unlock M,讀取volatile相當於lock M。但是不建議過度依賴volatile。 volatile一般推薦用於狀態位標誌,對於復合操作,volatile滿足不了原子性。 加鎖機制既能保證原子性,又能保證可見性。volatile只能保存可見性。

3.2 發布與溢出

發布就是指,對象能夠在當前作用域之外的代碼中使用。如果不該發布的對象發布出去,就會出現溢出。 (1)我們往往需要需要確保對象以及對象內部的狀態不能被發布出去。 (2)如果需要發布對象,我們要保證發布時的線程安全,不能破壞線程安全性。 例1:公共靜態變量的對象發布。看下面的例子:
public static Set<Secret> knownSecrets;
public void initialize(){
    knownSecrets = new HashSet<Secret>();
}
這裏,如果knownSecrets的Set發布的話,其內部的Secret對象就會被間接的發布出去。 例2:私有變量被發布,逃出了其本來的作用域。 看下面的例子:
class UnsafeStates {
    private String[] states = new String[]{"AK","AL"}
    public String[] getStates(){return states;}
}
如果按照這種方式發布states,就會出現問題,因為任何調用者都能修改這個數組內容,本來私有的變量,結果卻被發布了。 當發布一個對象的時候,該對象的非私有域中引用的所有對象都會被發布,包括通過非私有的變量或者方法到達的其他對象。 例3:發不了一個內部的類實例。再看下面的例子,問題更大。
public class ThisEscape {
    public ThisEscape(EventSource source){
        source.registerListener{
            new EventListener(){
                public void onEvent(Event e){
                    doSomething();
                }
            }
        }
    }
    public void doSomething(){
     //……
    }
}
上面的代碼解釋如下:在ThisEscape的構造函數中,通過EventSource註冊了一個事件監聽器。當執行“source.registerListener”的時候,等於開啟了一個線程,當事件發生的時候,會執行ThisEscape對象的doSomething()方法。也就是“this.doSomething()”。主線程和線程B對“this”都是可見的。線程B本質上是拿到了ThisEscape對象的“this”,然後執行的doSomething()方法。 如果在ThisEscape構造函數還沒有初始化完成的時候,就發生了event事件,那麽就會調用“this.doSomething”方法,但是,這個時候,ThisEscape還沒有初始化完成,其他線程就已經使用了“this”,這裏,就是this逸出。這會導致一些不可預料的現象發生。當且僅當對象的構造函數返回時,對象才會處於和預測的一致的狀態。 不要在構造函數中讓this引用逸出。
當構造函數啟動一個新線程的時候,無論是new Thread(),還是通過Runnable接口,this都會被新創建的線程共享。
例4:上面的例子的加強版。只要線程被啟動,該線程都能獲取到“this”,然後使用this的時候,就會出問題。
public class ThisEscape {
    private String name;

    public ThisEscape(String name) {

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(ThisEscape.this.name);
            }
        }).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.name = name;
    }

    public static void main(String[] args) {
        new ThisEscape("shenggang");
    }
}
打印“ThisEscape.this.name”會出現null。 例5:使用工廠方法,私有化構造函數,避免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;
    }
}
上面的代碼,通過工廠方法,newInstance的時候,只是創建了一個safe對象。這個時候,並沒有線程在用。等safe創建完成之後,其他線程可以隨意的使用了也不會有影響。

3.3 線程封閉

線程封閉(Thread Confinement),就是指不共享數據,數據僅僅在單線程裏面訪問。這是最簡單的線程安全性的實現方式。 典型使用:Swing(封閉到事件的分發線程)。JDBC的Connection對象(從連接池獲取的connection對象,都是由單線程采用同步的方式處理)。

(1)Ad-hoc線程封閉

完成通過程序去控制數據只能在某個線程中訪問。很脆弱,不建議使用。

(2)棧封閉

線程封閉的特例。只能通過局部變量才能訪問對象。因為局部變量的特點是,如果它們位於執行線程的棧中,那麽其他線程是無法訪問到這個棧的。也就是說,方法內的變量,其他線程是看不到的。
    private int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        /* animals被封閉在方法中 */
        animals = new TreeSet<Animal>();
        animals.addAll(candidates);
        for(Animal a : animals){
            if(candidate == null || !candidate.isGood()){
                candidate = a;
            } else {
                ++numPairs;
            }
        }

        return numPairs;
    }
上面的代碼,“animals”被封閉在了方法中,也就是局部變量,那麽其他線程是看不到的。

(3)ThreadLocal類

維持線程封閉性,更好的規範方法是使用ThreadLocal。這個類可以試線程中的某個值和保存值的對象關聯起來。ThreadLocal提供了get和set等方法接口 場景1: ThreadLocal最典型的應用場景:connection數據庫連接。一般情況下,為了避免每次調用方法都要傳遞一個Connection變量,因此一般Connection會創建為一個全局的數據庫連接變量。如果多線程的情況下,大家都會取使用,而Connection本身不是線程安全的。那麽,就可以將JDBC的連接保存到ThreadLocal對象中,每個線程都會有一個屬於自己的連接。 本質上,ThreadLocal對象用於放置可變的單例變量或者全局變量進行共享。 場景2: 某個頻繁的操作需要一個臨時對象,由不希望每次執行的時候,都去重新分配該臨時對象,可以使用ThreadLocal。 場景3: 單線程的程序移植到多線程裏,可以將共享的全局變量移動到ThreadLocal中,可以保持線程的安全性。 關於ThreadLocal的理解 1、ThreadLocal不是控制並發訪問同一個對象,而是給每個線程分配一個只屬於該線程的“線程級別的局部變量”。 2、ThreadLocal本質上是ThreadLocalMap,在connection中,每次創建新線程,就會從連接池中取出一個conn連接,放到該線程的ThreadLocal中,這樣可以保證線程內事務的統一。 3、ThreadLocal中的ThreadLocalMap,使用了弱引用(當沒有外部強引用的時候,就會被GC掉)。在用完某個ThreadLocal之後,如果沒有及時remove,會導致map中key為null的entry,這些對應的value永遠無法被回收,造成內存泄漏。 4、ThreadLocal使用場景:ThreadLocal不是解決對象共享訪問的問題的,而是一種避免頻繁復雜的參數傳遞,而采用的一種方便的對象訪問方式。最適合在多線程中,每個線程都需要一個實例的對象訪問,而且這個對象會在該線程中頻繁使用。

(4)不變性

不變對象可以滿足同步需求。不可變的對象一定是線程安全的。該對象只能通過構造函數創建。 註意,雖然不變對象可以用final類型聲明,但是,final類型的數據的域中,還是可以保存可變對象的引用的。

【Java並發編程實戰-閱讀筆記】02-對象的共享