線程安全性:num++操作為什么也會出問題?


  線程的安全性可能是非常復雜的,在沒有充足同步的情況下,由於多個線程中的操作執行順序是不可預測的,甚至會產生奇怪的結果(非預期的)。下面的Tools工具類的plus方法會使計數加一,為了方便,這里的num和plus()都是static的:

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次:

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,為什么?

  問題在於,如果執行的時機不對,那么兩個線程會在調用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;
    }

}

  這次我們啟動兩個線程就能看出問題:

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,這樣問題就出現了。

  因此,我們可以從兩個方面來考慮設計線程安全的類

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

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

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

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

  二、操作方面:在某個線程需要復合操作修改狀態變量時,通過某種方式防止其它線程使用這個變量,從而確保其它線程只能在修改操作完成之前或者之后讀取和修改狀態,而不是在修改狀態的過程中。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM