聽大牛來講:Java併發編程系列面試題(一)

併發編程的優缺點

為什麼要使用併發編程(併發編程的優點)

  • 充分利用多核CPU的計算能力:通過併發編程的形式可以將多核CPU的計算能力發揮到極致,性能得到提升
  • 方便進行業務拆分,提升系統併發能力和性能:在特殊的業務場景下,先天的就適合於併發編程。現在的系統動不動就要求百萬級甚至千萬級的併發量,而多線程併發編程正是開發高併發系統的基礎,利用好多線程機制可以大大提高系統整體的併發能力以及性能。面對複雜業務模型,並行程序會比串行程序更適應業務需求,而併發編程更能吻合這種業務拆分 。

併發編程有什麼缺點

併發編程的目的就是為了能提高程序的執行效率,提高程序運行速度,但是併發編程並不總是能提高程序運行速度的,而且併發編程可能會遇到很多問題,比如**:內存洩漏、上下文切換、線程安全、死鎖**等問題。

併發編程三要素是什麼?在 Java 程序中怎麼保證多線程的運行安全?

併發編程三要素(線程的安全性問題體現在):

原子性:原子,即一個不可再被分割的顆粒。原子性指的是一個或多個操作要麼全部執行成功要麼全部執行失敗。

可見性:一個線程對共享變量的修改,另一個線程能夠立刻看到。(synchronized,volatile)

有序性:程序執行的順序按照代碼的先後順序執行。(處理器可能會對指令進行重排序)

出現線程安全問題的原因:

  • 線程切換帶來的原子性問題
  • 緩存導致的可見性問題
  • 編譯優化帶來的有序性問題

解決辦法:

  • JDK Atomic開頭的原子類、synchronized、LOCK,可以解決原子性問題
  • synchronized、volatile、LOCK,可以解決可見性問題
  • Happens-Before 規則可以解決有序性問題

並行和併發有什麼區別?

  • 併發:多個任務在同一個 CPU 核上,按細分的時間片輪流(交替)執行,從邏輯上來看那些任務是同時執行。
  • 並行:單位時間內,多個處理器或多核處理器同時處理多個任務,是真正意義上的“同時進行”。
  • 串行:有n個任務,由一個線程按順序執行。由於任務、方法都在一個線程執行所以不存在線程不安全情況,也就不存在臨界區的問題。

做一個形象的比喻:

併發 = 兩個隊列和一臺咖啡機。

並行 = 兩個隊列和兩臺咖啡機。

串行 = 一個隊列和一臺咖啡機。

什麼是多線程,多線程的優劣?

多線程:多線程是指程序中包含多個執行流,即在一個程序中可以同時運行多個不同的線程來執行不同的任務。

多線程的好處

可以提高 CPU 的利用率。在多線程程序中,一個線程必須等待的時候,CPU 可以運行其它的線程而不是等待,這樣就大大提高了程序的效率。也就是說允許單個程序創建多個並行執行的線程來完成各自的任務。

多線程的劣勢

  • 線程也是程序,所以線程需要佔用內存,線程越多佔用內存也越多;
  • 多線程需要協調和管理,所以需要 CPU 時間跟蹤線程;
  • 線程之間對共享資源的訪問會相互影響,必須解決競用共享資源的問題。

線程和進程區別

什麼是線程和進程?

進程:一個在內存中運行的應用程序。每個進程都有自己獨立的一塊內存空間,一個進程可以有多個線程,比如在Windows系統中,一個運行的xx.exe就是一個進程。

線程:進程中的一個執行任務(控制單元),負責當前進程中程序的執行。一個進程至少有一個線程,一個進程可以運行多個線程,多個線程可共享數據。

進程與線程的區別

線程具有許多傳統進程所具有的特徵,故又稱為輕型進程(Light—Weight Process)或進程元;而把傳統的進程稱為重型進程(Heavy—Weight Process),它相當於只有一個線程的任務。在引入了線程的操作系統中,通常一個進程都有若干個線程,至少包含一個線程。

根本區別:進程是操作系統資源分配的基本單位,而線程是處理器任務調度和執行的基本單位

