1. 程式人生 > >Java併發程式設計的藝術(一)——併發程式設計需要注意的問題

Java併發程式設計的藝術(一)——併發程式設計需要注意的問題

這裡寫圖片描述

併發是為了提升程式的執行速度,但並不是多執行緒一定比單執行緒高效,而且併發程式設計容易出錯。若要實現正確且高效的併發,就要在開發過程中時刻注意以下三個問題:

  • 上下文切換
  • 死鎖
  • 資源限制

接下來會逐一分析這三個問題,並給出相應的解決方案。

問題一:上下文切換會帶來額外的開銷

執行緒的執行機制

  • 一個CPU每個時刻只能執行一條執行緒;
  • 作業系統給每條執行緒分配不同長度的時間片;
  • 作業系統會從一堆執行緒中隨機選取一條來執行;
  • 每條執行緒用完自己的時間片後,即使任務還沒完成,作業系統也會剝奪它的執行權,讓另一條執行緒執行

什麼是“上下文切換”?

當一條執行緒的時間片用完後,作業系統會暫停該執行緒,並儲存該執行緒相應的資訊,然後再隨機選擇一條新執行緒去執行,這個過程就稱為“執行緒的上下文切換”。

上下文切換的過程

  • 暫停正在執行的執行緒;
  • 儲存該執行緒的相關資訊(如:執行到哪一行、程式計算的中間結果等)
  • 從就緒佇列中隨機選一條執行緒;
  • 讀取該執行緒的上下文資訊,繼續執行

上下文切換是有開銷的

每次進行上下文切換時都需要儲存當前執行緒的執行狀態,並載入新執行緒先前的狀態。
如果上下文切換頻繁,CPU花在上下文切換上的時間佔比就會上升,而真正處理任務的時間佔比就會下降。
因此,為了提高併發程式的執行效率,讓CPU把時間花在刀刃上,我們需要減少上下文切換的次數。

如何減少上下文切換?

  • 減少執行緒的數量
    由於一個CPU每個時刻只能執行一條執行緒,而傲嬌的我們又想讓程式併發執行,作業系統只好不斷地進行上下文切換來使我們從感官上覺得程式是併發執的行。因此,我們只要減少執行緒的數量,就能減少上下文切換的次數。
    然而如果執行緒數量已經少於CPU核數,每個CPU執行一條執行緒,照理來說CPU不需要進行上下文切換了,但事實並非如此。

  • 控制同一把鎖上的執行緒數量
    如果多條執行緒共用同一把鎖,那麼當一條執行緒獲得鎖後,其他執行緒就會被阻塞;當該執行緒釋放鎖後,作業系統會從被阻塞的執行緒中選一條執行,從而又會出現上下文切換。
    因此,減少同一把鎖上的執行緒數量也能減少上下文切換的次數。

  • 採用無鎖併發程式設計
    我們知道,如果減少同一把鎖上執行緒的數量就能減少上下文切換的次數,那麼如果不用鎖,是否就能避免因競爭鎖而產生的上下文切換呢?
    答案是肯定的!但你需要根據以下兩種情況挑選不同的策略:

    1. 需要併發執行的任務是無狀態的:HASH分段
      所謂無狀態是指併發執行的任務沒有共享變數,他們都獨立執行。對於這種型別的任務可以按照ID進行HASH分段,每段用一條執行緒去執行。
    2. 需要併發執行的任務是有狀態的:CAS演算法
      如果任務需要修改共享變數,那麼必須要控制執行緒的執行順序,否則會出現安全性問題。你可以給任務加鎖,保證任務的原子性與可見性,但這會引起阻塞,從而發生上下文切換;為了避免上下文切換,你可以使用CAS演算法, 僅線上程內部需要更新共享變數時使用CAS演算法來更新,這種方式不會阻塞執行緒,並保證更新過程的安全性。

問題二:併發不當可能會產生死鎖

什麼是“死鎖”?

當多個執行緒相互等待已經被對方佔用的資源時,就會產生死鎖。

死鎖示例

class DeadLock {
    // 鎖A 
    private Object lockA;
    // 鎖B
    private Object lockB;

    // 第一條執行緒
    Thread t1 = new Thread(new Runnable(){
        void run () {
            synchronized (lockA) {
                Thread.sleep(5000);
                synchronized (lockB) {
                    System.out.println("執行緒1");
                }
            }
        }
    }).start();

    // 第二條執行緒
    Thread t2 = new Thread(new Runnable(){
        void run () {
            synchronized (lockB) {
                Thread.sleep(5000);
                synchronized (lockA) {
                    System.out.println("執行緒2");
                }
            }
        }
    }).start();
}
  • 執行緒1和執行緒2都需要鎖A和鎖B
  • 執行緒1首先獲得鎖A,然後sleep 5秒
    PS:執行緒sleep過程中會釋放執行權
  • 此時執行緒2執行,獲得鎖B,然後也sleep 5秒;
  • 執行緒1 sleep 5秒後繼續執行,此時需要鎖B,然而鎖B已經被執行緒2持有,因此執行緒1被阻塞;
  • 此時執行緒2醒了,它需要鎖A,然而鎖A已經被執行緒1持有,因此它也被阻塞;
  • 此時死鎖出現了!兩條執行緒相互等待已經被佔用的資源,程式就死在這了。
    死鎖是併發程式設計中一個重要的問題,上面介紹的減少上下文切換隻是為了提升程式的效能,而一旦產生死鎖,程式就不能正確執行!

如何避免死鎖?

  • 不要在一條執行緒中巢狀使用多個鎖;
  • 不要在一條執行緒中巢狀佔用多個計算機資源;
  • 給鎖和資源加超時時間
    如果你非要在一條執行緒中巢狀使用多個鎖或佔用多個資源,那你需要給鎖、資源加超時時間,從而避免無限期的等待。

問題三:計算機資源會限制併發

誤區:執行緒越多速度越快

在併發程式設計中,並不是執行緒越多越好,有時候執行緒多了反而會拉低執行效率,原因如下:

  • 執行緒多了會導致上下文切換增多,CPU花在上下文切換的時間增多後,花在處理任務上的時間自然就減少了。
  • 計算機資源會限制程式的併發度。
    • 比如:你家網入口頻寬10M,你寫了個多執行緒下載的軟體,同時開100條執行緒下載,那每條執行緒平均以每秒100k的速度下載,然而100條執行緒之間還要不斷進行上下文切換,所以你還不如只開5條執行緒,每條平均2M/s的速度下載。
    • 再比如:資料庫連線池最多給你用10個連線,然而你卻開了100條執行緒進行資料庫操作,那麼當10個用完後其他執行緒就要等待,從而作業系統要在這100條執行緒間不斷進行上下文切換;所以與其這樣還不如只開10條執行緒,減少上下文切換的次數。

說了這麼多隻想告訴你一個道理:執行緒並不是越多越好,要根據當前計算機所能提供的資源考慮。

什麼是“資源”?

資源分為硬體資源和軟體資源:

  • 硬體資源
    • 硬碟讀寫速度
    • 網路頻寬
  • 軟體資源
    • Socket連線數
    • 資料庫連線數

如何解決資源的限制?

  • 花錢買更高階的機器
  • 根據資源限制併發度

這裡寫圖片描述