前幾天小強去阿里巴巴面試Java崗,止步於二面。
他和我訴苦自己被虐的多慘多慘,特別是深挖線程和線程池的時候,居然被問到不知道如何作答。
對於他的遭遇,結合他過了一面的那個嘚瑟樣,我深表同情(加大力度)!
好了,不開玩笑了,在和小強的面試題中,我選取了幾個比較典型的線程和線程池的問題。
Java中的線程和作業系統的線程有什麼關係?
調用start方法是如何執行run方法的?
線程池提交任務有哪幾種方式?分別有什麼區別?
談談你對阻塞隊列的理解。
常見的線程池有哪些?為什麼阿里不允許使用 Executors 去創建線程池?
線程池任務調度的流程大致講一下。
線程池裡面的線程執行異常了會怎麼樣?
核心線程和非核心線程是如何區分的?
想要答對這些問題,並不是很難,但是想要答好,我覺得是非常考驗個人功底的。
為了弄清這些問題,我連夜加急,採訪了「線程」,下面是線程的自述。
我是一個線程,一個底層的打工人。
總有人把我和進程搞混,但其實我和進程的區別很大。進程是程序的一次執行,CPU的資源都是分發給進程而不是分發給我們線程,進程是資源分配的最小單位,一個進程可以包含很多向我這樣的線程。
我們線程是CPU調度執行的最小單位,真正的打工人。
在Java裡面,我的名字叫做java.lang.Thread。
需要注意的是,調用run方法和執行一個普通方法沒有區別。想要真正的創建一個線程並啟動,需要調用我的start方法。
有一點我必須告訴你,就是我也是有小弟的。
在JVM裡面,我有一個JavaThread的小弟,他幫我聯繫作業系統的osthread線程。
調用我的start方法之後,具體的執行流程是這樣的:
當然了,這個過程省略了很多細節,不過很明確的是,我和內核線程是一一對應的。
調度我就相當於調度內核線程,而調度內核線程需要在用戶態和內核態之間切換,這個過程開銷是非常大的。
所以,創建我成本是很高的,一定要慎重。
線程池和你們人類一樣,我也有著精彩的一生,也會經歷出生(創建)、奮鬥(Running)、死亡(銷毀)等過程,今天我主要和你講述的是我打工奮鬥的生活。
原來我是打零工的,有人需要我的時候就創建一個我,等我完成工作就把我銷毀。
上面也提到過,我和內核線程是一對一的,創建和銷毀的過程是非常消耗資源的,所以這樣的成本非常高。
於是,有人就想了一個辦法,開了一個公司,也就是你們說的線程池。
線程池公司統一管理調度我們線程。我們在線程池裡面重複著等待工作——完成工作的步驟。
這樣我就可以日復一日年復一年的重複打工了,這種提供了減少對象數量從而改善應用所需的對象結構的方式的模式,被你們人類叫做「享元模式」。
線程池公司有很多種,但都離不開這幾個主要指標:
maximumPoolSize:正式工+臨時工最大數量。keepAliveTime:臨時工多久沒做事情會被開除。threadFactory:行政部,負責招聘培訓員工的。handler:業務部接收業務到達上限了的處理方式。阻塞隊列線程池中的workQueue是一個阻塞隊列,用於存放線程池未能及時處理執行的任務。
它的存在既解耦了任務的提交與執行,又能起到一個緩衝的作用。
阻塞隊列有很多,下面我帶你了解一下常見的阻塞隊列。
ArrayBlockingQueue基於數組實現的有界阻塞隊列,創建的時候需要指定容量。此類型的隊列按照FIFO(先進先出)的規則對元素進行排序。
LinkedBlockingQueue基於鍊表實現阻塞隊列,默認大小為Integer.MAX_VALUE。按照FIFO(先進先出)的規則對元素進行排序。
SynchronousQueue一個不存儲元素的阻塞隊列。每一個put操作必須阻塞等待其他線程的take操作,take操作也必須等待其他線程的put操作。
PriorityBlockingQueue一個基於數組利用堆結構實現優先級效果的無界隊列,默認自然序排序,也可以自己實現compareTo方法自定義排序規則。
DelayedWorkQueue一個實現了優先級隊列功能且實現了延遲獲取的無界隊列,在創建元素時,可以指定多久多久才能在隊列中獲取當前元素。只有延時期滿了後才能從隊列中獲取元素。
拒絕策略當任務隊列滿了之後,如果還有任務提交過來,會觸發拒絕策略,常見的拒絕策略有:
AbortPolicy:丟棄任務並拋出異常,默認該方式。
CallerRunsPolicy:由調用線程自己處理該任務。誰調用,誰處理。
DiscardPolicy:丟棄任務,但是不拋出異常。
DiscardOldestPolicy:拋棄任務隊列中最舊的任務,也就是最先加入隊列的,再把這個新任務添加進去。先從任務隊列中彈出最先加入的任務,空出一個位置,然後再次執行execute方法把任務加入隊列。
當然,除了以上這幾種拒絕策略,你也可以根據實際的業務場景和業務需求去自定義拒絕策略,只需要實現RejectedExecutionHander接口,自定義裡面的rejectedExecution方法。
運行流程我們每個線程會被包裝成Worker,線程池裡面有一個HashSet存放Worker。
當有任務提交過來之後:
首先檢測線程池運行狀態,如果不是RUNNING,則直接拒絕,線程池要保證在RUNNING的狀態下執行任務。如果線程池中Worker的數量小於核心線程數,就會去創建一個新的線程,也就是招聘一個正式工讓他執行任務。如果Worker的數量大於或者等於核心線程數,就會把任務放到阻塞任務隊列裡面。如果任務隊列滿了還有任務過來,如果臨時工名額沒有滿(workerCount < maximumPoolSize),就去招聘臨時工讓臨時工執行任務。如果臨時工名額都滿了,觸發任務拒絕策略。總結而言,就是核心線程能幹的事情儘量不去創建非核心線程,這是線程池很關鍵的一點。
new ThreadPoolExecutor(4, 8, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(4));以這個線程池為例,下面是他的任務提交和執行流程:
有哪些線程池我有過四段工作經歷,每段經歷都有著精彩的故事。
SingleThreadExecutorSingleThreadExecutor是我加入的第一家線程池,這是一家創業公司,整個線程池就只有我一個線程。
所有的任務都由我幹,而且任務隊列是一個無界隊列。就是說,打工的線程只有我一個,但是需求任務可以是無限多。
在需求任務很多的時候,經常出現任務處理不過來的情況,導致任務堆積,出現OOM。
但因為所有的活都是我幹,沒有繁瑣的溝通成本,不需要處理線程同步的問題,這算是這種線程池的一個優點吧。
這種線程池適用於並發量不大且需要任務順序執行的場景。
FixedThreadPool後來公司倒閉了,我又加入了一個叫FixedThreadPool的線程池。
FixedThreadPool和SingleThreadExecutor唯一不同的地方就是核心線程的數量,FixedThreadPool可以招收很多的打工線程。
在這裡,我不再是孤軍奮鬥了,我有了一群共同打拼的小夥伴,大家一起完成任務,一起承擔壓力。
可這種線程池還是存在一個問題——任務隊列是無界的,需求任務過多的話,還是會造成OOM。
這種線程池線程數固定,且不被回收,線程與線程池的生命周期同步的線程池,適用於任務量比較固定但耗時長的任務。
CachedThreadPool後來,為了離家更近,我離職了。加入了一家叫CachedThreadPool的線程池,進去之後,卻發現這是一家外包公司。
這種線程池裡面沒有一個核心線程(正式工),一有需求就去招聘一個非核心線程(臨時工)。
如果一個線程任務幹完了之後,60秒之後沒有新的任務就會被辭退。
這種線程池的任務隊列採用的是SynchronousQueue,這個隊列是無法插入任務的,一有任務就創建一個線程執行,如果並發高且任務耗時長,創建太多線程也是可能導致OOM的。所以CachedThreadPool比較適合任務量大但耗時少的任務。
ScheduleThreadPool經歷了外面的風風雨雨,我覺得還是找份固定的工作比較可靠,於是我加入了一家叫做ScheduleThreadPool的國企。
在這裡,工作比較的輕鬆,多數情況下,我只需要在固定的時間幹固定的活。
任務忙不過來的時候,公司也會招聘一些臨時工幫忙處理,臨時工幹完活就會被辭退。
綜合來說,這類線程池適用於執行定時任務和具體固定周期的重複任務。由於採用的任務隊列是DelayedWorkQueue無界隊列,所以也是有OOM的風險的。
總結
好了,關於線程的故事就告一段落了。關於線程池的應用實踐,我們下次再聊。
文章開頭的面試題在大部分在文中都能找到答案,對於沒有提到的,這裡做一個補充:
1. 線程池提交任務有哪幾種方式?分別有什麼區別?有execute和submit兩種方式
execute只能提交Runnable類型的任務,無返回值。submit既可以提交Runnable類型的任務,也可以提交Callable類型的任務,會有一個類型為Future的返回值,但當任務類型為Runnable時,返回值為null。
execute在執行任務時,如果遇到異常會直接拋出,而submit不會直接拋出,只有在使用Future的get方法獲取返回值時,才會拋出異常。
2. 線程池裡面的線程執行異常了會怎麼樣?如果一個線程執行任務的過程中出現異常,那麼這個線程對應的Worker會被移出線程池,該線程也會被銷毀回收。
同時會通過指定的線程工廠創建一個線程,並封裝成Worker放入線程池代替移除的Worker。
3. 核心線程能被回收嗎?核心線程默認不會被回收。但是可以調用allowCoreThreadTimeOut讓核心線程可以被回收。
需要注意的是,調用這個方法的線程池必須將keepAliveTime設置為大於0,否則會拋出異常。
4. 核心線程和非核心線程是如何區分的?核心線程和非核心線程是一個抽象概念,只是用於更好的表述線程池的運行邏輯,實際上都對應作業系統的osThread,都是重量級線程。
在新增Worker的時候,通過一個boolean表達是核心線程還是非核心線程,本質上兩者沒有什麼不同。
5. 為什麼阿里不允許使用 Executors 去創建線程池?FixedThreadPool 和 SingleThreadPool:允許的請求隊列長度為 Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM。
CachedThreadPool:允許的創建線程數量為 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM。
總結來說就是,使用Executors創建線程池會容易忽視線程池的一些屬性,使用不當容易造成資源耗盡。
寫在最後
這個世界上或許沒有線程,又或許人人都是線程。
好了,今天的文章就到這裡了。
最後,感謝你的閱讀!
我是CoderW,一個普通的程式設計師。
點個關注,我們下期再見!