1. 程式人生 > >「建議心心」要就來15道多執行緒面試題一次爽到底(1.1w字用心整理)

「建議心心」要就來15道多執行緒面試題一次爽到底(1.1w字用心整理)


本文是給「建議收藏」200MB大廠面試文件,整理總結2020年最強面試題庫「CoreJava篇」寫的答案,所有相關文章已經收錄在碼雲倉庫:https://gitee.com/bingqilinpeishenme/Java-interview

千上萬水總是情,先贊後看行不行,奧力給

本文為多執行緒面試題答案的上篇:執行緒基本概念+執行緒池,鎖+其他面試題會在下篇寫出。

上篇情況:

  • 共有15道面試題
  • 圖文並茂,概念+程式碼相互輔助
  • 1.1w餘字兒,建議收藏方便以後查閱

1. 什麼是程序?什麼是執行緒?

程序(process)和執行緒(thread)是作業系統的基本概念,但是它們比較抽象,不容易掌握。
最近在阮一峰的部落格上看到了一個解釋,感覺非常的好,分享給小夥伴們。

  1. 計算機的核心是CPU,它承擔了所有的計算任務。它就像一座工廠,時刻在執行。

  2. 假定工廠的電力有限,一次只能供給一個車間使用。也就是說,一個車間開工的時候,其他車間都必須停工。背後的含義就是,單個CPU一次只能執行一個任務。

  3. 程序就好比工廠的車間,它代表CPU所能處理的單個任務。任一時刻,CPU總是執行一個程序,其他程序處於非執行狀態。

  4. 一個車間裡,可以有很多工人。他們協同完成一個任務

  5. 執行緒就好比車間裡的工人。一個程序可以包括多個執行緒。

程序

所謂程序就是執行在作業系統的一個任務,程序是計算機任務排程的一個單位,作業系統在啟動一個程式的時候,會為其建立一個程序,JVM就是一個程序。程序與程序之間是相互隔離的,每個程序都有獨立的記憶體空間。

計算機實現併發的原理是:CPU分時間片,交替執行,巨集觀並行,微觀序列。同理,在程序的基礎上分出更小的任務排程單元就是執行緒,我們所謂的多執行緒就是一個程序併發多個執行緒。

執行緒

在上面我們提到,一個程序可以併發出多個執行緒,而執行緒就是最小的任務執行單元,具體來說,一個程式順序執行的流程就是一個執行緒,我們常見的main就是一個執行緒(主執行緒)。

執行緒的組成

想要擁有一個執行緒,有這樣的一些不可或缺的部分,主要有:CPU時間片,資料儲存空間,程式碼。
CPU時間片都是有作業系統進行分配的,資料儲存空間就是我們常說的堆空間和棧空間,線上程之間,堆空間是多執行緒共享的,棧空間是互相獨立的,這樣做的好處不僅在於方便,也減少了很多資源的浪費。程式碼就不做過多解釋了,沒有程式碼搞個毛的多執行緒。

2. 什麼是執行緒安全?

關於什麼是執行緒安全,為什麼會有執行緒安全的出現,以及為什麼需要鎖,我在三四年前寫過一個小故事。

幾個小概念
臨界資源:當多執行緒訪問同一個物件時, 這個物件叫做臨界資源
原子操作:在臨界資源中不可分割的操作叫原子操作
執行緒不安全:多執行緒同時訪問同一個物件, 破壞了不可分割的操作, 就可能發生資料不一致

“弱肉強食”的執行緒世界

大家好,我叫王大錘,我的目標是當上CEO...額 不好意思拿錯劇本了。大家好,我叫0x7575,是一個執行緒,我的線生理想是永遠最快拿到CPU。

先給大家介紹一下執行緒世界,執行緒世界是一個弱肉強食的世界,資源永遠稀缺,什麼東西都要搶,這幾個納秒我有幸拿到CPU,對int a = 20進行一次加1操作,當我從記憶體中取出a,進行加1後就失去了CPU,休息結束之後準備寫入記憶體的時候,我驚奇的發現:記憶體中的a這時候已經變成了22。

一定有執行緒趁我不在修改了資料,我左右為難,很多執行緒也都勸我不要寫入,但是迫於指令,我只能把21寫入記憶體覆蓋掉不符合我的運算邏輯的22。

以上只是一個微小的事故,類似的事情線上程世界層出不窮,所以雖然我們每一個執行緒都盡職盡責,但是在人類看來我們是引起資料不安全的禍首。

這是何等的冤枉啊,執行緒世界一直都是競爭激烈的世界,尤其是對於一些共享變數,共享資源(臨界資源),同時有多個執行緒進行爭奪使用時再正常不過的事情了。除非消除共享的資源,但是這又是不可能的,於是事情就開始僵持了。

