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