Thread線程間協作

線程間的交互和協作從簡單到複雜有很多種方式, 下面會從最簡單的join開始到使用各種方式和工具來分析. 爲了輔助分析線程間的協作, 先擼一下線程的各個狀態和狀態間輪轉的條件.

狀態 描述
NEW 創建對象後start之前的狀態
RUNNABLE 調用start或yield之後, 代表可以隨時運行
BLOCKED 線程等待monitor enter時(等鎖), 阻塞狀態
WAITING 等待狀態, 和阻塞不一樣通常可以被interrupt
TIMED_WAITING 同WAITING, 但是TIMED_WAITING有時間限制, 超時後終止TIMED_WAITING進入RUNNABLE
TERMINATED 線程運行完畢或被關閉後的狀態

各個狀態之間的流轉參考下圖所示

在這裏插入圖片描述

join

join可以做到最簡單的線程交互, 可以讓某個線程阻塞起來進入WAITINGTIMED_WAITING狀態, 等另外一個線程執行完成或超時後再繼續運行. 以下面代碼爲例, 線程A啓動起來sleep兩秒鐘. 線程B緊隨着線程A啓動, 並在線程B中join線程A. 查看運行結果. 線程B在join等待時可以被interrupt.

final String tag = "testJoin";
final Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        log(tag, "Thread A: sleep 2s");
        Thread.sleep(2000);
        log(tag, "Thread A: finished");
    }
});

thread.start();
Thread threadB = new Thread(new Runnable() {
    @Override
    public void run() {
        log(tag, "Thread B joining Thread A");
        thread.join();
        log(tag, "Thread B joined Thread A");
        log(tag, "Thread B: finished");
    }
});
threadB.start();

運行結果:

testJoin, Thread A: sleep 2s
testJoin, Thread B joining Thread A
testJoin, Thread A: finished
testJoin, Thread B joined Thread A
testJoin, Thread B: finished

join也可以設置超時時間, 避免線程B等待時間多長. 根據下面join的源碼, 可以看到join是基於wait/notify來實現的. 在線程B中join線程A後, 線程B會持有線程A內部的lock對象鎖, 並且lock對象會根據設定的時間wait等待. 如果沒有設置join的時間, 那麼就不停得循環等待直到線程A運行結束或者被interrupt後纔會喚醒線程B並釋放鎖.

Thread::join()

synchronized(lock) {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            lock.wait(0);
        }
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            lock.wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

yield

yield是一個靜態native方法, 當某個線程調用yield後該線程會放棄CPU時間片, 將線程狀態從RUNNING轉爲RUNNABLE. 通常會將CPU讓給另外一個線程去執行. 以下面demo爲例, 線程A和B循環打印100個數組, 兩個線程每逢打印到10的倍數時就調用yield讓出CPU.

final String tag = "testYield";
new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0;i < 100; i++) {
            log(tag, "Thread A: " + i);
            if (i % 10 == 0) {
                log(tag, "Thread A: yield");
                Thread.yield();
            }
        }
    }
}).start();

new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0;i < 100; i++) {
            log(tag, "Thread B: " + i);
            if (i % 10 == 0) {
                log(tag, "Thread B: yield");
                Thread.yield();
            }
        }
    }
}).start();

執行結果太長這裏就不貼了, 觀察日誌可以看到demo是按照我們的期望運行的. 每次某個線程執行yield後就會切換另外一個線程運行.

其實切換另外一個線程運行這種說法是不準確的. 因爲yield只是讓當前線程放棄CPU時間片, 讓出的CPU時間片是需要多個線程去爭搶, 這些線程包括了調用yield方法的線程. 也就是說線程A調用yield放棄CPU時間片, 線程A, B, C, D四個線程去搶, 有可能還是線程A搶到了CPU. 那麼現象就是線程A雖然調用了yield方法, 但是他還是會繼續運行下去. 我的demo可能是因爲數據樣本太少沒有復現這種場景.

CountDownLatch

