JAVA 學習併發筆記(一)

huizhe發表於2018-09-03

執行緒:每一個任務稱為一個執行緒(thread),它是執行緒控制的簡稱。

可以同時執行一個以上執行緒的程式稱為多執行緒程式。

多程式和多執行緒的區別:

本質的區別在於每個程式擁有自己的一整套變數,而執行緒則共享資料。

共享變數使執行緒之間的通訊比程式之間的通訊更有效,更容易。

線上程中執行任務

  1. 將任務程式碼移到實現了 Runnable 介面的類的 run 方法中。這個介面只有一個方法

    public interface Runnable {
        public abstract void run();
    }
    複製程式碼

    由於 Runnable 是一個函式式介面,可以用 lambda 表示式建立一個例項:

    Runnable r = () -> { task code };
    複製程式碼
  2. 由 Runnable 建立一個Thread 物件:

    Thread t = new Thread(r);
    複製程式碼
  3. 啟動執行緒:

    t.start();
    複製程式碼

也可以通過構建一個Thread類的子類定義一個執行緒

class MyThread extends Thread
{
    public void run()
    {
        task code
    }
}
複製程式碼

注意: 不要呼叫 Thread 類或 Runnable 物件的run方法。直接呼叫run方法,只會執行同一個執行緒中的任務,而不會啟動新執行緒。應該呼叫 Thread.start 方法。。該方法將建立一個執行run方法的新執行緒。

java.lang.Thread

  1. Thread(Runnable target) 構造一個新執行緒,用於呼叫給定目標的 run() 方法
  2. void start() 啟動這個執行緒,將引發呼叫 run() 方法。這個方法將立即返回,並且新執行緒將併發執行
  3. void run() 呼叫關聯 Runnable 的 run 方法

java.lang.Runnable

  • void run() 必須覆蓋這個方法,並在這個方法中提供所要執行的任務指令

中斷執行緒

當執行緒的run方法執行方法體中最後一條語句後,並經由執行return語句返回時,或者出現了在方法中沒有捕獲的異常時,執行緒將終止。 沒有可以強制執行緒終止的方法。但是,interrupt 方法可以用來請求終止執行緒 當執行緒呼叫 interrupt 方法時,執行緒的中斷狀態將被置位。這是每一個執行緒都具有的 boolean 標誌 每個執行緒都應該不時地檢查這個標誌,以判斷執行緒是否被中斷。

可呼叫靜態的 Thread.currentThread 方法獲得當前執行緒,然後呼叫isInterrupted 方法

while(!Thread.currentThread().isInterrupted() && more work to do){
            do more work
}
複製程式碼

不過,如果執行緒被阻塞,就無法檢測中斷狀態,這時就會丟擲 InterruptedException 異常 當在一個被阻塞的執行緒(呼叫 sleepwait )上呼叫 interrupt 方法時,阻塞呼叫將會被 InterruptedException 異常中斷

java.lang.Thread

  1. void interrupt() 向執行緒傳送中斷請求。執行緒的中斷狀態將被設定為true。如果該執行緒目前被sleep 或 wait 呼叫阻塞,那麼,InterruptedException 異常被丟擲
  2. static boolean interrupted() 測試當前執行緒是否被中斷。注意,這是一個 靜態方法。這一呼叫會產生副作用——它將當前執行緒的中斷狀態重置為false
  3. boolean isInterrupted() 測試執行緒是否被終止。這一呼叫不改變執行緒的中斷狀態
  4. static Thread currentThread() 返回代表當前執行執行緒的Thread 物件

執行緒狀態

執行緒可以有如下6種狀態:

  1. New (新建立)
  2. Runnable (可執行)
  3. Blocked (被阻塞)
  4. Waiting (等待)
  5. Timed waiting (計時等待)
  6. Terminated (被終止)

執行緒狀態

新建立執行緒

當用 new 操作符建立一個新執行緒時,如 new Thread(r),該執行緒還沒有開始執行。這意味著它的狀態是new

可執行執行緒

一旦呼叫 start 方法,執行緒處於runnable 狀態。一個可執行的執行緒可能正在執行也可能沒有執行,這取決於作業系統給執行緒提供執行的時間。執行中的執行緒被中斷,目的是為了讓其他執行緒獲得執行機會。執行緒排程的細節依賴於作業系統提供的服務。搶佔式排程 給每一個可執行執行緒一個時間片來執行任務。當時間片用完,作業系統剝奪該執行緒的執行權,並給另一個執行緒執行機會。 在任何給定時刻,一個可執行的執行緒可能正在執行也可能沒有執行(這就是為什麼將這個狀態稱為可執行而不是執行)

被阻塞執行緒和等待執行緒

當執行緒處於被阻塞或等待狀態時,它暫時不活動。它不執行任何程式碼且小號最少的資源。知道執行緒排程器重新啟用它。細節取決於它是怎樣達到非活動狀態的

  • 當一個執行緒試圖獲取一個內部的物件鎖,而該鎖被其他執行緒持有,則該執行緒進入阻塞狀態。當所有其他執行緒釋放該鎖,並且執行緒排程器允許本執行緒持有它的時候,該執行緒將變成非阻塞狀態

  • 當執行緒等待另一個執行緒通知排程器一個條件時,它自己進入等待狀態。在呼叫 Object.wait 方法或 Thread.join 方法,或者是等待 java.util.concurrent 庫中的 Lock 或 Condition 時,就會出現這種情況

  • 有幾個方法有一個超時引數。呼叫它們導致執行緒進入計時等待狀態。這一狀態將一直保持到超時期滿或者接收到適當的通知。帶有超時的方法有 Thread.sleep 和 Object.wait 、 Object.join 、 Lock.tryLock 以及 Condition.await 的計時版

被終止的執行緒

  • 因為 run 方法正常退出而自然死亡
  • 因為一個沒有捕獲的異常終止了 run 方法而意外死亡

同步

在大多數實際的多執行緒應用中,兩個或兩個以上的執行緒需要共享對同一資料的存取。如果兩個執行緒存取相同的物件,並且每一個執行緒都呼叫了一個修改該物件狀態的方法,將會發生什麼呢?根據各執行緒訪問資料的的次序,可能會產生訛誤的物件。這樣的情況通常被稱為競爭條件

我們來模擬一個有若干賬戶的銀行。隨機地生成在這些賬戶之間轉移在錢款的交易。每一個賬戶有一個執行緒。每一筆交易中,會從執行緒所服務的賬戶中隨機轉移一定數目的錢款到另一個隨機賬戶。

賬戶轉移的方法編寫

