五、通勤路上搞定 Java 多線程面試(2)

漫談面試系列

前言

在上一章節(jié),我們了解到了多線程的一些基礎(chǔ)知識點(diǎn)——多線程的實(shí)現(xiàn)、CAS 是什么以及 AQS 的工作流程,接下來,在這章節(jié)中,我會(huì)通過有關(guān)于應(yīng)用性的多線程面試題的內(nèi)容,來給大家介紹相關(guān)的多線程知識點(diǎn)。

下面我們通過 2 道常問的面試題進(jìn)行相關(guān)知識點(diǎn)的學(xué)習(xí):

  1. BlockingQueue(阻塞隊(duì)列)原理是什么?手寫阻塞隊(duì)列的 take 和 put 方法
  2. 如何定義線程池初始化參數(shù)?使用無界隊(duì)列的線程池會(huì)導(dǎo)致內(nèi)存飆升嗎?

這里面談到的阻塞隊(duì)列和 AQS 的阻塞隊(duì)列不是同一個(gè)東西,該文章談到的阻塞隊(duì)列全都是關(guān)乎于BlockingQueue接口

1. BlockingQueue 原理是什么?手寫阻塞隊(duì)列的 take 和 put 方法

阻塞隊(duì)列,從字面意義上看,就是一個(gè)帶阻塞功能的隊(duì)列,那么它會(huì)在什么時(shí)候出現(xiàn)阻塞呢?

  1. 如果隊(duì)列為空,那么想要隊(duì)列出隊(duì)即執(zhí)行 take() 方法的線程會(huì)被阻塞。
  2. 如果隊(duì)列已滿,那么想要隊(duì)列入隊(duì)即執(zhí)行 put() 方法的線程會(huì)被阻塞。

在上一個(gè)章節(jié)我們了解到了 AQS 以及它的一個(gè)實(shí)現(xiàn)類 ReentrantLock,而在 Java 的阻塞隊(duì)列當(dāng)中,則是借助了 ReentrantLock 類實(shí)現(xiàn)了阻塞的功能,除此之外,為了實(shí)現(xiàn)鎖的細(xì)膩度,還額外使用了另一個(gè)接口—— Condition。

在介紹阻塞隊(duì)列之前,我們先來了解下 AQS 中的 Conditon 接口。傳統(tǒng)多線程方式當(dāng)中,我們通常會(huì)使用 synchronized 關(guān)鍵字配合 Object 類的 wait()/notifyAll() 方法實(shí)現(xiàn)多線程的協(xié)同工作,相同的,為了配合 AQS ,AQS 自身通過內(nèi)部類實(shí)現(xiàn) Condition 接口來完成多線程的協(xié)同。

上文提到了一個(gè)鎖的細(xì)膩度,可能有的人無法理解這個(gè)鎖的細(xì)膩度是什么,那么接下來我便簡單舉一個(gè)例子。

假如我們使用 synchronized 關(guān)鍵字配合 Object 類的 wait()/notifyAll() 來實(shí)現(xiàn)一個(gè)生產(chǎn)者和消費(fèi)者模式的時(shí)候,由于你無法指定喚醒哪一類線程,因此只能使用 notifyAll() 方法將生產(chǎn)者和消費(fèi)者等待的線程都喚醒,這樣的壞處便是無需被喚醒的線程被喚醒了,導(dǎo)致了性能的浪費(fèi)。而所謂的細(xì)膩度,便是針對性的喚醒線程,每次喚醒都不會(huì)導(dǎo)致性能的浪費(fèi),例如我生產(chǎn)者只能喚醒消費(fèi)者,而消費(fèi)者也只能喚醒生產(chǎn)者,這樣便不會(huì)出現(xiàn)無關(guān)線程被喚醒又重新進(jìn)入阻塞的問題。

Condition 類就是生成了一個(gè)與阻塞隊(duì)列隔離的條件隊(duì)列,每個(gè) Condition 對象都相當(dāng)于是一個(gè)隊(duì)列,里面由多個(gè) Node 元素以單向鏈表的形式組合而成,每個(gè)隊(duì)列元素出隊(duì)后都會(huì)加入到阻塞隊(duì)列的隊(duì)尾,我們通過一張圖來簡單了解下 Condition:

Condition

可以看出,條件隊(duì)列也是有序的,即在條件隊(duì)列中阻塞的線程如果沒有被中斷,都會(huì)根據(jù)隊(duì)列的順序進(jìn)行有序的喚醒出隊(duì),即每次喚醒的都是在隊(duì)列中等待最久的線程。