CountDownLatch是一個計數開關, 在多線程的情況下基於CAS(CAS無鎖優化)進行計數, 計數達到設置值後就會放開await的線程. 之後再await就不會讓線程進入等待狀態, 也就是說CountDownLatch只能計一輪數.

private static CountDownLatch countDownLatch = new CountDownLatch(2);
  1. 線程A啓動, 使用countDownLatch.await讓線程A進入等待狀態.
  2. 線程B啓動, 每隔一秒countDown一次. 一共countDown兩次.
  3. 線程A在線程B countDown2次後喚醒. 並且第二次await沒有讓線程A進入等待狀態.
final String tag = "testCountDownLatch";
Thread threadA = new Thread(new Runnable() {
    @Override
    public void run() {
        log(tag, "Thread A await");
        countDownLatch.await();
        countDownLatch.await();
        log(tag, "Thread A finished");
    }
});
log(tag, "Thread A start");
threadA.start();

Thread threadB = new Thread(new Runnable() {
    @Override
    public void run() {
        Thread.sleep(1000);
        log(tag, "Thread B countdown 1th");
        countDownLatch.countDown();
        Thread.sleep(1000);
        log(tag, "Thread B countdown 2th");
        countDownLatch.countDown();
    }
});
log(tag, "Thread B start");
threadB.start();

運行結果:

testCountDownLatch, Thread A start
testCountDownLatch, Thread A await
testCountDownLatch, Thread B start
testCountDownLatch, Thread B countdown 1th
testCountDownLatch, Thread B countdown 2th
testCountDownLatch, Thread A finished

CountDownLatch的特點:

  1. 基於CAS實現.
  2. 阻塞開關線程, 計數達到後再喚醒開關所在線程.
  3. 不能重複計數.
  4. await可以被interrupt.

CyclicBarrier

CyclicBarrier跟CountDownLatch作用都是開關, 但是使用的場景又不一樣. CyclicBarrier像是可以多次觸發開關的CountDownLatch, 但是CyclicBarrier阻塞的是計數線程, 並不像CountDownLatch阻塞的是開關線程.

我們設計一個例子來演示一下CyclicBarrier的交互. 假設有一個海邊小船租賃商店, 要求是必須兩個人來才能借走一艘小船. 這時有五個人來到了店裏, 我們用CyclicBarrier來模擬下這個過程. 先新建一個CyclicBarrier對象, 設定門檻爲2. 並在達到門檻後打印一下租船信息.

final String tag = "testCyclicBarrier";
final CyclicBarrier cyclicBarrier = new CyclicBarrier(2, new Runnable() {
    @Override
    public void run() {
        log(tag, Thread.currentThread().getName() + " rent a boat");
    }
});

循環創建並啓動五個線程, 每個線程進來都使用cyclicBarrier.await(). 如果湊齊了兩個人, 就觸發開關, 計數線程繼續運行. 沒湊齊的話就進入等待狀態. CyclicBarrier的await也可以被interrupt.

Runnable runnable = new Runnable() {
    @Override
    public void run() {
        log(tag, Thread.currentThread().getName() + " wait");
        cyclicBarrier.await();
        log(tag, Thread.currentThread().getName() + " go boating");
    }
};

for (int i = 0; i < 5; i++) {
    Thread people = new Thread(runnable);
    people.setName("People " + i);
    people.start();
    Thread.sleep(1000);
}

CyclicBarrier的特點

  1. 基於ReentrantLock和Condition實現.
  2. 可以多次觸發開關, 不同於CountDownLatch.
  3. 阻塞計數線程, 滿足條件後喚醒最後等待的計數線程.
  4. await可以被interrupt.

wait notify

上面講join的時候看源碼就是根據wait notify實現的, 直接用wait notify會更加靈活. 跟字面意思一致, 這兩個方法中wait會讓線程進入WAITING或TIMED_WAITING狀態, 而notify會通知當前正在waiting的線程退出等待狀態, 爭搶CPU輪值.

