文章首發於微信公衆號:BaronTalk,歡迎關注!git
高效併發是 JVM 系列的最後一篇,本篇主要介紹虛擬機如何實現多線程、多線程間如何共享和競爭數據以及共享和競爭數據帶來的問題及解決方案。github
讓計算機同時執行多個任務,不僅是由於處理器的性能更增強大了,更重要是由於計算機的運算速度和它的存儲以及通訊子系統速度差距太大,大量的時間都花費在磁盤 I/O 、網絡通訊和數據庫訪問上。爲了避免讓處理器由於等待其它資源而浪費處理器的資源與時間,咱們就必須採用讓計算機同時執行多任務的方式去充分利用處理器的性能;同時也是爲了應對服務端高併發的需求。而 Java 內存模型的設計和線程的存在正是爲了更好、更高效的實現多任務。數據庫
計算機中絕大多數的任務都不可能只靠處理器計算就能完成,處理器至少要和內存交互,如讀取數據、存儲結果等等,這個 I/O 操做是很難消除的。因爲計算器的存儲設備和處理器的運算速度有幾個量級的差距,因此計算機不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存來做爲內存與處理器之間的緩衝:將運算須要用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存中,這樣處理器就無需等待緩慢的內存讀寫了。數組
基於高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,可是也爲計算機系統帶來更高的複雜度,由於它引入了一個新的問題:緩存一致性。在多處理器中,每一個處理器都有本身的高速緩存,而它們又共享同一主內存。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致。爲了解決一致性的問題,須要各個處理器的訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做。緩存
除了增長高速緩存外,爲了使處理器內部的運算單元能儘可能被充分利用,處理器可能會對輸入的代碼進行亂序執行優化,處理器會在計算以後將亂序執行的結果重組,保證該結果與順序執行的結果一致,但不保證程序中各個語句計算的前後順序與輸入代碼中的順序一致,所以,若是存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的前後順序來保證。與處理器的亂象執行優化相似,JIT 編譯器中也有相似的指令重排優化。安全
Java 虛擬機規範中定義了 Java 內存模型,用來屏蔽各類硬件和操做系統的內存訪問差別,以實現讓 Java 程序在各類平臺下都能達到一致的內存訪問效果。像 C/C++ 這類語言直接使用了物理硬件和操做系統的內存模型,所以會因爲不一樣平臺上內存模型的差別,須要針對不一樣平臺來編寫代碼。微信
Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中讀取變量這樣的底層細節。這裏說的變量和 Java 代碼中的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括變量和方法參數,由於後者是線程私有的,不會被共享。爲了得到較好的執行性能,Java 內存模型並無限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制 JIT 編譯器進行代碼執行順序這類優化措施。網絡
Java 內存模型規定了全部的變量都存儲在主內存,每條線程都有本身單獨的工做內存,線程的工做內存中保存了被該線程使用到的變量的主內存的副本拷貝,線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存,線程間變量值的傳遞均須要經過主內存來完成。多線程
關於主內存與工做內存間具體的交互協議,即一個變量如何從主內存拷貝到工做內存、如何從工做內存同步回主內存之類的細節,Java 內存模型定義瞭如下 8 種操做來完成,虛擬機實現時必須保證下面的每一種操做都是原子的、不可再分的。併發
這 8 種操做分別是:lock(鎖定)、unlock(解鎖)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(存儲)、write(寫入)。
volatile 是 Java 虛擬機提供的最輕量級的同步機制。當一個變量被定義爲 volatile 後,它將具有兩種特性:
第一是保證此變量對全部線程的可見性,這裏的「可見性」是指當一條線程修改了這個變量的值,新值對於其餘線程來講是能夠當即得知的。普通變量則作不到這一點,須要經過主內存來在線程間傳遞數據。好比,線程 A 修改了一個普通的變量值,而後向主內存進行回寫,另外一條線程 B 在 A 線程回寫完成以後再從主內存進行讀寫操做,新變量值纔會對線程 B 可見。
第二是禁止指令重排優化。普通變量僅僅會保證方法的執行過程當中全部依賴賦值結果的地方 可以獲取到正確的結果,而不能保證變量賦值操做的順序與程序代碼中的執行順序一致。由於在一個線程的方法執行過程當中沒法感知到這點,這也就是 Java 內存模型中描述的所謂的「線程內表現爲串行的語義」。
Java 內存模型要求 lock、unlock、read、load、assign、use、store、writer 這 8 個操做都具備原子性,但對於 64 位數據類型(long 和 double),在模型中特別定義了一條相對寬鬆的規定:容許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操做劃分爲兩次 32 位的操做來進行,即容許虛擬機實現選擇能夠不保證 64 位數據類型的 load、store、read 和 write 這 4 個操做的原子性。這點就是所謂的 long 和 double 的非原子協定。
若是有多個線程共享一個未聲明爲 volatile 的 long 或 double 類型的變量,而且同時對它們進行讀取和修改操做,那麼某些線程可能會讀取到一個錯誤的值。好在這種狀況很是罕見,主流商業虛擬機中也都把對 long 和 double 的操做視爲原子性,所以在實際開發中無需使用 volatile 來修飾變量。
Java 內存模型是圍繞着在併發過程當中如何處理原子性、可見性和有序性 3 個特質來創建的。
若是 Java 內存模型中全部的有序性都僅僅靠 volatile 和 synchronized 來保證,那麼有一些操做就會變得很繁瑣,可是咱們在編寫 Java 併發代碼的時候並無感受到這一點,這是由於 Java 語言中有一個「先行發生」(happens-before)原則。這個原則很是重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,咱們能夠經過幾條規則一攬子解決併發環境下兩個操做之間是否可能存在衝突的全部問題。
先行發生是 Java 內存模型中定義的兩項操做之間的偏序關係,若是說操做 A 先行發生於操做 B,其實就是說在發生操做 B 以前,操做 A 產生的影響能被操做 B 觀察到,「影響」包括修改了內存中共享變量的值、發送了消息、調用了方法等。
Java 內存模型下有一些自然的先行發生關係,這些先行發生關係無需任何同步器協助就已存在,能夠在編碼中直接使用。若是兩個操做之間的關係不在此列,而且沒法從下列規則推導出來,它們就沒有順序性保障,虛擬機就能夠隨意的對它們進行重排序。
程序次序規則:在一個線程內,按照程序代碼順序,寫在前面的代碼先行發生寫在後面的代碼。準確的講,應該是控制流順序而不是程序代碼順序,由於要考慮分支、循環等結構;
管程鎖定規則:一個 unlock 操做先行發生於後面對於同一個鎖的 lock 操做;
volatile 變量規則:對一個 volatile 變量的寫操做先行發生於後面對這個變量的讀操做,理解了這個原則咱們就能理解爲何 DCL 單例模式中爲何要用 volatile 來標識實例對象了;
線程啓動規則:線程的 start() 方法先行發生於此線程的全部其它動做;
線程終止規則:線程中全部的操做都先行發生於對此線程的終止檢測;
程序中斷規則:對線程 interrupt() 的調用先行發生於被中斷線程的代碼檢測到中斷時間的發生;
對象終結規則:一個對象的初始化完成先行發生於它的 finalize() 的開始;
傳遞性:操做 A 先行發生於 B,B 先行發生於 C,那麼 A 就先行發生於 C。
談論 Java 中的併發,一般都是和多線程相關的。這一小節咱們就講講 Java 線程在虛擬機中的實現。
主流的操做系統都提供了線程實現,Java 語言則提供了在不一樣硬件和操做系統平臺下對線程操做的統一處理,每一個已經執行 start() 且還未結束的 Thread 類的實例就表明了一個線程。Thread 類全部關鍵方法都是 Native 的。Java API 中,一個 Native 方法每每意味着這個方法沒有使用或者沒法使用平臺無關的手段來實現(固然也多是爲了執行效率而使用 Native 方法,不過,一般最高效率的手段就是平臺相關的手段)。
實現線程主要有 3 種方式:使用內核線程實現、使用用戶線程實現、使用用戶線程加輕量級進程混合實現。
Java 線程在 JDK 1.2 以前是基於稱爲「綠色線程」的用戶線程實現的。而在 JDK 1.2 中,線程模型替換爲基於操做系統原生線程模型來實現。所以,在目前的 JDK 版本中,操做系統支持怎樣的線程模型,在很大程度上決定了 Java 虛擬機的線程是怎樣映射的,這點在不一樣的平臺上沒有辦法達成一致,虛擬機規範中也沒有限定 Java 線程須要使用哪一種線程模型來實現。線程模型只對線程的併發規模和操做成本產生影響,對 Java 程序的編碼和運行過程來講,這些差別都透明的。
線程調度是指系統爲線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度和搶佔式線程調度。
若是是使用協同式調度的多線程系統,線程的執行時間由線程自己來控制,線程把本身的工做執行完以後,要主動通知系統切換到另一個線程上。協同式多線程的最大好處是實現簡單,並且因爲線程要把本身的事情作完後纔會進行線程切換,切換操做對線程本身是可知的,全部沒有線程同步的問題。可是它的壞處也很明顯:線程執行時間不可控,甚至若是一個線程編寫有問題,一直不告訴操做系統進行線程切換,那麼程序就會一直阻塞在那裏。好久之前的 Windows 3.x 系統就是使用協同式來實現對進程多任務,至關不穩定,一個進程堅持不讓出 CPU 執行時間就可能致使整個系統崩潰。
若是是使用搶佔式調度的多線程系統,那麼每一個線程將由系統來分配執行時間,線程的切換不禁線程自己來決定。在這種實現線程調度的方式下,線程的執行實現是系統可控的,也不會有一個線程致使整個進程阻塞的問題,Java 使用的線程調度方式就是搶佔式的。和前面所說的 Windows 3.x 的例子相對,在 Windows 9x/NT 內核中就是使用搶佔式來實現多進程的,當一個進程出了問題,咱們還可使用任務管理器把這個進程「殺掉」,而不至於致使系統崩潰。
Java 語言定義了 5 種線程狀態,在任意一個時間點,一個線程只能有且只有其中一種狀態,它們分別是:
上述 5 中狀態遇到特定事件發生的時候將會互相轉換,以下圖:
本文的主題是高效併發,但高效的前提是首先要保證併發的正確性和安全性,因此這一小節咱們先從如何保證線程併發安全提及。
那麼什麼是線程安全呢?能夠簡單的理解爲多線程對同一塊內存區域操做時,內存值的變化是可預期的,不會由於多線程對同一塊內存區域的操做和訪問致使內存中存儲的值出現不可控的問題。
若是咱們不把線程安全定義成一個非此即彼的概念(要麼線程絕對安全,要麼線程絕對不安全),那麼咱們能夠根據線程安全的程度由強至弱依次分爲以下五檔:
不可變;
絕對線程安全;
相對線程安全;
線程兼容;
線程對立。
雖然線程安全與否與編碼實現有着莫大的關係,但虛擬機提供的同步和鎖機制也起到了很是重要的做用。下面咱們就來看看虛擬機層面是如何保證線程安全的。
互斥同步是常見的一種併發正確性保障的手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一時間只被一個線程使用。而互斥是實現同步的一種手段。Java 中最基本的互斥同步手段就是 synchronized 關鍵字,synchronized 關鍵字在通過編譯以後,會在同步塊的先後分別造成 monitorenter 和 monitorexit 這兩個字節碼指令,這兩個字節碼都須要一個 reference 類型的參數來指明要鎖定和解鎖的對象。若是 Java 程序中的 synchronized 明確指明瞭對象參數,那就是這個對象的 reference;若是沒有,那就根據 synchronized 修飾的是實例方法仍是類方法,去取對應的對象實例或 class 對象來做爲鎖對象。
根據虛擬機規範的要求,在執行 monitorenter 指令時,首先要嘗試獲取對象的鎖。若是這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,就把鎖的計數器加 1;相應的,在執行monitorexit 指令時將鎖計數器減 1,當鎖計數器爲 0 時,鎖就被釋放。若是獲取鎖對象失敗,當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放爲止。
另外要說明的一點是,同步塊在已進入的線程執行完以前,會阻塞後面其它線程的進入。因爲 Java 線程是映射到操做系統原生線程之上的,若是要阻塞或者喚醒一個線程,都須要操做系統來幫忙完成,這就須要從用戶態轉換到內核態,線程狀態轉換須要耗費不少的處理器時間。對於簡單的同步塊(如被 synchronized 修飾的 getter() 和 setter() 方法),狀態轉換消耗的時間可能比用戶代碼消耗的時間還要長。因此 synchronized 是 Java 中一個重量級的操做,所以咱們只有在必要的狀況下才應該使用它。固然虛擬機自己也會作相應的優化,好比在操做系統阻塞線程前加入一段自旋等待過程,避免頻繁的用戶態到內核態的轉換過程。這一點咱們在介紹鎖優化的時候再細聊。
非阻塞同步
互斥同步最大的問題就是進行線程阻塞和喚醒所帶來的性能問題,所以這種同步也成爲阻塞同步。從處理問題的方式上來講,互斥同步是一種悲觀的併發策略,認爲只要不去作正確的同步措施(例如加鎖),就確定會出問題,不管共享數據是否會出現競爭,它都要進行加鎖(固然虛擬機也會優化掉一些沒必要要的鎖)。隨着硬件指令集的發展,咱們有了另一個選擇:基於衝突檢查的樂觀併發策略。通俗的說,就是先進行操做,若是沒有其餘線程競爭,那操做就成功了;若是共享數據有其它線程競爭,產生了衝突,就採起其它的補救措施,這種樂觀的併發策略的許多實現都不須要把線程掛起,所以這種同步操做稱爲非阻塞同步。
前面之因此說須要硬件指令集的發展,是由於咱們須要操做和衝突檢測這兩個步驟具有原子性。
這個原子性靠什麼來保證呢?若是這裏再使用互斥同步來保證原子性就失去意義了,因此咱們只能靠硬件來完成這件事,保證一個從語義上看起來須要屢次操做的行爲只經過一條處理器指令就能完成,這類指令經常使用的有:
前三條是以前的處理器指令集裏就有的,後兩條是新增的。
CAS 指令須要 3 個操做數,分別是內存位置(在 Java 中能夠簡單理解爲變量的內存地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 執行指令時,當且僅當 V 符合舊預期值 A 時,處理器用新值 B 更新 V 的值,不然他就不執行更新,可是不管是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操做。
在 JDK 1.5 以後,Java 程序中才可使用 CAS 操做,該操做由 sun.misc.Unsafe 類裏的 compareAndSwapInt() 和 compareAndSwapLong() 等幾個方法包裝提供,虛擬機在內部對這些方法作了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器 CAS 指令,沒有方法的調用過程,或者能夠認爲是無條件內聯進去了。
因爲 Unsafe 類不是提供給用戶程序調用的類,所以若是不用反射,咱們只能經過其餘的 Java API 來間接使用,好比 J.U.C 包裏的整數原子類,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操做。
儘管 CAS 看起來很美,可是這種操做卻沒法覆蓋互斥同步的全部場景,而且 CAS 從語義上來講並非完美的。若是一個變量 V 初次讀取的時候是 A 值,而且在準備賦值的時候檢查它仍然是 A 值,那咱們就能說它的值沒有被其餘線程修改過嗎?若是在這段時間內曾經被改成了 B,後來又被改回爲 A,那 CAS 操做就會認爲它歷來沒有被改變過。這個漏洞稱爲 CAS 操做的「ABA」問題。
爲了解決「ABA」問題,J.U.C 包提供了一個帶有標記的原子引用類 AtomicStamoedReference
,它能夠經過控制變量值的版原本保證 CAS 的正確性。不過這個類比較「雞肋」,大部分狀況下 ABA 問題不會影響程序併發的正確性,若是須要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
無同步方案
要保證線程安全不必定要進行同步,若是一個方法原本就不涉及共享數據,那它天然無需任何同步措施,所以會有一些代碼天生就是線程安全的,其中就包括下面要說的可重入代碼和線程本地存儲。
可重入代碼(Reentrant Code):也叫純代碼,能夠在代碼執行的任什麼時候候中斷它,轉而去執行另外一端代碼(包括遞歸調用本身),而在從新得到控制權後,原來的程序不會出現任何錯誤。可重入代碼有一些共同特徵,例如不依賴存儲在堆上的數據和公用的系統資源,用到的狀態量都由參數傳入、不調用非可重入的方法等。若是一個方法的返回結果能夠預測,只要輸入相同,就能返回相同的輸出,那它就是可重入代碼,固然也就是線程安全的。
線程本地存儲(Thread Local Storage):也就是說這個數據是線程獨有的,ThreadLocal 就是用來實現線程本地存儲的。
HotSpot 虛擬機開發團隊花費了很大的精力實現了各類鎖優化,好比自旋鎖與自適應自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等。
自旋鎖前面咱們在聊互斥同步的時候就提到過,互斥同步對性能最大的影響就是阻塞的實現,掛起線程和恢復線程都涉及到了用戶態到內核態的轉換,這種狀態的轉換會給系統併發性能帶來很大的壓力。可是大多數場景下,共享數據的鎖定狀態只會持續很短的一段時間,爲了這短暫的時間去掛起和恢復線程顯得不那麼划算。若是物理機有一個以上的處理器,能讓兩個或以上的線程同時並行處理,咱們就可讓後面請求鎖的那個線程「稍等一下」,可是不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,咱們只須要執行一個空轉的循環(自旋),這就是所謂的自旋鎖。
自旋等待雖然避免了線程切換的開銷,可是它要佔用處理器的時間。若是鎖被佔用的時間很短,那麼自旋等待的效果固然很好;反之,若是鎖被佔用的時間很長,那麼自旋的線程就會白白消耗處理器資源,反而造成負優化。因此自旋等待必須有個限度,可是這個限度若是設置一個固定值並非最有選擇,所以虛擬機開發團隊設計了自適應自旋鎖,讓自旋等待的時間再也不固定,而是由前一次在同一個鎖上自旋的時間及鎖的擁有者的狀態來決定。若是在同一個鎖對象上,自旋等待剛剛成功得到過鎖,而且持有鎖的線程正在運行,那麼虛擬機就會認爲此次自旋也有可能會成功,會將自旋等待的時間延長。若是對於某個鎖,自旋等待不多成功得到過,那在之後要獲取這個鎖的時候就會放棄自旋。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的情況預測就會愈來愈準確。
即時編譯器在運行時,對一些代碼上要求同步,可是被檢測到不可能存在共享數據競爭的鎖就會進行鎖消除。所消除的主要斷定依據來源於逃逸分析的數據支持,若是斷定一段代碼中,堆上的全部數據都不會逃逸出去從而被其它線程訪問到,那就能夠把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖天然就不必了。
咱們在編碼時,老是推薦將同步塊的做用範圍限制到最小,只在共享數據的實際做用域中才進行同步,這樣是爲了使得須要的同步操做數量儘量變小,若是存在競爭,那等待鎖的線程也能儘快拿到鎖。一般,這樣作是正確的,可是若是一系列的連續操做都對同一個對象反覆加鎖和解鎖,甚至加鎖操做是出如今循環體中,那即便沒有線程競爭,頻繁的進行互斥同步也會致使沒必要要的性能損耗。那加鎖出如今循環體中來舉例,虛擬機遇到這種狀況,就會把加鎖同步的範圍擴展(粗化)到循環體外,這樣只要加鎖一次就能夠了,這就是鎖粗化。
關於輕量級鎖和偏向鎖這裏就再也不介紹,若是你們有興趣能夠留言反饋,我在單獨發文介紹。
至此,整個 JVM 系列就更新完了,這個系列的文章基本上都是由個人讀書筆記整理而成,但願能對你們有幫助。因爲篇幅限制,加上本人水平有限,書中精華未能一一呈現。想進一步 Java 虛擬機的同窗推薦去閱讀周志明老師的原著。
參考資料:
若是你喜歡個人文章,就關注下個人公衆號 BaronTalk 、 知乎專欄 或者在 GitHub 上添個 Star 吧!
- 微信公衆號:BaronTalk
- 知乎專欄:zhuanlan.zhihu.com/baron
- GitHub:github.com/BaronZ88