2018跳槽面試必備之深入理解 Java 多執行緒核心知識

伊竹凌發表於2018-04-02

導語:多執行緒相對於其他 Java 知識點來講,有一定的學習門檻,並且瞭解起來比較費勁。在平時工作中如若使用不當會出現資料錯亂、執行效率低(還不如單執行緒去執行)或者死鎖程式掛掉等等問題,所以掌握瞭解多執行緒至關重要。

本文從基礎概念開始到最後的併發模型由淺入深,講解下執行緒方面的知識。

概念梳理

本節我將帶大家瞭解多執行緒中幾大基礎概念。

併發與並行

並行,表示兩個執行緒同時做事情。

併發,表示一會做這個事情,一會做另一個事情,存在著排程。單核 CPU 不可能存在並行(微觀上)。

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

臨界區

臨界區用來表示一種公共資源或者說是共享資料,可以被多個執行緒使用。但是每一次,只能有一個執行緒使用它,一旦臨界區資源被佔用,其他執行緒要想使用這個資源,就必須等待。

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

阻塞與非阻塞

阻塞和非阻塞通常用來形容多執行緒間的相互影響。比如一個執行緒佔用了臨界區資源,那麼其它所有需要這個資源的執行緒就必須在這個臨界區中進行等待,等待會導致執行緒掛起。這種情況就是阻塞。此時,如果佔用資源的執行緒一直不願意釋放資源,那麼其它所有阻塞在這個臨界區上的執行緒都不能工作。阻塞是指執行緒在作業系統層面被掛起。阻塞一般效能不好,需大約8萬個時鐘週期來做排程。非阻塞則允許多個執行緒同時進入臨界區。

死鎖

死鎖是程式死鎖的簡稱,是指多個程式迴圈等待他方佔有的資源而無限的僵持下去的局面。

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

活鎖

假設有兩個執行緒1、2,它們都需要資源 A/B,假設1號執行緒佔有了 A 資源,2號執行緒佔有了 B 資源;由於兩個執行緒都需要同時擁有這兩個資源才可以工作,為了避免死鎖,1號執行緒釋放了 A 資源佔有鎖,2號執行緒釋放了 B 資源佔有鎖;此時 AB 空閒,兩個執行緒又同時搶鎖,再次出現上述情況,此時發生了活鎖。簡單類比,電梯遇到人,一個進的一個出的,對面佔路,兩個人同時往一個方向讓路,來回重複,還是堵著路。如果線上應用遇到了活鎖問題,恭喜你中獎了,這類問題比較難排查。

飢餓

飢餓是指某一個或者多個執行緒因為種種原因無法獲得所需要的資源,導致一直無法執行。

執行緒的生命週期

線上程的生命週期中,它要經歷建立、可執行、不可執行幾種狀態。

建立狀態

當用 new 操作符建立一個新的執行緒物件時,該執行緒處於建立狀態。

處於建立狀態的執行緒只是一個空的執行緒物件,系統不為它分配資源。

可執行狀態

執行執行緒的 start() 方法將為執行緒分配必須的系統資源,安排其執行,並呼叫執行緒體——run()方法,這樣就使得該執行緒處於可執行狀態(Runnable)。

這一狀態並不是執行中狀態(Running),因為執行緒也許實際上並未真正執行。

不可執行狀態

當發生下列事件時,處於執行狀態的執行緒會轉入到不可執行狀態:

呼叫了 sleep() 方法;

執行緒呼叫 wait() 方法等待特定條件的滿足;

執行緒輸入/輸出阻塞;

返回可執行狀態;

處於睡眠狀態的執行緒在指定的時間過去後;

如果執行緒在等待某一條件,另一個物件必須通過 notify() 或 notifyAll() 方法通知等待執行緒條件的改變;

如果執行緒是因為輸入輸出阻塞,等待輸入輸出完成。

執行緒的優先順序執行緒優先順序及設定

執行緒的優先順序是為了在多執行緒環境中便於系統對執行緒的排程,優先順序高的執行緒將優先執行。一個執行緒的優先順序設定遵從以下原則:

執行緒建立時,子繼承父的優先順序;

執行緒建立後,可通過呼叫 setPriority() 方法改變優先順序;

執行緒的優先順序是1-10之間的正整數。

執行緒的排程策略

執行緒排程器選擇優先順序最高的執行緒執行。但是,如果發生以下情況,就會終止執行緒的執行:

執行緒體中呼叫了 yield() 方法,讓出了對 CPU 的佔用權;

