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

  • 2020 年 3 月 30 日
  • 筆記

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

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

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

上篇情況:

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

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

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

  1. 電腦的核心是CPU,它承擔了所有的計算任務。它就像一座工廠,時刻在運行。
  1. 假定工廠的電力有限,一次只能供給一個車間使用。也就是說,一個車間開工的時候,其他車間都必須停工。背後的含義就是,單個CPU一次只能運行一個任務
  1. 進程就好比工廠的車間,它代表CPU所能處理的單個任務。任一時刻,CPU總是運行一個進程,其他進程處於非運行狀態。
  1. 一個車間里,可以有很多工人。他們協同完成一個任務
  1. 執行緒就好比車間里的工人。一個進程可以包括多個執行緒

進程

所謂進程就是運行在作業系統的一個任務,進程是電腦任務調度的一個單位,作業系統在啟動一個程式的時候,會為其創建一個進程,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()方法添加一個請求任務時,執行緒池會做如下判斷:
  3. 如果正在運行的執行緒數量小於corePoolSize,那麼馬上創建馬上創建執行緒運行這個任務。
  4. 如果正在運行的執行緒數量大於或等於corePoolSize,那麼將這個任務放入隊列
  5. 如果這個時候隊列滿了且正在運行的執行緒數量還小於maximumPoolSize,那麼還是要創建非核心執行緒立刻運行這個任務。
  6. 如果隊列滿了且正在運行的執行緒數量大於或等於maximumPoolSize,那麼執行緒池會啟動飽和拒絕策略來執行
  7. 當一個執行緒完成任務時,它會從隊列中取下一個任務來執行。
  8. 當一個執行緒無事可做超過一定的時間(keepAlilveTime)時,執行緒池會判斷:
  9. 如果當前運行的執行緒數大於corePoolSize,那麼這個執行緒就被停掉。
  10. 所以執行緒池的所有任務完成後它最終會收縮到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. 深入理解執行緒池原理篇

乾貨環節

最後環節,是一個放福利的環節——PDF面試文檔「居家旅行行走江湖防身必備寶典」,將面試題做成電子書有這樣幾個原因:

  1. PDF的排版不會亂掉,排版精美,人人都適合擁有一本
  2. 文章圖片用了圖床工具,圖片可能會出現無法顯示的情況

基於以上原因,我決定將面試題相關的文章都整理成PDF/HTML文檔和EPUB電子書。更加方便閱讀和查閱。說實話,整理面試題,寫面試題答案以及打造文檔花費了大量的時間。為了防止白嫖,關注我的公眾號:鹿小洋的Java筆記,回復 「857」 即可獲取下載鏈接。

文檔的內容均為手打,有任何的不懂都可以直接來問我