【搞定 Java 併發面試】面試最常問的 Java 併發基礎常見面試題總結!

Guide哥發表於2019-11-22

本文為 SnailClimb 的原創,目前已經收錄自我開源的 JavaGuide 中(61.5 k Star!【Java學習+面試指南】 一份涵蓋大部分Java程式設計師所需要掌握的核心知識。覺得內容不錯再 Star!)。

另外推薦一篇原創:終極推薦!可能是最適合你的Java學習路線+方法+網站+書籍推薦!

Java 併發基礎常見面試題總結

1. 什麼是執行緒和程式?

1.1. 何為程式?

程式是程式的一次執行過程,是系統執行程式的基本單位,因此程式是動態的。系統執行一個程式即是一個程式從建立,執行到消亡的過程。

在 Java 中,當我們啟動 main 函式時其實就是啟動了一個 JVM 的程式,而 main 函式所在的執行緒就是這個程式中的一個執行緒,也稱主執行緒。

如下圖所示,在 windows 中通過檢視工作管理員的方式,我們就可以清楚看到 window 當前執行的程式(.exe 檔案的執行)。

程式示例圖片-Windows

1.2. 何為執行緒?

執行緒與程式相似,但執行緒是一個比程式更小的執行單位。一個程式在其執行的過程中可以產生多個執行緒。與程式不同的是同類的多個執行緒共享程式的方法區資源,但每個執行緒有自己的程式計數器虛擬機器棧本地方法棧,所以系統在產生一個執行緒,或是在各個執行緒之間作切換工作時,負擔要比程式小得多,也正因為如此,執行緒也被稱為輕量級程式。

Java 程式天生就是多執行緒程式,我們可以通過 JMX 來看一下一個普通的 Java 程式有哪些執行緒,程式碼如下。

public class MultiThread {
	public static void main(String[] args) {
		// 獲取 Java 執行緒管理 MXBean
	ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
		// 不需要獲取同步的 monitor 和 synchronizer 資訊,僅獲取執行緒和執行緒堆疊資訊
		ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
		// 遍歷執行緒資訊,僅列印執行緒 ID 和執行緒名稱資訊
		for (ThreadInfo threadInfo : threadInfos) {
			System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
		}
	}
}
複製程式碼

上述程式輸出如下(輸出內容可能不同,不用太糾結下面每個執行緒的作用,只用知道 main 執行緒執行 main 方法即可):

[5] Attach Listener //新增事件
[4] Signal Dispatcher // 分發處理給 JVM 訊號的執行緒
[3] Finalizer //呼叫物件 finalize 方法的執行緒
[2] Reference Handler //清除 reference 執行緒
[1] main //main 執行緒,程式入口
複製程式碼

從上面的輸出內容可以看出:一個 Java 程式的執行是 main 執行緒和多個其他執行緒同時執行

2. 請簡要描述執行緒與程式的關係,區別及優缺點?

從 JVM 角度說程式和執行緒之間的關係

2.1. 圖解程式和執行緒的關係

下圖是 Java 記憶體區域,通過下圖我們從 JVM 的角度來說一下執行緒和程式之間的關係。如果你對 Java 記憶體區域 (執行時資料區) 這部分知識不太瞭解的話可以閱讀一下這篇文章:《可能是把 Java 記憶體區域講的最清楚的一篇文章》

【搞定 Java 併發面試】面試最常問的 Java 併發基礎常見面試題總結!

從上圖可以看出:一個程式中可以有多個執行緒,多個執行緒共享程式的方法區 (JDK1.8 之後的元空間)資源,但是每個執行緒有自己的程式計數器虛擬機器棧本地方法棧

總結: 執行緒 是 程式 劃分成的更小的執行單位。執行緒和程式最大的不同在於基本上各程式是獨立的,而各執行緒則不一定,因為同一程式中的執行緒極有可能會相互影響。執行緒執行開銷小,但不利於資源的管理和保護;而程式正相反

下面是該知識點的擴充套件內容!

下面來思考這樣一個問題:為什麼程式計數器虛擬機器棧本地方法棧是執行緒私有的呢?為什麼堆和方法區是執行緒共享的呢?

2.2. 程式計數器為什麼是私有的?

程式計數器主要有下面兩個作用:

  1. 位元組碼直譯器通過改變程式計數器來依次讀取指令,從而實現程式碼的流程控制,如:順序執行、選擇、迴圈、異常處理。
  2. 在多執行緒的情況下,程式計數器用於記錄當前執行緒執行的位置,從而當執行緒被切換回來的時候能夠知道該執行緒上次執行到哪兒了。

需要注意的是,如果執行的是 native 方法,那麼程式計數器記錄的是 undefined 地址,只有執行的是 Java 程式碼時程式計數器記錄的才是下一條指令的地址。

所以,程式計數器私有主要是為了執行緒切換後能恢復到正確的執行位置

2.3. 虛擬機器棧和本地方法棧為什麼是私有的?

  • 虛擬機器棧: 每個 Java 方法在執行的同時會建立一個棧幀用於儲存區域性變數表、運算元棧、常量池引用等資訊。從方法呼叫直至執行完成的過程,就對應著一個棧幀在 Java 虛擬機器棧中入棧和出棧的過程。
  • 本地方法棧: 和虛擬機器棧所發揮的作用非常相似,區別是: 虛擬機器棧為虛擬機器執行 Java 方法 (也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。 在 HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。

所以,為了保證執行緒中的區域性變數不被別的執行緒訪問到,虛擬機器棧和本地方法棧是執行緒私有的。

2.4. 一句話簡單瞭解堆和方法區

堆和方法區是所有執行緒共享的資源,其中堆是程式中最大的一塊記憶體,主要用於存放新建立的物件 (所有物件都在這裡分配記憶體),方法區主要用於存放已被載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

3. 說說併發與並行的區別?

  • 併發: 同一時間段,多個任務都在執行 (單位時間內不一定同時執行);
  • 並行: 單位時間內,多個任務同時執行。

4. 為什麼要使用多執行緒呢?

先從總體上來說:

  • 從計算機底層來說: 執行緒可以比作是輕量級的程式,是程式執行的最小單位,執行緒間的切換和排程的成本遠遠小於程式。另外,多核 CPU 時代意味著多個執行緒可以同時執行,這減少了執行緒上下文切換的開銷。
  • 從當代網際網路發展趨勢來說: 現在的系統動不動就要求百萬級甚至千萬級的併發量,而多執行緒併發程式設計正是開發高併發系統的基礎,利用好多執行緒機制可以大大提高系統整體的併發能力以及效能。

再深入到計算機底層來探討:

  • 單核時代: 在單核時代多執行緒主要是為了提高 CPU 和 IO 裝置的綜合利用率。舉個例子:當只有一個執行緒的時候會導致 CPU 計算時,IO 裝置空閒;進行 IO 操作時,CPU 空閒。我們可以簡單地說這兩者的利用率目前都是 50%左右。但是當有兩個執行緒的時候就不一樣了,當一個執行緒執行 CPU 計算時,另外一個執行緒可以進行 IO 操作,這樣兩個的利用率就可以在理想情況下達到 100%了。
  • 多核時代: 多核時代多執行緒主要是為了提高 CPU 利用率。舉個例子:假如我們要計算一個複雜的任務,我們只用一個執行緒的話,CPU 只會一個 CPU 核心被利用到,而建立多個執行緒就可以讓多個 CPU 核心被利用到,這樣就提高了 CPU 的利用率。

5. 使用多執行緒可能帶來什麼問題?

併發程式設計的目的就是為了能提高程式的執行效率提高程式執行速度,但是併發程式設計並不總是能提高程式執行速度的,而且併發程式設計可能會遇到很多問題,比如:記憶體洩漏、上下文切換、死鎖還有受限於硬體和軟體的資源閒置問題。

6. 說說執行緒的生命週期和狀態?

Java 執行緒在執行的生命週期中的指定時刻只可能處於下面 6 種不同狀態的其中一個狀態(圖源《Java 併發程式設計藝術》4.1.4 節)。

Java 執行緒的狀態

執行緒在生命週期中並不是固定處於某一個狀態而是隨著程式碼的執行在不同狀態之間切換。Java 執行緒狀態變遷如下圖所示(圖源《Java 併發程式設計藝術》4.1.4 節):

Java 執行緒狀態變遷

由上圖可以看出:執行緒建立之後它將處於 NEW(新建) 狀態,呼叫 start() 方法後開始執行,執行緒這時候處於 READY(可執行) 狀態。可執行狀態的執行緒獲得了 CPU 時間片(timeslice)後就處於 RUNNING(執行) 狀態。

作業系統隱藏 Java 虛擬機器(JVM)中的 RUNNABLE 和 RUNNING 狀態,它只能看到 RUNNABLE 狀態(圖源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系統一般將這兩個狀態統稱為 RUNNABLE(執行中) 狀態 。

RUNNABLE-VS-RUNNING

當執行緒執行 wait()方法之後,執行緒進入 WAITING(等待) 狀態。進入等待狀態的執行緒需要依靠其他執行緒的通知才能夠返回到執行狀態,而 TIME_WAITING(超時等待) 狀態相當於在等待狀態的基礎上增加了超時限制,比如通過 sleep(long millis)方法或 wait(long millis)方法可以將 Java 執行緒置於 TIMED WAITING 狀態。當超時時間到達後 Java 執行緒將會返回到 RUNNABLE 狀態。當執行緒呼叫同步方法時,在沒有獲取到鎖的情況下,執行緒將會進入到 BLOCKED(阻塞) 狀態。執行緒在執行 Runnable 的run()方法之後將會進入到 TERMINATED(終止) 狀態。

7. 什麼是上下文切換?

多執行緒程式設計中一般執行緒的個數都大於 CPU 核心的個數,而一個 CPU 核心在任意時刻只能被一個執行緒使用,為了讓這些執行緒都能得到有效執行,CPU 採取的策略是為每個執行緒分配時間片並輪轉的形式。當一個執行緒的時間片用完的時候就會重新處於就緒狀態讓給其他執行緒使用,這個過程就屬於一次上下文切換。

概括來說就是:當前任務在執行完 CPU 時間片切換到另一個任務之前會先儲存自己的狀態,以便下次再切換回這個任務時,可以再載入這個任務的狀態。任務從儲存到再載入的過程就是一次上下文切換

上下文切換通常是計算密集型的。也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是作業系統中時間消耗最大的操作。

Linux 相比與其他作業系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。

8. 什麼是執行緒死鎖?如何避免死鎖?

8.1. 認識執行緒死鎖

多個執行緒同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於執行緒被無限期地阻塞,因此程式不可能正常終止。

如下圖所示,執行緒 A 持有資源 2,執行緒 B 持有資源 1,他們同時都想申請對方的資源,所以這兩個執行緒就會互相等待而進入死鎖狀態。

執行緒死鎖示意圖

下面通過一個例子來說明執行緒死鎖,程式碼模擬了上圖的死鎖的情況 (程式碼來源於《併發程式設計之美》):

public class DeadLockDemo {
    private static Object resource1 = new Object();//資源 1
    private static Object resource2 = new Object();//資源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "執行緒 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "執行緒 2").start();
    }
}
複製程式碼

Output

Thread[執行緒 1,5,main]get resource1
Thread[執行緒 2,5,main]get resource2
Thread[執行緒 1,5,main]waiting get resource2
Thread[執行緒 2,5,main]waiting get resource1
複製程式碼

執行緒 A 通過 synchronized (resource1) 獲得 resource1 的監視器鎖,然後通過Thread.sleep(1000);讓執行緒 A 休眠 1s 為的是讓執行緒 B 得到執行然後獲取到 resource2 的監視器鎖。執行緒 A 和執行緒 B 休眠結束了都開始企圖請求獲取對方的資源,然後這兩個執行緒就會陷入互相等待的狀態,這也就產生了死鎖。上面的例子符合產生死鎖的四個必要條件。

學過作業系統的朋友都知道產生死鎖必須具備以下四個條件:

  1. 互斥條件:該資源任意一個時刻只由一個執行緒佔用。
  2. 請求與保持條件:一個程式因請求資源而阻塞時,對已獲得的資源保持不放。
  3. 不剝奪條件:執行緒已獲得的資源在末使用完之前不能被其他執行緒強行剝奪,只有自己使用完畢後才釋放資源。
  4. 迴圈等待條件:若干程式之間形成一種頭尾相接的迴圈等待資源關係。

8.2. 如何避免執行緒死鎖?

我們只要破壞產生死鎖的四個條件中的其中一個就可以了。

破壞互斥條件

這個條件我們沒有辦法破壞,因為我們用鎖本來就是想讓他們互斥的(臨界資源需要互斥訪問)。

破壞請求與保持條件

一次性申請所有的資源。

破壞不剝奪條件

佔用部分資源的執行緒進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源。

破壞迴圈等待條件

靠按序申請資源來預防。按某一順序申請資源,釋放資源則反序釋放。破壞迴圈等待條件。

我們對執行緒 2 的程式碼修改成下面這樣就不會產生死鎖了。

        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "執行緒 2").start();