執行緒體中呼叫了 sleep() 方法,使執行緒進入睡眠狀態;

執行緒由於 I/O 操作而受阻塞;

另一個更高優先順序的執行緒出現;

在支援時間片的系統中,該執行緒的時間片用完。

單執行緒建立方式

單執行緒建立方式比較簡單,一般只有兩種方式:繼承 Thread 類和實現 Runnable 介面;這兩種方式比較常用就不在 Demo 了,但是對於新手需要注意的問題有:

不管是繼承 Thread 類還是實現 Runable 介面,業務邏輯是寫在 run 方法裡面,執行緒啟動的時候是執行 start() 方法;

開啟新的執行緒,不影響主執行緒的程式碼執行順序也不會阻塞主執行緒的執行;

新的執行緒和主執行緒的程式碼執行順序是不能夠保證先後的;

對於多執行緒程式,從微觀上來講某一時刻只有一個執行緒在工作,多執行緒目的是讓 CPU 忙起來;

通過檢視 Thread 的原始碼可以看到,Thread 類是實現了 Runnable 介面的,所以這兩種本質上來講是一個;

PS:平時在工作中也可以借鑑這種程式碼結構,對上層呼叫來講提供更多的選擇,作為服務提供方核心業務歸一維護

為什麼要用執行緒池

通過上面的介紹,完全可以開發一個多執行緒的程式,為什麼還要引入執行緒池呢。主要是因為上述單執行緒方式存在以下幾個問題:

執行緒的工作週期:執行緒建立所需時間為 T1,執行緒執行任務所需時間為 T2,執行緒銷燬所需時間為 T3,往往是 T1+T3 大於 T2,所有如果頻繁建立執行緒會損耗過多額外的時間;

如果有任務來了,再去建立執行緒的話效率比較低,如果從一個池子中可以直接獲取可用的執行緒,那效率會有所提高。所以執行緒池省去了任務過來,要先建立執行緒再去執行的過程,節省了時間,提升了效率;

執行緒池可以管理和控制執行緒,因為執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一的分配,調優和監控;

執行緒池提供佇列,存放緩衝等待執行的任務。

大致總結了上述的幾個原因,所以可以得出一個結論就是在平時工作中,如果要開發多執行緒程式,儘量要使用執行緒池的方式來建立和管理執行緒。

通過執行緒池建立執行緒從呼叫 API 角度來說分為兩種,一種是原生的執行緒池,另外該一種是通過 Java 提供的併發包來建立,後者比較簡單,後者其實是對原生的執行緒池建立方式做了一次簡化包裝,讓呼叫者使用起來更方便,但道理都是一樣的。所以搞明白原生執行緒池的原理是非常重要的。

ThreadPoolExecutor

通過 ThreadPoolExecutor 建立執行緒池,API 如下所示:

publicThreadPoolExecutor(intcorePoolSize,intmaximumPoolSize,longkeepAliveTime,TimeUnit unit,BlockingQueue workQueue);

先來解釋下其中的引數含義(如果看的比較模糊可以大致有個印象,後面的圖是關鍵)。

corePoolSize

核心池的大小。

在建立了執行緒池後,預設情況下,執行緒池中並沒有任何執行緒,而是等待有任務到來才建立執行緒去執行任務,除非呼叫了 prestartAllCoreThreads() 或者 prestartCoreThread() 方法,從這兩個方法的名字就可以看出,是預建立執行緒的意思,即在沒有任務到來之前就建立 corePoolSize 個執行緒或者一個執行緒。預設情況下,在建立了執行緒池後,執行緒池中的執行緒數為0,當有任務來之後,就會建立一個執行緒去執行任務,當執行緒池中的執行緒數目達到 corePoolSize 後,就會把到達的任務放到快取佇列當中。

maximumPoolSize

執行緒池最大執行緒數,這個引數也是一個非常重要的引數,它表示線上程池中最多能建立多少個執行緒。

keepAliveTime

表示執行緒沒有任務執行時最多保持多久時間會終止。預設情況下,只有當執行緒池中的執行緒數大於 corePoolSize 時,keepAliveTime 才會起作用,直到執行緒池中的執行緒數不大於 corePoolSize,即當執行緒池中的執行緒數大於 corePoolSize 時,如果一個執行緒空閒的時間達到 keepAliveTime,則會終止,直到執行緒池中的執行緒數不超過 corePoolSize。

但是如果呼叫了 allowCoreThreadTimeOut(boolean) 方法,線上程池中的執行緒數不大於 corePoolSize 時,keepAliveTime 引數也會起作用,直到執行緒池中的執行緒數為0。

unit

引數 keepAliveTime 的時間單位。

workQueue

一個阻塞佇列,用來儲存等待執行的任務,這個引數的選擇也很重要,會對執行緒池的執行過程產生重大影響,一般來說,這裡的阻塞佇列有以下這幾種選擇:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。

threadFactory

執行緒工廠,主要用來建立執行緒。

handler

表示當拒絕處理任務時的策略,有以下四種取值:

ThreadPoolExecutor.AbortPolicy:丟棄任務並丟擲 RejectedExecutionException 異常;

ThreadPoolExecutor.DiscardPolicy:也是丟棄任務,但是不丟擲異常;

ThreadPoolExecutor.DiscardOldestPolicy:丟棄佇列最前面的任務,然後重新嘗試執行任務(重複此過程);

ThreadPoolExecutor.CallerRunsPolicy:由呼叫執行緒處理該任務。

上面這些引數是如何配合工作的呢?請看下圖:

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

注意圖上面的序號。

簡單總結下執行緒池之間的引數協作分為以下幾步:

執行緒優先向 CorePool 中提交;

在 Corepool 滿了之後,執行緒被提交到任務佇列,等待執行緒池空閒;

在任務佇列滿了之後 corePool 還沒有空閒,那麼任務將被提交到 maxPool 中,如果 MaxPool 滿了之後執行 task 拒絕策略。

流程圖如下:

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

以上就是原生執行緒池建立的核心原理。除了原生執行緒池之外併發包還提供了簡單的建立方式,上面也說了它們是對原生執行緒池的一種包裝,可以讓開發者簡單快捷的建立所需要的執行緒池。

ExecutorsnewSingleThreadExecutor

建立一個執行緒的執行緒池,在這個執行緒池中始終只有一個執行緒存在。如果執行緒池中的執行緒因為異常問題退出,那麼會有一個新的執行緒來替代它。此執行緒池保證所有任務的執行順序按照任務的提交順序執行。

newFixedThreadPool

建立固定大小的執行緒池。每次提交一個任務就建立一個執行緒,直到執行緒達到執行緒池的最大大小。執行緒池的大小一旦達到最大值就會保持不變,如果某個執行緒因為執行異常而結束,那麼執行緒池會補充一個新執行緒。

newCachedThreadPool

可根據實際情況,調整執行緒數量的執行緒池,執行緒池中的執行緒數量不確定,如果有空閒執行緒會優先選擇空閒執行緒,如果沒有空閒執行緒並且此時有任務提交會建立新的執行緒。在正常開發中並不推薦這個執行緒池,因為在極端情況下,會因為 newCachedThreadPool 建立過多執行緒而耗盡 CPU 和記憶體資源。

newScheduledThreadPool

此執行緒池可以指定固定數量的執行緒來週期性的去執行。比如通過 scheduleAtFixedRate 或者 scheduleWithFixedDelay 來指定週期時間。

PS:另外在寫定時任務時(如果不用 Quartz 框架),最好採用這種執行緒池來做,因為它可以保證裡面始終是存在活的執行緒的。

推薦使用 ThreadPoolExecutor 方式

在阿里的 Java 開發手冊時有一條是不推薦使用 Executors 去建立,而是推薦去使用 ThreadPoolExecutor 來建立執行緒池。

這樣做的目的主要原因是:使用 Executors 建立執行緒池不會傳入核心引數,而是採用的預設值,這樣的話我們往往會忽略掉裡面引數的含義,如果業務場景要求比較苛刻的話,存在資源耗盡的風險;另外採用 ThreadPoolExecutor 的方式可以讓我們更加清楚地瞭解執行緒池的執行規則,不管是面試還是對技術成長都有莫大的好處。

文末福利:

文章最後,給大家推薦一個交流學習群:650385180,裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化這些成為架構師必備的知識體系。群裡也有關於2018年一線網際網路公司的多執行緒面試題的彙總。還能領取免費的學習資源,目前受益良多,以下的知識體系圖也是在群裡獲取。

2018跳槽面試必備之深入理解 Java 多執行緒核心知識

小結:以上就是我要說的內容,希望以上的內容可以幫助到正在默默艱辛,遇到瓶疾且不知道怎麼辦的Java程式設計師們,我能幫你的只有這麼多了,多執行緒知識點已經總結完了,我能幫的,也只有這麼多了,希望大家在往後的工作與面試中,一切順利。


相關文章