Java 并發(fā)編程基礎(chǔ)小結(jié)
一、并發(fā)編程中的一些核心思想
1. 為什么需要多線程
計(jì)算機(jī)發(fā)展初期都是以進(jìn)程為維度分配內(nèi)存、文件句柄以及安全證書等資源,同時多個進(jìn)程之間采用一些比較粗粒度的通信機(jī)制來交換數(shù)據(jù),包括:
- 套接字
- 信號處理器
- 共享內(nèi)存
基于并發(fā)編程實(shí)戰(zhàn)的思想:
高效做事的人,總能在串行性和異步性之間找到一個合理的平衡點(diǎn),程序也是如此。
于是操作系統(tǒng)就引入多進(jìn)程運(yùn)行的調(diào)度機(jī)制,例如:在一個單核的計(jì)算機(jī)上進(jìn)程1得到CPU執(zhí)行權(quán),隨后進(jìn)入IO任務(wù)阻塞掛起,此時進(jìn)程2、進(jìn)程3先后在此阻塞期間獲得CPU執(zhí)行權(quán)執(zhí)行任務(wù):
基于上述基礎(chǔ)上,考慮到每一個進(jìn)程都獨(dú)有各自的內(nèi)存空間和文件句柄等資源,以如此龐大級別的單位處理一些單一的工作而在CPU之間進(jìn)行頻繁切換開銷是非常不客觀的,于是就有了輕量級調(diào)度單位——多線程。
以多線程調(diào)度為例,假設(shè)進(jìn)程1、進(jìn)程2分別對應(yīng)讀取定時讀取網(wǎng)絡(luò)數(shù)據(jù)、定時寫入數(shù)據(jù)到網(wǎng)絡(luò)系統(tǒng)日志,按照多線程維度將二者合并,最終的進(jìn)程交由CPU執(zhí)行,我們就可以得到這樣一個場景:
- CPU執(zhí)行到線程1,讀取網(wǎng)絡(luò)數(shù)據(jù),IO阻塞,讓出CPU。
- 線程2寫入之前的網(wǎng)絡(luò)系統(tǒng)日志到磁盤,進(jìn)行write調(diào)用時切換到內(nèi)核態(tài),讓出CPU。
- 線程1完成數(shù)據(jù),進(jìn)程終端輸出結(jié)果,讓出CPU。
- 線程2write調(diào)用返回,繼續(xù)進(jìn)行下一次的寫入......
2. 多線程有哪些優(yōu)勢
如上面所說,多線程存在如下優(yōu)勢:
- 輕量:以線程為單位構(gòu)成進(jìn)程,共享進(jìn)程范圍內(nèi)的資源,例如內(nèi)存、文件句柄等。
- 返回多核處理器的強(qiáng)大能力:操作系統(tǒng)以更輕量級的線程為單位進(jìn)行高效的調(diào)度和切換,在設(shè)計(jì)合理的情況下,可以大大提升CPU的利用率。
- 建模簡單性:利用多線程技術(shù),可以將復(fù)雜的異步任務(wù)組合的同步工作流(例如JDK8中的CompleteFuture工具類),并利用多線程分別執(zhí)行這些任務(wù),在指定時機(jī)進(jìn)行同步交互。
- 異步事件簡化處理:有了多線程的概念之后,早期嘗試過用BIO技術(shù)即一個線程分配一個客戶端socket,好在現(xiàn)代Unix系統(tǒng)提出epoll、io_uring的良好設(shè)計(jì),使得多線程技術(shù)有了更好的發(fā)揮。
3. 并發(fā)編程需要關(guān)注的問題
(1) 安全性問題
首先是線程安全性問題,因?yàn)槎嗑€程共享了一塊進(jìn)程的數(shù)據(jù),如果沒有充分的做好線程間的同步,就會出現(xiàn)一些意外的情況,就例如下面這段代碼,多線程操作一個num,因?yàn)樽栽霾僮鞣菑?fù)合操作且多線程操作彼此不可見,出現(xiàn)意外結(jié)果:
private staticint num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println(num);//輸出1499633
}
同樣的,如果沒有良好的同步機(jī)制,編譯器、處理器都可以針對指令進(jìn)行任意順序和時間執(zhí)行,同時在處理器或者寄存器緩存線程變量的情況下的修改操作,其他處理器的線程是無法看到其修改操作,也會導(dǎo)致邏輯運(yùn)算上的錯亂:
(2) 活躍性問題
線程活躍性問題即線程未能按照預(yù)期的時許執(zhí)行,導(dǎo)致線程持續(xù)的活躍最典型的表現(xiàn)就是無限循環(huán),打滿CPU。例如并發(fā)環(huán)境下兩個CPU分別執(zhí)行線程0和線程1的邏輯,即:
- 線程0執(zhí)行無限循環(huán),只要val變?yōu)閠rue則終止無限循環(huán),
- 線程1休眠一段時間后將val修改為true。
對于java并發(fā)編程而言,如果沒有添加保證可見性的關(guān)鍵字進(jìn)行修飾,線程1的修改操作對于線程0來說是不可見的,此時就會出現(xiàn)下圖所示的線程1修改僅對自己可見,并不會即使刷新到CPU多核共享內(nèi)存L3 Cache,進(jìn)而導(dǎo)致線程0無限循環(huán),也就是我們所說的活躍性問題:
對應(yīng)我們也可以出示例代碼,同時筆者也會在后續(xù)的文章中來補(bǔ)充說明這一點(diǎn)的解決方案:
private staticboolean val = false;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
while (!val) {//下方線程操作對于線程1不可見,進(jìn)行無限循環(huán)
}
System.out.println("thread-1 executed finished");
countDownLatch.countDown();
}).start();
new Thread(() -> {
ThreadUtil.sleep(5, TimeUnit.SECONDS);
val = true;
System.out.println("設(shè)置val為true");
countDownLatch.countDown();
}).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
通常來說活躍性問題都是由以下幾種錯誤導(dǎo)致:
- 死鎖:即兩個線程互相等待對象持有的資源進(jìn)入阻塞
- 活鎖:上述的活躍性問題就是最經(jīng)典的活鎖
- 線程饑餓:因?yàn)榫€程過多或者某些原因?qū)е履硞€線程長時間未能分配到CPU時間片,導(dǎo)致任務(wù)遲遲無法結(jié)束,這就是典型的線程饑餓問題
(3) 性能問題
這一點(diǎn)是老生常態(tài)了,應(yīng)對并發(fā)安全的手段就是保證可見性和互斥,這涉及CPU緩存更新和臨界資源維度的把控和并發(fā)運(yùn)算技巧,一般來說導(dǎo)致多線程性能瓶頸的幾種原因可分為:
同步機(jī)制抑制了某些編譯器的優(yōu)化,例如synchronized關(guān)鍵字。
共享變量在多處理器之間不同線程執(zhí)行,線程切換時處理器的緩存數(shù)據(jù)局部性失效,使得開銷大部分時間都在處理線程調(diào)度而非運(yùn)算,這也會導(dǎo)致程序的執(zhí)行性能下降。
多線程并發(fā)處理時切換線程時產(chǎn)生保存和恢復(fù)上下文的開銷。
二、JVM視角下的進(jìn)程和線程
如下圖所示,可以看出線程是比進(jìn)程更小的單位,進(jìn)程是獨(dú)立的,彼此之間不會干擾,但是線程在同一個進(jìn)程中共享堆區(qū)和方法區(qū),雖然開銷較小,但是資源之間管理和分配處理相對于進(jìn)程之間要更加小心。
三、多線程常見問題
1. 程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧為什么線程中是各自獨(dú)立的
- 程序計(jì)數(shù)器私有的原因:學(xué)過計(jì)算機(jī)組成原理的小伙伴應(yīng)該都知曉,程序計(jì)數(shù)器用于記錄當(dāng)前下一條要執(zhí)行的指令的單元地址,JVM也一樣,有了程序計(jì)數(shù)器才能保證在多線程的情況下,這個線程被掛起再被恢復(fù)時,我們可以根據(jù)程序計(jì)數(shù)器找到下一次要執(zhí)行的指令的位置。
- 虛擬機(jī)棧私有的原因:每一個Java線程在執(zhí)行方法時,都會創(chuàng)建一個棧幀用于保存局部變量、常量池引用、操作數(shù)棧等信息,在這個方法調(diào)用到完成前,它對應(yīng)的信息都會基于棧幀保存在虛擬機(jī)棧上。
- 本地方法棧私有的原因:和虛擬機(jī)棧類似,只不過本地方法棧保存的native方法的信息。
所以為了保證局部變量不被別的線程訪問到,虛擬機(jī)棧和本地方法棧都是私有的,這就是我們解決某些線程安全問題時,常會用到一個叫棧封閉技術(shù)。
關(guān)于棧封閉技術(shù)如下所示,將變量放在局部,每個線程都有自己的虛擬機(jī)棧,線程安全:
public class StackConfinement implements Runnable {
//全部變量 多線操作會有現(xiàn)場問題
int globalVariable = 0;
public void inThread() {
//棧封閉技術(shù),將變量放在局部,每個線程都有自己的虛擬機(jī)棧 線程安全
int neverGoOut = 0;
synchronized (this) {
for (int i = 0; i < 10000; i++) {
neverGoOut++;
}
}
System.out.println("棧內(nèi)保護(hù)的數(shù)字是線程安全的:" + neverGoOut);//棧內(nèi)保護(hù)的數(shù)字是線程安全的:10000
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
globalVariable++;
}
inThread();
}
public static void main(String[] args) throws InterruptedException {
StackConfinement r1 = new StackConfinement();
Thread thread1 = new Thread(r1);
Thread thread2 = new Thread(r1);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(r1.globalVariable); //13257
}
}
2. 并發(fā)和并行的區(qū)別是什么?
- 并發(fā):并發(fā)我們可以理解為,兩個線程先后執(zhí)行,但是從宏觀角度來看,他們幾乎是并行的。
- 并行:并行我們可以理解為兩個線程同一時間都在運(yùn)行。
3. 同步和異步是什么意思?
- 同步:同步就是一個調(diào)用沒有結(jié)果前,不會返回,直到有結(jié)果的才返回。
- 異步:異步即發(fā)起一個調(diào)用后,不等結(jié)果如何直接返回。
4. 為什么需要多線程,多線程解決了什么問題
從宏觀角度來看:線程可以理解為輕量級進(jìn)程,切換開銷遠(yuǎn)遠(yuǎn)小于進(jìn)程,所以在多核CPU的計(jì)算機(jī)下,使用多線程可以更好的利用計(jì)算機(jī)資源從而提高計(jì)算機(jī)利用率和效率來應(yīng)對現(xiàn)如今的高并發(fā)網(wǎng)絡(luò)環(huán)境。
從微觀場景下來說: 單核場景,在單核CPU情況下,假如一個線程需要進(jìn)行IO才能執(zhí)行業(yè)務(wù)邏輯,若只有單線程,這就意味著IO期間發(fā)生阻塞線程卻只能干等。假如我們使用多線程的話,在當(dāng)前線程IO期間,我們可以將其掛起,讓出CPU時間片讓其他線程工作。
多核場景下,假如我們有一個很復(fù)雜的任務(wù)需要進(jìn)程各種IO和業(yè)務(wù)計(jì)算,假如只有一個線程的話,無論我們有多少個CPU核心,因?yàn)閱尉€程的緣故他永遠(yuǎn)只能利用一個CPU核心,假如我們使用多線程,那么這些線程就會映射到不同的CPU核心上,做到最好的利用計(jì)算機(jī)資源,提高執(zhí)行效率,執(zhí)行事件約為單線程執(zhí)行事件/CPU核心數(shù)。
5. 創(chuàng)建線程方式有哪些
直接繼承Thread啟動運(yùn)行:
public static void main(String[] args) {
new Task().start();
}
/**
* 繼承thread重寫run方法
*/
private static class Task extends Thread {
@Override
public void run() {
Console.log("{} is running", Thread.currentThread().getName());
}
}
通過繼承Runable實(shí)現(xiàn)run方法并提交給thread運(yùn)行:
public static void main(String[] args) {
new Thread(new Task()).start();
}
/**
* 繼承Runnable重寫run方法
*/
private static class Task implements Runnable {
@Override
public void run() {
Console.log("{} is running", Thread.currentThread().getName());
}
}
6. 為什么需要Runnable接口實(shí)現(xiàn)多線程
由于Java為避免棱形問題所以只支持單繼承,當(dāng)一個類已有繼承類時,某個函數(shù)需要實(shí)現(xiàn)異步功能的時候只能通過接口進(jìn)行拓展,所以才有了Runnable接口。
7. Thread和Runnable使用的區(qū)別
- 繼承Thread:線程代碼存放在Thread子類的run方法中,調(diào)用start()即可實(shí)現(xiàn)調(diào)用。
- Runnable:線程代碼存在接口子類的run方法中,需要實(shí)例化一個線程對象Thread并將其作為參數(shù)傳入,才能調(diào)用到run方法。
8. Thread類中run()和start()的區(qū)別
- run:僅僅是方法,在線程實(shí)例化之后使用run等于一個普通對象的直接調(diào)用。
- start:開啟了線程并執(zhí)行線程中的run方法,這期間程序才真正執(zhí)行從用戶態(tài)到內(nèi)核態(tài),創(chuàng)建線程的動作。
9. Java線程有哪幾種狀態(tài)
- 新建(NEW):新創(chuàng)建的了一個線程對象,該對象并沒有調(diào)用start()。
- 可運(yùn)行(RUNNABLE):線程對象創(chuàng)建后,并調(diào)用了start方法,等待分配CPU時間執(zhí)行代碼邏輯。
- 阻塞(BLOCKED):阻塞狀態(tài),等待鎖的釋放。當(dāng)線程在synchronized 中被wait,然后再被喚醒時,若synchronized 有其他線程在執(zhí)行,那么它就會進(jìn)入BLOCKED狀態(tài)。
- 等待(WAITING):因?yàn)槟承┰虮粧炱穑却渌€程通知或者喚醒。
- 超時等待(TIME_WAITING):等待時間后自行返回,而不像WAITING那樣沒有通知就一直等待。
- 終止(TERMINATED):該線程執(zhí)行完畢,終止?fàn)顟B(tài)了。
public enum State {
//線程尚未啟動
NEW,
//可運(yùn)行的線程狀態(tài),該狀態(tài)代表的是操作系統(tǒng)中線程狀態(tài)的ready或者running狀態(tài)
RUNNABLE,
//阻塞等待監(jiān)視器(synchronized底層的monitor lock實(shí)現(xiàn))或者主動調(diào)用wait后被喚醒等待獲取監(jiān)視鎖也會處于該狀態(tài)
BLOCKED,
//調(diào)用wait掛起等待notify或者notifyAll
WAITING,
//設(shè)置時限的wait調(diào)用掛起,可能是調(diào)用下面某個方法
//Thread.sleep
//Object.wait with timeout
//Thread.join with timeout
//LockSupport.parkNanos
//LockSupport.parkUntil
TIMED_WAITING,
//線程已完成執(zhí)行并終止
TERMINATED;
}
10. 和操作系統(tǒng)的線程狀態(tài)的區(qū)別
如下圖所示,實(shí)際上操作系統(tǒng)層面可將RUNNABLE分為Running以及Ready,Java設(shè)計(jì)者之所以沒有區(qū)分那么細(xì)是因?yàn)楝F(xiàn)代計(jì)算機(jī)執(zhí)行效率非常高,這兩個狀態(tài)在宏觀角度幾乎無法感知。現(xiàn)代操作系統(tǒng)對多線程采用時間分片的搶占式調(diào)度算法,使得每個線程得到CPU在10-20ms 處于運(yùn)行狀態(tài),然后在讓出CPU時間片,在不久后又會被調(diào)度執(zhí)行,所以對于這種微觀狀態(tài)區(qū)別,Java設(shè)計(jì)者認(rèn)為沒有必要為了這么一瞬間進(jìn)行這么多的狀態(tài)劃分。
11. 什么是上下文切換
線程在執(zhí)行過程中都會有自己的運(yùn)行條件和狀態(tài),這些運(yùn)行條件和狀態(tài)我們就稱之為線程上下文,這些信息例如程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧等信息。當(dāng)出現(xiàn)以下幾種情況的時候就會從占用CPU狀態(tài)中退出:
- 線程主動讓出CPU,例如調(diào)用wait或者sleep等方法。
- 線程的CPU 時間片用完 而退出CPU占用狀態(tài) (因?yàn)椴僮飨到y(tǒng)為了避免某些線程獨(dú)占CPU導(dǎo)致其他線程饑餓的情況就設(shè)定的例如時間分片算法)。
- 線程調(diào)用了阻塞類型的系統(tǒng)中斷,例如IO請求等。
- 線程被終止或者結(jié)束運(yùn)行。
上述的前三種情況都會發(fā)生上下文切換。為了保證線程被切換在恢復(fù)時能夠繼續(xù)執(zhí)行,所以上下文切換都需要保存線程當(dāng)前執(zhí)行的信息,并恢復(fù)下一個要執(zhí)行線程的現(xiàn)場。這種操作就會占用CPU和內(nèi)存資源,頻繁的進(jìn)行上下文切換就會導(dǎo)致整體效率低下。
12. 線程死鎖問題
如下圖所示,兩個線程各自持有一把鎖,必須拿到對方手中那把鎖才能釋放自己的鎖,正是這樣一種雙方僵持的狀態(tài)就會導(dǎo)致線程死鎖問題。
翻譯稱代碼就如下圖所示:
public class DeadLockDemo {
publicstaticfinal Object lock1 = new Object();
publicstaticfinal Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1){
System.out.println("線程1獲得鎖1,準(zhǔn)備獲取鎖2");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2){
System.out.println("線程1獲得鎖2");
}
}
}).start();
new Thread(() -> {
synchronized (lock2){
System.out.println("線程2獲得鎖2,準(zhǔn)備獲取鎖1");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1){
System.out.println("線程2獲得鎖1");
}
}
}).start();
}
}
輸出結(jié)果:
線程1獲得鎖1,準(zhǔn)備獲取鎖2
線程2獲得鎖2,準(zhǔn)備獲取鎖1
符合以下4個條件的場景就會發(fā)生死鎖問題:
- 互斥:一個資源任意時間只能被一個線程獲取。
- 請求與保持條件:一個線程拿到資源后,在獲取其他資源而進(jìn)入阻塞期間,不會釋放已有資源。
- 不可剝奪條件:該資源被線程使用時,其他線程無法剝奪該線程使用權(quán),除非這個線程主動釋放。
- 循環(huán)等待條件:若干線程獲取資源時,取鎖的流程構(gòu)成一個頭尾相接的環(huán),如上圖。
預(yù)防死鎖的幾種方式:
- 破壞請求與保持條件:以上面代碼為例,我們要求所有線程必須一次性獲得兩個鎖才能進(jìn)行業(yè)務(wù)處理。即要求線程一次性獲得所有資源才能進(jìn)行邏輯處理。
- 破壞不可剝奪:資源被其他線程獲取時,我們可以強(qiáng)行剝奪使用權(quán)。
- 破壞循環(huán)等待:這個就比較巧妙了,例如我們上面lock1 id為1,lock2id為2,我們讓每個線程取鎖時都按照lock的id順序取鎖,這樣就避免構(gòu)成循環(huán)隊(duì)列。
- 操作系統(tǒng)思想(銀行家算法):這個就涉及到操作系統(tǒng)知識了,大抵的意思是在取鎖之前對資源分配進(jìn)行評估,如果在給定資源情況下不能完成業(yè)務(wù)邏輯,那么就避免這個線程取鎖,感興趣的讀者可以
13. sleep和wait方法區(qū)別
- sleep不會釋放鎖,只是單純休眠一會。而wait則會釋放鎖。
- sleep單純讓線程休眠,在給定時間后就會蘇醒,而wait若沒有設(shè)定時間的話,只能通過notify或者notifyAll喚醒。
- sleep是Thread 的方法,而wait是Object 的方法
- wait常用于線程之間的通信或者交互,而sleep單純讓線程讓出執(zhí)行權(quán)。
14. 為什么sleep會定義在Thread
因?yàn)閟leep要做的僅僅是讓線程休眠,所以不涉及任何鎖釋放等邏輯,放在Thread上最合適。
15. 為什么wait會定義在Object 上
我們都知道使用wait時就會釋放鎖,并讓對象進(jìn)入WAITING 狀態(tài),會涉及到資源釋放等問題,所以我們需要將wait放在Object 類上。
16. 可以直接調(diào)用 Thread 類的 run 方法嗎?
若我們編寫run方法,然后調(diào)用Thread 的start方法,線程就會從用戶態(tài)轉(zhuǎn)內(nèi)核態(tài)創(chuàng)建線程,并在獲取CPU時間片的時候開始運(yùn)行,然后運(yùn)行run方法。 若直接調(diào)用run方法,那么該方法和普通方法沒有任何差別,它僅僅是一個名字為run的普通方法。
17. 假如在進(jìn)程中, 已經(jīng)開辟了多個線程, 其中一個線程怎么中斷其它線程?
找到線程對應(yīng)線程組并基于線程id即可定位到線程,然后調(diào)用interrupt將其打斷即可:
public static Thread getThreadById(long threadId) {
//獲取線程對應(yīng)線程組
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
//比對id定位線程
if (threadGroup != null) {
Thread[] threads = new Thread[(int) (threadGroup.activeCount() * 1.2)];
//獲取線程組中獲取的線程數(shù)
int count = threadGroup.enumerate(threads, true);
for (int i = 0; i < count; i++) {
if (threads[i].getId() == threadId) {
return threads[i];
}
}
}
thrownew RuntimeException("未找到線程");
}
對應(yīng)的我們也給出使用示例,感興趣的讀者可自行參閱注釋了解實(shí)現(xiàn)細(xì)節(jié):
//創(chuàng)建含有2個線程的線程池
privatestaticfinal ExecutorService threadPool = Executors.newFixedThreadPool(2);
//記錄用于打斷的線程id
privatestatic Long threadId;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
//線程1無限休眠,直到被打斷
threadPool.execute(() -> {
Console.log("線程池線程啟動執(zhí)行,線程id:{}", Thread.currentThread().getId());
threadId = Thread.currentThread().getId();
try {
TimeUnit.DAYS.sleep(1);
} catch (InterruptedException e) {
Console.error("當(dāng)前線程被打斷,線程id:{}", Thread.currentThread().getId(), e);
} finally {
countDownLatch.countDown();
}
});
//線程2用于打斷線程1
threadPool.execute(() -> {
while (true) {
if (threadId != null) {
Console.log("打斷線程,線程id:{}", threadId);
getThreadById(threadId).interrupt();
countDownLatch.countDown();
break;
}
ThreadUtil.sleep(5000);
}
});
countDownLatch.await();
threadPool.shutdownNow();
}
對應(yīng)輸出結(jié)果如下,可以看到threadId 非空時,線程2就會將休眠的線程1打斷:
18. IO阻塞的線程會占用CPU資源嗎?如何避免線程霸占CPU?
由于該問題的篇幅比較大,筆者專門寫了一篇文章來討論這兩個問題,感興趣的朋友可以看看:《IO任務(wù)與CPU調(diào)度藝術(shù)》