資源開銷:每個進程都有獨立的代碼和數據空間(程序上下文),程序之間的切換會有較大的開銷;線程可以看做輕量級的進程,同一類線程共享代碼和數據空間,每個線程都有自己獨立的運行棧和程序計數器(PC),線程之間切換的開銷小。

包含關係:如果一個進程內有多個線程,則執行過程不是一條線的,而是多條線(線程)共同完成的;線程是進程的一部分,所以線程也被稱為輕權進程或者輕量級進程。

內存分配:同一進程的線程共享本進程的地址空間和資源,而進程之間的地址空間和資源是相互獨立的

影響關係:一個進程崩潰後,在保護模式下不會對其他進程產生影響,但是一個線程崩潰整個進程都死掉。所以多進程要比多線程健壯。

執行過程:每個獨立的進程有程序運行的入口、順序執行序列和程序出口。但是線程不能獨立執行,必須依存在應用程序中,由應用程序提供多個線程執行控制,兩者均可併發執行

什麼是上下文切換?

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

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

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

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

守護線程和用戶線程有什麼區別呢?

守護線程和用戶線程

  • 用戶 (User) 線程:運行在前臺,執行具體的任務,如程序的主線程、連接網絡的子線程等都是用戶線程
  • 守護 (Daemon) 線程:運行在後臺,為其他前臺線程服務。也可以說守護線程是 JVM 中非守護線程的 “傭人”。一旦所有用戶線程都結束運行,守護線程會隨 JVM 一起結束工作

main 函數所在的線程就是一個用戶線程啊,main 函數啟動的同時在 JVM 內部同時還啟動了好多守護線程,比如垃圾回收線程。

比較明顯的區別之一是用戶線程結束,JVM 退出,不管這個時候有沒有守護線程運行。而守護線程不會影響 JVM 的退出。

注意事項:

  1. setDaemon(true)必須在start()方法前執行,否則會拋出 IllegalThreadStateException 異常
  2. 在守護線程中產生的新線程也是守護線程
  3. 不是所有的任務都可以分配給守護線程來執行,比如讀寫操作或者計算邏輯
  4. 守護 (Daemon) 線程中不能依靠 finally 塊的內容來確保執行關閉或清理資源的邏輯。因為我們上面也說過了一旦所有用戶線程都結束運行,守護線程會隨 JVM 一起結束工作,所以守護 (Daemon) 線程中的 finally 語句塊可能無法被執行。

如何在 Windows 和 Linux 上查找哪個線程cpu利用率最高?

windows上面用任務管理器看,linux下可以用 top 這個工具看。

  • 找出cpu耗用厲害的進程pid, 終端執行top命令,然後按下shift+p 查找出cpu利用最厲害的pid號
  • 根據上面第一步拿到的pid號,top -H -p pid 。然後按下shift+p,查找出cpu利用率最厲害的線程號,比如top -H -p 1328
  • 將獲取到的線程號轉換成16進制,去百度轉換一下就行
  • 使用jstack工具將進程信息打印輸出,jstack pid號 > /tmp/t.dat,比如jstack 31365 > /tmp/t.dat
  • 編輯/tmp/t.dat文件,查找線程號對應的信息

什麼是線程死鎖

百度百科:死鎖是指兩個或兩個以上的進程(線程)在執行過程中,由於競爭資源或者由於彼此通信而造成的一種阻塞的現象,若無外力作用,它們都將無法推進下去。此時稱系統處於死鎖狀態或系統產生了死鎖,這些永遠在互相等待的進程(線程)稱為死鎖進程(線程)。

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

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

聽大牛來講:Java併發編程系列面試題(一)


下面通過一個例子來說明線程死鎖,代碼模擬了上圖的死鎖的情況 (代碼來源於《併發編程之美》):


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();

}

}

輸出結果

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 得到CPU執行權,然後獲取到 resource2 的監視器鎖。線程 A 和線程 B 休眠結束了都開始企圖請求獲取對方的資源,然後這兩個線程就會陷入互相等待的狀態,這也就產生了死鎖。上面的例子符合產生死鎖的四個必要條件。

