Java併發編程之設計線程安全的類

設計線程安全的類

前邊咱們對線程安全性的分析都停留在一兩個可變共享變量的基礎上,真實併發程序中可變共享變量會很是多,在出現安全性問題的時候很難準肯定位是哪塊兒出了問題,並且修復問題的難度也會隨着程序規模的擴大而提高(由於在程序的各個位置均可以隨便使用可變共享變量,每一個操做均可能致使安全性問題的發生)。比方說咱們設計了一個這樣的類:程序員

public class Increment {
    private int i;

    public void increase() {
        i++;
    }

    public int getI() {
        return i;
    }
}

而後有不少客戶端程序員在多線程環境下都使用到了這個類,有的程序員很聰明,他在調用increase方法時使用了適當的同步操做:緩存

public class RightUsageOfIncrement {

    public static void main(String[] args) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[20];  //建立20個線程
        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 100000; i++) {
                        synchronized (RightUsageOfIncrement.class) {    // 使用Class對象加鎖
                            increment.increase();
                        }
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (int i = 0; i < threads.length; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(increment.getI());
    }
}

在調用Incrementincrease方法的時候,使用RightUsageOfIncrement.class這個對象做爲鎖,有效的對i++操做進行了同步,的確不錯,執行以後的結果是:安全

2000000

但是並非每一個客戶端程序員都會這麼聰明,有的客戶端程序員壓根兒不知道啥叫個同步,因此寫成了這樣:多線程

public class WrongUsageOfIncrement {

    public static void main(String[] args) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[20];  //建立20個線程
        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 100000; i++) {
                        increment.increase();   //沒有進行有效的同步
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (int i = 0; i < threads.length; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(increment.getI());
    }
}

沒有進行有效同步的執行結果是(每次執行均可能不同):併發

1815025

其實對於Increment這個類的開發者來講,本質上是把對可變共享變量的必要同步操做轉嫁給客戶端程序員處理。有的狀況下咱們但願本身設計的類可讓客戶端程序員們不須要使用額外的同步操做就能夠放心的在多線程環境下使用,咱們就把這種類成爲線程安全類。其實就是類庫設計者把一些在多線程環境下可能致使安全性問題的操做封裝到類裏邊兒,好比Incrementincrease方法,咱們能夠寫成這樣:ide

public synchronized void increase() {
    i++;
}

也就是說把對可變共享變量i可能形成多線程安全性問題的i++操做在Increment類內就封裝好,其餘人直接調用也不會出現安全性問題。使用封裝也是無奈之舉:你沒法控制其餘人對你的代碼調用,風險始終存在,封裝使無心中破壞設計約束條件變得更難性能

封裝變量訪問

找出共享、可變的字段this

設計線程安全類的第一步就是要找出全部的字段,這裏的字段包括靜態變量也包括成員變量,而後再分析這些字段是不是共享而且可變的。spa

首先辨別一下字段是不是共享的。因爲咱們沒法控制客戶端程序員以怎樣的方式來使用這個類,因此咱們能夠經過訪問權限,也就是public權限protected權限默認權限以及private權限來控制哪些代碼是能夠被客戶端程序員調用的,哪些是不能夠調用的。通常狀況下,咱們須要把全部字段都聲明爲 private 的,把對它們的訪問都封裝到方法中,對這些方法再進行必要的同步控制,也就是說咱們只暴露給客戶端程序員一些能夠調用的方法來間接的訪問到字段,由於若是直接把字段暴露給客戶端程序員的話,咱們沒法控制客戶端程序員如何使用該字段,好比他能夠隨意的在多線程環境下對字段進行累加操做,從而不能保證把全部同步邏輯都封裝到類中。因此若是一個字段是能夠經過對外暴露的方法訪問到,那這個字段就是共享的。線程

而後再看一下字段是不是可變的。若是該字段的類型是基本數據類型,能夠看一下類全部對外暴露的方法中是否有修改該字段值的操做,若是有,那這個字段就是可變的。若是該字段的類型是非基本數據類型的,那這個字段可變就有兩層意思了,第一是在對外暴露的方法中有直接修改引用的操做,第二是在對外暴露的方法中有直接修改該對象中字段的操做。好比一個類長這樣:

public class MyObj {
    private List<String> list;

    public void m1() {
        list = new ArrayList<>(); //直接修改字段指向的對象
    }

    public void m2() {
        list[0] = "aa"; //修改該字段指向對象的字段
    }
}

代碼中的m1m2均可以算作是修改字段list,若是類暴露的方法中有這兩種修改方式中的任意一種,就能夠算做這個字段是可變的。