該系列方法是在Object基類中通過native方法實現, 方法需要在同步塊內使用(當前線程需要獲得鎖對象的監視器), 否則會拋出IllegalMonitorStateException異常. wait系列方法是可以被interrupt. 並且線程調用wait系列方法後會釋放同步鎖, 因爲不釋放的話其他的線程就無法獲得到鎖調用notify方法, 這樣就死鎖了. 調用notify系列方法並不會釋放鎖.

demo要求如下: 提供兩個數組, 數組的長度都一樣內容分別是字母從a到i和數字從1到9. 使用兩個線程, 一個線程讀取字母數組並輸出, 另一個線程讀取數組數組輸出. 要求兩個線程交替輸出內容, 輸出格式如下: 1a2b3c4d5e6f7g8h9i

private static char[] letters = "abcdefghi".toCharArray();
private static char[] letterNum = "123456789".toCharArray();
private static Thread letterThread, numThread;
  1. 由於要先打印數字, 所以letter線程先啓動獲取到鎖後直接調用鎖的wait方法, letter線程進入等待狀態並釋放鎖.
  2. num線程隨後啓動, 等letter線程釋放鎖後num線程獲得鎖, 打印第一個數字.
  3. num線程調用notifyAll方法, 通知letter線程可以繼續運行了. 但是由於調用notify方法並不會釋放鎖, 所以letter會阻塞在那裏等待獲取到鎖.
  4. num線程調用wait方法, 進入等待狀態並釋放鎖.
  5. letter線程獲取到鎖, 打印第一個字母后通知num線程退出等待狀態.
  6. letter線程進行第二次循環, 並在此進入等待狀態並釋放鎖.
  7. 重複循環上面1-6六個步驟, 完成要求輸出1a2b3c4d5e6f7g8h9i結果.
private static Object lock = new Object();

final String tag = "testSyncWaitNotify";
letterThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (lock) {
            for (char letter : letters) {
                lock.wait();
                log(tag, String.valueOf(letter));
                lock.notifyAll();
            }
        }
    }
});

numThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (lock) {
            for (char num : letterNum) {
                log(tag, String.valueOf(num));
                lock.notifyAll();
                lock.wait();
            }
        }
    }
});

letterThread.start();
numThread.start();

由於wait notify需要配合鎖才能使用, 在上面的demo中我們是letter線程先啓動進入等待狀態, num線程緊隨啓動申請到鎖後再通知lock鎖釋放等待狀態. 這樣進行線程間輪轉是沒有問題的. 如果我們調換線程的啓動順序呢?

numThread.start();
letterThread.start();

調換線程的啓動順序, 先啓動num線程 -> 獲得鎖 -> 打印數字1 -> 通知正在wait的線程 -> num線程wait釋放鎖. letter線程隨後啓動 -> 阻塞等鎖 -> 等num線程運行完第一個循環釋放鎖後得到同步鎖 -> letter線程wait. 由於同步鎖的競爭關係, num線程先調用了notify, letter才調用了wait. 所以letter線程會一直wait在那裏, 而num線程也在wait, 完蛋兩個線程都等待在那裏了. 雖然不是死鎖, 但是沒有外部interrupt或notify這兩個線程跟廢了沒什麼區別. 爲了避免這種wait notify的時序問題, 最好自己捋好線程輪轉邏輯, 或者使用帶超時的wait方法.

總結一下wait notify的特點:

  1. 必須在同步塊中使用(monitorenter和monitorexit之間), 否則會拋異常.
  2. wait可以被interrupt, wait後釋放鎖.
  3. wait notify需要注意時序問題.

LockSupport

使用LockSupport的park和unpark方法也可以進行線程間協作, 並且它比wait notify還要更加靈活. park unpark方法和wait notify方法作用上類似, 都是讓當前線程進入等待狀態. park unpark方法底層是基於Unsafe類的native方法來實現的, 他們不需要再同步塊中使用, 並且是指定某個線程unpark喚醒. AQS和下一節要講的ReentrantLock的Condition也是基於LockSupport實現.

