關於Java併發多執行緒的一點思考

JavaBuild發表於2024-03-06

寫在開頭

在過去的2023年雙11活動中,天貓的累計訪問人次達到了8億,京東超60個品牌銷售破10億,直播觀看人數3.0億人次,訂單支付頻率1分鐘之內可達百萬級峰值,這樣的瞬間高併發活動,給服務端帶來的衝擊可想而知,就如同醫院那麼多醫生,去看病掛號時,有時候都需要排隊,對於很多時間就是金錢的場景來說,是不可忍受的。

為什麼要使用多執行緒併發

在上述這種場景下我們就不得不去學習多執行緒下的併發處理,我們先來了解一下併發與執行緒的概念

併發: 併發指在某時刻只有一個事件在發生,某個時間段內由於 CPU 交替執行,可以發生多個事件,存在對 CPU 資源進行搶佔。

執行緒: 是程序的子任務,因此它本身不會獨立存在,系統不會為執行緒分配記憶體,執行緒組之間只能共享所屬程序的資源,而執行緒僅僅是CPU 排程和分派的基本單位,當前執行緒 CPU 時間片用完後,會讓出 CPU 等下次輪到自己時候在執行。

有了這兩個概念後,我們再來聊一聊併發多執行緒的必要性或者它所具有的優勢😊

從計算機底層出發:

  1. 單核時代:在單核時代多執行緒主要是為了提高單程序利用 CPU 和 IO 系統的效率。 假設只執行了一個 Java 程序的情況,當我們請求 IO 的時候,如果 Java 程序中只有一個執行緒,此執行緒被 IO 阻塞則整個程序被阻塞。CPU 和 IO 裝置只有一個在執行,那麼可以簡單地說系統整體效率只有 50%。當使用多執行緒的時候,一個執行緒被 IO 阻塞,其他執行緒還可以繼續使用 CPU。從而提高了 Java 程序利用系統資源的整體效率。
  2. 多核時代: 隨著網際網路的深入發展,計算機CPU也進入到了多核時代,舉個例子:假如我們要計算一個複雜的任務,我們只用一個執行緒的話,不論系統有幾個 CPU 核心,都只會有一個 CPU 核心被利用到。而建立多個執行緒,這些執行緒可以被對映到底層多個 CPU 上執行,在任務中的多個執行緒沒有資源競爭的情況下,任務執行的效率會有顯著性的提高,約等於(單核時執行時間/CPU 核心數)。

從網際網路現狀出發:

隨著科技的進步,網際網路上的應用場景更加複雜化,使用網際網路的網民也呈指數增長,動輒就是百萬甚至千萬級的併發吞吐量,這也促進者開發者們不斷的提升系統的效能,而提升高併發處理效率的基礎就是多執行緒!

image

總結: 基於上述內容,我們可以做如下3點總結:

  1. 在硬體上提高了 CPU 的核數和個數以後,多執行緒併發可以提升 CPU 的計算能力的利用率。
  2. 多執行緒併發可以提升程式的效能,如:響應時間、吞吐量、計算機資源使用率等。
  3. 併發多執行緒可以更好地處理複雜業務,對複雜業務進行多工拆分,簡化任務排程,同步執行任務。

多執行緒會帶來什麼問題?

上面我們闡述了多執行緒使用的好處,以及它發展的必然趨勢,但這裡我們同樣還要思考另外一個問題,那就是多執行緒真的完美無缺的嗎?

答案當然是個否命題了,經過多年積累我們發現多執行緒在使用上其實也存在很多的問題:

  • Java 中的執行緒對應是作業系統級別的執行緒,執行緒數量控制不好,頻繁的建立、銷燬執行緒和執行緒間的切換,比較消耗記憶體和時間;
  • 容易帶來執行緒安全問題。如執行緒的可見性、有序性、原子性問題,會導致程式出現的結果與預期結果不一致;
  • 多執行緒容易造成死鎖、活鎖、執行緒飢餓、記憶體洩露等問題。此類問題往往只能透過手動停止執行緒、甚至是程序才能解決,影響嚴重;
  • 開發難度相對較高,需要相當開發人員充分的瞭解多執行緒,才能開發出高效的併發程式。

探索問題的根本原因

在一個Java程式或者說程序執行的過程中,會涉及到CPU、記憶體、IO裝置,這三者在讀寫速度上存在著巨大差異:CPU速度-優於-記憶體的速度-優於-IO裝置的速度

為了平衡這三者之間的速度差異,達到程式響應最大化,計算機、作業系統、編譯器都做出了自己的努力。

  • 計算機體系結構:給 CPU 增加了快取,均衡 CPU 和記憶體的速度差異;
  • 作業系統:增加了程序與執行緒,分時複用 CPU,均衡 CPU 和 IO 裝置的速度差異;
  • 編譯器:增加了指令執行重排序(這個也會帶來另外的問題,我們在後面的學習中會提到),更好地利用快取,提高程式的執行速度。

這種最佳化是充分必要的,但這種最佳化同時會給多執行緒程式帶來原子性、可見性和有序性的問題。

關於多執行緒的原子性、可見性、有序性問題

我們接著上面的問題向下深入討論,先來看看什麼是原子性、可見性、有序性。

原子性:一個或者多個操作在 CPU 執行的過程中不被中斷的特性;
可見性:一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到;
有序性:程式執行的順序按照程式碼的先後順序執行;

原子性分析

作業系統對當前執行執行緒的切換,可能會帶來了原子性問題。