執行緒世界出現了一把鎖

幸好還是又聰明人的,有人想到了一個解決問題的好方法。雖然不知道誰想到的注意,但是這個注意確實解決了一部分問題,解決的方案是加鎖。

你想要進行對一組加鎖的程式碼進行操作嗎?想的話就先去搶到鎖,拿到鎖之後就可以對被加鎖的程式碼為所欲為了,倘若拿不到鎖的話就只能在程式碼塊門口等著,因為等的執行緒太多了,這還成為了一種社會現象(狀態),該社會現象被命名為執行緒的阻塞。

聽上去很簡單,但是實際上加鎖有很多詳細的規定的,詳情政府釋出了《關於synchronzied使用的若干規定》以及後來釋出的《關於Lock使用的若干規定》。

執行緒和執行緒之間是共享記憶體的,當多執行緒對共享記憶體進行操作的時候有幾個問題是難以避免的,競態條件(race condition)和記憶體可見性。
競態條件:當多執行緒訪問和操作同一物件的時候,最終結果和執行時序有關,正確性是不能夠人為控制的,可能正確也可能不正確。(如上文例子)

上文中說到的加鎖就是為了解決這個問題,常見的解決方案有:

  • 使用synchronized關鍵字
  • 使用顯式鎖(Lock)
  • 使用原子變數

記憶體可見性:關於記憶體可見性問題要先從記憶體和cpu的配合談起,記憶體是一個硬體,執行速度比CPU慢幾百倍,所以在計算機中,CPU在執行運算的時候,不會每次運算都和記憶體進行資料互動,而是先把一些資料寫入CPU中的快取區(暫存器和各級快取),在結束之後寫入記憶體。這個過程是及其快的,單執行緒下並沒有任何問題。

但是在多執行緒下就出現了問題,一個執行緒對記憶體中的一個數據做出了修改,但是並沒有及時寫入記憶體(暫時存放在快取中);這時候另一個執行緒對同樣的資料進行修改的時候拿到的就是記憶體中還沒有被修改的資料,也就是說一個執行緒對一個共享變數的修改,另一個執行緒不能馬上看到,甚至永遠看不到。

這就是記憶體的可見性問題。

解決這個問題的常見方法是:

  • 使用volatile關鍵字
  • 使用synchronized關鍵字或顯式鎖同步

3. 執行緒的狀態有哪些?

一個執行緒在啟動之後不會立馬執行,而是處於就緒狀態(Ready),就緒狀態就是執行緒的狀態的一種,處於這種狀態的執行緒意味著一切準備就緒, 需要等待系統分配到時間片。為什麼沒有立馬執行呢,因為同一時間只有一個執行緒能夠拿到時間片執行,新執行緒啟動的時候讓它啟動的執行緒(主執行緒)正在執行,只有等主執行緒結束,它才有機會拿到時間片執行。

執行緒的狀態:初始狀態(New),就緒狀態(Ready),執行狀態(Running)(特別說明:在語法的定義中,就緒狀態和執行狀態是一個狀態Runable),等待狀態(Waitering),終止狀態(Terminated)

  1. 初始狀態(New)
    1. 執行緒物件被創建出來,便是初始狀態,這時候執行緒物件只是一個普通的物件,並不是一個執行緒
  2. Runable
    1. 就緒狀態(Ready):執行start方法之後,進入就緒狀態,等待被分配到時間片。
    2. 執行狀態(Running):拿到CPU的執行緒開始執行。處於執行時間的執行緒並不是永久的持有CPU直到執行結束,很可能沒有執行完畢時間片到期,就被收回CPU的使用權了,之後將會處於等待狀態。
  3. 等待狀態(Waiting)
    1. 等待狀態分為有限期等待和無限期等待,所謂有限期等待是執行緒使用sleep方法主動進入休眠,有一定的時間限制,時間到期就重新進入就緒狀態,再次等待被CPU選中。
    2. 而無限期等待就有些不同了,無限期並不是指永遠的等待下去,而是指沒有時間限制,可能等待一秒也可能很多秒。至於進入等待的原因也不盡相同,可能是因為CPU時間片到期,也可能是因為一個比較耗時的操作(資料庫),或者主動的呼叫join方法。
  4. 阻塞狀態(Blocked)
    1. 阻塞狀態實際上是一種比較特殊的等待狀態,處於其他等待狀態的執行緒是在等著別的執行緒執行結束,等著拿CPU的使用權;而處於阻塞狀態的執行緒等待的不僅僅是CPU的使用權,主要是鎖標記,沒有拿到鎖標記,即便是CPU有空也沒有辦法執行。
  5. 終止執行緒(Terminated)
    1. 已經終止的執行緒會處於該種狀態。

4. wait和sleep的區別

5. 等待和阻塞的區別

