學妹問我,併發問題的根源到底是什麼?

HollisChuang發表於2021-07-05

併發程式設計是 java 高階程式設計師的必備的基礎技能之一。但是想要寫好併發程式並非易事。

那究竟是什麼原因導致大把的“格子衫”朋友無法寫出優質和效能穩定的併發程式呢?根本原因就是大家對併發程式設計的核心理論的模糊和不理解。想要運用好一項技術。理論知識和核心概念是一定要理解透徹的。

今天我們就來一起看下併發程式設計三大核心基礎理論:原子性、可見性、有序性

1、原子性

先來看下什麼叫原子性

第一種理解:原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意 為“不可被中斷的一個或一系列操作”

第二種理解:原子性,即一個操作或多個操作,要麼全部執行並且在執行的過程中不被打斷,要麼全部不執行。(提供了互斥訪問,在同一時刻只有一個執行緒進行訪問)

原子,在物理學中定義是組成物體的不可分割的最小的單位。在 java 併發程式設計中我們可以將其理解為:一組要麼成功要麼失敗的操作

1.1、原子性問題的產生的原因

原子性問題產生的根本原因是什麼?我們只要知道了症狀才能準確的對症下藥,本小節,我們就來一起探討下原子性問題的由來。

我們都知道,程式在執行的時候,一定是以執行緒為單位在執行的,因為執行緒是 CPU 進行任務排程的基本單位

電腦的 CPU 會根據不同的任務排程演算法去執行執行緒的排程,將時間分片並派分給各個執行緒。

當某個執行緒獲得CPU的時間片之後就獲取了CPU的執行權,就可以執行任務,當時間片耗盡之後,就會失去CPU使用權。

進而本任務會暫時的停止執行。多執行緒場景下,由於時間片線上程間輪換,就會發生原子性問題

看完理論似乎並不能直觀的理解原子性問題。下面我們就通過程式碼的方式來具體闡述下原子性問題的產生原因。

1.2、案例分析

我們以常見的 i++ 為例,這是一個老生常談的原子性問題了,先來看下程式碼

public class AtomicDemo {

    private int count = 0;

    public void add() {
        count++;
    }

    public int get() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        AtomicDemo atomicDemo = new AtomicDemo();
        IntStream.rangeClosed(0, 100).forEach(item -> {
            new Thread(() -> {
                IntStream.rangeClosed(1, 100).forEach(i -> {
                    atomicDemo.add();
                });
            }).start();
            countDownLatch.countDown();
        });
        countDownLatch.await();
        System.out.println(atomicDemo.get());
    }
}

image-20210430092331457

上面 程式碼的作用是將初始值為0的 count 變數,通過100執行緒每個執行緒累加100次的方式來累加。想要得到一個結果為 10000 的值。但是實際上結果很難達到10000。

產生這個問題的原因:

count++ 的執行實際上這個操作不是原子性的,因為 count++ 會被拆分成以下三個步驟執行(這樣的步驟不是虛擬的,而是真實情況就是這麼執行的)

第一步:讀取 count 的值;

第二步:計算 +1 的結果;

第三步:將 +1 的結果賦值給 count變數

那問題又來了。分三步又咋樣?讓他執行完不就行了?

理論上是這樣子的,大家都很友好,你執行完我執行,我執行完你繼續。你想象的可能是這樣的”烏托邦圖“

image-20210430131612018

但是實際上這些執行緒已經”黑化”了。他們絕不可能互相謙讓。CPU或者是程式的世界觀裡面。大家做任何事情都是在”爭搶“。我們來看下面這張圖:

image-20210430095826861

上圖詳細分析:

第一步:A執行緒從主記憶體中讀取 count 的值 0;

第二步:A執行緒開始對 count 值進行累加;

第三步:B執行緒從主記憶體中讀取 count 的值 0(PS:具體第三步從哪裡開始都不是重點,重點是:A執行緒將 count 值寫入主記憶體之前 B 執行緒就開始讀取 count 並執行此時 B執行緒 讀取到的 count 值依舊是還未被操作過的原始值);

第四步:(PS:到這裡其實已經不重要了。因為不管 A執行緒和B執行緒現在怎麼操作。結果已經不可逆轉,已經錯了)B執行緒開始對 count 值進行累加;

第五步:A 執行緒將累加後的結果賦值給 count 結果為 1;

第六步:B 執行緒將累加後的結果賦值給 count 結果為 1;

第七步:A 執行緒將結果 count =1 刷回到主記憶體;

第八步:B 執行緒將結果 count =1 刷回到主記憶體;

相信大家此時已經非常清晰地分析出了原子性產生的根本原因了。

至於解決方案可以通過鎖或者是 CAS 的方式。具體方案就不再這裡贅述了。

2、可見性

萬丈高樓平地起,再複雜的技術我們也需要從基本的概念看起來:

可見性:一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,我們稱為可見性。

2.1、可見性問題產生的原因

在很多年前,那個嫁妝只需要一個手電筒的年代你或許還不會出現可見性這樣的問題,因為大家都是單核處理器,不存在併發的情況。

而對於現在“視金錢如糞土”的年代。多核處理器已經是現代超級計算機的基礎硬體。高速的CPU處理器和緩慢的記憶體之前資料的通訊成了矛盾。

所以為了解決和緩和這樣的情況,每個CPU和執行緒都有自己的本地快取,所謂本地快取即該快取僅僅對它所在的處理器可見,CPU快取與記憶體的資料不容易保證一致。

為了避免這種因為寫資料速度不一致而導致 CPU 的效能浪費的情況,處理器通過使用寫緩衝區來臨時儲存待寫入主記憶體的資料。寫緩衝區合併對同一記憶體地址的多次寫,並以批處理的方式重新整理,也就是說寫緩衝區不會立即將資料重新整理到主記憶體中

快取不能及時重新整理到主記憶體就是導致可見性問題產生的根本原因。

2.2、案例分析

public class AtomicDemo {

    private int count = 0;

    public void add() {
        count++;
    }

    public int get() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        AtomicDemo atomicDemo = new AtomicDemo();
        IntStream.rangeClosed(0, 100).forEach(item -> {
            new Thread(() -> {
                IntStream.rangeClosed(1, 100).forEach(i -> {
                    atomicDemo.add();
                });
            }).start();
            countDownLatch.countDown();
        });
        countDownLatch.await();
        System.out.println(atomicDemo.get());
    }
}

“what * *”,怎麼和上面程式碼一樣。。。結果就不截圖了,必然不是10000。

image-20210430103319794

我們來看下執行的流程圖(PS:不要糾結於為什麼和上面的不一樣,特定問題特定分析。在闡述一種問題的時候,一定會在某些層面上遮蔽另外一種問題的干擾)

image-20210430104310676

假設 A 執行緒和 B 執行緒同時開始執行,首先 A 執行緒和 B 執行緒會將主記憶體中的 count 的值載入/快取到自己的本地記憶體中。然後會讀取各自的記憶體中的值去執行操作,也就是說此時 A 執行緒和 B 執行緒就好像是兩個世界的人,彼此不會產生人和關聯。

操作完之後 A 執行緒將結果寫回到自己的本地記憶體中,同樣 B 執行緒將結果寫回到自己的本地記憶體中。然後回來某個時機各自將結果刷會到主記憶體。那最終必然是一方的資料被另一方覆蓋。這就是快取的可見性問題

3、有序性

不積跬步無以至千里,我們還是先來看概念

有序性:程式執行的順序按照程式碼的先後順序執行。

這有啥的,程式老老實實按照程式設計師寫的程式碼執行就完事了,這還會有什麼問題嗎?

3.1、有序性問題產生的原因

實際上編譯器為了提高程式執行的效能。會改變我們程式碼的執行順序的。即你寫在前面的程式碼不一定是先被執行完的。

例如:int a = 1;int b =4;從表面和常規角度來看,程式的執行應該是先初始化 a ,然後初始化 b 。但是實際上非常有可能是先初始化 b,然後初始化 a。因為在編譯器看了來,先初始化誰對這兩個變數不會有任何影響。即這兩個變數之間沒有任何的資料依賴。

指令重排序有三種型別,分別為:

編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應 機器指令的執行順序。

記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上 去可能是在亂序執行。

3.2、案例分析

有序性的案例最常見的就是 DCL了(double check lock)就是單例模式中的雙重檢查鎖功能。先來看下程式碼

public class SingletonDclDemo {

    private SingletonDclDemo(){}

    private static SingletonDclDemo instance;

    public static SingletonDclDemo getInstance(){
        if (Objects.isNull(instance)) {
            synchronized (SingletonDclDemo.class) {
                if (Objects.isNull(instance)) {
                    instance = new SingletonDclDemo();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        IntStream.rangeClosed(0,100).forEach(item->{
            new Thread(SingletonDclDemo::getInstance).start();
        });
    }
}

這個程式碼還是比較簡單的。

在獲取物件例項的方法中,程式首先判斷 instance 物件是否為空,如果為空,則鎖定SingletonDclDemo.class 並再次檢查instance是否為空,如果還為空則建立 Singleton的一個例項。看似很完美,既保證了執行緒完全的初始化單例,又經過判斷 instance 為 null 時再用 synchronized 同步加鎖。但是還有問題!

instance = new SingletonDclDemo(); 建立物件的程式碼,分為三步: ① 分配記憶體空間; ② 初始化物件SingletonDclDemo; ③ 將記憶體空間的地址賦值給instance;

但是這三步經過重排之後: ① 分配記憶體空間 ② 將記憶體空間的地址賦值給instance ③ 初始化物件SingletonDclDemo

會導致什麼結果呢?

執行緒 A 先執行 getInstance() 方法,當執行完指令②時恰好發生了執行緒切換,切換到了執行緒B上;如果此時執行緒B也執行 getInstance() 方法,那麼執行緒B在執行第一個判斷時會發現instance!=null,所以直接返回instance,而此時的instance是沒有初始化過的,如果我們這個時候訪問instance的成員變數就可能觸發空指標異常。

繼續來張圖來更直觀的理解下:

image-20210430111911421

具體的執行流程在上面已經分析了。相信這張圖片一定能讓你徹底理解。

4、本文小結

併發程式設計的學習和使用並非一朝一夕的事情,也並非會幾個理論就能寫好優質的併發程式。這需要長時間的實踐和總結。好的程式碼很少是寫出來的,都是迭代和優化的。

image-20210430112136271

相關文章