首頁 > 軟體

Java多執行緒的原子性,可見性,有序性你都瞭解嗎

2022-03-02 10:00:43

問題:

1.什麼是原子性、可見性、有序性?

1. 原子性問題

原子性、可見性、有序性是並行程式設計所面臨的三大問題。

所謂原子操作,就是“不可中斷的一個或一系列操作”,是指不會被執行緒排程機制打斷的操作。這種操作一旦開始,就一直執行到結束,中間不會有任何執行緒的切換。

例如對於 i++ 而言,實際會產生如下的 JVM 位元組碼指令:

getstatic i  // 獲取靜態變數i的值(記憶體取值)
iconst_1     // 準備常數1
iadd         // 自增 (暫存器增加1)
putstatic i  // 將修改後的值存入靜態變數i(存值到記憶體)

如果是單執行緒以上 8 行程式碼是順序執行(不會交錯)沒有問題:

但多執行緒下這 8 行程式碼可能交錯執行:

出現負數的情況:

出現正數的情況:

一個自增運運算元是一個複合操作,“記憶體取值”“暫存器增加1”和“存值到記憶體”這三個JVM指令本身是不可再分的,它們都具備原子性,是執行緒安全的,也叫原子操作。但是,兩個或者兩個以上的原子操作合在一起進行操作就不再具備原子性了。比如先讀後寫,就有可能在讀之後,其實這個變數被修改了,出現讀和寫資料不一致的情況。

因為這4個操作之間是可以發生執行緒切換的,或者說是可以被其他執行緒中斷的。所以,++操作不是原子操作,在並行場景會發生原子性問題。

2. 可見性問題

一個執行緒對共用變數的修改,另一個執行緒能夠立刻可見,我們稱該共用變數具備記憶體可見性。

談到記憶體可見性,要先引出Java記憶體模型的概念。JMM規定,將所有的變數都存放在公共主記憶體中,當執行緒使用變數時會把主記憶體中的變數複製到自己的工作記憶體(私有記憶體)中,執行緒對變數的讀寫操作,是自己工作記憶體中的變數副本。

如果兩個執行緒同時操作一個共用變數,就可能發生可見性問題:

(1) 主記憶體中有變數sum,初始值sum=0;

(2) 執行緒A計劃將sum加1,先將sum=0複製到自己的私有記憶體中,然後更新sum的值,執行緒A操作完成之後其私有記憶體中sum=1,然而執行緒A將更新後的sum值回刷到主記憶體的時間是不固定的;

(3) 線上程A沒有回刷sum到主記憶體前,剛好執行緒B同樣從主記憶體中讀取sum,此時值為0,和執行緒A進行同樣的操作,最後期盼的sum=2目標沒有達成,最終sum=1;

執行緒A和執行緒B並行操作sum發生記憶體可見性問題:

要想解決多執行緒的記憶體可見性問題,所有執行緒都必須將共用變數重新整理到主記憶體,一種簡單的方案是:使用Java提供的關鍵字volatile修飾共用變數。

為什麼Java區域性變數、方法引數不存在記憶體可見性問題?

在Java中,所有的區域性變數、方法定義引數都不會線上程之間共用,所以也就不會有記憶體可見性問題。所有的Object範例、Class範例和陣列元素都儲存在JVM堆記憶體中,堆記憶體線上程之間共用,所以存在可見性問題。

3. 有序性問題

所謂程式的有序性,是指程式按照程式碼的先後順序執行。如果程式執行的順序與程式碼的先後順序不同,並導致了錯誤的結果,即發生了有序性問題。

@Slf4j
public class Test3 {
    private static volatile int x=0,y=0;
    private static int a=0,b=0;

    public static void main(String[] args) throws InterruptedException {
        for(int i=0;;i++){
            a=0;
            b=0;
            x=0;
            y=0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            // 假如t1執行緒先執行,t2執行緒後執行,則結果為a=1,x=0,b=1,y=1  (0,1)
            // 假如t2執行緒先執行,t1執行緒後執行,則結果為b=1,y=0,a=1,x=1  (1,0)
            // 假如t1執行緒和t2執行緒的指令是同時或交替執行的,則結果為a=1,b=1,x=1,y=1 (1,1)
            // 但是不可能出現(0,0)
            if(x==0 && y==0){
                log.debug("x:{}, y:{}",x,y);
            }
        }
    }
}

由於並行執行的無序性,賦值之後x、y的值可能為(1,0)、(0,1)或(1,1)。為什麼呢?因為執行緒t1可能線上程t2開始之前就執行完了,也可能執行緒t2線上程t1開始之前就執行完了,甚至有可能二者的指令是同時或交替執行的。

然而,執行以上程式碼時,出乎意料的事情發生了:這段程式碼的執行結果也可能是(0,0),部分結果如下:

19:37:32.113 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:33.041 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:34.501 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:41.825 [main] DEBUG com.example.test.Test3 - x:0, y:0

於以上程式來說,(0,0)結果是錯誤的,意味著已經發生了並行的有序性問題。為什麼會出現(0,0)結果呢?可能在程式的執行過程中發生了指令重排序。對於執行緒t1來說,可能a=1和x=b這兩個語句的賦值操作順序被顛倒了,對於執行緒t2來說,可能b=1和y=a這兩個語句的賦值操作順序被顛倒了,從而出現了(x,y)值為(0,0)的錯誤結果。

什麼是指令重排序?

一般來說,CPU為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行順序同程式碼中的先後順序一致,但是它會保證程式最終的執行結果和程式碼順序執行的結果是一致的。

重排序也是單核時代非常優秀的優化手段,有足夠多的措施保證其在單核下的正確性。在多核時代,如果工作執行緒之間不共用資料或僅共用不可變資料,重排序也是效能優化的利器。然而,如果工作執行緒之間共用了可變資料,由於兩種重排序的結果都不是固定的,因此會導致工作執行緒似乎表現出了隨機行為。指令重排序不會影響單個執行緒的執行,但是會影響多個執行緒並行執行的正確性。

事實上,輸出了亂序的結果,並不代表一定發生了指令重排序,記憶體可見性問題也會導致這樣的輸出。但是,指令重排序也是導致亂序的原因之一。

總之,要想並行程式正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有得到保證,就有可能會導致程式執行不正確。

總結

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!     


IT145.com E-mail:sddin#qq.com