【程式碼示例1】

public class Test {
    //計數變數
    static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        //執行緒 1 給 count 加 10000
        Thread t1 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t1 count 加 10000 結束");
        });
        //執行緒 2 給 count 加 10000
        Thread t2 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t2 count 加 10000 結束");
        });
        //啟動執行緒 1
        t1.start();
        //啟動執行緒 2
        t2.start();
        //等待執行緒 1 執行完成
        t1.join();
        //等待執行緒 2 執行完成
        t2.join();
        //列印 count 變數
        System.out.println(count);
    }
}

我們建立了2個執行緒,分別對count進行加10000操作,理論上最終輸出的結果應該是20000萬對吧,但實際並不是,我們看一下真實輸出。

輸出:

thread t1 count 加 10000 結束
thread t2 count 加 10000 結束
14281

原因:
Java 程式碼中 的 count++ ,至少需要三條CPU指令:

  • 指令 1:把變數 count 從記憶體載入到CPU的暫存器
  • 指令 2:在暫存器中執行 count + 1 操作
  • 指令 3:+1 後的結果寫入CPU快取或記憶體

即使是單核的 CPU,當執行緒 1 執行到指令 1 時發生執行緒切換,執行緒 2 從記憶體中讀取 count 變數,此時執行緒 1 和執行緒 2 中的 count 變數值是相等,都執行完指令 2 和指令 3,寫入的 count 的值是相同的。從結果上看,兩個執行緒都進行了 count++,但是 count 的值只增加了 1。這種情況多發生在cpu佔用時間較長的執行緒中,若單執行緒對count僅增加100,那我們就很難遇到執行緒的切換,得出的結果也就是200啦。
image

解決辦法:
可以透過JDK Atomic開頭的原子類、synchronized、LOCK,解決多執行緒原子性問題,後面的博文會詳細分析,這裡只給結果哈。

可見性分析

CPU 快取,在多核 CPU 的情況下,帶來了可見性問題。

【程式碼示例2】

public class Test {
    //是否停止 變數
    private static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        //啟動執行緒 1,當 stop 為 true,結束迴圈
        new Thread(() -> {
            System.out.println("執行緒 1 正在執行...");
            while (!stop) ;
            System.out.println("執行緒 1 終止");
        }).start();
        //休眠 1 秒
        Thread.sleep(1000);
        //啟動執行緒 2, 設定 stop = true
        new Thread(() -> {
            System.out.println("執行緒 2 正在執行...");
            stop = true;
            System.out.println("設定 stop 變數為 true.");
        }).start();
    }
}

輸出:

執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.

原因:
我們會發現,執行緒1執行起來後,休眠1秒,啟動執行緒2,可即便執行緒2把stop設定為true了,執行緒1仍然沒有停止,這個就是因為 CPU 快取導致的可見性導致的問題。執行緒 2 設定 stop 變數為 true,執行緒 1 在 CPU 1上執行,讀取的 CPU 1 快取中的 stop 變數仍然為 false,執行緒 1 一直在迴圈執行。
image

解決辦法:

透過 volatile、synchronized、Lock介面、Atomic 型別保障可見性

有序性分析

編譯器指令重排最佳化,帶來了有序性問題。

【程式碼示例3】

public class Test {

    static int x;//靜態變數 x
    static int y;//靜態變數 y

    public static void main(String[] args) throws InterruptedException {
        Set<String> valueSet = new HashSet<String>();//記錄出現的結果的情況
        Map<String, Integer> valueMap = new HashMap<String, Integer>();//儲存結果的鍵值對

        //迴圈 1萬次,記錄可能出現的 v1 和 v2 的情況
        for (int i = 0; i <10000; i++) {
            //給 x y 賦值為 0
            x = 0;
            y = 0;
            valueMap.clear();//清除之前記錄的鍵值對
            Thread t1 = new Thread(() -> {
                int v1 = y;//將 y 賦值給 v1 ----> Step1
                x = 1;//設定 x 為 1  ----> Step2
                valueMap.put("v1", v1);//v1 值存入 valueMap 中  ----> Step3
            }) ;

            Thread t2 = new Thread(() -> {
                int v2 = x;//將 x 賦值給 v2  ----> Step4
                y = 1;//設定 y 為 1  ----> Step5
                valueMap.put("v2", v2);//v2 值存入 valueMap 中  ----> Step6
            });

            //啟動執行緒 t1 t2
            t1.start();
            t2.start();
            //等待執行緒 t1 t2 執行完成
            t1.join();
            t2.join();

            //利用 Set 記錄並列印 v1 和 v2 可能出現的不同結果
            valueSet.add("(v1=" + valueMap.get("v1") + ",v2=" + valueMap.get("v2") + ")");
            System.out.println(valueSet);
        }
    }
}

輸出:

...
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
...

v1=0,v2=0 的執行順序是 Step1 和 Step 4 先執行

v1=1,v2=0 的執行順序是 Step5 先於 Step1 執行

v1=0,v2=1 的執行順序是 Step2 先於 Step4 執行

v1=1,v2=1 出現的機率極低,就是因為 CPU 指令重排序造成的。Step2 被最佳化到 Step1 前,Step5 被最佳化到 Step4 前,至少需要成立一個。

解決辦法:
Happens-Before 規則可以解決有序性問題,後續會的博文中也會提到。

總結

好啦,關於Java併發多執行緒的思考就寫這麼多啦🤗

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!
image

相關文章