線程安全性:num++操做爲何也會出問題?

  線程的安全性多是很是複雜的,在沒有充足同步的狀況下,因爲多個線程中的操做執行順序是不可預測的,甚至會產生奇怪的結果(非預期的)。下面的Tools工具類的plus方法會使計數加一,爲了方便,這裏的num和plus()都是static的:java

public class Tools {

    private static int num = 0;

    public  static int plus() {
        num++;
        return num;
    }

}

  咱們再編寫一個任務,調用這個plus()方法並輸出計數:安全

public class Task implements Runnable {

    @Override
    public void run(){
        int num = Tools.plus();
        System.out.println(num);
    }
}

  最後建立10個線程,驅動任務:多線程

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(new Task()).start();
        }
    }
}

  輸出:併發

2
4
3
1
5
6
7
8
9
10

  看起來一切正常,10個線程併發地執行,獲得了0累加10次的結果。咱們把10次改成10000次:ide

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            new Thread(new Task()).start();
        }
    }
}

  輸出:工具

...
9994
9995
9996
9997
9998

  在個人電腦上,這個程序只能偶爾輸出10000,爲何?spa

  問題在於,若是執行的時機不對,那麼兩個線程會在調用plus()方法時獲得相同的值,num++看上去是單個操做,但事實上包含三個操做:讀取num,將num加一,將計算結果寫入num。因爲運行時可能多個線程之間的操做交替執行,所以這多個線程可能會同時執行讀操做,從而使它們獲得相同的值,並將這個值加1,結果就是,在不一樣的線程調用中返回了相同的數值。線程

A線程:num=9→→→9+1=10→→→num=10
B線程:→→→→num=9→→→9+1=10→→→num=10

  若是把這個操做換一種寫法,會看的更清晰,num加一後賦值給一個臨時變量tmp,並睡眠一秒,最後將tmp賦值給num:設計

public class Tools {

    private static int num = 0;

    public static int plus() {
        int tmp = num + 1;
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        num = tmp;
        return num;
    }

}

  此次咱們啓動兩個線程就能看出問題:code

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(new Task()).start();
        }
    }
}

  啓動程序後,控制檯1s後輸出:

1
1
A線程:num=0→→→0+1=1→→→num=1
B線程:→num=0→→→0+1=1→→→num=1

  上面的例子是一種常見的併發安全問題,稱爲競態條件(Race Condition),在多線程環境下,plus()是否會返回惟一的值,取決於運行時對線程中操做的交替執行方式,這並非咱們但願看到的狀況。

  因爲多個線程要共享相同的內存地址空間,而且是併發運行,所以它們可能會訪問或修改其餘線程正在使用的變量,線程會因爲沒法預料的數據變化而發生錯誤。要使多線程程序的行爲能夠預測,必須對共享變量的訪問操做進行協同,這樣纔不會在線程之間發生彼此干擾。幸運的是,java提供了各類同步機制來協同這種訪問。

  將plus()修改成一個同步方法,同一時間只有一個線程能夠進入該方法,能夠修復錯誤:

public class Tools {

    private static int num = 0;

    public synchronized static int plus() {
        int tmp = num + 1;
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        num = tmp;
        return num;
    }

}

  控制檯前後輸出:

1
2

  這時若是將plus()方法改成num++,驅動10000個線程去執行,也能夠保證每次都能輸出到10000了。

  那麼如何設計一個線程安全的類避免出現此類問題呢?

  若是咱們寫了這樣一個Tools工具類,沒有考慮併發的狀況,其餘調用者可能就會在多線程調用plus()方法中產生問題,咱們也不但願在多線程調用其餘開發者編寫的類時產生和單線程調用不同的結果,咱們但願不管單線程仍是多線程調用一個類時,無須使用額外的同步,這個類即能表現出正確的行爲,這樣的類是線程安全的。

  觀察上面程序,咱們在對num變量進行操做時出了問題,首先,num變量具備兩個特色:共享的(Shared)和可變的(Mutable)。「共享」意味着變量能夠由多個線程同時訪問,而「可變」意味着變量的值在其生命週期內能夠發生變化;其次,看一下咱們對num的操做,讀取num,將num加一,將計算結果寫入num,這是一個「讀取-修改-寫入」的操做,而且其結果依賴於以前的狀態。這是一種最多見的競態條件——「先檢查後執行(Check-Then-Act)」操做,首先觀察某個條件爲真(num爲0),而後根據觀察結果採起相應的動做(將num加1),可是,當咱們採起相應動做的時候,系統的狀態就可能發生變化,觀察結果可能變得無效(另外一個線程在這期間將num加1),這樣的例子還有不少,好比觀察某路徑不存在文件夾X,線程A開始建立文件夾X,可是當線程A開始建立文件夾X的時候,它先前觀察的結果就失效了,可能會有另外一個線程B在這期間建立了文件夾X,這樣問題就出現了。

  所以,咱們能夠從兩個方面來考慮設計線程安全的類

  1、狀態變量方面:(對象的狀態是指存儲在實例變量與靜態域成員中的數據,還可能包括其餘依賴對象的域。例如,某HashMap的狀態不只存儲在HashMap自己,還存儲在許多Map.Entry對象中。)多線程訪問同一個可變的狀態變量沒有使用合適的同步會出現問題,所以:

  1.不在線程之間共享該狀態變量(即每一個線程都有獨自的狀態變量)

  2.將狀態變量修改成不可變的變量

  3.在訪問狀態變量時使用同步

  2、操做方面:在某個線程須要複合操做修改狀態變量時,經過某種方式防止其它線程使用這個變量,從而確保其它線程只能在修改操做完成以前或者以後讀取和修改狀態,而不是在修改狀態的過程當中。

相關文章
相關標籤/搜索