多線程是所有程式設計技術的基礎概念,java的多線程機制是完整而且友善的。就猶如算法,可能90%的時候我們都不會思考多線程相關的使用,但不能心中無算法(多線程),否則一般坑比較深,不容易爬出來。更何況Web開發本身就是多線程環境,所有Java開發者還有必要撸一撸多線程,遇到問題不慌,解決問題不難。
1、 線程是什麼?
要完全搞懂線程是啥,需要大家去看作業系統原理。但是我們可以這麼來簡單把握下線程的核心:線程是作業系統中排程CPU計算資源的最小機關,或者叫一個執行單元。實際上,作業系統就是資源的排程者,這個資源主要是指中斷、記憶體、CPU,中斷在上層開發中不會遇到(屬于硬體事件,由系統核心處理);記憶體實際上是以程序為機關排程的(當然也有系統級記憶體管理,甚至還有不受作業系統管理的記憶體);而CPU的排程則是以線程為機關。這意味着任何一個程式(程序)都至少有一個線程。
關于作業系統原理,去看看RTOS等這些嵌入式開發的微型核心也許不錯,不用擔心一臉懵B,也能很好了解系統核心的基本運作原理。
2、 多線程的作用是?
首先,多線程程式的性能并不一定最好,單線程也不一定差,最經典的案例是Redis,單機40K+QPS能力(雖然Redis6.0引入了多線程,但僅有IO采用多線程邏輯,核心邏輯仍是單線程)。有時候多線程會帶來明顯的額外開銷,尤其是在防止競争沖突這件事情上,不信大家可以去看法院這種解決沖突的地方,哪天都有事務要處理。
多線程的目的主要兩個:建立多個獨立的任務避免阻塞(典型的是網絡IO)、充分利用多核實作性能提升(極端案例是顯示卡的數千個核心,也可以了解為線程的充分利用)。
在單核時代,比如當年老司機的CopperMine 奔騰III時代,其實多線程就已經很普遍了。哪怕是Windows98也是支援多線程的。那麼這個時候多線程的唯一價值就是多任務,基本原理是分時複用,即讓多個線程通過不同的時間執行來實作“并發”多任務效果。
後來伺服器端硬體逐漸過渡到多核多CPU時代,如何有效利用多核的計算性能提高并發?多線程天生就适合做這個。以前作業系統排程線程時必須一個個來,現在有多個了,做好現場保護(資料、執行指針等),作業系統可以根據負載情況将執行任務均分到各個CPU上。
這是多線程的好處,但是多線程帶來了一些具體的問題,其中最基礎的就是并發沖突。
3、為什麼多線程有并發沖突
首先搞清楚什麼是并發問題:下面的代碼似乎沒有問題,但是執行時的結果幾乎不可能為目标結果:20000。為什麼會出現這個問題?

