天天看點

手撕面試題:多個線程順序執行問題手撕面試題:多個線程順序執行問題

手撕面試題:多個線程順序執行問題

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

第一篇打算總結下阿裡最喜歡問的多個線程順序列印問題,我遇到的是機試,直接寫出運作。同類型的題目有很多,比如

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

思路:還是以第一題為例,我們用對象螢幕來實作,通過

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 中的

await()

方法相當于 Object 的

wait()

方法,Condition 中的

signal()

方法相當于Object 的

notify()

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 已經收錄,服務端開發、面試必備技能兵器譜,有你想要的。