有個(gè)注意點(diǎn),Condition 對象的 await()/signal() 方法必須要線程獲取到鎖只能才會(huì)正常執(zhí)行,否則會(huì)拋出異常,即在沒有獲取鎖的環(huán)境調(diào)用這些方法,便會(huì)拋出 IllegalMonitorStateException 異常,這個(gè)和 Object 對象的 wait()/notify() 方法一樣。

接下來我簡單描述下 Condtion 條件隊(duì)列的 入隊(duì) await() / 出隊(duì) signal() 執(zhí)行流程。

await() 方法:

  1. 將當(dāng)前線程包裝為一個(gè) waitStatus 字段為 CONDITION(-2) 的 node 對象并加入到條件隊(duì)列尾部。
  2. 記錄當(dāng)前鎖的 state 字段值,并將當(dāng)前鎖的狀態(tài)值改為 0 (重入鎖也一樣改為0)。
  3. 通過 LockSupport.park() 方法進(jìn)入阻塞。

signal() 方法

  1. 斷開與條件隊(duì)列的關(guān)系,即將當(dāng)前 node 對象的 nextWaiter 指針字段設(shè)置為 null。
  2. 通過 CAS 將當(dāng)前 node 對象的 waitStatus 字段設(shè)置為 0,并將設(shè)置好的節(jié)點(diǎn)通過自旋 CAS 加入到阻塞隊(duì)列的隊(duì)尾
  3. 將當(dāng)前 node 對象的前驅(qū)節(jié)點(diǎn)的 waitStatus 字段設(shè)置為 SIGNAL(-1),代表當(dāng)前節(jié)點(diǎn)需要這個(gè)節(jié)點(diǎn)進(jìn)行喚醒;如果這個(gè)前驅(qū)節(jié)點(diǎn)處于取消狀態(tài),那么直接喚醒當(dāng)前 node 對象的線程,讓其去競爭鎖,相當(dāng)于一個(gè)新的線程進(jìn)入 AQS 一樣,接下來的步驟讀者可以參考上一篇 AQS 的運(yùn)行流程。

了解完 AQS(ReentrantLock)/Conditon 的內(nèi)容,接下來再看阻塞隊(duì)列就很簡單了,其實(shí)阻塞隊(duì)列的原理就是:Lock 配合多個(gè) Condtion 進(jìn)行阻塞控制,配合多個(gè) Conodtion 只是為了針對性喚醒來減少性能的損耗。開發(fā)者不需要擔(dān)心什么時(shí)候需要阻塞隊(duì)列,只需要往隊(duì)列存入/取出數(shù)據(jù)即可,因?yàn)橐磺杏嘘P(guān)阻塞的功能都在內(nèi)部進(jìn)行實(shí)現(xiàn)。

接下來,我通過一個(gè)簡單的例子來實(shí)現(xiàn)阻塞隊(duì)列的 take()/put(E e) 方法。

public class BlockQueue<E>{
        //一個(gè) Lock 對應(yīng)多個(gè) Condition
        Lock lock = new ReentrantLock();
        Condition producer = lock.newCondition();
        Condition consumer = lock.newCondition();
        //有界隊(duì)列,即隊(duì)列有容量限制
        int size;
        //這里通過一個(gè)List存儲(chǔ)隊(duì)列元素
        List<E> queue;
        public BlockQueue(int size){
            this.size = size;
            queue = new ArrayList<>(size);
        }
        public void put(E e) throws InterruptedException {
            try {
                lock.lock();
                //如果容量已滿,對生產(chǎn)者線程進(jìn)行阻塞
                while(size == queue.size()){
                    producer.await();
                }
                queue.add(e);
                //有新的商品生產(chǎn)了,可以喚醒被阻塞的消費(fèi)者進(jìn)行消費(fèi)
                consumer.signal();
            }finally {
                lock.unlock();
            }
        }
        public E take() throws InterruptedException {
            try{
                lock.lock();
                //沒有商品便阻塞消費(fèi)者
                while (queue.size() == 0){
                    consumer.await();
                }
                E e = queue.get(0);
                queue.remove(0);
                //商品被消費(fèi)了一個(gè),可以喚醒生產(chǎn)者繼續(xù)生產(chǎn)
                producer.signal();
                return e;
            }finally {
                lock.unlock();
            }
        }

        public static void main(String[] args) {
            BlockQueue<Object> queue = new BlockQueue<>(2);
            Thread producer = new Thread(()->{
                while (true){
                    try {
                        queue.put(new Object());
                        System.out.println(Thread.currentThread().getName() + " 生產(chǎn)了一個(gè)商品");
                    } catch (InterruptedException e) {}
                }
            });
            producer.setName("producer");
            Thread consumer = new Thread(()->{
                while (true){
                    try {
                        Object take = queue.take();
                        System.out.println(Thread.currentThread().getName() + " 消費(fèi)了一個(gè)商品");
                        //每1秒才會(huì)有一個(gè)商品被消費(fèi)
                        try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
                    } catch (InterruptedException e) {}
                }
            });
            consumer.setName("consumer");
            producer.start();
            consumer.start();
        }
    }

