一、Java 系統(tǒng)內(nèi)部鎖優(yōu)化
當(dāng)使用 Java 多線程訪問共享資源的時候,會出現(xiàn)競態(tài)的現(xiàn)象。即隨著時間的變化,多線程“寫”共享資源的最終結(jié)果會有所不同。為了解決這個問題,讓多線程“寫”資源的時候有先后順序,引入了鎖的概念。每次一個線程只能持有一個鎖進(jìn)行寫操作,其它線程等待該線程釋放鎖以后才能進(jìn)行后續(xù)操作。從這個角度來看,鎖的使用在 Java 多線程編程中是相當(dāng)重要的,那么是如何對鎖進(jìn)行優(yōu)化?Java 的鎖分為兩種:
一種是內(nèi)部鎖,它用 Synchronized 關(guān)鍵字來修飾,由 JVM 負(fù)責(zé)管理,并且不會出現(xiàn)鎖泄漏的情況。
另外一種是顯示鎖。
這里重點(diǎn)討論的是內(nèi)部鎖優(yōu)化。內(nèi)部鎖的優(yōu)化方式由 Java 內(nèi)部機(jī)制完成,雖然不需要程序員直接參與,但了解它對理解多線程優(yōu)化原理有很大幫助。這部分的優(yōu)化主要包括四部分:
- 鎖消除
- 鎖粗化
- 偏向鎖
- 適應(yīng)鎖
1??鎖消除(Lock Elision),JIT 編譯器對內(nèi)部鎖的優(yōu)化。在介紹其原理之前先說說,逃逸和逃逸分析。
逃逸是指在方法之內(nèi)創(chuàng)建的對象,除了在方法體之內(nèi)被引用之外,還在方法體之外被其它變量引用。也就是,在方法體之外引用方法內(nèi)的對象。在方法執(zhí)行完畢之后,方法中創(chuàng)建的對象應(yīng)該被 GC 回收,但由于該對象被其他變量引用,導(dǎo)致 GC無法回收。這個無法回收的對象稱為“逃逸”對象。Java 中的逃逸分析,就是對這種對象的分析。
回到鎖消除,Java JIT 會通過逃逸分析的方式,去分析加鎖的代碼段/共享資源,是否被一個或者多個線程使用,或者等待被使用。如果通過分析證實(shí),只被一個線程訪問,在編譯這個代碼段的時候就不生成 Synchronized 關(guān)鍵字,僅僅生成代碼對應(yīng)的機(jī)器碼。
換句話說,即便開發(fā)人員對代碼段/共享資源加上了 Synchronized(鎖),只要 JIT 發(fā)現(xiàn)這個代碼段/共享資源只被一個線程訪問,也會把這個 Synchronized(鎖)去掉。從而避免競態(tài),提高訪問資源的效率。
作為開發(fā)人員來說,只需要在代碼層面去考慮是否用 Synchronized(鎖)。說白了,就是感覺這段代碼有可能出現(xiàn)競態(tài),那么就使用 Synchronized(鎖),至于這個鎖是否真的會使用,則由 Java JIT 編譯器來決定。
2??鎖粗化(Lock Coarsening) ,是 JIT 編譯器對內(nèi)部鎖具體實(shí)現(xiàn)的優(yōu)化。假設(shè)有幾個在程序上相鄰的同步塊(代碼段/共享資源)上,每個同步塊使用的是同一個鎖實(shí)例。那么 JIT 會在編譯的時候?qū)⑦@些同步塊合并成一個大同步塊,并且使用同一個鎖實(shí)例。這樣避免一個線程反復(fù)申請/釋放鎖。
如圖三塊代碼段,分割成三個臨界區(qū),JIT 會將其合并為一個臨界區(qū),用一個鎖對其進(jìn)行訪問控制。即使在臨界區(qū)的空隙中,有其他的線程可以獲取鎖信息,JIT 編譯器執(zhí)行鎖粗化優(yōu)化的時候,會進(jìn)行命令重排到后一個同步塊的臨界區(qū)中。
鎖粗化默認(rèn)是開啟的。如果要關(guān)閉這個特性可以在 Java 程序的啟動命令行中添加虛擬機(jī)參數(shù)“-XX:-EliminateLocks”。
3??偏向鎖(Biased Locking),顧名思義,它會偏向于第一個訪問鎖的線程。如果在接下來的運(yùn)行中,該鎖沒有被其他線程訪問,則持有偏向鎖的線程不會觸發(fā)同步。相反,在運(yùn)行過程中,遇到了其他線程搶占鎖,則持有偏向鎖的線程會被掛起,JVM 會消除掛起線程的偏向鎖。換句話說,偏向鎖只能在單個線程反復(fù)持有該鎖的時候起效。其目的是,為了避免相同線程獲取同一個鎖時,產(chǎn)生的線程切換,以及同步操作。
從實(shí)現(xiàn)機(jī)制上講, 每個偏向鎖都關(guān)聯(lián)一個計(jì)數(shù)器和一個占有線程。最開始沒有線程占有的時候,計(jì)數(shù)器為 0,鎖被認(rèn)為是 unheld 狀態(tài)。當(dāng)有線程請求 unheld 鎖時,JVM 記錄鎖的擁有者,并把鎖的請求計(jì)數(shù)加 1。如果同一線程再次請求鎖時,計(jì)數(shù)器就會增加 1,當(dāng)線程退出 Syncronized 時,計(jì)數(shù)器減 1,當(dāng)計(jì)數(shù)器為 0 時,鎖被釋放。
為了完成上述實(shí)現(xiàn),鎖對象中有個 ThreadId 字段。第一次獲取鎖之前,該字段是空的。持有鎖的線程,會將自身的 ThreadId 寫入到鎖的 ThreadId 中。下次有線程獲取鎖時,先檢查自身 ThreadId 是否和偏向鎖保存的 ThreadId 一致。如果一致,則認(rèn)為當(dāng)前線程已經(jīng)獲取了鎖,不需再次獲取鎖。偏向鎖默認(rèn)是開啟的。
如果要關(guān)閉這個特性,可以在 Java 程序的啟動命令行中添加虛擬機(jī)參數(shù)“-XX:-UseBiasedLocks”。
4??適應(yīng)鎖(Adaptive Locking):當(dāng)一個線程持申請鎖時,該鎖正在被其他線程持有。那么申請鎖的線程會進(jìn)入等待,等待的線程會被暫停,暫停的線程會產(chǎn)生上下文切換。由于上下文切換是比較消耗系統(tǒng)資源的,所以這種暫停線程的方式比較適合線程處理時間較長的情況。前面一個線程執(zhí)行的時間較長,才能彌補(bǔ)后面等待線程上下文切換的消耗。如果說線程執(zhí)行較短,那么也可以采取忙等(Busy Wait)的狀態(tài)。這種方式不會暫停線程,通過代碼中的 while 循環(huán)檢查鎖是否被釋放,一旦釋放就持有鎖的執(zhí)行權(quán)。
這種方式雖然不會帶來上下文的切換,但是會消耗 CPU 的資源。為了綜合較長和較短兩種線程等待模式,JVM 會根據(jù)運(yùn)行過程中收集到的信息來判斷,鎖持有時間是較長時間或者較短時間。然后再采取線程暫停或忙等的策略。
二、Java 代碼中如何進(jìn)行鎖優(yōu)化
鎖的開銷主要是在爭用鎖上,當(dāng)多線程對共享資源進(jìn)行訪問時,會出現(xiàn)線程等待。即便是使用內(nèi)存屏障,也會導(dǎo)致沖刷寫緩沖器,清空無效化隊(duì)列等開銷。
為了降低這種開銷,通常可以從幾個方面入手,例如:減少線程申請鎖的頻率(減少臨界區(qū))和減少線程持有鎖的時間長度(減小鎖顆粒)以及多線程的設(shè)計(jì)模式。
1??縮小臨界區(qū)的范圍
當(dāng)共享資源需要被多線程訪問時,會將共享資源或者代碼段放到臨界區(qū)中。如果在代碼書寫中減少臨界區(qū)的長度,就可以減少鎖被持有的時間,從而降低鎖被征用的概率,達(dá)到減少鎖開銷的目的。
如圖,盡量避免對一個方法進(jìn)行加鎖同步,可以只針對方法中的需要同步資源/變量進(jìn)行同步。其他的代碼段不放到 Synchronzied 中,減少臨界區(qū)的范圍。
2??減小鎖的顆粒度
減小鎖的顆粒度可以降低鎖的申請頻率,從而減小鎖被爭用的概率。其中一種常見的方法就是將一個顆粒度較粗的鎖拆分成顆粒度較細(xì)的鎖。
假設(shè)有一個類 ServerStatus,里面包含了四個方法:
- addUser
- addQuery
- removeUser
- removeQuery
如果分別在每個方法加上 Synchronized。在一個線程訪問其中任意一個方法的時候,將鎖住 ServerStatus,此時其他線程都無法訪問另外三個方法,從而進(jìn)入等待。
如果只針對每個方法內(nèi)部操作的對象加鎖,例如:addUser 和 removeUser 方法針對 users 對象加鎖;addQuery 和 removeQuery 方法針對 queries 對象加鎖。
假設(shè),當(dāng)一個線程池調(diào)用 addUser 方法的時候,只會鎖住 user 對象。另外一個線程是可以執(zhí)行 addQuery 和 removeQuery 方法的。
并不會因?yàn)殒i住整個對象而進(jìn)入等待。JDK 內(nèi)置的 ConcurrentHashMap 與 SynchronizedMap 就使用了類似的設(shè)計(jì)。
針對不同的方法中使用的對象進(jìn)行鎖定
3??讀寫鎖
也叫做線程的讀寫模式(Read-Write Lock),其本質(zhì)是一種多線程設(shè)計(jì)模式。將讀取操作和寫入操作分開考慮,在執(zhí)行讀取操作之前,線程必須獲取讀取的鎖。在執(zhí)行寫操作之前,必須獲取寫鎖。當(dāng)線程執(zhí)行讀取操作時,共享資源的狀態(tài)不會發(fā)生變化,其他的線程也可以讀取。但是在讀取時,不可以寫入。其實(shí),讀寫模式就是將原來共享資源的鎖,轉(zhuǎn)化成為讀和寫兩把鎖,將其分兩種情況考慮。
如果都是讀操作可以支持多線程同時進(jìn)行,只有在寫時其他線程才會進(jìn)入等待。
說完了讀寫鎖的基本原理,再來看看參與的角色:
- Reader(讀者):對 SharedResource 角色執(zhí)行 Read 操作。
- Writer(寫者):對 SharedResource 角色執(zhí)行 Write 操作。
- SharedResource(共享資源):表示對 Reader 和 Writer 兩者共享的資源。
- ReadWriteLock(讀寫鎖),提供了 SharedResource 角色實(shí)現(xiàn) Read 操作和 Write 操作時所需的鎖。針對 Read 操作提供 readLock 和 readUnlock,對 Write 操作提供 writeLock 和 writeUnlock。
特別注意,在這里需要解決讀寫沖突的問題。
- 當(dāng)線程 A 獲取讀鎖時,如果有線程 B 正在執(zhí)行寫操作,線程 A 需要等待,否則會引起 read-write conflict(讀寫沖突)。
- 如果線程 B 正在執(zhí)行讀操作,線程 A 不需要等待,因?yàn)?read-read 不會引起conflict(沖突)。
- 當(dāng)線程 A 要獲取寫入鎖時,線程 B 正在執(zhí)行寫操作,線程 A 需要等待,否則會引起 write-write conflict(寫寫沖突)。
- 如果線程 B 正在執(zhí)行讀操作,則線程 A 需要等待,否則會引起 read-write conflict(讀寫沖突)。
三、線程池優(yōu)化
1??基本概念與原理
Java 線程池會生成一個隊(duì)列,要執(zhí)行的任務(wù)會被提交到這個隊(duì)列中。有一定數(shù)量的線程會在隊(duì)列中取任務(wù),然后執(zhí)行。任務(wù)執(zhí)行完畢以后,線程會返回任務(wù)隊(duì)列,等待其他任務(wù)并執(zhí)行。線程池中有一定數(shù)量的線程隨時待命。由于生成和維持這些線程是需要耗費(fèi)資源的,維持太多或者太少的線程都會對系統(tǒng)運(yùn)行效率造成影響,因此對線程池優(yōu)化是有意義的。
在做線程池調(diào)優(yōu)之前,先介紹一下線程的幾個基本參數(shù),以及線程池運(yùn)行的原理:
- 【corePoolSize】線程池的基本大小,無論是否有任務(wù)需要執(zhí)行,線程池中線程的個數(shù)。只有在工作隊(duì)列占滿的情況下,才會創(chuàng)建超出這個數(shù)量的線程。
- 【maximumPoolSize】線程池中允許存在的最大線程數(shù)。
- 【poolSize】線程池中線程的數(shù)量。
當(dāng)提交任務(wù)需要流程池處理時,會經(jīng)過以下判斷:
- 線程池中的線程數(shù)還沒有達(dá)到基本大小,也就是 poolSize<corePoolSize。
- 線程池中的線程數(shù)大于或等于基本大小,也就是 poolSize>=corePoolSize,且任務(wù)隊(duì)列未滿時,將任務(wù)提交到阻塞隊(duì)列排隊(duì)等候處理。
- 線程池中的線程數(shù)大于或等于基本大小,也就是 poolSize>=corePoolSize,且任務(wù)隊(duì)列占滿時,需要分兩種情況考慮:
①當(dāng) poolSize<maximumPoolSize,新增線程來處理任務(wù);
②當(dāng) poolSize=maximumPoolSize,線程池的處理能力達(dá)到極限,因此拒絕新增加的任務(wù)。
2??線程池容量配置
從上面線程池原理可以看出,corePoolSize 設(shè)置是整個線程池中最關(guān)鍵的參數(shù)。如果設(shè)置太小會導(dǎo)致線程池的吞吐量不足,因?yàn)樾绿峤坏娜蝿?wù)需要排隊(duì)或者被拒絕處理;設(shè)置太大可能會耗盡計(jì)算機(jī)的 CPU 和內(nèi)存資源。
那么如何配置合理的線程池大小呢?如果將被處理的任務(wù)分為,CPU 密集型任務(wù)和 IO 密集型任務(wù)。前者需要更多 CPU 的運(yùn)算操作,后者需要更多的 IO 操作。
CPU 密集型任務(wù)應(yīng)配置盡可能小的線程,如配置 CPU 個數(shù) +1 的線程數(shù)。IO 密集型任務(wù)應(yīng)配置盡可能多的線程,因?yàn)?IO 操作不占用 CPU,不要讓 CPU 閑下來,應(yīng)加大線程數(shù)量,如配置兩倍 CPU 個數(shù) +1。
CPU 的數(shù)字是一個假設(shè),實(shí)際環(huán)境中需要進(jìn)行測試,這里給大家一個思路。
若任務(wù)對其他系統(tǒng)資源有依賴,如任務(wù)依賴數(shù)據(jù)庫返回的結(jié)果(IO 操作)。其等待時間越長,CPU 空閑時間就越長,那么線程數(shù)量應(yīng)該越大,才能更好的利用 CPU。因此在 IO 優(yōu)化中發(fā)現(xiàn)一個估算公式:
最佳線程數(shù)目=((線程等待時間 + 線程CPU時間) / 線程CPU時間 ) * CPU數(shù)目
將公式進(jìn)一步化簡,得到:
最佳線程數(shù)目= (線程等待時間與線程CPU時間之比 + 1) * CPU數(shù)目
因此得到結(jié)論:線程等待時間所占比例越高,需要越多線程。線程 CPU 時間所占比例越高,需要越少線程。從另外一個角度驗(yàn)證上面對 IO 密集型(線程等待時間占比高)和 CPU 密集型(CPU 時間占比高)設(shè)置線程池大小的想法。