併發問題的三大根源是什麼?

柒墨轩發表於2024-07-17
1.前言
從程序與執行緒中我們瞭解到,一個任務中CPU的的運算速度要遠遠大於I0的速度,當CPU和IO一起協作時就容易產生問題,一個任務在等待I0的時候,CPU無法進行工作,所以後續為了提高CPU的利用率,程序中誕生執行緒,CPU新增快取,編譯程式最佳化指令執行次序,使得快取能夠得到更加合理地利用但是與之而來的,還有大量的併發問題。
2.CPU快取導致的可見性
在早期CPU是單核的時候,所有執行緒都在一個CPU上執行,CPU的快取與記憶體之間的資料一致性就很好解決了。CPU快取記憶體是用於減少CPU訪問記憶體所需的時間,提高CPU的使用率,一個執行緒的操作對於另一個執行緒來說是一定可以看到的。一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,我們稱為可見性

隨著時間的發展,CPU變為多核的時候,由於CPU都有自己獨立的快取,當多個執行緒分別在不同的CPU上執行時,會把記憶體中操作的變數複製到自己的CPU快取中,處理完成後在寫入記憶體,這樣執行緒之間的可見性就無法保障了。

下面透過一段程式碼來驗證可見性問題
public class Test{

    private static long sum =0;

    public static void main(String[] args) throws InterruptedException {

        //建立兩個執行緒,每個執行緒對sum進行加10000的操作

        Thread t1=new Thread(()->{
            for(int i=0;i<10000;i++){
                sum++;
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<10000;i++){
                sum++;
            }
        });
        t1.start();
        t2.start();
        // 等待兩個執行緒執行結束
        t1.join();
        t2.join();
        System.out.println(sum);
     }
}
透過看程式碼,直覺告訴我們最後輸出結果是20000,但是當實際執行時,我們發現每次的輸出結果都是小於20000的。
我們假設兩個執行緒同時執行,把sum=0載入到自己的CPU快取中,t1 t2兩個執行緒進行sum++的操作,此時各自快取中的sum值都是1,這時候把sum寫入到記憶體的時候,我們發現不是我們期望的2,而是1。
3.執行緒切換導致的原子性問題
我們知道執行緒是透過獲取CPU時間片來進行排程的,執行緒只有得到CPU時間片才能執行指令,處於執行狀態,沒有得到時間片的執行緒處於就緒狀態,等待系統分配下一個CPU時間片。
執行緒的排程模型目前主要分為兩種:分時排程模型搶佔式排程模型
1.分時排程模型:系統平均分配CPU時間片,所有執行緒輪流獲取CPU時間片
2.搶佔式排程:系統按照執行緒優先順序分配CPU時間片。優先順序高的執行緒優先分配CPU時間片,如果所有就緒執行緒的優先順序相同,那麼會隨機選擇一個,優先順序高的執行緒獲取的CPU時間片相對多一些。Java的執行緒排程模型採用的就是搶佔式排程。
我們知道所有的高階程式語言,都需要轉換成CPU指令,這樣CPU才能執行操作。就對於上述sum++這行操作,轉換成CPU指令後,至少有三條:
1.將sum變數從記憶體中載入到CPU暫存器。
2.在暫存器中執行+1操作。
3.將sum變數寫入到快取中
當執行第1步的時候,一個執行緒的時間片消耗完了,發生了執行緒切換,最後也會導致最終的結果是1而不是2。

我們看程式碼是一行,就感覺是一步就完成了,其實被分成這麼多條CPU指令,上述問題中的任務切換,還有可能發生在任何一步,所以我們在程式設計時需要注意程式碼層面的原子性:一個或者多個操作在 CPU 執行的過程中不被中斷的特性。
4.編譯最佳化導致的有序性問題
之前我們瞭解到CPU增加快取記憶體,程序中誕生執行緒等等為了提高CPU利用率的方式,其實在CPU內部執行CPU指令時,會調整每一步CPU指令的執行順序,來提升CPU的效率。指令順序最佳化可能發生在編譯、CPU指令執行、快取最佳化幾個階段,其最佳化原則就是隻要能保證重排序後不影響單執行緒的執行結果,那麼就允許指令重排序的發生。
一個物件的new操作,也不是原子性的,他分為下面三步:
1.給物件分配記憶體
2.在這塊記憶體上初始化物件
3.將這塊地址賦值給變數
public class Singleton {
    private static Singleton instance;
    public static Singleton getInstance(){
        if(instance ==null){
            synchronized(Singleton.class){
                if(instance == null){
                    instance =new Singleton();
                }
            }
        }
        return instance;
    }
}

在這個不正確的單例模式中,我們假設A B兩個執行緒同時呼叫getInstance方法,同時發現instance == null,但是有可能因為指令最佳化,導致並不是按照正常123的順序,當發生132的順序時,可能會出現下面的情況。

這時候可能就會發生空指標異常了,我們使用idea時,idea會提醒你為instance變數新增volatile變數,主要是為了解決上述的問題。之後我們再詳細介紹Java是如何解決這三大問題的。

相關文章