形成死鎖的四個必要條件是什麼

  • 互斥條件:線程(進程)對於所分配到的資源具有排它性,即一個資源只能被一個線程(進程)佔用,直到被該線程(進程)釋放
  • 請求與保持條件:一個線程(進程)因請求被佔用資源而發生阻塞時,對已獲得的資源保持不放。
  • 不剝奪條件:線程(進程)已獲得的資源在末使用完之前不能被其他線程強行剝奪,只有自己使用完畢後才釋放資源。
  • 循環等待條件:當發生死鎖時,所等待的線程(進程)必定會形成一個環路(類似於死循環),造成永久阻塞

如何避免線程死鎖

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

  • 破壞互斥條件:這個條件我們沒有辦法破壞,因為我們用鎖本來就是想讓他們互斥的(臨界資源需要互斥訪問)。
  • 破壞請求與保持條件:一次性申請所有的資源
  • 破壞不剝奪條件:佔用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源。
  • 破壞循環等待條件:靠按序申請資源來預防。按某一順序申請資源,釋放資源則反序釋放。破壞循環等待條件。

我們對線程 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();

輸出結果


Thread[線程 1,5,main]get resource1Thread[線程 1,5,main]waiting get resource2Thread[線程 1,5,main]get resource2Thread[線程 2,5,main]get resource1Thread[線程 2,5,main]waiting get resource2Thread[線程 2,5,main]get resource2

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

  • 線程 1 首先獲得到 resource1 的監視器鎖,這時候線程 2 就獲取不到了。
  • 然後線程 1 再去獲取 resource2 的監視器鎖,可以獲取到。
  • 然後線程 1 釋放了對 resource1、resource2 的監視器鎖的佔用,線程 2 獲取到就可以執行了。

這樣就破壞了破壞循環等待條件,因此避免了死鎖。

創建線程的四種方式

創建線程有哪幾種方式?

創建線程有四種方式:

  • 繼承 Thread 類;
  • 實現 Runnable 接口;
  • 實現 Callable 接口;
  • 使用 Executors 工具類創建線程池

1、繼承 Thread 類

步驟

  1. 定義一個Thread類的子類,重寫run方法,將相關邏輯實現,run()方法就是線程要執行的業務邏輯方法
  2. 創建自定義的線程子類對象
  3. 調用子類實例的star()方法來啟動線程

public class MyThread extends Thread { @Override public void run() { System.out.println(Thread.currentThread().getName() + " run()方法正在執行..."); }}


public class TheadTest { public static void main(String[] args) { MyThread myThread = new MyThread(); myThread.start(); System.out.println(Thread.currentThread().getName() + " main()方法執行結束"); }}

運行結果


main main()方法執行結束Thread-0 run()方法正在執行...

2、實現 Runnable 接口

步驟

  1. 定義Runnable接口實現類MyRunnable,並重寫run()方法
  2. 創建MyRunnable實例myRunnable,以myRunnable作為target創建Thead對象,該Thread對象才是真正的線程對象
  3. 調用線程對象的start()方法

public class MyRunnable implements Runnable {

@Override

public void run() {

System.out.println(Thread.currentThread().getName() + " run()方法執行中...");

}

}


public class RunnableTest {

public static void main(String[] args) {

MyRunnable myRunnable = new MyRunnable();

Thread thread = new Thread(myRunnable);

thread.start();

System.out.println(Thread.currentThread().getName() + " main()方法執行完成");

}

}

執行結果


main main()方法執行完成

Thread-0 run()方法執行中...

3、實現 Callable 接口

步驟

創建實現Callable接口的類myCallable

以myCallable為參數創建FutureTask對象

將FutureTask作為參數創建Thread對象

調用線程對象的start()方法


public class MyCallable implements Callable<integer> {/<integer>

@Override

public Integer call() {

System.out.println(Thread.currentThread().getName() + " call()方法執行中...");

return 1;

}

}


public class CallableTest {

public static void main(String[] args) {

FutureTask<integer> futureTask = new FutureTask<integer>(new MyCallable());/<integer>/<integer>

Thread thread = new Thread(futureTask);

thread.start();

try {

Thread.sleep(1000);

System.out.println("返回結果 " + futureTask.get());

} catch (InterruptedException e) {

e.printStackTrace();

} catch (ExecutionException e) {

e.printStackTrace();

}

System.out.println(Thread.currentThread().getName() + " main()方法執行完成");

}

}

執行結果


Thread-0 call()方法執行中...

返回結果 1

main main()方法執行完成

