今天,咱們從 Java 內部鎖優化,代碼中的鎖優化,以及線程池優化幾個方面展開討論。程序員
當使用 Java 多線程訪問共享資源的時候,會出現競態的現象。即隨着時間的變化,多線程「寫」共享資源的最終結果會有所不一樣。數據庫
爲了解決這個問題,讓多線程「寫」資源的時候有前後順序,引入了鎖的概念。每次一個線程只能持有一個鎖進行寫操做,其餘的線程等待該線程釋放鎖之後才能進行後續操做。編程
從這個角度來看,鎖的使用在 Java 多線程編程中是至關重要的,那麼是如何對鎖進行優化?設計模式
衆所周知,Java 的鎖分爲兩種:多線程
這裏重點討論的是內部鎖優化。內部鎖的優化方式由 Java 內部機制完成,雖然不須要程序員直接參與,但瞭解它對理解多線程優化原理有很大幫助。ide
這部分的優化主要包括四部分:測試
鎖消除(Lock Elision),JIT 編譯器對內部鎖的優化。在介紹其原理以前先說說,逃逸和逃逸分析。優化
逃逸是指在方法以內建立的對象,除了在方法體以內被引用以外,還在方法體以外被其餘變量引用。命令行
也就是,在方法體以外引用方法內的對象。在方法執行完畢以後,方法中建立的對象應該被 GC 回收,但因爲該對象被其餘變量引用,致使 GC 沒法回收。線程
這個沒法回收的對象稱爲「逃逸」對象。Java 中的逃逸分析,就是對這種對象的分析。
回到鎖消除,Java JIT 會經過逃逸分析的方式,去分析加鎖的代碼段/共享資源,他們是否被一個或者多個線程使用,或者等待被使用。
若是經過分析證明,只被一個線程訪問,在編譯這個代碼段的時候就不生成 Synchronized 關鍵字,僅僅生成代碼對應的機器碼。
換句話說,即使開發人員對代碼段/共享資源加上了 Synchronized(鎖),只要 JIT 發現這個代碼段/共享資源只被一個線程訪問,也會把這個 Synchronized(鎖)去掉。從而避免競態,提升訪問資源的效率。
鎖消除示意圖
做爲開發人員來講,只須要在代碼層面去考慮是否用 Synchronized(鎖)。
說白了,就是感受這段代碼有可能出現競態,那麼就使用 Synchronized(鎖),至於這個鎖是否真的會使用,則由 Java JIT 編譯器來決定。
鎖粗化(Lock Coarsening) ,是 JIT 編譯器對內部鎖具體實現的優化。假設有幾個在程序上相鄰的同步塊(代碼段/共享資源)上,每一個同步塊使用的是同一個鎖實例。
那麼 JIT 會在編譯的時候將這些同步塊合併成一個大同步塊,而且使用同一個鎖實例。這樣避免一個線程反覆申請/釋放鎖。
鎖粗化示意圖
如上圖存在三塊代碼段,分割成三個臨界區,JIT 會將其合併爲一個臨界區,用一個鎖對其進行訪問控制。
即便在臨界區的空隙中,有其餘的線程能夠獲取鎖信息,JIT 編譯器執行鎖粗化優化的時候,會進行命令重排到後一個同步塊的臨界區中。
鎖粗化默認是開啓的。若是要關閉這個特性能夠在 Java 程序的啓動命令行中添加虛擬機參數「-XX:-EliminateLocks」。
偏向鎖(Biased Locking),顧名思義,它會偏向於第一個訪問鎖的線程。若是在接下來的運行中,該鎖沒有被其餘線程訪問,則持有偏向鎖的線程不會觸發同步。
相反,在運行過程當中,遇到了其餘線程搶佔鎖,則持有偏向鎖的線程會被掛起,JVM 會消除掛起線程的偏向鎖。
換句話說,偏向鎖只能在單個線程反覆持有該鎖的時候起效。其目的是,爲了不相同線程獲取同一個鎖時,產生的線程切換,以及同步操做。
從實現機制上講, 每一個偏向鎖都關聯一個計數器和一個佔有線程。最開始沒有線程佔有的時候,計數器爲 0,鎖被認爲是 unheld 狀態。
當有線程請求 unheld 鎖時,JVM 記錄鎖的擁有者,並把鎖的請求計數加 1。
若是同一線程再次請求鎖時,計數器就會增長 1,當線程退出 Syncronized 時,計數器減 1,當計數器爲 0 時,鎖被釋放。
爲了完成上述實現,鎖對象中有個 ThreadId 字段。第一次獲取鎖以前,該字段是空的。持有鎖的線程,會將自身的 ThreadId 寫入到鎖的 ThreadId 中。
下次有線程獲取鎖時,先檢查自身 ThreadId 是否和偏向鎖保存的 ThreadId 一致。
若是一致,則認爲當前線程已經獲取了鎖,不需再次獲取鎖。偏向鎖默認是開啓的。
若是要關閉這個特性,能夠在 Java 程序的啓動命令行中添加虛擬機參數「-XX:-UseBiasedLocks」。
適應鎖(Adaptive Locking):當一個線程持申請鎖時,該鎖正在被其餘線程持有。
那麼申請鎖的線程會進入等待,等待的線程會被暫停,暫停的線程會產生上下文切換。
因爲上下文切換是比較消耗系統資源的,因此這種暫停線程的方式比較適合線程處理時間較長的狀況。
前面一個線程執行的時間較長,才能彌補後面等待線程上下文切換的消耗。若是說線程執行較短,那麼也能夠採起忙等(Busy Wait)的狀態。
這種方式不會暫停線程,經過代碼中的 while 循環檢查鎖是否被釋放,一旦釋放就持有鎖的執行權。
這種方式雖然不會帶來上下文的切換,可是會消耗 CPU 的資源。爲了綜合較長和較短兩種線程等待模式,JVM 會根據運行過程當中收集到的信息來判斷,鎖持有時間是較長時間或者較短期。而後再採起線程暫停或忙等的策略。
Java 代碼中如何進行鎖優化
前面講了 Java 系統是如何針對內部鎖進行優化的。若是說內部鎖的優化是 Java 系統自身完成的話,那麼接下來的優化就須要經過代碼實現了。
鎖的開銷主要是在爭用鎖上,當多線程對共享資源進行訪問時,會出現線程等待。
即使是使用內存屏障,也會致使沖刷寫緩衝器,清空無效化隊列等開銷。
爲了下降這種開銷,一般能夠從幾個方面入手,例如:減小線程申請鎖的頻率(減小臨界區)和減小線程持有鎖的時間長度(減少鎖顆粒)以及多線程的設計模式。
減小臨界區的範圍
當共享資源須要被多線程訪問時,會將共享資源或者代碼段放到臨界區中。
若是在代碼書寫中減小臨界區的長度,就能夠減小鎖被持有的時間,從而下降鎖被徵用的機率,達到減小鎖開銷的目的。
減小臨界區示例圖
如上圖,儘可能避免對一個方法進行加鎖同步,能夠只針對方法中的須要同步資源/變量進行同步。其餘的代碼段不放到 Synchronzied 中,減小臨界區的範圍。
減少鎖的顆粒度
減少鎖的顆粒度能夠下降鎖的申請頻率,從而減少鎖被爭用的機率。其中一種常見的方法就是將一個顆粒度較粗的鎖拆分紅顆粒度較細的鎖。
拆分鎖的顆粒度
假設有一個類 ServerStatus,裏面包含了四個方法:
若是分別在每一個方法加上 Synchronized。在一個線程訪問其中任意一個方法的時候,將鎖住 ServerStatus,此時其餘線程都沒法訪問另外三個方法,從而進入等待。
若是隻針對每一個方法內部操做的對象加鎖,例如:addUser 和 removeUser 方法針對 users 對象加鎖。又例如:addQuery 和 removeQuery 方法針對 queries 對象加鎖。
假設,當一個線程池調用 addUser 方法的時候,只會鎖住 user 對象。另一個線程是能夠執行 addQuery 和 removeQuery 方法的。
並不會由於鎖住整個對象而進入等待。JDK 內置的 ConcurrentHashMap 與 SynchronizedMap 就使用了相似的設計。
針對不一樣的方法中使用的對象進行鎖定
讀寫鎖
也叫作線程的讀寫模式(Read-Write Lock),其本質是一種多線程設計模式。
將讀取操做和寫入操做分開考慮,在執行讀取操做以前,線程必須獲取讀取的鎖。
在執行寫操做以前,必須獲取寫鎖。當線程執行讀取操做時,共享資源的狀態不會發生變化,其餘的線程也能夠讀取。可是在讀取時,不能夠寫入。
其實,讀寫模式就是將原來共享資源的鎖,轉化成爲讀和寫兩把鎖,將其分兩種狀況考慮。
若是都是讀操做能夠支持多線程同時進行,只有在寫時其餘線程纔會進入等待。
Reader 線程正在讀取,Writer 線程正在等待Writer 線程正在寫入,Reader 線程正在等待讀寫鎖類圖
說完了讀寫鎖的基本原理,再來看看參與的角色:
特別須要注意的是,在這裏須要解決讀寫衝突的問題。當線程 A 獲取讀鎖時,若是有線程 B 正在執行寫操做,線程 A 須要等待,不然會引發 read-write conflict(讀寫衝突)。
若是線程 B 正在執行讀操做,線程 A 不須要等待,由於 read-read 不會引發 conflict(衝突)。
當線程 A 要獲取寫入鎖時,線程 B 正在執行寫操做,線程 A 須要等待,不然會引發 write-write conflict(寫寫衝突)。
若是線程 B 正在執行讀操做,則線程 A 須要等待,不然會引發 read-write conflict(讀寫衝突)。
讀寫鎖衝突示例圖
上面基本把讀寫鎖的基本原理說完了,接下來經過一些代碼片斷來看看它是如何實現的。
咱們經過 Data 類 SharedResource,ReaderThread 和 WriterThread 來實現 Reader 和 Writer,ReadWriteLock 類來實現讀寫鎖。
首先來看 ReaderThread 和 WriterThread,它們的實現相對簡單。僅僅調用 Data 類中的 Read 和 Write 方法來實現讀寫操做。
ReaderThread 對 Reader 的實現WriterThread 對 Writer 的實現
接下來就是 ReadWriteLock 類,它實現了讀寫鎖的具體功能。其中的幾個變量用來控制訪問線程和寫入優先級:
裏面包含了四個方法,分別是:
顧名思義,分別對應讀鎖定,讀解鎖,寫鎖定,寫解鎖的操做。兩兩組合之後一共四種方法。
ReadWriteLock 示例圖
在 ReadWriteLock 定義的四種方法中,各自完成不一樣的任務:
最後,來看共享資源的類:Data。它主要承載讀寫的方法。須要注意的是在作讀/寫的先後,須要加上對應的鎖。
例如:在作讀操做(doRead)以前須要加上 readLock(讀鎖),在完成讀操做之後釋放讀鎖(readUnlock)。
又例如:在作寫操做(doWrite)以前須要加上 writeLock(寫鎖),在完成寫操做之後釋放寫鎖(writeUnlock)。
共享資源類 Data 示例圖
上面的幾個類已經介紹完了,若是須要測試能夠經過調用 ReaderThread 和 WriterThread 來完成調試。
讀寫鎖測試
線程池優化
前面兩部分談到多線程對內部鎖的優化,以及代碼中對鎖的優化。是從減小競態的角度來優化程序的。
若是從提升線程執行效率,來對多線程程序進行優化,天然讓人聯想到了線程池技術。
基本概念與原理
Java 線程池會生成一個隊列,要執行的任務會被提交到這個隊列中。有必定數量的線程會在隊列中取任務,而後執行。
任務執行完畢之後,線程會返回任務隊列,等待其餘任務並執行。線程池中有必定數量的線程隨時待命。
因爲生成和維持這些線程是須要耗費資源了,維持太多或者太少的線程都會對系統運行效率形成影響,所以對線程池優化是有意義的。
在作線程池調優以前,先介紹一下線程的幾個基本參數,以及線程池運行的原理:
當提交任務須要流程池處理時,會通過如下判斷:
線程池容量配置
從上面線程池原理能夠看出,corePoolSize 設置是整個線程池中最關鍵的參數。
若是設置過小會致使線程池的吞吐量不足,由於新提交的任務須要排隊或者被拒絕處理;設置太大可能會耗盡計算機的 CPU 和內存資源。
那麼如何配置合理的線程池大小呢?若是將被處理的任務分爲,CPU 密集型任務和 IO 密集型任務。前者須要更多 CPU 的運算操做,後者須要更多的 IO 操做。
CPU 密集型任務應配置儘量小的線程,如配置 CPU 個數 +1 的線程數,IO 密集型任務應配置儘量多的線程,由於 IO 操做不佔用 CPU,不要讓 CPU 閒下來,應加大線程數量,如配置兩倍 CPU 個數 +1。
CPU 的數字是一個假設,實際環境中須要進行測試,這裏給你們一個思路。
若任務對其餘系統資源有依賴,如任務依賴數據庫返回的結果(IO 操做)。其等待時間越長,CPU 空閒時間就越長,那麼線程數量應該越大,才能更好的利用 CPU。
所以在 IO 優化中發現一個估算公式:
最佳線程數目=((線程等待時間+線程 CPU 時間)/線程 CPU 時間 )* CPU 數目。
將公式進一步化簡,獲得:
最佳線程數目= (線程等待時間與線程 CPU 時間之比+1)* CPU 數目。
所以獲得結論:線程等待時間所佔比例越高,須要越多線程。線程 CPU 時間所佔比例越高,須要越少線程。
從另一個角度驗證上面對 IO 密集型(線程等待時間佔比高)和 CPU 密集型(CPU 時間佔比高)設置線程池大小的想法。
總結
Java 多線程開發優化有兩個思路:
咱們從內部鎖優化原理入手,分別介紹了鎖消除,鎖粗化,偏向鎖,適應鎖,都是以 Java 系統自己來作優化的,做爲程序員須要瞭解其實現原理。
針對 Java 代碼中鎖的優化,咱們又提出了,減小臨界區範圍,減少鎖的顆粒度,讀寫鎖(設計模式)等方法。
其中,讀寫鎖只是多線程設計模式中的一種,若是有興趣能夠擴展閱讀其餘的設計模式,協助進行多線程開發。最後針對線程池實現原理,提出了設置線程池大小的思路。
原做者: 崔皓
原文連接: Java多線程優化都不會,怎麼拿Offer?
原出處:公衆號