小貼士:是否是把字段聲明成final類型,該字段就不可變了呢?

若是該字段是基本數據類型,那聲明爲final的確能夠保證在程序運行過程當中不可變,可是若是該字段是非基本數據類型,那麼須要讓該字段表明的對象中的全部字段都是不可變字段才能保證該final字段不可變。

因此在使用字段的過程當中,應該儘量的讓字段不共享或者不可變,不共享或者不可變的字段纔不會引發安全性問題哈哈。

這讓我想起了一句老話:只有死人才不會說話~

用鎖來保護訪問

肯定了哪些字段必須是共享、可變的以後,就要分析在哪些對外暴露的方法中訪問了這些字段,咱們須要在全部的訪問位置都進行必要的同步處理,這樣才能夠保證這個類是一個線程安全類。一般,咱們會使用來保證多線程在訪問共享可變字段時是串行訪問的。

可是一種常見的錯誤就是:只有在寫入共享可變字段時才須要使用同步,就像這樣:

public class Test {
    private int i;

    public int getI() {
        return i;
    }

    public synchronized void setI(int i) {
        this.i = i;
    }
}

爲了使Test類變爲線程安全類,也就是須要保證共享可變字段i在全部外界能訪問的位置都是線程安全的,而上邊getI方法能夠訪問到字段i,卻沒有進行有效的同步處理,因爲內存可見性問題的存在,在調用getI方法時仍有可能獲取的是舊的字段值。因此再次強調一遍:咱們須要在全部的訪問位置都進行必要的同步處理

使用同一個鎖

還有一點須要強調的是:若是使用鎖來保護共享可變字段的訪問的話,對於同一個字段來講,在多個訪問位置須要使用同一個鎖。

咱們知道若是多個線程競爭同一個鎖的話,在一個線程獲取到鎖後其餘線程將被阻塞,若是是使用多個鎖來保護同一個共享可變字段的話,多個線程並不會在一個線程訪問的時候阻塞等待,而是會同時訪問這個字段,咱們的保護措施就變得無效了。

通常狀況下,在一個線程安全類中,咱們使用同步方法,也就是使用this對象做爲鎖來保護字段的訪問就OK了~。

封不封裝取決於你的心情

雖然面向對象技術封裝了安全性,可是打破這種封裝也沒啥不能夠,只不過安全性會更脆弱,增長開發成本和風險。也就是說你把字段聲明爲public訪問權限也沒人攔得住你,固然你也可能由於某種性能問題而打破封裝,不過對於咱們實現業務的人來講,仍是建議先使代碼正確運行,再考慮提升代碼執行速度吧~。

不變性條件

現實中有些字段之間是有實際聯繫的,好比說下邊這個類:

public class SquareGetter {
    private int numberCache;    //數字緩存
    private int squareCache;    //平方值緩存

    public int getSquare(int i) {
        if (i == numberCache) {
            return squareCache;
        }
        int result = i*i;
        numberCache = i;
        squareCache = result;
        return result;
    }

    public int[] getCache() {
        return new int[] {numberCache, squareCache};
    }
}

這個類提供了一個很簡單的getSquare功能,能夠獲取指定參數的平方值。可是它的實現過程使用了緩存,就是說若是指定參數和緩存的numberCache的值同樣的話,直接返回緩存的squareCache,若是不是的話,計算參數的平方,而後把該參數和計算結果分別緩存到numberCachesquareCache中。

從上邊的描述中咱們能夠知道,squareCache不論在任何狀況下都是numberCache平方值,這就是SquareGetter類的一個不變性條件,若是違背了這個不變性條件的話,就可能會得到錯誤的結果。

在單線程環境中,getSquare方法並不會有什麼問題,可是在多線程環境中,numberCachesquareCache都屬於共享的可變字段,而getSquare方法並無提供任何同步措施,因此可能形成錯誤的結果。假設如今numberCache的值是2,squareCache的值是3,一個線程調用getSquare(3),另外一個線程調用getSquare(4),這兩個線程的一個可能的執行時序是:

圖片描述

兩個線程執行事後,最後numberCache的值是4,而squareCache的值居然是9,也就意味着多線程會破壞不變性條件。爲了保持不變性條件咱們須要把保持不變性條件的多個操做定義爲一個原子操做,即用鎖給保護起來

咱們能夠這樣修改getSquare方法的代碼:

public synchronized int getSquare(int i) {
    if (i == numberCache) {
        return squareCache;
    }
    int result = i*i;
    numberCache = i;
    squareCache = result;
    return result;
}

可是不要忘了將代碼都放在同步代碼塊是會形成阻塞的,能不進行同步,就不進行同步,因此咱們修改一下上邊的代碼:

public int getSquare(int i) {

    synchronized(this) {
        if (i == numberCache) {  // numberCache字段的讀取須要進行同步
            return squareCache;
        }
    }

    int result = i*i;   //計算過程不須要同步

    synchronized(this) {   // numberCache和squareCache字段的寫入須要進行同步
        numberCache = i;
        squareCache = result;
    }
    return result;
}

雖然getSquare方法同步操做已經作好了,可是別忘了SquareGetter類getCache方法也訪問了numberCachesquareCache字段,因此對於每一個包含多個字段的不變性條件,其中涉及的全部字段都須要被同一個鎖來保護,因此咱們再修改一下getCache方法

public synchronized int[] getCache() {
    return new int[] {numberCache, squareCache};
}

這樣修改後的SquareGetter類才屬於一個線程安全類

使用volatile修飾狀態

使用鎖來保護共享可變字段雖然好,可是開銷大。使用volatile修飾字段來替換掉鎖是一種可能的考慮,可是必定要記住volatile是不能保證一系列操做的原子性的,因此只有咱們的業務場景符合下邊這兩個狀況的話,才能夠考慮:

  • 對變量的寫入操做不依賴當前值,或者保證只有單個線程進行更新。
  • 該變量不須要和其餘共享變量組成不變性條件。

比方說下邊的這個類:

public class VolatileDemo {

    private volatile int i;

    public int getI() {
        return i;
    }

    public void setI(int i) {
        this.i = i;
    }
}

VolatileDemo中的字段i並不和其餘字段組成不變性條件,並且對於能夠訪問這個字段的方法getIsetI來講,並不須要以來i的當前值,因此可使用volatile來修飾字段i,而不用在getIsetI的方法上使用鎖。

避免this引用逸出

咱們先來看一段代碼:

public class ExplicitThisEscape {

    private final int i;

    public static ThisEscape INSTANCE;

    public ThisEscape() {
        INSTANCE = this;
        i = 1;
    }
}

在構造方法中就把this引用給賦值到了靜態變量INSTANCE中,而別的線程是能夠隨時訪問INSTANCE的,咱們把這種在對象建立完成以前就把this引用賦值給別的線程能夠訪問的變量的這種狀況稱爲 this引用逸出,這種方式是極其危險的!,這意味着在ThisEscape對象建立完成以前,別的線程就能夠經過訪問INSTANCE來獲取到i字段的信息,也就是說別的線程可能獲取到字段i的值爲0,與咱們指望的final類型字段值不會改變的結果是相違背的。因此千萬不要在對象構造過程當中使this引用逸出

上邊的this引用逸出是經過顯式將this引用賦值的方式致使逸出的,也可能經過內部類的方式神不知鬼不覺的形成this引用逸出:

public class ImplicitThisEscape {

    private final int i;

    private Thread t;

    public ThisEscape() {
        t = new Thread(new Runnable() {
            @Override
            public void run() {
                // ... 具體的任務
            }
        });
        i = 1;
    }
}

雖然在ImplicitThisEscape的構造方法中並無顯式的將this引用賦值,可是因爲Runnable內部類的存在,做爲外部類的ImplicitThisEscape,內部類對象能夠輕鬆的獲取到外部類的引用,這種狀況下也算this引用逸出

this引用逸出意味着建立對象的過程是不安全的,在對象還沒有建立好的時候別的線程就能夠來訪問這個對象。雖然咱們不肯定客戶端程序員會怎麼使用這個逸出的this引用,可是風險始終存在,因此強烈建議千萬不要在對象構造過程當中使this引用逸出

總結

  1. 客戶端程序員不靠譜,咱們有必要把線程安全性封裝到類中,只給客戶端程序員提供線程安全的方法。
  2. 認真找出代碼中既共享又可變的變量,並把它們使用鎖來保護起來,同一個字段的多個訪問位置須要使用同一個鎖來保護。
  3. 對於每一個包含多個字段的不變性條件,其中涉及的全部字段都須要被同一個鎖來保護。
  4. 在對變量的寫入操做不依賴當前值以及該變量不須要和其餘共享變量組成不變性條件的狀況下能夠考慮使用volatile變量來保證併發安全。
  5. 千萬不要在對象構造過程當中使this引用逸出。
相關文章
相關標籤/搜索