4、使用 Executors 工具類創建線程池

Executors提供了一系列工廠方法用於創先線程池,返回的線程池都實現了ExecutorService接口。

主要有newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,後續詳細介紹這四種線程池


public class MyRunnable implements Runnable {

@Override

public void run() {

System.out.println(Thread.currentThread().getName() + " run()方法執行中...");

}

}


public class SingleThreadExecutorTest {

public static void main(String[] args) {

ExecutorService executorService = Executors.newSingleThreadExecutor();

MyRunnable runnableTest = new MyRunnable();

for (int i = 0; i < 5; i++) {

executorService.execute(runnableTest);

}

System.out.println("線程任務開始執行");

executorService.shutdown();

}

}

執行結果


線程任務開始執行

pool-1-thread-1 is running...

pool-1-thread-1 is running...

pool-1-thread-1 is running...

pool-1-thread-1 is running...

pool-1-thread-1 is running...

說一下 runnable 和 callable 有什麼區別?

相同點

  • 都是接口
  • 都可以編寫多線程程序
  • 都採用Thread.start()啟動線程

主要區別

  • Runnable 接口 run 方法無返回值;Callable 接口 call 方法有返回值,是個泛型,和Future、FutureTask配合可以用來獲取異步執行的結果
  • Runnable 接口 run 方法只能拋出運行時異常,且無法捕獲處理;Callable 接口 call 方法允許拋出異常,可以獲取異常信息

注:Callalbe接口支持返回執行結果,需要調用FutureTask.get()得到,此方法會阻塞主進程的繼續往下執行,如果不調用不會阻塞。

線程的 run()和 start()有什麼區別?

每個線程都是通過某個特定Thread對象所對應的方法run()來完成其操作的,run()方法稱為線程體。通過調用Thread類的start()方法來啟動一個線程。

start() 方法用於啟動線程,run() 方法用於執行線程的運行時代碼。run() 可以重複調用,而 start() 只能調用一次。

start()方法來啟動一個線程,真正實現了多線程運行。調用start()方法無需等待run方法體代碼執行完畢,可以直接繼續執行其他的代碼;此時線程是處於就緒狀態,並沒有運行。然後通過此Thread類調用方法run()來完成其運行狀態, run()方法運行結束, 此線程終止。然後CPU再調度其它線程。

run()方法是在本線程裡的,只是線程裡的一個函數,而不是多線程的。如果直接調用run(),其實就相當於是調用了一個普通函數而已,直接待用run()方法必須等待run()方法執行完畢才能執行下面的代碼,所以執行路徑還是隻有一條,根本就沒有線程的特徵,所以在多線程執行時要使用start()方法而不是run()方法。

為什麼我們調用 start() 方法時會執行 run() 方法,為什麼我們不能直接調用 run() 方法?

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

new 一個 Thread,線程進入了新建狀態。調用 start() 方法,會啟動一個線程並使線程進入了就緒狀態,當分配到時間片後就可以開始運行了。start() 會執行線程的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多線程工作。

而直接執行 run() 方法,會把 run 方法當成一個 main 線程下的普通方法去執行,並不會在某個線程中執行它,所以這並不是多線程工作。

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

什麼是 Callable 和 Future?

Callable 接口類似於 Runnable,從名字就可以看出來了,但是 Runnable 不會返回結果,並且無法拋出返回結果的異常,而 Callable 功能更強大一些,被線程執行後,可以返回值,這個返回值可以被 Future 拿到,也就是說,Future 可以拿到異步執行任務的返回值。

Future 接口表示異步任務,是一個可能還沒有完成的異步任務的結果。所以說 Callable用於產生結果,Future 用於獲取結果。

什麼是 FutureTask

FutureTask 表示一個異步運算的任務。FutureTask 裡面可以傳入一個 Callable 的具體實現類,可以對這個異步運算的任務的結果進行等待獲取、判斷是否已經完成、取消任務等操作。只有當運算完成的時候結果才能取回,如果運算尚未完成 get 方法將會阻塞。一個 FutureTask 對象可以對調用了 Callable 和 Runnable 的對象進行包裝,由於 FutureTask 也是Runnable 接口的實現類,所以 FutureTask 也可以放入線程池中。



分享到:


相關文章: