本文關鍵字:
線程
,線程池
,單線程
,多線程
,線程池的好處
,線程回收
,創建方式
,核心參數
,底層機制
,拒絕策略
,參數設置
,動態監控
,線程隔離
線程和線程池相關的知識,是Java學習或者面試中一定會遇到的知識點,本篇我們會從線程和進程,並行與並發,單線程和多線程等,一直講解到線程池,線程池的好處,創建方式,重要的核心參數,幾個重要的方法,底層實現,拒絕策略,參數設置,動態調整,線程隔離等等。主要的大綱如下(本文只涉及線程部分,線程池下篇講):
進程和線程
從線程到進程
要說線程池,就不得不先講講線程,什么是線程?
那么問題來了,進程又是什么?
進程是操作系統中進行保護和資源分配的基本單位。
是不是有點懵,進程摸得着看得見么?具體怎么表現?打開Windows
的任務管理器或者Mac
的活動監視器,就可以看到,基本每一個打開的App
就是一個進程,但是並不是一定的,一個應用程序可能存在多個進程。
比如下面的Typora
就顯示了兩個進程,每個進程后面有一個PID
是唯一的標識,也是由系統分配的。除此之外,每個進程都可以看到有多少個線程在執行,比如微信有32
個線程在執行。重要的一句話:一個程序運行之后至少有一個進程,一個進程可以包含多個線程。

為什么需要進程?
程序,就是指令的集合,指令的集合說白了就是文件,讓程序跑起來,在執行的程序,才是進程。程序是靜態的描述文本,而進程是程序的一次執行活動,是動態的。進程是擁有計算機分配的資源的運行程序。
我們不可能一個計算機只有一個進程,就跟我們全國不可能只有一個市或者一個部門,計算機是一個龐然大物,里面的運轉需要有條理,就需要按照功能划分出比較獨立的單位,分開管理。每個進程有自己的職責,也有自己的獨立內存空間,不可能混着使用,要是所有的程序共用一個進程就會亂套。
每個進程,都有各自獨立的內存,進程之間內存地址隔離,進程的資源,比如:代碼段,數據集,堆等等,還可能包括一些打開的文件或者信號量,這都是每個進程自己的數據。同時,由於進程的隔離性,即使有一個程序的進程出現問題了,一般不會影響到其他的進程的使用。
進程在Linux系統中,進程有一個比較重要的東西,叫進程控制塊(PCB
),僅做了解:
PCB
是進程的唯一標識,由鏈表實現,是為了動態的插入以及刪除,創建進程的時候,生成一個PCB
,進程結束的時候,回收這個PCB
。PCB
主要包括以下的信息:
- 進程狀態
- 進程標識信息
- 定時器
- 用戶可見的寄存器,控制狀態寄存區,棧指針等等。
進程怎么切換的呢?
先明白計算機里面的一個事實:CPU運轉得超級無敵快,快到其他的只有寄存器差不多能匹配它的速度,但是很多時候我們需要從磁盤或者內存讀或者寫數據,這些設備的速度太慢了,與之相差太遠。(如果不特殊說明,默認是單核的CPU)
假設一個程序/進程的任務執行一段時間,要寫磁盤,寫磁盤不需要CUP
進行計算,那CPU
就空出來了,但是其他的程序也不能用,CPU
就干等着,等到寫完磁盤再接着執行。這多浪費,CPU
又不是這個程序一家的,其他的應用也要使用。CPU
你不用的時候,總有別人需要用。
所以CPU
資源需要調度,程序A
不用的時候,可以切出來,讓程序B
去使用,但是程序A
切回來的時候怎么保證它能夠接着之前的位置繼續執行呢?這時候不得不提上下文的事。
當程序A
(假設為單進程)放棄CPU
的時候,需要保存當前的上下文,何為上下文?也就是除了CPU
之外,寄存器或者其他的狀態,就跟犯罪現場一樣,需要拍個照,要不到時候別的程序執行完之后,怎么知道接下來怎么執行程序A
,之前執行到哪一步了。總結一句話:保存當前程序的執行狀態。
上下文切換一般還涉及緩存的開銷,也就是緩存會失效,一般執行的時候,CPU會緩存一些數據方便下次更快的執行,一旦進行上下文切換,原來的緩存就失效了,需要重新緩存。
調度一般有兩種(一般是按照線程維度來調度),CPU
的時間被分為特別小的時間片:
- 分時調度:每個線程或者進程輪流的使用
CPU
,平均時間分配到每個線程或者進程。 - 搶占式調度:優先級高的線程/進程立即搶占下一個時間片,如果優先級相同,那么隨機選擇一個進程。
時間片超級短,CPU超級快,給我們無比絲滑的感覺,就像是多個任務在同時進行
我們現在操作系統或者其他的系統,基本都是搶占式調度,為什么?
因為如果使用分時調度,很難做到實時響應,當后台的聊天程序在進行網絡傳輸的時候,分配予它的時間片還沒有使用完,那我點擊瀏覽器,是沒有辦法實時響應的。除此之外,如果前面的進程掛了,但是一直占有CPU
,那么后面的任務將永遠得不到執行。
由於CPU
的處理能力超級快,就算是單核的CPU
,運行着多個程序,多個進程,經過搶占式的調度,每一個程序使用的時候都像是獨享了CPU
一樣順滑。進程有效的提高了CPU
的使用率,但是進程在上下文切換的時候是存在着一定的成本的。
線程和進程什么關系?
前面說了進程,那有了進程,為啥還要線程,多個應用程序,假設我們每個應用程序要做n
件事,就用n
個進程不行么?
可以,但是沒必要。
進程一般由程序,數據集合和進程控制塊組成,同一個應用程序一般是需要使用同一個數據空間的,要是一個應用程序搞很多個進程,就算有能力做到數據空間共享,進程的上下文切換都會消耗很多資源。(一般一個應用程序不會有很多進程,大多數一個,少數有幾個)
進程的顆粒度比較大,每次執行都需要上下文切換,如果同一個程序里面的代碼段A
,B
,C
,做不一樣的東西,如果分給多個進程去處理,那么每次執行都有切換進程上下文。這太慘了。一個應用程序的任務是一家人,住在同一個屋子下(同一個內存空間),有必要每個房間都當成每一戶,去派出所登記成一個戶口么?
進程缺點:
- 信息共享難,空間獨立
- 切換需要
fork()
,切換上下文,開銷大 - 只能在一個時間點做一件事
- 如果進程阻塞了,要等待網絡傳過來數據,那么其他不依賴這個數據的任務也做不了
但是有人會說,那我一個應用程序有很多事情要做,總不能只用一個進程,所有事情都等着它來處理啊?那不是會阻塞住么?
確實啊,單獨一個進程處理不了問題,那么我們把進程分得更小,里面分成很多線程,一家人,每個人都有自己的事情做,那我們每個人就是一個線程,一家人就是一個進程,這樣豈不是更好么?
進程是描述CPU時間片調度的時間片段,但是線程是更細小的時間片段,兩者的顆粒度不一樣。線程可以稱為輕量級的進程。其實,線程也不是一開始就有的概念,而是隨着計算機發展,對多個任務上下文切換要求越來越高,隨之抽象出來的概念。
$$進程時間段 = CPU加載程序上下文的時間 + CPU執行時間 + CPU保存程序上下文的時間$$
$$
線程時間段 = CPU加載線程上下文的時間 + CPU執行時間 + CPU保存線程上下文的時間$$
最重要的是,進程切換上下文的時間遠比線程切換上下文的時間成本要高,如果是同一個進程的不同線程之間搶占到CPU
,切換成本會比較低,因為他們共享了進程的地址空間,線程間的通信容易很多,通過共享進程級全局變量即可實現。
況且,現在多核的處理器,讓不同進程在不同核上跑,進程內的線程在同個核上做切換,盡量減少(不可以避免)進程的上下文切換,或者讓不同線程跑在不同的處理器上,進一步提高效率。
進程和線程的模型如下:
線程和進程的區別或者優點
- 線程是程序執行的最小單位,進程是操作系統分配資源的最小單位。
- 一個應用可能多個進程,一個進程由一個或者多個線程組成
- 進程相互獨立,通信或者溝通成本高,在同一個進程下的線程共享進程的內存等,相互之間溝通或者協作成本低。
- 線程切換上下文比進程切換上下文要快得多。
線程有哪些狀態
現在我們所說的是Java
中的線程Thread
,一個線程在一個給定的時間點,只能處於一種狀態,這些狀態都是虛擬機的狀態,不能反映任何操作系統的線程狀態,一共有六種/七種狀態:
-
NEW
:創建了線程對象,但是還沒有調用Start()
方法,還沒有啟動的線程處於這種狀態。 -
Running
:運行狀態,其實包含了兩種狀態,但是Java
線程將就緒和運行中統稱為可運行Runnable
:就緒狀態:創建對象后,調用了start()
方法,該狀態的線程還位於可運行線程池中,等待調度,獲取CPU
的使用權- 只是有資格執行,不一定會執行
start()
之后進入就緒狀態,sleep()
結束或者join()
結束,線程獲得對象鎖等都會進入該狀態。CPU
時間片結束或者主動調用yield()
方法,也會進入該狀態
Running
:獲取到CPU
的使用權(獲得CPU時間片),變成運行中
-
BLOCKED
:阻塞,線程阻塞於鎖,等待監視器鎖,一般是Synchronize
關鍵字修飾的方法或者代碼塊 -
WAITING
:進入該狀態,需要等待其他線程通知(notify
)或者中斷,一個線程無限期地等待另一個線程。 -
TIMED_WAITING
:超時等待,在指定時間后自動喚醒,返回,不會一直等待 -
TERMINATED
:線程執行完畢,已經退出。如果已終止再調用start(),將會拋出java.lang.IllegalThreadStateException
異常。
可以看到Thread.java
里面有一個State
枚舉類,枚舉了線程的各種狀態(Java
線程將就緒和運行中統稱為可運行):
public enum State {
/**
* 尚未啟動的線程的線程狀態。
*/
NEW,
/**
* 可運行線程的線程狀態,一個處於可運行狀態的線程正在Java虛擬機中執行,但它可能正在等待來自操作系統(如處理器)的其他資源。
*/
RUNNABLE,
/**
* 等待監視器鎖而阻塞的線程的線程狀態。
* 處於阻塞狀態的線程正在等待一個監視器鎖進入一個同步的塊/方法,或者在調用Oject.wait()方法之后重新進入一個同步代碼塊
*/
BLOCKED,
/**
* 等待線程的線程狀態,線程由於調用其中一個線程而處於等待狀態
*/
WAITING,
/**
* 具有指定等待時間的等待線程的線程狀態,線程由於調用其中一個線程而處於定時等待狀態。
*/
TIMED_WAITING,
/**
* 終止線程的線程狀態,線程已經完成執行。
*/
TERMINATED;
}
除此之外,Thread類還有一些屬性是和線程對象有關的:
- long tid:線程序號
- char name[]:線程名稱
- int priority:線程優先級
- boolean daemon:是否守護線程
- Runnable target:線程需要執行的方法
介紹一下上面圖中講解到線程的幾個重要方法,它們都會導致線程的狀態發生一些變化:
Thread.sleep(long)
:調用之后,線程進入TIMED_WAITING
狀態,但是不會釋放對象鎖,到時間蘇醒后進入Runnable
就緒狀態Thread.yield()
:線程調用該方法,表示放棄獲取的CPU
時間片,但是不會釋放鎖資源,同樣變成就緒狀態,等待重新調度,不會阻塞,但是也不能保證一定會讓出CPU
,很可能又被重新選中。thread.join(long)
:當前線程調用其他線程thread
的join()
方法,當前線程不會釋放鎖,會進入WAITING
或者TIMED_WAITING
狀態,等待thread執行完畢或者時間到,當前線程進入就緒狀態。object.wait(long)
:當前線程調用對象的wait()
方法,當前線程會釋放獲得的對象鎖,進入等待隊列,WAITING
,等到時間到或者被喚醒。object.notify()
:喚醒在該對象監視器上等待的線程,隨機挑一個object.notifyAll()
:喚醒在該對象監視器上等待的所有線程
單線程和多線程
單線程,就是只有一條線程在執行任務,串行的執行,而多線程,則是多條線程同時執行任務,所謂同時,並不是一定真的同時,如果在單核的機器上,就是假同時,只是看起來同時,實際上是輪流占據CPU時間片。
下面的每一個格子是一個時間片(每一個時間片實際上超級無敵短),不同的線程其實可以搶占不同的時間片,獲得執行權。時間片分配的單位是線程,而不是進程,進程只是容器
如何啟動一個線程
其實Java
的main()
方法本質上就啟動了一個線程,但是本質上不是只有一個線程,看結果的 5 就大致知道,其實一共有 5 個線程,主線程是第 5 個,大多是后台線程:
public class Test {
public static void main(String[] args) {
System.out.println(Thread.currentThread().toString());
}
}
執行結果:
Thread[main,5,main]
可以看出上面的線程是main
線程,但是要想創建出有別於main
線程的方式,有四種:
- 自定義類去實現
Runnable
接口 - 繼承
Thread
類,重寫run()
方法 - 通過
Callable
和FutureTask
創建線程 - 線程池直接啟動(本質上不算是)
實現Runnable接口
class MyThread implements Runnable{
@Override
public void run(){
System.out.println("Hello world");
}
}
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();
System.out.println("Main Thread");
}
}
運行結果:
Main Thread
Hello world
如果看底層就可以看到,構造函數的時候,我們將Runnable
的實現類對象傳遞進入,會將Runnable
實現類對象保存下來:
public Thread(Runnable target) {
this(null, target, "Thread-" + nextThreadNum(), 0);
}
然后再調用start()
方法的時候,會調用原生的start0()
方法,原生方法是由c
或者c++
寫的,這里看不到具體的實現:
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
// 正式的調用native原生方法
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
Start0()
在底層確實調用了run()
方法,並且不是直接調用的,而是啟用了另外一個線程進行調用的,這一點在代碼注釋里面寫得比較清楚,在這里我們就不展開講,我們將關注點放到run()
方法上,調用的就是剛剛那個Runnable
實現類的對象的run()
方法:
@Override
public void run() {
if (target != null) {
target.run();
}
}
繼承Thread類
由於Thread
類本身就實現了Runnable
接口,所以我們只要繼承它就可以了:
class Thread implements Runnable {
}
繼承之后重寫run()方法即可:
class MyThread extends Thread{
@Override
public void run(){
System.out.println("Hello world");
}
}
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();
System.out.println("Main Thread");
}
}
執行結果和上面的一樣,其實兩種方式本質上都是一樣的,一個是實現了Runnable
接口,另外一個是繼承了實現了Runnable
接口的Thread
類。兩種都沒有返回值,因為run()
方法的返回值是void
。
Callable和FutureTask創建線程
要使用該方式,按照以下步驟:
- 創建
Callable
接口的實現類,實現call()
方法 - 創建
Callable
實現類的對象實例,用FutureTask
包裝Callable的實現類實例,包裝成FutureTask
的實例,FutureTask
的實例封裝了Callable
對象的Call()
方法的返回值 - 使用
FutureTask
對象作為Thread
對象的target
創建並啟動線程,FutureTask
實現了RunnableFuture
,RunnableFuture
繼承了Runnable
- 調用
FutureTask
對象的get()
來獲取子線程執行結束的返回值
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class CallableTest {
public static void main(String[] args) throws Exception{
Callable<String> callable = new MyCallable<String>();
FutureTask<String> task = new FutureTask<String>(callable);
Thread thread = new Thread(task);
thread.start();
System.out.println(Thread.currentThread().getName());
System.out.println(task.get());
}
}
class MyCallable<String> implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println(
Thread.currentThread().getName() +
" Callable Thread");
return (String) "Hello";
}
}
執行結果:
main
Thread-0 Callable Thread
Hello
其實這種方式本質上也是Runnable
接口來實現的,只不過做了一系列的封裝,但是不同的是,它可以實現返回值,如果我們期待一件事情可以通過另外一個線程來獲取結果,但是可能需要消耗一些時間,比如異步網絡請求,其實可以考慮這種方式。
Callable
和FutureTask
是后面才加入的功能,是為了適應多種並發場景,Callable
和Runnable
的區別如下:
Callable
定義方法是call()
,Runnable
定義的方法是run()
Callable
的call()
方法有返回值,Runnable
的run()
方法沒有返回值Callable
的call()
方法可以拋出異常,Runnable
的run()
方法不能拋出異常
線程池啟動線程
本質上也是通過實現Runnable
接口,然后放到線程池中進行執行:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class MyThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " : hello world");
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
MyThread thread = new MyThread();
executorService.execute(thread);
}
executorService.shutdown();
}
}
執行結果如下,可以看到五個核心線程一直在執行,沒有規律,循環十次,但是並沒有創建出十個線程,這和線程池的設計以及參數有關,后面會講解:
pool-1-thread-5 : hello world
pool-1-thread-4 : hello world
pool-1-thread-5 : hello world
pool-1-thread-3 : hello world
pool-1-thread-2 : hello world
pool-1-thread-1 : hello world
pool-1-thread-2 : hello world
pool-1-thread-3 : hello world
pool-1-thread-5 : hello world
pool-1-thread-4 : hello world
總結一下,啟動一個線程,其實本質上都離不開Runnable
接口,不管是繼承還是實現接口。
多線程可能帶來的問題
- 消耗資源:上下文切換,或者創建以及銷毀線程,都是比較消耗資源的。
- 競態條件:多線程訪問或者修改同一個對象,假設自增操作
num++
,操作分為三步,讀取num
,num
加1,寫回num
,並非原子操作,那么多個線程之間交叉運行,就會產生不如預期的結果。 - 內存的可見性:每個線程都有自己的內存(緩存),一般修改的值都放在自己線程的緩存上,到刷新至主內存有一定的時間,所以可能一個線程更新了,但是另外一個線程獲取到的還是久的值,這就是不可見的問題。
- 執行順序難預知:線程先
start()
不一定先執行,是由系統決定的,會導致共享的變量或者執行結果錯亂
並發與並行
並發是指兩個或多個事件在同一時間間隔發生,比如在同1s
中內計算機不僅計算數據1
,同時也計算了數據2
。但是兩件事情可能在某一個時刻,不是真的同時進行,很可能是搶占時間片就執行,搶不到就別人執行,但是由於時間片很短,所以在1s中內,看似是同時執行完成了。當然前面說的是單核的機器,並發不是真的同時執行,但是多核的機器上,並發也可能是真的在同時執行,只是有可能,這個時候的並發也叫做並行。
並行是指在同一時刻,有多條指令在多個處理器上同時執行,真正的在同時執行。
如果是單核的機器,最多只能並發,不可能並行處理,只能把CPU運行時間分片,分配給各個線程執行,執行不同的線程任務的時候需要上下文切換。而多核機器,可以做到真的並行,同時在多個核上計算,運行。並行操作一定是並發的,但是並發的操作不一定是並行的。
關於作者
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java源碼解析,JDBC,Mybatis,Spring,redis,分布式,劍指Offer,LeetCode等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花里胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查找資料。遺漏或者錯誤之處,還望指正。