本篇文章給大家總結(jié)了一下Java并發(fā)基礎(chǔ)常見面試題,有一定的參考價值,有需要的朋友可以參考一下,希望對大家有所幫助。
1. 什么是線程和進程?
1.1. 何為進程?
進程是程序的一次執(zhí)行過程,是系統(tǒng)運行程序的基本單位,因此進程是動態(tài)的。系統(tǒng)運行一個程序即是一個進程從創(chuàng)建,運行到消亡的過程。
在 Java 中,當(dāng)我們啟動 main 函數(shù)時其實就是啟動了一個 JVM 的進程,而 main 函數(shù)所在的線程就是這個進程中的一個線程,也稱主線程。
如下圖所示,在 windows 中通過查看任務(wù)管理器的方式,我們就可以清楚看到 window 當(dāng)前運行的進程(.exe 文件的運行)。
1.2. 何為線程?
線程與進程相似,但線程是一個比進程更小的執(zhí)行單位。一個進程在其執(zhí)行的過程中可以產(chǎn)生多個線程。與進程不同的是同類的多個線程共享進程的堆和方法區(qū)資源,但每個線程有自己的程序計數(shù)器、虛擬機棧和本地方法棧,所以系統(tǒng)在產(chǎn)生一個線程,或是在各個線程之間作切換工作時,負擔(dān)要比進程小得多,也正因為如此,線程也被稱為輕量級進程。
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()); } } }
上述程序輸出如下(輸出內(nèi)容可能不同,不用太糾結(jié)下面每個線程的作用,只用知道 main 線程執(zhí)行 main 方法即可):
[5] Attach Listener //添加事件 [4] Signal Dispatcher // 分發(fā)處理給 JVM 信號的線程 [3] Finalizer //調(diào)用對象 finalize 方法的線程 [2] Reference Handler //清除 reference 線程 [1] main //main 線程,程序入口
從上面的輸出內(nèi)容可以看出:一個 Java 程序的運行是 main 線程和多個其他線程同時運行。
2. 請簡要描述線程與進程的關(guān)系,區(qū)別及優(yōu)缺點?
從 JVM 角度說進程和線程之間的關(guān)系
2.1. 圖解進程和線程的關(guān)系
下圖是 Java 內(nèi)存區(qū)域,通過下圖我們從 JVM 的角度來說一下線程和進程之間的關(guān)系。如果你對 Java 內(nèi)存區(qū)域 (運行時數(shù)據(jù)區(qū)) 這部分知識不太了解的話可以閱讀一下這篇文章:《可能是把 Java 內(nèi)存區(qū)域講的最清楚的一篇文章》
<p align="center">
<img src="https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-3/JVM運行時數(shù)據(jù)區(qū)域.png" width="600px"/>
</p>
從上圖可以看出:一個進程中可以有多個線程,多個線程共享進程的堆和方法區(qū) (JDK1.8 之后的元空間)資源,但是每個線程有自己的程序計數(shù)器、虛擬機棧 和 本地方法棧。
總結(jié): 線程 是 進程 劃分成的更小的運行單位。線程和進程最大的不同在于基本上各進程是獨立的,而各線程則不一定,因為同一進程中的線程極有可能會相互影響。線程執(zhí)行開銷小,但不利于資源的管理和保護;而進程正相反
下面是該知識點的擴展內(nèi)容!
下面來思考這樣一個問題:為什么程序計數(shù)器、虛擬機棧和本地方法棧是線程私有的呢?為什么堆和方法區(qū)是線程共享的呢?
2.2. 程序計數(shù)器為什么是私有的?
程序計數(shù)器主要有下面兩個作用:
- 字節(jié)碼解釋器通過改變程序計數(shù)器來依次讀取指令,從而實現(xiàn)代碼的流程控制,如:順序執(zhí)行、選擇、循環(huán)、異常處理。
- 在多線程的情況下,程序計數(shù)器用于記錄當(dāng)前線程執(zhí)行的位置,從而當(dāng)線程被切換回來的時候能夠知道該線程上次運行到哪兒了。
需要注意的是,如果執(zhí)行的是 native 方法,那么程序計數(shù)器記錄的是 undefined 地址,只有執(zhí)行的是 Java 代碼時程序計數(shù)器記錄的才是下一條指令的地址。
所以,程序計數(shù)器私有主要是為了線程切換后能恢復(fù)到正確的執(zhí)行位置。
2.3. 虛擬機棧和本地方法棧為什么是私有的?
- 虛擬機棧: 每個 Java 方法在執(zhí)行的同時會創(chuàng)建一個棧幀用于存儲局部變量表、操作數(shù)棧、常量池引用等信息。從方法調(diào)用直至執(zhí)行完成的過程,就對應(yīng)著一個棧幀在 Java 虛擬機棧中入棧和出棧的過程。
- 本地方法棧: 和虛擬機棧所發(fā)揮的作用非常相似,區(qū)別是: 虛擬機棧為虛擬機執(zhí)行 Java 方法 (也就是字節(jié)碼)服務(wù),而本地方法棧則為虛擬機使用到的 Native 方法服務(wù)。 在 HotSpot 虛擬機中和 Java 虛擬機棧合二為一。
所以,為了保證線程中的局部變量不被別的線程訪問到,虛擬機棧和本地方法棧是線程私有的。
2.4. 一句話簡單了解堆和方法區(qū)
堆和方法區(qū)是所有線程共享的資源,其中堆是進程中最大的一塊內(nèi)存,主要用于存放新創(chuàng)建的對象 (所有對象都在這里分配內(nèi)存),方法區(qū)主要用于存放已被加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù)。
3. 說說并發(fā)與并行的區(qū)別?
- 并發(fā): 同一時間段,多個任務(wù)都在執(zhí)行 (單位時間內(nèi)不一定同時執(zhí)行);
- 并行: 單位時間內(nèi),多個任務(wù)同時執(zhí)行。
4. 為什么要使用多線程呢?
先從總體上來說:
- 從計算機底層來說: 線程可以比作是輕量級的進程,是程序執(zhí)行的最小單位,線程間的切換和調(diào)度的成本遠遠小于進程。另外,多核 CPU 時代意味著多個線程可以同時運行,這減少了線程上下文切換的開銷。
- 從當(dāng)代互聯(lián)網(wǎng)發(fā)展趨勢來說: 現(xiàn)在的系統(tǒng)動不動就要求百萬級甚至千萬級的并發(fā)量,而多線程并發(fā)編程正是開發(fā)高并發(fā)系統(tǒng)的基礎(chǔ),利用好多線程機制可以大大提高系統(tǒng)整體的并發(fā)能力以及性能。
再深入到計算機底層來探討:
- 單核時代: 在單核時代多線程主要是為了提高 CPU 和 IO 設(shè)備的綜合利用率。舉個例子:當(dāng)只有一個線程的時候會導(dǎo)致 CPU 計算時,IO 設(shè)備空閑;進行 IO 操作時,CPU 空閑。我們可以簡單地說這兩者的利用率目前都是 50%左右。但是當(dāng)有兩個線程的時候就不一樣了,當(dāng)一個線程執(zhí)行 CPU 計算時,另外一個線程可以進行 IO 操作,這樣兩個的利用率就可以在理想情況下達到 100%了。
- 多核時代: 多核時代多線程主要是為了提高 CPU 利用率。舉個例子:假如我們要計算一個復(fù)雜的任務(wù),我們只用一個線程的話,CPU 只會一個 CPU 核心被利用到,而創(chuàng)建多個線程就可以讓多個 CPU 核心被利用到,這樣就提高了 CPU 的利用率。
5. 使用多線程可能帶來什么問題?
并發(fā)編程的目的就是為了能提高程序的執(zhí)行效率提高程序運行速度,但是并發(fā)編程并不總是能提高程序運行速度的,而且并發(fā)編程可能會遇到很多問題,比如:內(nèi)存泄漏、上下文切換、死鎖還有受限于硬件和軟件的資源閑置問題。
6. 說說線程的生命周期和狀態(tài)?
Java 線程在運行的生命周期中的指定時刻只可能處于下面 6 種不同狀態(tài)的其中一個狀態(tài)(圖源《Java 并發(fā)編程藝術(shù)》4.1.4 節(jié))。
線程在生命周期中并不是固定處于某一個狀態(tài)而是隨著代碼的執(zhí)行在不同狀態(tài)之間切換。Java 線程狀態(tài)變遷如下圖所示(圖源《Java 并發(fā)編程藝術(shù)》4.1.4 節(jié)):
由上圖可以看出:線程創(chuàng)建之后它將處于 NEW(新建) 狀態(tài),調(diào)用 start()
方法后開始運行,線程這時候處于 READY(可運行) 狀態(tài)??蛇\行狀態(tài)的線程獲得了 CPU 時間片(timeslice)后就處于 RUNNING(運行) 狀態(tài)。
操作系統(tǒng)隱藏 Java 虛擬機(JVM)中的 RUNNABLE 和 RUNNING 狀態(tài),它只能看到 RUNNABLE 狀態(tài)(圖源:HowToDoInJava:Java Thread Life Cycle and Thread States),所以 Java 系統(tǒng)一般將這兩個狀態(tài)統(tǒng)稱為 RUNNABLE(運行中) 狀態(tài) 。
當(dāng)線程執(zhí)行 wait()
方法之后,線程進入 WAITING(等待) 狀態(tài)。進入等待狀態(tài)的線程需要依靠其他線程的通知才能夠返回到運行狀態(tài),而 TIME_WAITING(超時等待) 狀態(tài)相當(dāng)于在等待狀態(tài)的基礎(chǔ)上增加了超時限制,比如通過 sleep(long millis)
方法或 wait(long millis)
方法可以將 Java 線程置于 TIMED WAITING 狀態(tài)。當(dāng)超時時間到達后 Java 線程將會返回到 RUNNABLE 狀態(tài)。當(dāng)線程調(diào)用同步方法時,在沒有獲取到鎖的情況下,線程將會進入到 BLOCKED(阻塞) 狀態(tài)。線程在執(zhí)行 Runnable 的 run()
方法之后將會進入到 TERMINATED(終止) 狀態(tài)。
7. 什么是上下文切換?
多線程編程中一般線程的個數(shù)都大于 CPU 核心的個數(shù),而一個 CPU 核心在任意時刻只能被一個線程使用,為了讓這些線程都能得到有效執(zhí)行,CPU 采取的策略是為每個線程分配時間片并輪轉(zhuǎn)的形式。當(dāng)一個線程的時間片用完的時候就會重新處于就緒狀態(tài)讓給其他線程使用,這個過程就屬于一次上下文切換。
概括來說就是:當(dāng)前任務(wù)在執(zhí)行完 CPU 時間片切換到另一個任務(wù)之前會先保存自己的狀態(tài),以便下次再切換回這個任務(wù)時,可以再加載這個任務(wù)的狀態(tài)。任務(wù)從保存到再加載的過程就是一次上下文切換。
上下文切換通常是計算密集型的。也就是說,它需要相當(dāng)可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統(tǒng)來說意味著消耗大量的 CPU 時間,事實上,可能是操作系統(tǒng)中時間消耗最大的操作。
Linux 相比與其他操作系統(tǒng)(包括其他類 Unix 系統(tǒng))有很多的優(yōu)點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。
8. 什么是線程死鎖?如何避免死鎖?
8.1. 認識線程死鎖
多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由于線程被無限期地阻塞,因此程序不可能正常終止。
如下圖所示,線程 A 持有資源 2,線程 B 持有資源 1,他們同時都想申請對方的資源,所以這兩個線程就會互相等待而進入死鎖狀態(tài)。
下面通過一個例子來說明線程死鎖,代碼模擬了上圖的死鎖的情況 (代碼來源于《并發(fā)編程之美》):
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 的監(jiān)視器鎖,然后通過 Thread.sleep(1000);
讓線程 A 休眠 1s 為的是讓線程 B 得到執(zhí)行然后獲取到 resource2 的監(jiān)視器鎖。線程 A 和線程 B 休眠結(jié)束了都開始企圖請求獲取對方的資源,然后這兩個線程就會陷入互相等待的狀態(tài),這也就產(chǎn)生了死鎖。上面的例子符合產(chǎn)生死鎖的四個必要條件。
學(xué)過操作系統(tǒng)的朋友都知道產(chǎn)生死鎖必須具備以下四個條件:
- 互斥條件:該資源任意一個時刻只由一個線程占用。
- 請求與保持條件:一個進程因請求資源而阻塞時,對已獲得的資源保持不放。
- 不剝奪條件:線程已獲得的資源在末使用完之前不能被其他線程強行剝奪,只有自己使用完畢后才釋放資源。
- 循環(huán)等待條件:若干進程之間形成一種頭尾相接的循環(huán)等待資源關(guān)系。
8.2. 如何避免線程死鎖?
我們只要破壞產(chǎn)生死鎖的四個條件中的其中一個就可以了。
破壞互斥條件
這個條件我們沒有辦法破壞,因為我們用鎖本來就是想讓他們互斥的(臨界資源需要互斥訪問)。
破壞請求與保持條件
一次性申請所有的資源。
破壞不剝奪條件
占用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它占有的資源。
破壞循環(huán)等待條件
靠按序申請資源來預(yù)防。按某一順序申請資源,釋放資源則反序釋放。破壞循環(huán)等待條件。
我們對線程 2 的代碼修改成下面這樣就不會產(chǎn)生死鎖了。
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
我們分析一下上面的代碼為什么避免了死鎖的發(fā)生?
線程 1 首先獲得到 resource1 的監(jiān)視器鎖,這時候線程 2 就獲取不到了。然后線程 1 再去獲取 resource2 的監(jiān)視器鎖,可以獲取到。然后線程 1 釋放了對 resource1、resource2 的監(jiān)視器鎖的占用,線程 2 獲取到就可以執(zhí)行了。這樣就破壞了破壞循環(huán)等待條件,因此避免了死鎖。
9. 說說 sleep() 方法和 wait() 方法區(qū)別和共同點?
- 兩者最主要的區(qū)別在于:sleep 方法沒有釋放鎖,而 wait 方法釋放了鎖 。
- 兩者都可以暫停線程的執(zhí)行。
- Wait 通常被用于線程間交互/通信,sleep 通常被用于暫停執(zhí)行。
- wait() 方法被調(diào)用后,線程不會自動蘇醒,需要別的線程調(diào)用同一個對象上的 notify() 或者 notifyAll() 方法。sleep() 方法執(zhí)行完成后,線程會自動蘇醒?;蛘呖梢允褂脀ait(long timeout)超時后線程會自動蘇醒。
10. 為什么我們調(diào)用 start() 方法時會執(zhí)行 run() 方法,為什么我們不能直接調(diào)用 run() 方法?
這是另一個非常經(jīng)典的 java 多線程面試問題,而且在面試中會經(jīng)常被問到。很簡單,但是很多人都會答不上來!
new 一個 Thread,線程進入了新建狀態(tài);調(diào)用 start() 方法,會啟動一個線程并使線程進入了就緒狀態(tài),當(dāng)分配到時間片后就可以開始運行了。 start() 會執(zhí)行線程的相應(yīng)準備工作,然后自動執(zhí)行 run() 方法的內(nèi)容,這是真正的多線程工作。 而直接執(zhí)行 run() 方法,會把 run 方法當(dāng)成一個 main 線程下的普通方法去執(zhí)行,并不會在某個線程中執(zhí)行它,所以這并不是多線程工作。
總結(jié): 調(diào)用 start 方法方可啟動線程并使線程進入就緒狀態(tài),而 run 方法只是 thread 的一個普通方法調(diào)用,還是在主線程里執(zhí)行。
推薦教程:java教程