還是以數字字母交替輸出爲demo, 實現的思路跟wait notify類似.

  1. letter線程先啓動, 進入循環後直接park等待.
  2. num線程緊隨啓動, 打印第一個數字後調用unpark(letterThread), 喚醒letter線程.
  3. num線程park等待.
  4. letter線程在第2步後同步運行, 打印第一個字母后調用unpark(numThread), 喚醒num線程.
  5. letter線程進入第二輪循環並park等待.
  6. 循環上面1-5五個步驟, 完成要求輸出1a2b3c4d5e6f7g8h9i結果.
final String tag = "testLockSupport";
letterThread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (char letter : letters) {
            // Thread.sleep(2000);
            LockSupport.park();
            log(tag, String.valueOf(letter));
            LockSupport.unpark(numThread);
        }
    }
});

numThread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (char num : letterNum) {
            log(tag, String.valueOf(num));
            LockSupport.unpark(letterThread);
            LockSupport.park();
        }
    }
});

letterThread.start();
numThread.start();

上面在講wait notify的時候如果調用的時序不對, 會有讓兩個線程都進入等待狀態的問題. 那使用LockSupport可以復現嗎? 我們調轉兩個線程的啓動順序試一下. 由於LockSupport不需要使用同步塊, 所以我們不僅調換線程的啓動順序, 還放開letter線程中的註釋. 確保num線程已經unpark過letter線程後, letter線程再park.

numThread.start();
letterThread.start();

運行後發現雖然打印慢了點, 但是還是能夠輸出正確的結果. 說明park unpark是沒有時序性的, 先unpark線程再park線程也不會讓線程進入等待, 有點類似預授權的意思. 簡單看下Hotspot源碼可以發現park unpark兩個狀態是根據屬性_counter來區分的.

_counter值 含義
0 park或初始值
1 unpark

_counter字段默認是0, 如果先調用了unpark將_counter值設置爲1. 等待2s後線程park時會先判斷_counter是否大於0, 如果大於0說明已經事先設置了unpark, 將_counter置爲0並不需要讓線程等待. 由於每次park都會將_counter置爲0, 所以不管事先unpark了多少次, 連續兩次park肯定會讓線程進入等待狀態.

總結一下LockSupport的特點:

  1. 不需要在同步塊中使用.
  2. 可以喚醒指定的線程.
  3. park時被interrupt, 不會拋異常而是直接喚醒. 如果線程在中斷狀態, 會忽略park.
  4. park unpark沒有時序問題, 可以先unpark再park.
  5. 連續兩次park肯定會讓線程等待.

Condition

在多線程使用同一個ReentrantLock的時候, 可以通過Condition來進行不同線程之間的交互. 首先我們先創建一個ReentrantLock對象, 在根據鎖對象創建一個Condition. 多線程間可以利用condition的await 和signal方法實現線程的等待和喚醒. 其實Condition的使用條件的效果跟wait notify很類似. 只是這裏的Condition是基於AQS和LockSupport實現的.

private static ReentrantLock reentrantLock = new ReentrantLock();
private static Condition condition = reentrantLock.newCondition();
  1. letter線程啓動, 加鎖後await進入等待狀態並釋放鎖.
  2. num線程緊隨啓動, 加鎖打印1.
  3. num線程調用signal喚醒letter線程, num線程await並釋放鎖.
  4. letter線程申請到鎖後打印a.
  5. letter線程通過signal喚醒num線程, 並進入第二次循環await釋放鎖.
  6. 循環1-5五個步驟正確打印出 1a2b3c4d5e6f7g8h9i.
final String tag = "testCondition";
letterThread = new Thread(new Runnable() {
    @Override
    public void run() {
        reentrantLock.lock();
        for (char letter : letters) {
            condition.await();
            log(tag, String.valueOf(letter));
            condition.signal();
        }
        reentrantLock.unlock();
    }
});

numThread = new Thread(new Runnable() {
    @Override
    public void run() {
        reentrantLock.lock();
        for (char num : letterNum) {
            log(tag, String.valueOf(num));
            condition.signal();
            condition.await();
        }
        reentrantLock.unlock();
    }
});