6. Java中建立執行緒的方式

  1. 繼承Thread
  2. 實現Runnable介面
  3. 實現Callable介面,結合 FutureTask使用
  4. 利用該執行緒池
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

public class NewThreadDemo {

    public static void main(String[] args) throws Exception {
        
        //第一種方式
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println("第1種方式:new Thread 1");
            }
        };
        t1.start();
        
        TimeUnit.SECONDS.sleep(1);
        
        //第二種方式
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("第2種方式:new Thread 2");
            }
        });
        t2.start();

        TimeUnit.SECONDS.sleep(1);
        
        
        //第三種方式
        FutureTask<String> ft = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                String result = "第3種方式:new Thread 3";
                return result;
            }
        });
        Thread t3 = new Thread(ft);
        t3.start();
        
        // 執行緒執行完,才會執行get(),所以FutureTask也可以用於閉鎖
        String result = ft.get();
        System.out.println(result);
        
        TimeUnit.SECONDS.sleep(1);
        
         //第四種方式
        ExecutorService pool = Executors.newFixedThreadPool(5);

        Future<String> future = pool.submit(new Callable<String>(){
            @Override
            public String call() throws Exception {
                String result = "第4種方式:new Thread 4";
                return result;
            }
        });

        pool.shutdown();
        System.out.println(future.get());
    }
}

7. Callable和Runnable的區別?

    class c implements Callable<String>{
        @Override
        public String call() throws Exception {
            return null;
        }
    }
    
    class r implements Runnable{
        @Override
        public void run() {
        }
    }

相同點:

  1. 兩者都是介面
  2. 兩者都需要呼叫Thread.start啟動執行緒

不同點:

  1. 如上面程式碼所示,callable的核心是call方法,允許返回值,runnable的核心是run方法,沒有返回值
  2. call方法可以丟擲異常,但是run方法不行
  3. 因為runnable是java1.1就有了,所以他不存在返回值,後期在java1.5進行了優化,就出現了callable,就有了返回值和拋異常
  4. callable和runnable都可以應用於executors。而thread類只支援runnable

8. 什麼是執行緒池?有什麼好處?

談到執行緒池就會想到池化技術,其中最核心的思想就是把寶貴的資源放到一個池子中;每次使用都從裡面獲取,用完之後又放回池子供其他人使用,有點吃大鍋飯的意思。

Java執行緒池有以下優點:

  1. 執行緒是稀缺資源,不能頻繁的建立。
  2. 解耦作用;執行緒的創建於執行完全分開,方便維護。
  3. 應當將其放入一個池子中,可以給其他任務進行復用。

9. 建立執行緒池的方式

  1. 通過Executors類
  2. 通過ThreadPoolExecutor類

在Java中,我們可以通過Executors類建立執行緒池,常見的API有:

  1. Executors.newCachedThreadPool():無限執行緒池。
  2. Executors.newFixedThreadPool(nThreads):建立固定大小的執行緒池。
  3. Executors.newSingleThreadExecutor():建立單個執行緒的執行緒池。
  4. Executors.newScheduledThreadPool()
  5. Executors.newWorkStealingPool(int) java8新增,使用目前機器上可用的處理器作為它的並行級別

以上的這些建立執行緒池的方法,實際上JDK已經給我們寫好的,可以拿來即用的。但是隻要我們檢視上述方法的原始碼就會發現:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

以上方法實際上都是利用 ThreadPoolExecutor 類實現的。

所以第二種建立執行緒方式是自己通過 new ThreadPoolExecutor來進行建立。

10. Executors 有那麼多建立執行緒池的方法,開發中用哪個比較好?

答案:一個都不用。

從《阿里巴巴Java開發手冊》中可以看到

關於引數的詳細解釋見下一個問題。

11. 如何通過 ThreadPoolExecutor 自定義執行緒池?即執行緒池有哪些重要的引數?

在上一個問題中,我們提到了建立執行緒池要通過 new ThreadPoolExecutor 的方式,那麼,如何建立呢?在建立的時候,又需要哪些引數呢?

我們直接看一下 ThreadPoolExecutor 的構造方法原始碼,如下:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

密密麻麻都是引數,那麼這些引數都什麼呢?

大致的流程就是

  1. 建立執行緒池之後,有任務提交給執行緒池,會先由 核心執行緒執行
  2. 如果任務持續增加,corePoolSize用完並且任務佇列滿了,這個時候執行緒池會增加執行緒的數量,增大到最大執行緒數
  3. 這個時候如果任務繼續增加,那麼由於執行緒數量已經達到最大執行緒數,等待佇列也已經滿了,這個時候執行緒池實際上是沒有能力執行新的任務的,就會採用拒絕策略
  4. 如果任務量下降,就會有很多執行緒是不需要的,無所事事,而只要這些執行緒空閒的時間超過空閒執行緒時間,就會被銷燬,直到剩餘執行緒數為corePoolSize。