public void transfer(int from, int to, double amount)
{
    if (accounts[from] < amount)
        return;
    System.out.println(Thread.currentThread());
    accounts[from] -= amount;
    System.out.printf("%10.2f from %d to %d", amount, from, to);
    accounts[to] += amount;
    System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
複製程式碼

Runnable 類的程式碼

Runnable r = () ->
{
    try {
        while (true){
            //bank.size 返回的是Bank類accounts陣列的長度,這裡設定為100,陣列中的值均為1000
            int toAccount = (int)(bank.size() * Math.random());
            //MAX_AMOUNT 為 1000
            double amount = MAX_AMOUNT * Math.random();
            bank.transfer(fromAccount, toAccount, amount);
            ///DELAY 為 10
            Thread.sleep((int)(DELAY * Math.random()));
        }
    }catch (InterruptedException e){

    }
};
複製程式碼

當這個模擬程式執行時,我們不清楚某一時刻某一個賬戶餘額剩多少錢,不過我們唯一能確定的是,所有賬戶的總金額應該保持不變。應為 NACCOUNTS * INITIAL_BALANCE 所以我們在每次交易的結尾,transfer 方法重新計算總值列印出來。 ps:這個程式是個死迴圈,只能按 Ctrl+C 終止程式

程式執行結果如下:

非同步結果

正如結果所示,出現了錯誤。銀行的餘額應該保持在10W,才是正確的結果。但是過了一段時間之間,這個結果變了。

附上完整原始碼

//測試類
public class UnsynchBankTest {

    public static final int NACCOUNTS = 100;
    public static final double INITIAL_BALANCE = 1000;
    public static final double MAX_AMOUNT = 1000;
    public static final int DELAY = 10;

    public static void main(String[] args)
    {
        BankUnSynch bank = new BankUnSynch(NACCOUNTS, INITIAL_BALANCE);

        for (int i=0;i<NACCOUNTS;i++) {
            int fromAccount = i;
            Runnable r = () ->
            {
                try {
                    while (true){
                        int toAccount = (int)(bank.size() * Math.random());
                        double amount = MAX_AMOUNT * Math.random();
                        bank.transfer(fromAccount, toAccount, amount);
                        Thread.sleep((int)(DELAY * Math.random()));
                    }
                }catch (InterruptedException e){

                }
            };
            Thread t = new Thread(r);
            t.start();
        }
    }
}

//銀行類
import java.util.*;

public class BankUnSynch {

    private final double[] accounts;

    public BankUnSynch(int n, double initialBalance)
    {
        accounts = new double[n];
        Arrays.fill(accounts, initialBalance);
    }

    /**
     * 轉賬操作
     * @param from
     * @param to
     * @param amount
     */
    public void transfer(int from, int to, double amount)
    {
        if (accounts[from] < amount)
            return;
        //獲取當前執行緒
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf("%10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
    }

    /**
     * 獲取 accounts 陣列的總金額
     * @return
     */
    public double getTotalBalance()
    {
        double sum = 0;
        for (double a: accounts)
            sum += a;
        return sum;
    }

    public int size()
    {
        return accounts.length;
    }
}
複製程式碼

競爭條件詳解

上面的程式碼執行時,其實有幾個執行緒更新銀行賬戶餘額。一段時間之後,就出現了錯誤。總額要麼增加了,要是變少了。當執行緒試圖同時更新同一個賬戶的時候,這個問題就出現了。假設兩個執行緒同時執行指令

accounts[to] += amount;
複製程式碼

這個時候會發生什麼呢?由於這不是原子操作。該指令可能會被處理為:

  1. 將 accounts[to] 載入到暫存器
  2. 增加 amount
  3. 將結果寫回 accounts[to]

現在我們假定第一個執行緒執行步驟1和2,然後,它被剝奪了執行權。第二個執行緒被喚醒並修改了 accounts 陣列中的同一項,即第二個執行緒執行完了這三個步驟。然後,第一個執行緒被喚醒並完成其第三步 這樣,這一動作就擦去了第二個執行緒所作的更新,於是,總金額不在正確。

鎖物件

如何防止上面情況的發生呢? 有兩種機制防止程式碼塊受併發訪問的干擾。

  1. 鎖和條件物件
  2. synchronized 關鍵字

加鎖

使用 ReentrantLock 保護程式碼塊。程式碼如下

myLock.lock();// 一個ReentrantLock 物件 
try{
	critical section
}
finally{
	myLock.unlock();
}
複製程式碼

這樣我們就可以確保任何時刻只有一個執行緒進入臨界區。一旦一個執行緒封鎖了鎖物件,其他任何執行緒都無法通過lock語句。當其他執行緒呼叫lock時,它們被阻塞,直到第一個執行緒釋放鎖物件

條件物件

現在我們來回想一下業務,當賬戶中沒有足夠的餘額時,我們是不是應該等待直到另一個執行緒向賬戶中注入資金?但是因為這一執行緒剛剛獲得了對bankLock的排他性訪問,因此導致了其他執行緒都被阻塞,這就是成了死鎖。這時,我們就需要用到條件物件了。

一個鎖物件可以有一個或多個相關的條件物件。可以用 newCondition 方法獲得一個條件物件。習慣上給每一個條件物件命名為可以反映它所表達的條件的名字。

例如:

class Bank 
{
    private Lock bankLock;
    private Condition sufficientFunds;

    public Bank()
    {
        ....
        bankLock = new ReentrantLock();
        sufficientFunds = bankLock.newCondition();
    }
}
複製程式碼

transfer 方法發現餘額不足,呼叫

sufficientFunds.await();
複製程式碼

表示當前執行緒現在被阻塞了,並放棄了鎖。我們希望這樣可以使得另一個執行緒可以進行增加賬戶餘額的操作。

注意

等待獲得鎖的執行緒和呼叫 await 方法的執行緒存在本質上的不同。一旦一個執行緒呼叫 await 方法,它進入該條件的 等待集。當鎖可用時,該執行緒不能馬上解除阻塞。相反,它處於阻塞狀態,直到另一執行緒呼叫同一條件上的 signalAll 方法時為止。

signalAll 方法啟用因為這一條件而等待的所有執行緒。當這些執行緒從等待集當中移出時,它們再次成為可執行的,排程器將再次啟用它們。此時,執行緒應該再次測試該條件。由於無法確保該條件被滿足—— signalAll 方法僅僅是通知正在等待的執行緒:此時有可能已經滿足條件,值得再次去檢測該條件。 因此,對await 的呼叫應該採用以下的方式

while(!(ok to proceed))
	condition.await();
複製程式碼

何時呼叫 signalAll 方法呢?建議在物件的狀態有利於等待執行緒的方向改變時呼叫 signalAll。如:當完成轉賬時,就呼叫 signalAll 方法。

public void transfer(int from,int to, double amount) throws InterruptedException
{
    bankLock.lock();
    try
    {
        while (accounts[from] < amount)
            sufficientFunds.await();
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf("%10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
        sufficientFunds.signalAll();
    }finally {
        bankLock.unlock();
    }
}
複製程式碼

另一個方法是signal,此方法是隨機解除選擇等待集中某個執行緒的阻塞狀態。這個方法存在危險,即隨機選擇的執行緒發現自己仍然不能執行,那它就會再次被阻塞,若無其他執行緒再次呼叫signal,那系統就死鎖了。

附上修改後的 Bank 原始碼

import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {

    private final double[] accounts;
    private Lock bankLock;
    private Condition sufficientFunds;

    /**
     * 初始化
     * @param n 陣列長度
     * @param initialBalance
     */
    public Bank(int n ,double initialBalance)
    {
        accounts = new double[n];
        Arrays.fill(accounts, initialBalance);
        bankLock = new ReentrantLock();
        sufficientFunds = bankLock.newCondition();
    }

    /**
     * 轉賬操作
     * @param from
     * @param to
     * @param amount
     * @throws InterruptedException
     */
    public void transfer(int from,int to, double amount) throws InterruptedException
    {
        bankLock.lock();
        try
        {
            while (accounts[from] < amount)
                sufficientFunds.await();
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf("%10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
            sufficientFunds.signalAll();
        }finally {
            bankLock.unlock();
        }
    }
    /**
     * 獲取 accounts 陣列的總金額
     * @return
     */
    public double getTotalBalance()
    {
        bankLock.lock();
        try{
            double sum = 0;
            for (double a: accounts)
                sum += a;
            return sum;
        }finally {
            bankLock.unlock();
        }
    }

    public int size()
    {
        return accounts.length;
    }
}
複製程式碼

總結一下鎖和條件的關鍵之處

  1. 鎖用來保護程式碼片段,任何時刻只能有一個執行緒執行被保護的程式碼
  2. 鎖可以管理試圖進入被保護程式碼段的執行緒
  3. 鎖可以擁有一個或多個相關的條件物件
  4. 每個條件物件管理那些已經進入被保護的程式碼段但還不能執行的執行緒

synchronized 關鍵字

Lock 和 Condition 介面為程式設計人員提供了高度的鎖定控制。然而大多數情況下,並不需要那樣的控制,並且可以使用一種嵌入到Java語言內部的機制。從 1.0 版本開始,Java 中的每一個物件都有一個內部鎖。如果一個方法用 synchronized 關鍵字宣告,那麼物件的鎖將保護整個方法。即,要呼叫該方法,執行緒必須獲得內部的物件鎖。 也就是說:

public synchronized void method()
{
	do something
}
//等價於
public void method()
{
	this.intrinsicLock.lock(); 
	try{
		do something
	}
	finally{
		this.intrinsicLock.unlock();
	}
}
複製程式碼

內部物件鎖只有一個相關條件。wait 方法新增一個執行緒到等待集中,notifyAll/notify 方法接觸等待執行緒的阻塞狀態。即,呼叫wait或notifyAll等價於

intrinsicCondition.await(); 
intrinsicCondition.signalAll(); 
複製程式碼

內部鎖和條件存在一些侷限:

  1. 不能中斷一個正在試圖獲得鎖的執行緒
  2. 試圖獲得鎖時不能設定超時
  3. 每個鎖僅有單一的條件,可能是不夠的

Lock 和 Condition 物件、同步方法的使用的建議:

  • 最好既不使用Lock/Condition 也不使用 synchronized 關鍵字。在許多情況下可以使用 java.util.concurrent 包中的一種機制,它會為你處理所有的加鎖
  • 如果 synchronized 關鍵字適合你的程式,那麼就儘量使用它。
  • 如果特別需要 Lock/Condition 結構提供的獨有特性時,才使用 Lock/Condition

使用 synchronized 關鍵字修改原始碼

import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class BankSynch {

    private final double[] accounts;

    /**
     * 初始化
     * @param n 陣列長度
     * @param initialBalance
     */
    public BankSynch(int n ,double initialBalance)
    {
        accounts = new double[n];
        Arrays.fill(accounts, initialBalance);
    }

    /**
     * 轉賬操作
     * @param from
     * @param to
     * @param amount
     * @throws InterruptedException
     */
    public synchronized void transfer(int from,int to, double amount) throws InterruptedException
    {
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf("%10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
    }
    /**
     * 獲取 accounts 陣列的總金額
     * @return
     */
    public synchronized double getTotalBalance()
    {
        double sum = 0;
        for (double a : accounts)
            sum += a;
        return sum;
    }

    public int size()
    {
        return accounts.length;
    }
}
複製程式碼

還有一種是客戶端鎖定,不過客戶端鎖定是非常脆弱的,通常不推薦使用,這裡就不敘述了。

參考

JAVA核心技術(卷1)原書第10版

相關文章