複製程式碼

Output

Thread[執行緒 1,5,main]get resource1
Thread[執行緒 1,5,main]waiting get resource2
Thread[執行緒 1,5,main]get resource2
Thread[執行緒 2,5,main]get resource1
Thread[執行緒 2,5,main]waiting get resource2
Thread[執行緒 2,5,main]get resource2

Process finished with exit code 0
複製程式碼

我們分析一下上面的程式碼為什麼避免了死鎖的發生?

執行緒 1 首先獲得到 resource1 的監視器鎖,這時候執行緒 2 就獲取不到了。然後執行緒 1 再去獲取 resource2 的監視器鎖,可以獲取到。然後執行緒 1 釋放了對 resource1、resource2 的監視器鎖的佔用,執行緒 2 獲取到就可以執行了。這樣就破壞了破壞迴圈等待條件,因此避免了死鎖。

9. 說說 sleep() 方法和 wait() 方法區別和共同點?

  • 兩者最主要的區別在於:sleep 方法沒有釋放鎖,而 wait 方法釋放了鎖
  • 兩者都可以暫停執行緒的執行。
  • Wait 通常被用於執行緒間互動/通訊,sleep 通常被用於暫停執行。
  • wait() 方法被呼叫後,執行緒不會自動甦醒,需要別的執行緒呼叫同一個物件上的 notify() 或者 notifyAll() 方法。sleep() 方法執行完成後,執行緒會自動甦醒。或者可以使用wait(long timeout)超時後執行緒會自動甦醒。

10. 為什麼我們呼叫 start() 方法時會執行 run() 方法,為什麼我們不能直接呼叫 run() 方法?

這是另一個非常經典的 java 多執行緒面試問題,而且在面試中會經常被問到。很簡單,但是很多人都會答不上來!

new 一個 Thread,執行緒進入了新建狀態;呼叫 start() 方法,會啟動一個執行緒並使執行緒進入了就緒狀態,當分配到時間片後就可以開始執行了。 start() 會執行執行緒的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多執行緒工作。 而直接執行 run() 方法,會把 run 方法當成一個 main 執行緒下的普通方法去執行,並不會在某個執行緒中執行它,所以這並不是多執行緒工作。

總結: 呼叫 start 方法方可啟動執行緒並使執行緒進入就緒狀態,而 run 方法只是 thread 的一個普通方法呼叫,還是在主執行緒裡執行。

開源專案推薦

作者的其他開源專案推薦:

  1. JavaGuide:【Java學習+面試指南】 一份涵蓋大部分Java程式設計師所需要掌握的核心知識。
  2. springboot-guide : 適合新手入門以及有經驗的開發人員查閱的 Spring Boot 教程(業餘時間維護中,歡迎一起維護)。
  3. programmer-advancement : 我覺得技術人員應該有的一些好習慣!
  4. spring-security-jwt-guide :從零入門 !Spring Security With JWT(含許可權驗證)後端部分程式碼。

公眾號

我的公眾號

相關文章