從上面代碼可以看出,阻塞隊(duì)列的核心就在如何正確的使用 Lock 和 Condition,當(dāng)然,Java 提供的阻塞隊(duì)列的功能還不僅僅如此,但由于篇幅原因,這里不在闡述過多內(nèi)容,有興趣的讀者可以自行查閱相關(guān)資料。

2. 如何定義線程池初始化參數(shù)?使用無界隊(duì)列的線程池會(huì)導(dǎo)致內(nèi)存飆升嗎?

在了解如何定義初始化參數(shù)前,我們通過一個(gè)圖來了解 jdk5 提供的線程池的構(gòu)造參數(shù):

線程池參數(shù)

從上圖可以看出,線程池的構(gòu)造參數(shù)分為三大類,分別是線程相關(guān)、時(shí)間相關(guān)、任務(wù)相關(guān),下面我們主要根據(jù)線程相關(guān)以及任務(wù)相關(guān)進(jìn)行分析:

  1. 線程相關(guān)我們只需要根據(jù)業(yè)務(wù)的類型進(jìn)行最大線程數(shù)的設(shè)值
  2. 任務(wù)相關(guān)首先是禁止使用無界阻塞隊(duì)列,其次便是根據(jù)業(yè)務(wù)實(shí)現(xiàn)自定義的拒絕策略,例如任務(wù)允許丟失,則可以通過日志簡單記錄該任務(wù)然后丟棄,如果任務(wù)不允許丟失,可以考慮新建線程去處理(如果服務(wù)器性能夠強(qiáng)大),或者記錄詳細(xì)的任務(wù)信息,等線程池空閑的時(shí)候在進(jìn)行處理。

了解完構(gòu)造參數(shù)的定義規(guī)則,接下來我們來看看為什么線程池禁止使用無界隊(duì)列。
回到問題,該問題的答案便是存在使用無界隊(duì)列會(huì)導(dǎo)致內(nèi)存飆升的場景,至于為什么,這個(gè)便涉及到線程池的執(zhí)行過程,因此下面我們通過了解線程池的執(zhí)行過程來詳細(xì)解答這個(gè)問題。

線程池執(zhí)行流程
  1. 首先將任務(wù)給常駐線程處理
  2. 如果常駐線程來不及處理,則將任務(wù)放入阻塞隊(duì)列
  3. 如果阻塞隊(duì)列滿了并且線程數(shù)量少于maximumPoolSize,則將線程數(shù)擴(kuò)展至maximumPoolSize
  4. 如果仍然處理不完,阻塞隊(duì)列滿了,則進(jìn)行拒絕策略。
  5. 如果任務(wù)被快速處理完,除常駐線程外的其他線程,在經(jīng)過keepAliveTime(unit)時(shí)間后自動(dòng)銷毀

從線程池執(zhí)行流程的第三步可以看出,創(chuàng)建非常駐線程的前提是阻塞隊(duì)列已滿,但是對于無界隊(duì)列來說(上限為Integer.MAX_VALUE),幾乎不可能出現(xiàn)阻塞隊(duì)列滿的情況,如果常駐線程無法快速執(zhí)行任務(wù),任務(wù)便會(huì)一直往隊(duì)列中添加積累,最終導(dǎo)致內(nèi)存飆升的問題。

總結(jié)

在多線程方面,首先我們得先了解多線程的實(shí)現(xiàn),實(shí)現(xiàn)完多線程后便考慮如何管理多線程的競爭,多線程的競爭可以用到基于 CAS 和 AQS 實(shí)現(xiàn)的鎖和阻塞隊(duì)列,最后通過線程池來進(jìn)行任務(wù)分配和線程的回收利用,如果讀者在看這兩章節(jié)內(nèi)容時(shí)理不清楚順序的話,可以基于這段總結(jié)在回看一次。

接下來,我們回到上面的兩個(gè)問題

  1. BlockingQueue(阻塞隊(duì)列)原理是什么?手寫阻塞隊(duì)列的 take 和 put 方法
  2. 如何定義線程池初始化參數(shù)?使用無界隊(duì)列的線程池會(huì)導(dǎo)致內(nèi)存飆升嗎?

如果你們可以很流暢的回答這些問題,那么恭喜你,該章節(jié)的內(nèi)容已經(jīng)全部掌握,如果不行,希望可以回到對應(yīng)問題講解的地方,或者對某個(gè)不了解的點(diǎn)進(jìn)行額外的知識搜索,盡量用自己組織的語言回答這些問題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容