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

不假發表於2020-10-30

大家在換工作面試中,除了一些常規演算法題,還會遇到各種需要手寫的題目,所以打算總結出來,給大家個參考。

第一篇打算總結下阿里最喜歡問的多個執行緒順序列印問題,我遇到的是機試,直接寫出執行。同型別的題目有很多,比如

  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

其實這類題目考察的都是執行緒間的通訊問題,基於這類題目,做一個整理,方便日後手撕面試官,文明的打工人,手撕面試題。

使用 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 這樣的思路。算是比較傳統且萬能的解決方案。也有些面試官會要求不能使用這種方式。

思路:還是以第一題為例,我們用物件監視器來實現,通過 waitnotify() 方法來實現等待、通知的邏輯,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,並通知另一執行緒從等待集合中釋放,然後自身執行緒加入等待集合並釋放鎖即可。

圖:throwable-blog

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 中的 await() 方法相當於 Object 的 wait() 方法,Condition 中的 signal() 方法相當於Object 的 notify() 方法,Condition 中的 signalAll() 相當於 Object 的 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

在訊號量上我們定義兩種操作: 訊號量主要用於兩個目的,一個是用於多個共享資源的互斥使用,另一個用於併發執行緒數的控制。

  1. acquire(獲取) 當一個執行緒呼叫 acquire 操作時,它要麼通過成功獲取訊號量(訊號量減1),要麼一直等下去,直到有執行緒釋放訊號量,或超時。
  2. 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 已經收錄,服務端開發、面試必備技能兵器譜,有你想要的。

相關文章