兩個原因:
第一:我們看到的num++并不是一個指令,在被翻譯為Java位元組碼後,i++會被翻譯為取值,++,回寫這麼幾個步驟,如下圖(使用javap -c Xxx.class):
第二:線程什麼時候執行什麼時候被中斷執行,完全是“随機”的,程式決定不了,甚至作業系統也決定不了(實際是作業系統由算法決定,而算法依賴的因素是人參與的,人什麼時候參與影響、影響如何是随機的)這就是說上面的i++的讀取可能被另第二個線程打斷,然後另外一個線程讀取了并指派給i。然後第一個線程恢複後不知道發生了什麼,仍在它之前讀取的資料(髒資料)上加1并回寫,然後問題就發生了,第二個線程加的1被覆寫。
TIPS:标重點,線程什麼時候執行,什麼時候被停止執行,都沒有辦法預測,這也是多線程問題不一定能重制的原因。我的天哪,為啥多線程相關問題總是很複雜,這就是根源。
當然,上面的兩個原因也給我們提供了處理并發的思路:鎖和原子操作。
4、原子操作
那麼解決同步問題的措施有哪些呢?我們前面的分析給了我們思路,如果操作本身不會被拆分為多個步驟或者不會被打斷,那麼不存在所謂的同步問題,這就是原子操作和鎖的作用。同時,理論上來說,我們還可以設計某種算法邏輯實作資料的操作即算有沖突也是可檢測和恢複的,這就是所謂的無鎖化算法實作,比如CAS(Compare And Swap)算法,就是JDK中一些基礎類的基本實作算法。
那麼什麼是原子操作呢?實際上理論上來說原子操作就是任何操作(比如一個指令,或者一個方法調用)具有原子性,也就是整體不可分。
java.util.concurrent.atomic.AtomicInteger就是這麼一個實作,其基本操作方法都是原子化的。上面的代碼使用該類實作計數,則不會出現并發問題,參考代碼如下:
OK原子操作就是這樣一個作用。注意能用原子操作實作的算法,就盡量不要使用鎖機制了,鎖的開銷可不小。
5、鎖、可重入鎖、讀寫鎖
那麼什麼是鎖呢?很多操作沒辦法實作原子化,那麼就隻有一個辦法了,搶啊!
你沒聽錯,就是靠搶的,但是這個搶是有規則的:誰搶到歸誰,除非誰用完又給出來了。
這就是鎖:多個線程共享通路同一資源時,可以通過标記(也就是鎖)給唯一的一個線程操作權限,完成後釋放标記就可以讓其他線程繼續操作了。當然這裡有個顯然的前提,加鎖的過程必須是原子操作。
在Java代碼中如何加鎖呢?最基本的方式是使用lock關鍵字,lock關鍵字的操作對象可以是任何一個Object對象,這個對象決定了所謂鎖定的範圍。
下面的代碼也可以保證所有的累加都是正确的(僅展示累加部分的代碼):
這裡的lock就是一個鎖,作用就是“搶”的規則建立:lock()方法是嘗試搶資源,如果别人已經搶了就隻能等待直到對方釋放資源,如果沒有人搶了就馬上占為己有,直到自己不需要使用後釋放(lock.unlock)。
鎖的機制的實作可以簡單了解為:以原子操作的方式給對應的對象上加标記,有這個标記表示某個線程持有這個鎖。緻于怎麼對應到線程,大家應該大概能了解,關鍵就是lock()方法的實作,最終的實作預設(NonFair)是由ReentrantLock的内部類NonFairSync的下面的方法實作加鎖的:
具體實作,大家可以檢視源碼。
當然出來手動加鎖,Java也直接提供了synchronized實作基于鎖的通路,好處是不用自己操作鎖和釋放鎖(可以認為是一種文法糖),壞處就是無法實作進階的鎖定方式。
加鎖有個細節一定注意:多個線程使用鎖進行鎖定時必須是使用同一把鎖。看個代碼:
這裡為什麼要寫Test2.class呢?鎖機制有個細節是要知道同步的範圍,而Test2.class是在整個類上同步,相當于在整個類上加鎖(所有線程判斷鎖才是統一的一個),這樣保障了靜态成員的通路不會有沖突。反之如果使用this,則是各自在各自的線程範圍内檢測鎖,互相并不影響,導緻的結果就是等于沒有統一的商量,問題依然存在。用一個例子來說明,村裡分果子,如果張家和李家自己各自商量怎麼分,那麼競争沖突還是存在,而如果是到村上大家一起公開開會分,确定好後就不會有競争沖突了。
那麼問題又來了,為啥我使用的是ReentrantLock呢?下面我們來看看鎖的相關概念。
根據鎖的不同特性,可以分類為:
1) 公平鎖和非公平鎖
公平鎖是指多個線程按照申請鎖的順序來擷取鎖。
非公平鎖是指多個線程擷取鎖的順序并不是按照申請鎖的順序,有可能後申請的線程比先申請的線程優先擷取鎖。有可能,會造成優先級反轉或者饑餓現象。
2) 可重入鎖不可重入鎖
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法擷取鎖的時候,在進入内層方法會自動擷取鎖。說的有點抽象,下面會有一個代碼的示例。ReentrantLock是課重入鎖,可重入鎖的一個好處是可一定程度避免死鎖。
3) 獨占鎖和非獨占鎖/普通鎖和讀寫鎖
獨享鎖是指該鎖一次隻能被一個線程所持有。
共享鎖是指該鎖可被多個線程所持有。
ReentrantLock是獨占鎖,synchronized同步加的鎖也是獨占鎖。這意味着其實使用這兩種方式雖然實作了多線程,其實并發性能不會有提高(僅僅能實作多任務),因為永遠隻有一個現場能拿到鎖并執行。
于是Java還有非獨占鎖,比如讀寫鎖ReadWriteLock,這種鎖允許一個線程寫鎖定,但是允許多個線程同時讀鎖定(注意不如果一個線程擷取了寫鎖并鎖定,其他線程也不能直接獲得讀鎖并鎖定)。這種鎖定方式能大大提高寫少讀多的資料的并發處理性能,比如緩存。
4) 悲觀鎖和樂觀鎖(這其實不是Java中的兩種具體的鎖,而是兩種政策)
這是兩種處理并發沖突的态度。
樂觀鎖認為出現沖突的可能性不是很大,設法檢測并恢複沖突,是一種“無鎖”機制,比如CAS算法實作的原子操作。
悲觀鎖就是“搶”,前面說的所有鎖都是悲觀鎖,通過鎖機制保證不出現問題。
樂觀鎖因為不使用真正的鎖定機制,性能較好,但僅使用沖突較少的情況(比如5%的可能性會出現沖突)。悲觀鎖慎用,但對資料要求高度一緻性時可能會采用。
5) 自旋鎖
在Java中,自旋鎖是指嘗試擷取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試擷取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
6) 偏向鎖、輕量級鎖、重量級鎖
這三種鎖是指鎖的狀态,并且是針對synchronized。在Java 5通過引入鎖更新的機制來實作高效synchronized。這三種鎖的狀态是通過對象螢幕在對象頭中的字段來表明的。
偏向鎖是指一段同步代碼一直被一個線程所通路,那麼該線程會自動擷取鎖。降低擷取鎖的代價,這樣大部分的執行過程都接近無鎖化的性能。
輕量級鎖,當一個線程時采用偏向鎖鎖定一個資源的時候,資源被另一個線程所通路,偏向鎖就會更新為輕量級鎖,其他線程會通過自旋的形式嘗試擷取鎖,不會阻塞,提高性能,但自旋實際上需要消耗一定CPU資源,相對自動擷取鎖,其開銷要大。
重量級鎖是指當鎖為輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有擷取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低(重量級鎖基于系統底層的互斥鎖實作,設計上下文切換成本,效率比較低)。
當然線程問題隻有這麼一方面嗎?隻有并發沖突問題嗎?實際上為了解決沖突,我們引入鎖後可能會導緻一個更嚴重的問題:死鎖。當然死鎖不是鎖,是一種使用鎖不規範導緻的問題,下面有一分模拟敬禮(規則是敬禮時對方不起身你就不能起身)會導緻死鎖的代碼(有興趣可以嘗試):
public class DeathLock {
public static void main(String[] args) {
ThreadA th1 = new ThreadA();
ThreadB th2 = new ThreadB();
th1.start();
th2.start();
}
public static class ThreadA extends Thread{
@Override
public void run() {
synchronized (ThreadA.class) {//A自己彎腰下去,自己加鎖就是彎腰
System.out.println("A開始敬禮");
synchronized (ThreadB.class) {//看對方能不能給我鎖:看對方起來了沒有
System.out.println("A起身");
}
}
}
}
public static class ThreadB extends Thread{
@Override
public void run() {
synchronized (ThreadB.class) {//A自己彎腰下去,自己加鎖就是彎腰
System.out.println("B開始敬禮");
synchronized (ThreadA.class) {//看對方能不能給我鎖:看對方起來了沒有
System.out.println("B起身");
}
}
}
}
}
在程式中出現死鎖通常是非常嚴重的問題,如何避免呢?線程來自作業系統,還得看作業系統的線程管理出現死鎖的四個必要條件:
1)互斥條件:程序對所配置設定到的資源不允許其他程序進行通路,若其他程序通路該資源,隻能等待,直至占有該資源的程序使用完成後釋放該資源。
2)請求和保持條件:程序獲得一定的資源之後,又對其他資源送出請求,但是該資源可能被其他程序占有,此事請求阻塞,但又對自己獲得的資源保持不放。
3)不可剝奪條件:是指程序已獲得的資源,在未完成使用之前,不可被剝奪,隻能在使用完後自己釋放。
4)環路等待條件:是指程序發生死鎖後,必然存在一個程序–資源之間的環形鍊。
以上四個條件有一個被打破即可以避免死鎖,比如設計上避免環形鍊,比如設定請求條件的逾時機制(比如資料庫的for update鎖資料也有逾時機制),也可以設計算法根據業務邏輯設計檢測死鎖并從外部打斷。
6、JavaWeb開發中的多線程
JavaWeb天然是多線程的,但是似乎我們從Servlet時代一直到微服務時代,都很少考慮線程安全問題。實際上,在JavaWeb中遵循規範,多線程問題會由容器自動處理。但有一個點是大家可以驗證的:在Servlet或者Controller中建立類變量并在請求中通路,是存在競争問題的,有興趣的同學可以嘗試下。當然,Spring的bean作用域如果設定為request(在web上下文中是可用的,在非web上下文改scope不可用)卻不會有問題(隻是性能差而已)。
再進一步,在JavaWeb開發中如果我們使用了靜态資源,而且會讀寫,也會導緻線程問題。這種靜态資源實際設計中不應該有業務代碼的讀和寫,一般應該在Spring容器初始化時直接指派。
再再進一步,如果在分布式或者多伺服器負載均衡環境中,我們對一個資源進行公共通路,比如定時器操作資料,則也是會發生“線程”安全問題的,解決的方法是分布式鎖,比如redis的分布式鎖。
線程安全問題,說簡單也簡單,說複雜也複雜,其實最重要的是搞清楚環境模型,了解清除線程基本概念,基本就能很好避免線程安全問題。