1. 程式人生 > >多個執行緒順序列印問題,一網打盡

多個執行緒順序列印問題,一網打盡

大家在換工作面試中,除了一些常規演算法題,還會遇到各種需要手寫的題目,所以打算總結出來,給大家個參考。 第一篇打算總結下阿里最喜歡問的多個執行緒順序列印問題,我遇到的是機試,直接寫出執行。同類型的題目有很多,比如 1. 三個執行緒分別列印 A,B,C,要求這三個執行緒一起執行,列印 n 次,輸出形如“ABCABCABC....”的字串 2. 兩個執行緒交替列印 0~100 的奇偶數 3. 通過 N 個執行緒順序迴圈列印從 0 至 100 4. 多執行緒按順序呼叫,A->B->C,AA 列印 5 次,BB 列印10 次,CC 列印 15 次,重複 10 次 5. 用兩個執行緒,一個輸出字母,一個輸出數字,交替輸出 1A2B3C4D...26Z 其實這類題目考察的都是**執行緒間的通訊問題**,基於這類題目,做一個整理,方便日後手撕面試官,文明的打工人,手撕面試題。 ![](https://cdn.jsdelivr.net/gh/Jstarfish/picBed/img/20201029114231.jpg) ## 使用 Lock 我們以第一題為例:三個執行緒分別列印 A,B,C,要求這三個執行緒一起執行,列印 n 次,輸出形如“ABCABCABC....”的字串。 思路:使用一個取模的判斷邏輯 **C%M ==N**,題為 3 個執行緒,所以可以按取模結果編號:0、1、2,他們與 3 取模結果仍為本身,則執行列印邏輯。 ```java public class PrintABCUsingLock { private int times; // 控制列印次數 private int state; // 當前狀態值:保證三個執行緒之間交替列印 private Lock lock = new ReentrantLock(); public PrintABCUsingLock(int times) { this.times = times; } private void printLetter(String name, int targetNum) { for (int i = 0; i < times; ) { lock.lock(); if (state % 3 == targetNum) { state++; i++; System.out.print(name); } lock.unlock(); } } public static void main(String[] args) { PrintABCUsingLock loopThread = new PrintABCUsingLock(1); new Thread(() -> { loopThread.printLetter("B", 1); }, "B").start(); new Thread(() -> { loopThread.printLetter("A", 0); }, "A").start(); new Thread(() -> { loopThread.printLetter("C", 2); }, "C").start(); } } ``` main 方法啟動後,3 個執行緒會搶鎖,但是 state 的初始值為 0,所以第一次執行 if 語句的內容只能是 **執行緒 A**,然後還在 for 迴圈之內,此時 `state = 1`,只有 **執行緒 B** 才滿足 `1% 3 == 1`,所以第二個執行的是 B,同理只有 **執行緒 C** 才滿足 `2% 3 == 2`,所以第三個執行的是 C,執行完 ABC 之後,才去執行第二次 for 迴圈,所以要把 i++ 寫在 for 迴圈裡邊,不能寫成 `for (int i = 0; i < times;i++)` 這樣。 ## 使用 wait/notify 其實遇到這型別題目,好多同學可能會先想到的就是 join(),或者 wati/notify 這樣的思路。算是比較傳統且萬能的解決方案。也有些面試官會要求不能使用這種方式。 思路:還是以第一題為例,我們用物件監視器來實現,通過 `wait` 和 `notify()` 方法來實現等待、通知的邏輯,A 執行後,喚醒 B,B 執行後喚醒 C,C 執行後再喚醒 A,這樣迴圈的等待、喚醒來達到目的。 ```java public class PrintABCUsingWaitNotify { private int state; private int times; private static final Object LOCK = new Object(); public PrintABCUsingWaitNotify(int times) { this.times = times; } public static void main(String[] args) { PrintABCUsingWaitNotify printABC = new PrintABCUsingWaitNotify(10); new Thread(() -> { printABC.printLetter("A", 0); }, "A").start(); new Thread(() -> { printABC.printLetter("B", 1); }, "B").start(); new Thread(() -> { printABC.printLetter("C", 2); }, "C").start(); } private void printLetter(String name, int targetState) { for (int i = 0; i < times; i++) { synchronized (LOCK) { while (state % 3 != targetState) { try { LOCK.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } state++; System.out.print(name); LOCK.notifyAll(); } } } } ``` 同樣的思路,來解決下第 2 題:兩個執行緒交替列印奇數和偶數 使用物件監視器實現,兩個執行緒 A、B 競爭同一把鎖,只要其中一個執行緒獲取鎖成功,就列印 ++i,並通知另一執行緒從等待集合中釋放,然後自身執行緒加入等待集合並釋放鎖即可。 ![圖:throwable-blog](https://cdn.jsdelivr.net/gh/Jstarfish/picBed/img/20201029160341.png) ```java public class OddEvenPrinter { private Object monitor = new Object(); private final int limit; private volatile int count; OddEvenPrinter(int initCount, int times) { this.count = initCount; this.limit = times; } public static void main(String[] args) { OddEvenPrinter printer = new OddEvenPrinter(0, 10); new Thread(printer::print, "odd").start(); new Thread(printer::print, "even").start(); } private void print() { synchronized (monitor) { while (count < limit) { try { System.out.println(String.format("執行緒[%s]列印數字:%d", Thread.currentThread().getName(), ++count)); monitor.notifyAll(); monitor.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } //防止有子執行緒被阻塞未被喚醒,導致主執行緒不退出 monitor.notifyAll(); } } } ``` 同樣的思路,來解決下第 5 題:用兩個執行緒,一個輸出字母,一個輸出數字,交替輸出 1A2B3C4D...26Z ```java public class NumAndLetterPrinter { private static char c = 'A'; private static int i = 0; static final Object lock = new Object(); public static void main(String[] args) { new Thread(() -> printer(), "numThread").start(); new Thread(() -> printer(), "letterThread").start(); } private static void printer() { synchronized (lock) { for (int i = 0; i < 26; i++) { if (Thread.currentThread().getName() == "numThread") { //列印數字1-26 System.out.print((i + 1)); // 喚醒其他在等待的執行緒 lock.notifyAll(); try { // 讓當前執行緒釋放鎖資源,進入wait狀態 lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } else if (Thread.currentThread().getName() == "letterThread") { // 列印字母A-Z System.out.print((char) ('A' + i)); // 喚醒其他在等待的執行緒 lock.notifyAll(); try { // 讓當前執行緒釋放鎖資源,進入wait狀態 lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } lock.notifyAll(); } } } ``` ## 使用 Lock/Condition 還是以第一題為例,使用 Condition 來實現,其實和 wait/notify 的思路一樣。 > Condition 中的 `await()` 方法相當於 Object 的 `wait()` 方法,Condition 中的 `signal()` 方法相當於Object 的 `notify()` 方法,Condition 中的 `signalAll()` 相當於 Object 的 `notifyAll()` 方法。 > > 不同的是,Object 中的 `wait(),notify(),notifyAll()`方法是和`"同步鎖"`(synchronized關鍵字)捆綁使用的;而 Condition 是需要與`"互斥鎖"/"共享鎖"`捆綁使用的。 ```java public class PrintABCUsingLockCondition { private int times; private int state; private static Lock lock = new ReentrantLock(); private static Condition c1 = lock.newCondition(); private static Condition c2 = lock.newCondition(); private static Condition c3 = lock.newCondition(); public PrintABCUsingLockCondition(int times) { this.times = times; } public static void main(String[] args) { PrintABCUsingLockCondition print = new PrintABCUsingLockCondition(10); new Thread(() -> { print.printLetter("A", 0, c1, c2); }, "A").start(); new Thread(() -> { print.printLetter("B", 1, c2, c3); }, "B").start(); new Thread(() -> { print.printLetter("C", 2, c3, c1); }, "C").start(); } private void printLetter(String name, int targetState, Condition current, Condition next) { for (int i = 0; i < times; ) { lock.lock(); try { while (state % 3 != targetState) { current.await(); } state++; i++; System.out.print(name); next.signal(); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } } } ``` 使用 Lock 鎖的多個 Condition 可以實現精準喚醒,所以碰到那種多個執行緒交替列印不同次數的題就比較容易想到,比如解決第四題:多執行緒按順序呼叫,A->B->C,AA 列印 5 次,BB 列印10 次,CC 列印 15 次,重複 10 次 程式碼就不貼了,思路相同。 > 以上幾種方式,其實都會存在一個鎖的搶奪過程,如果搶鎖的的執行緒數量足夠大,就會出現很多執行緒搶到了鎖但不該自己執行,然後就又解鎖或 wait() 這種操作,這樣其實是有些浪費資源的。 ## 使用 Semaphore > 在訊號量上我們定義兩種操作: 訊號量主要用於兩個目的,一個是用於多個共享資源的互斥使用,另一個用於併發執行緒數的控制。 > > 1. acquire(獲取) 當一個執行緒呼叫 acquire 操作時,它要麼通過成功獲取訊號量(訊號量減1),要麼一直等下去,直到有執行緒釋放訊號量,或超時。 > 2. release(釋放)實際上會將訊號量的值加1,然後喚醒等待的執行緒。 先看下如何解決第一題:三個執行緒迴圈列印 A,B,C ```java public class PrintABCUsingSemaphore { private int times; private static Semaphore semaphoreA = new Semaphore(1); // 只有A 初始訊號量為1,第一次獲取到的只能是A private static Semaphore semaphoreB = new Semaphore(0); private static Semaphore semaphoreC = new Semaphore(0); public PrintABCUsingSemaphore(int times) { this.times = times; } public static void main(String[] args) { PrintABCUsingSemaphore printer = new PrintABCUsingSemaphore(1); new Thread(() -> { printer.print("A", semaphoreA, semaphoreB); }, "A").start(); new Thread(() -> { printer.print("B", semaphoreB, semaphoreC); }, "B").start(); new Thread(() -> { printer.print("C", semaphoreC, semaphoreA); }, "C").start(); } private void print(String name, Semaphore current, Semaphore next) { for (int i = 0; i < times; i++) { try { System.out.println("111" + Thread.currentThread().getName()); current.acquire(); // A獲取訊號執行,A訊號量減1,當A為0時將無法繼續獲得該訊號量 System.out.print(name); next.release(); // B釋放訊號,B訊號量加1(初始為0),此時可以獲取B訊號量 System.out.println("222" + Thread.currentThread().getName()); } catch (InterruptedException e) { e.printStackTrace(); } } } } ``` 如果題目中是多個執行緒迴圈列印的話,一般使用訊號量解決是效率較高的方案,上一個執行緒持有下一個執行緒的訊號量,通過一個訊號量陣列將全部關聯起來,這種方式不會存在浪費資源的情況。 接著用訊號量的方式解決下第三題:通過 N 個執行緒順序迴圈列印從 0 至 100 ```java public class LoopPrinter { private final static int THREAD_COUNT = 3; static int result = 0; static int maxNum = 10; public static void main(String[] args) throws InterruptedException { final Semaphore[] semaphores = new Semaphore[THREAD_COUNT]; for (int i = 0; i < THREAD_COUNT; i++) { //非公平訊號量,每個訊號量初始計數都為1 semaphores[i] = new Semaphore(1); if (i != THREAD_COUNT - 1) { System.out.println(i+"==="+semaphores[i].getQueueLength()); //獲取一個許可前執行緒將一直阻塞, for 迴圈之後只有 syncObjects[2] 沒有被阻塞 semaphores[i].acquire(); } } for (int i = 0; i < THREAD_COUNT; i++) { // 初次執行,上一個訊號量是 syncObjects[2] final Semaphore lastSemphore = i == 0 ? semaphores[THREAD_COUNT - 1] : semaphores[i - 1]; final Semaphore currentSemphore = semaphores[i]; final int index = i; new Thread(() -> { try { while (true) { // 初次執行,讓第一個 for 迴圈沒有阻塞的 syncObjects[2] 先獲得令牌阻塞了 lastSemphore.acquire(); System.out.println("thread" + index + ": " + result++); if (result > maxNum) { System.exit(0); } // 釋放當前的訊號量,syncObjects[0] 訊號量此時為 1,下次 for 迴圈中上一個訊號量即為syncObjects[0] currentSemphore.release(); } } catch (Exception e) { e.printStackTrace(); } }).start(); } } } ``` ## 使用 LockSupport LockSupport 是 JDK 底層的基於 `sun.misc.Unsafe` 來實現的類,用來建立鎖和其他同步工具類的基本執行緒阻塞原語。它的靜態方法`unpark()`和`park()`可以分別實現阻塞當前執行緒和喚醒指定執行緒的效果,所以用它解決這樣的問題會更容易一些。 (在 AQS 中,就是通過呼叫 `LockSupport.park( )`和 `LockSupport.unpark()` 來實現執行緒的阻塞和喚醒的。) ```java public class PrintABCUsingLockSupport { private static Thread threadA, threadB, threadC; public static void main(String[] args) { threadA = new Thread(() -> { for (int i = 0; i < 10; i++) { // 列印當前執行緒名稱 System.out.print(Thread.currentThread().getName()); // 喚醒下一個執行緒 LockSupport.unpark(threadB); // 當前執行緒阻塞 LockSupport.park(); } }, "A"); threadB = new Thread(() -> { for (int i = 0; i < 10; i++) { // 先阻塞等待被喚醒 LockSupport.park(); System.out.print(Thread.currentThread().getName()); // 喚醒下一個執行緒 LockSupport.unpark(threadC); } }, "B"); threadC = new Thread(() -> { for (int i = 0; i < 10; i++) { // 先阻塞等待被喚醒 LockSupport.park(); System.out.print(Thread.currentThread().getName()); // 喚醒下一個執行緒 LockSupport.unpark(threadA); } }, "C"); threadA.start(); threadB.start(); threadC.start(); } } ``` 理解了思路,解決其他問題就容易太多了。 比如,我們再解決下第五題:用兩個執行緒,一個輸出字母,一個輸出數字,交替輸出 1A2B3C4D...26Z ```java public class NumAndLetterPrinter { private static Thread numThread, letterThread; public static void main(String[] args) { letterThread = new Thread(() -> { for (int i = 0; i < 26; i++) { System.out.print((char) ('A' + i)); LockSupport.unpark(numThread); LockSupport.park(); } }, "letterThread"); numThread = new Thread(() -> { for (int i = 1; i <= 26; i++) { System.out.print(i); LockSupport.park(); LockSupport.unpark(letterThread); } }, "numThread"); numThread.start(); letterThread.start(); } } ``` ## 寫在最後 好了,以上就是常用的五種實現方案,多練習幾次,手撕沒問題。 當然,這類問題,解決方式不止是我列出的這些,還會有 join、CountDownLatch、也有放在佇列裡解決的,思路有很多,面試官想考察的其實只是對多執行緒的程式設計功底,其實自己練習的時候,是個很好的鞏固理解 JUC 的過程。 > 以夢為馬,越騎越傻。詩和遠方,越走越慌。不忘初心是對的,但切記要出發,加油吧,程式設計師。 > 在路上的你,可以微信搜「 **JavaKeeper** 」一起前行,無套路領取 500+ 本電子書和 30+ 視訊教學和原始碼,本文 **GitHub** [github.com/JavaKeeper](https://github.com/Jstarfish/JavaKeeper) 已經收錄,服務端開發、面試必備技能兵器譜,有你想