通過以上引數可以就可以靈活的設定一個執行緒池了,示例程式碼如下:

/**
* 獲取cpu核心數
*/
 private static int corePoolSize = Runtime.getRuntime().availableProcessors();

    /**
     * corePoolSize用於指定核心執行緒數量
     * maximumPoolSize指定最大執行緒數
     * keepAliveTime和TimeUnit指定執行緒空閒後的最大存活時間
     */
    public static ThreadPoolExecutor executor  = new ThreadPoolExecutor(corePoolSize, corePoolSize+1, 10l, TimeUnit.SECONDS,
            new LinkedBlockingQueue<Runnable>(1000));

12. 執行緒池底層工作原理?

關於執行緒池的工作原理和執行流程,通過兩張圖來進行展示

  1. 在建立了執行緒池後,等待提交過來的任務請求。
  2. 當呼叫execute()方法新增一個請求任務時,執行緒池會做如下判斷:
    1. 如果正在執行的執行緒數量小於corePoolSize,那麼馬上建立馬上建立執行緒執行這個任務。
    2. 如果正在執行的執行緒數量大於或等於corePoolSize,那麼將這個任務放入佇列。
    3. 如果這個時候佇列滿了且正在執行的執行緒數量還小於maximumPoolSize,那麼還是要建立非核心執行緒立刻執行這個任務。
    4. 如果佇列滿了且正在執行的執行緒數量大於或等於maximumPoolSize,那麼執行緒池會啟動飽和拒絕策略來執行。
  3. 當一個執行緒完成任務時,它會從佇列中取下一個任務來執行。
  4. 當一個執行緒無事可做超過一定的時間(keepAlilveTime)時,執行緒池會判斷:
    1. 如果當前執行的執行緒數大於corePoolSize,那麼這個執行緒就被停掉。
    2. 所以執行緒池的所有任務完成後它最終會收縮到corePoolSize的大小。

13. 談談執行緒池的飽和策略,也叫做拒絕策略。

所謂飽和策略就是:當等待佇列已經排滿,再也發不下新的任務的時候,這時,執行緒池的最大執行緒數也到了最大值,意味著執行緒池沒有能力繼續執行新任務了,這個時候再有新任務提交到執行緒池,如何進行處理,就是飽和(拒絕)策略

14. 如何合理配置一個執行緒池

通常我們是需要根據這批任務執行的性質來確定的。

  • IO 密集型任務:由於執行緒並不是一直在執行,所以可以儘可能的多配置執行緒,比如 CPU 個數 * 2
    • IO密集型,即該任務需要大量的IO,即大量的阻塞。
    • 在單執行緒上執行IO密集型的任務會導致浪費大量的CPU運算能力浪費在等待。
    • 所以IO密集型任務中使用多執行緒可以大大的加速程式執行,即使在單核CPU上,這種加速主要就是利用了被浪費掉的阻塞時間。
  • CPU 密集型任務(大量複雜的運算)應當分配較少的執行緒,比如 CPU 個數相當的大小。CPU密集的意思是該任務需要大量的運算,而沒有阻塞,CPU一直全速執行。

當然這些都是經驗值,最好的方式還是根據實際情況測試得出最佳配置。

15. 如何關閉執行緒池

關閉執行緒池的方法有兩個:shutdown()/shutdownNow()

  • shutdown() 執行後停止接受新任務,會把佇列的任務執行完畢。
  • shutdownNow() 也是停止接受新任務,但會中斷所有的任務,將執行緒池狀態變為 stop。

關閉執行緒池的程式碼:

long start = System.currentTimeMillis();
for (int i = 0; i <= 5; i++) {
    pool.execute(new Job());
}
pool.shutdown();
while (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
    LOGGER.info("執行緒還在執行。。。");
}
long end = System.currentTimeMillis();
LOGGER.info("一共處理了【{}】", (end - start));

pool.awaitTermination(1, TimeUnit.SECONDS) 會每隔一秒鐘檢查一次是否執行完畢(狀態為 TERMINATED),當從 while 迴圈退出時就表明執行緒池已經完全終止了。

參考資料:

  1. 程序和執行緒的一個簡單解釋
  2. Java—多執行緒基礎
  3. Java—執行緒同步
  4. 建立執行緒的四種方式
  5. Callable和Runnable的區別
  6. 如何優雅的使用和理解執行緒池
  7. 《阿里巴巴Java開發手冊》
  8. 深入理解執行緒池原理篇

歡迎關注本人公眾號:鹿老師的Java筆記,將在長期更新Java技術圖文教程和視訊教程,Java學習經驗,Java面試經驗以及Java實戰開發經驗。