手撕面試題:多個線程順序執行問題
大家在換工作面試中,除了一些正常算法題,還會遇到各種需要手寫的題目,是以打算總結出來,給大家個參考。
第一篇打算總結下阿裡最喜歡問的多個線程順序列印問題,我遇到的是機試,直接寫出運作。同類型的題目有很多,比如
- 三個線程分别列印 A,B,C,要求這三個線程一起運作,列印 n 次,輸出形如“ABCABCABC....”的字元串
- 兩個線程交替列印 0~100 的奇偶數
- 通過 N 個線程順序循環列印從 0 至 100
- 多線程按順序調用,A->B->C,AA 列印 5 次,BB 列印10 次,CC 列印 15 次,重複 10 次
- 用兩個線程,一個輸出字母,一個輸出數字,交替輸出 1A2B3C4D...26Z
其實這類題目考察的都是線程間的通信問題,基于這類題目,做一個整理,友善日後手撕面試官,文明的打勞工,手撕面試題。
使用 Lock
我們以第一題為例:三個線程分别列印 A,B,C,要求這三個線程一起運作,列印 n 次,輸出形如“ABCABCABC....”的字元串。
思路:使用一個取模的判斷邏輯 C%M ==N,題為 3 個線程,是以可以按取模結果編号:0、1、2,他們與 3 取模結果仍為本身,則執行列印邏輯。
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,這樣循環的等待、喚醒來達到目的。
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,并通知另一線程從等待集合中釋放,然後自身線程加入等待集合并釋放鎖即可。

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
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 中的方法相當于 Object 的
await()
方法,Condition 中的
wait()
方法相當于Object 的
signal()
notify()
相當于 Object 的
signalAll()
notifyAll()
方法。
不同的是,Object 中的
方法是和
wait(),notify(),notifyAll()
(synchronized關鍵字)捆綁使用的;而 Condition 是需要與
"同步鎖"
捆綁使用的。
"互斥鎖"/"共享鎖"
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
在信号量上我們定義兩種操作: 信号量主要用于兩個目的,一個是用于多個共享資源的互斥使用,另一個用于并發線程數的控制。
- acquire(擷取) 當一個線程調用 acquire 操作時,它要麼通過成功擷取信号量(信号量減1),要麼一直等下去,直到有線程釋放信号量,或逾時。
- release(釋放)實際上會将信号量的值加1,然後喚醒等待的線程。
先看下如何解決第一題:三個線程循環列印 A,B,C
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
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()
來實作線程的阻塞和喚醒的。)
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
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 已經收錄,服務端開發、面試必備技能兵器譜,有你想要的。