letterThread.start();
numThread.start();

總結一下Condition的特點:

  1. 必須在同步塊中使用(monitorenter和monitorexit之間), 否則會拋異常.
  2. await可以被interrupt, await後釋放鎖.
  3. await signal需要注意時序問題.

Conditions

基於上一節的Condition, ReentrantLock對象可以創建多個Condition, 這樣可以以更細的粒度來處理不同類型線程間的交互. 例如創建兩個Condition可以很好的適配生產者消費者場景. 這裏還是以數字字母交替打印爲例.

private static Condition letterCondition = reentrantLock.newCondition();
private static Condition numCondition = reentrantLock.newCondition();
  1. letter線程啓動, 加鎖後使用letterCondition.await進入等待狀態, 釋放鎖.
  2. num線程緊隨啓動, 申請到鎖後打印1.
  3. num線程使用letterCondition.signal來喚醒letter線程.
  4. num線程使用numCondition.await進入等待狀態, 釋放鎖.
  5. letter線程申請到鎖後, 打印a.
  6. letter線程使用numCondition.signal喚醒num線程.
  7. letter線程進入第二次循環, 再次進入等待狀態並釋放鎖.
  8. 循環1-7七個步驟, 同樣能打印出1a2b3c4d5e6f7g8h9i.
final String tag = "testConditions";
letterThread = new Thread(new Runnable() {
    @Override
    public void run() {
        reentrantLock.lock();
        for (char letter : letters) {
            letterCondition.await();
            log(tag, String.valueOf(letter));
            numCondition.signal();
        }
        reentrantLock.unlock();
    }
});

numThread = new Thread(new Runnable() {
    @Override
    public void run() {
        reentrantLock.lock();
        for (char num : letterNum) {
            log(tag, String.valueOf(num));
            letterCondition.signal();
            numCondition.await();
        }
        reentrantLock.unlock();
    }
});

letterThread.start();
numThread.start();

SynchronousQueue

基於BlockingQueue實現的SynchronousQueue特性, 也可以做到兩個線程數字和字母交替打印. 我們先新建一個SynchronousQueue對象. SynchronousQueue的put和take方法可以阻塞線程: put阻塞線程, 直到有另外的線程take. 同理take阻塞, 直到有線程put. 因爲這個特性, SynchronousQueue又被叫做手遞手隊列.

private static SynchronousQueue synchronousQueue = new SynchronousQueue();
  1. 啓動letter線程, letter線程從synchronousQueue中獲取數據. 由於沒有線程put, 所以letter線程進入等待狀態.
  2. 啓動num線程, 將數字1通過put傳遞給letter線程, 並喚醒letter線程.
  3. num線程從synchronousQueue中獲取數據, 進入等待狀態.
  4. letter線程將從隊列中獲取到的數字1打印, 並將字母a放入隊列喚醒num線程.
  5. letter線程進入第二次循環, take等待.
  6. num線程獲取到字母a並打印
  7. 循環1-7七個步驟. 正確打印出結果. 這個demo中不再是letter線程打印letter, num線程打印數字. 而是反過來打印的.
final String tag = "testSynchronousQueue";
letterThread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (char letter : letters) {
            String takeFromNumThread = String.valueOf(synchronousQueue.take());
            log(tag, takeFromNumThread);
            synchronousQueue.put(letter);
        }
    }
});

numThread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (char num : letterNum) {
            synchronousQueue.put(num);
            String takeFromLetterThread = String.valueOf(synchronousQueue.take());
            log(tag, takeFromLetterThread);
        }
    }
});

letterThread.start();
numThread.start();

SynchronousQueue特點:

  1. 隊列0容量, 只能一個線程take, 同時另一個線程put.
  2. 沒有put時, take阻塞線程.
  3. 沒有take時, put阻塞線程.
  4. put和take可以被interrupt.

轉載請註明出處:https://blog.csdn.net/l2show/article/details/104063430

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章