最全java多執行緒總結2--如何進行執行緒同步

煩囂的人發表於2019-07-02

  上篇對執行緒的一些基礎知識做了總結,本篇來對多執行緒程式設計中最重要,也是最麻煩的一個部分——同步,來做個總結。

  建立執行緒並不難,難的是如何讓多個執行緒能夠良好的協作執行,大部分需要多執行緒處理的事情都不是完全獨立的,大都涉及到資料的共享,本篇是對執行緒同步的一個總結,如有紕漏的地方,歡迎在評論中指出。

為什麼要有同步

  我們來看一個簡單的例子,有兩個數 num1,num2,現在用 10 個執行緒來做這樣一件事--每次從 num1 中減去一個隨機的數 a,加到 num2 上。

public class Demo1 {
    public static void main(String[] args) {
        Bank bank = new Bank();
        //建立10個執行緒,不停的將一個賬號資金轉移到另一個賬號上
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                while (true) {
                    int account1 = ((Double) Math.floor(Math.random() * 10)).intValue();
                    int account2 = ((Double) Math.floor(Math.random() * 10)).intValue();
                    int num = ((Long) Math.round(Math.random() * 100)).intValue();
                    bank.transfer(account1, account2, num);
                    try {
                        Thread.sleep(((Double) (Math.random() * 10)).intValue());
                    } catch (Exception e) {
                    }
                }
            }).start();
        }
    }
}

class Bank {
    /**
     * 10個資金賬戶
     */
    public int[] accounts = new int[10];

    public Bank() {
        Arrays.fill(accounts, 1000);
    }

    public void transfer(int from, int to, int num) {
        accounts[from] -= num;
        accounts[to] += num;
        //計算和
        int sum = 0;
        for (int j = 0; j < 10; j++) {
            sum += accounts[j];
        }
        System.out.println(sum);
    }
}

正常情況下,無論什麼時候資金賬號的和應該都是 10000.然而真的會這樣嗎?執行程式一段時間後會發現和不等於 10000 了,可能變大也可能變小了。

競爭

  上面的程式碼中有多個程式同時更新賬戶資訊,因此出現了競爭關係。假設兩個執行緒同時執行下面的一句程式碼:

accounts[account1] -= num;

該程式碼不是原子性的,可能會被處理成如下三條指令:

  1. 將 accounts[account1]載入到暫存器
  2. 值減少 num
  3. 結果寫回到 accounts[account1]

這裡僅說明單核心情況下的問題(多核一樣會有問題),單核心是不能同時執行兩個執行緒的,如果一個執行緒 A 執行到第三步時,被剝奪了執行權,執行緒 B 開始執行完成了整個過程,然後執行緒 A 繼續執行第三步,這就產生了錯誤,執行緒 A 的結果覆蓋了執行緒 B 的結果,總金額不再正確。如下圖所示:
競爭

如何同步

鎖物件

  為了防止併發導致資料錯亂,Java 語言提供了 synchronized 關鍵字,並且在 Java SE 5 的時候加入了 ReentrantLock 類。synchronized 關鍵字自動提供了一個鎖以及相關的條件,這個後面再說。ReentrantLock 的基本使用如下:

myLock.lock()//myLock是一個ReetrantLock物件示例
try{
    //要保護的程式碼塊
}finally{
    //一定要在finally中釋放鎖
    myLock.unlock();
}

  上述結構保證任意時刻只有一個執行緒進入臨界區,一旦一個執行緒呼叫 lock 方法獲取了鎖,其他所有執行緒都會阻塞在 lock 方法處,直到有鎖執行緒呼叫 unlock 方法。

  將 ban 類中的 transfer 方法加鎖,程式碼如下:

class Bank {
    /**
     * 10個資金賬戶
     */
    public int[] accounts = new int[10];

    private ReentrantLock lock = new ReentrantLock();

    public Bank() {
        Arrays.fill(accounts, 1000);
    }

    public void transfer(int from, int to, int num) {
        try {
            lock.lock();
            accounts[from] -= num;
            accounts[to] += num;
            //計算和
            int sum = 0;
            for (int j = 0; j < 10; j++) {
                sum += accounts[j];
            }
            System.out.println(sum);
        } finally {
            lock.unlock();
        }
    }
}

經過加鎖,無論多少執行緒同時執行,都不會導致資料錯亂。

  鎖是可以重入的,已經持有鎖的執行緒可以重複獲取已經持有的鎖。鎖有一個持有計數(hold count)來跟蹤 lock 方法的巢狀呼叫。每 lock 一次計數+1,unlock 一次計數-1,當 lock 為 0 時鎖釋放掉。

  可以通過帶 boolean 引數構造一個帶有公平策略的鎖--new ReentrantLock(true)。公平鎖偏愛等待時間最長的執行緒。但是會導致效能大幅降低,而且即使使用公平鎖,也不能確保執行緒排程器是公平的。

條件物件

  通常我們會遇到這樣的問題,當一個執行緒獲取鎖後,發現需要滿足某個條件才能繼續往後執行,這就需要一個條件物件來管理已經獲取鎖但是卻不能做有用工作的執行緒。

  現在來考慮給轉賬加一個限制,只有資金充足的賬戶才能作為轉出賬戶,也就是不能出現負值。注意下面的程式碼是不可行的:

if(bank.accounts[from]>=num){
    bank.transfer(from,to,num);
}

因為多執行緒下極有可能 if 判斷成功後,剛好資料被其他執行緒修改了。

  可以通過條件物件來這樣實現判斷:

class Bank {
    /**
     * 10個資金賬戶
     */
    public int[] accounts = new int[10];

    private ReentrantLock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public Bank() {
        Arrays.fill(accounts, 1000);
    }

    public void transfer(int from, int to, int num) {
        try {
            lock.lock();
            while (accounts[from] < num) {
                //進入阻塞狀態
                condition.await();
            }
            accounts[from] -= num;
            accounts[to] += num;
            //計算和
            int sum = 0;
            for (int j = 0; j < 10; j++) {
                sum += accounts[j];
            }
            System.out.println(sum);
            //通知解除阻塞
            condition.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

  在 while 迴圈中判斷是否滿足,如果不滿足條件,呼叫await方法進入阻塞狀態,同時放棄鎖。這樣讓其他執行緒有機會給轉出賬戶轉入資金也滿足判斷條件。

  當某一個執行緒完成轉賬工作後,應該呼叫signalAll方法讓所有阻塞執行緒接觸阻塞狀態,因為此時可能會滿足判斷條件,可以繼續轉賬操作。

  注意:呼叫signalAll不會立即啟用一個等待執行緒,僅僅只是接觸阻塞狀態,以便這些執行緒可以通過競爭獲取鎖,繼續進行 while 判斷。

  還有一個方法signal隨機解除一個執行緒的阻塞狀態。這裡可能會導致死鎖的產生。

synchronized 關鍵詞

  上一節中的 Lock 和 Condition 為開發人員提供了強大的同步控制。但是大多數情況並不需要那麼複雜的控制。從 java 1.0 版本開始,Java 中的每個物件都有一個內部鎖。如果一個方法用synchronized宣告,那麼物件的鎖將保護整個方法,也就是呼叫方法時自動獲取內部鎖,方法結束時自動解除內部鎖。

  同 ReentrantLock 鎖一樣,內部鎖也有 wait/notifyAll/notify 方法,對應關係如下:

  • wait 對應 await
  • notifyAll 對應 signalAll
  • notify 對應 signal
    之所以方法名不同是因為 wait 這幾個方法是 Object 類的 final 方法,為了不發生衝突,ReentrantLock類中方法需要重新命名。

  用 synchronized 實現的 ban 類如下:

class Bank {
    /**
     * 10個資金賬戶
     */
    public int[] accounts = new int[10];

    private ReentrantLock lock = new ReentrantLock();
//    private Condition condition = lock.newCondition();

    public Bank() {
        Arrays.fill(accounts, 1000);
    }

    synchronized public void transfer(int from, int to, int num) {
        try {
//            lock.lock();
            while (accounts[from] < num) {
                //進入阻塞狀態
//                condition.await();
                this.wait();
            }
            accounts[from] -= num;
            accounts[to] += num;
            //計算和
            int sum = 0;
            for (int j = 0; j < 10; j++) {
                sum += accounts[j];
            }
            System.out.println(sum);
            //通知解除阻塞
//            condition.signalAll();
            this.notifyAll();
        } catch (Exception e) {
            e.printStackTrace();
        }
//        finally {
//            lock.unlock();
//        }
    }
}

  靜態方法也可以宣告為 synchronized,呼叫這中方法,獲取到的是對應類的類物件的內部鎖。

程式碼中怎麼用

  • 最好既不使用 Lock/Condition 也不使用 synchronized 關鍵字,大多是情況下都可以用 java.util.concurrent 包中的類來完成資料同步,該包中的類都是執行緒安全的。會在下一篇中講到。

  • 如果能用 synchronized 的,儘量用它,這樣既可以減少程式碼數量,減少出錯的機率。
  • 如果上面都不能解決問題,那就只能使用 Lock/Condition 了。

本篇所用全部程式碼:github

本文原創釋出於:https://www.tapme.top/blog/detail/2019-04-10-20-52

相關文章