深入理解Java虛擬機(高效并發(fā))
高效并發(fā)是 JVM 系列的最后一篇,本篇主要介紹虛擬機如何實現(xiàn)多線程、多線程間如何共享和競爭數(shù)據(jù)以及共享和競爭數(shù)據(jù)帶來的問題及解決方案。
一. Java 內(nèi)存模型與線程
讓計算機同時執(zhí)行多個任務,不只是因為處理器的性能更加強大了,更重要是因為計算機的運算速度和它的存儲以及通信子系統(tǒng)速度差距太大,大量的時間都花費在磁盤 I/O 、網(wǎng)絡通信和數(shù)據(jù)庫訪問上。為了不讓處理器因為等待其它資源而浪費處理器的資源與時間,我們就必須采用讓計算機同時執(zhí)行多任務的方式去充分利用處理器的性能;同時也是為了應對服務端高并發(fā)的需求。而 Java 內(nèi)存模型的設計和線程的存在正是為了更好、更高效的實現(xiàn)多任務。
1.硬件與效率的一致性
計算機中絕大多數(shù)的任務都不可能只靠處理器計算就能完成,處理器至少要和內(nèi)存交互,如讀取數(shù)據(jù)、存儲結(jié)果等等,這個 I/O 操作是很難消除的。由于計算器的存儲設備和處理器的運算速度有幾個量級的差距,所以計算機不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存來作為內(nèi)存與處理器之間的緩沖:將運算需要用到的數(shù)據(jù)復制到緩存中,讓運算能快速進行,當運算結(jié)束后再從緩存同步回內(nèi)存中,這樣處理器就無需等待緩慢的內(nèi)存讀寫了。
基于高速緩存的存儲交互很好的解決了處理器與內(nèi)存的速度矛盾,但是也為計算機系統(tǒng)帶來更高的復雜度,因為它引入了一個新的問題:緩存一致性。在多處理器中,每個處理器都有自己的高速緩存,而它們又共享同一主內(nèi)存。當多個處理器的運算任務都涉及同一塊主內(nèi)存區(qū)域時,將可能導致各自的緩存數(shù)據(jù)不一致。為了解決一致性的問題,需要各個處理器的訪問緩存時都遵循一些協(xié)議,在讀寫時要根據(jù)協(xié)議來進行操作。
除了增加高速緩存外,為了使處理器內(nèi)部的運算單元能盡量被充分利用,處理器可能會對輸入的代碼進行亂序執(zhí)行優(yōu)化,處理器會在計算之后將亂序執(zhí)行的結(jié)果重組,保證該結(jié)果與順序執(zhí)行的結(jié)果一致,但不保證程序中各個語句計算的先后順序與輸入代碼中的順序一致,因此,如果存在一個計算任務依賴另一個計算任務的中間結(jié)果,那么其順序性并不能靠代碼的先后順序來保證。與處理器的亂象執(zhí)行優(yōu)化類似,JIT 編譯器中也有類似的指令重排優(yōu)化。
2.Java 內(nèi)存模型
Java 虛擬機規(guī)范中定義了 Java 內(nèi)存模型,用來屏蔽各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,以實現(xiàn)讓 Java 程序在各種平臺下都能達到一致的內(nèi)存訪問效果。像 C/C++ 這類語言直接使用了物理硬件和操作系統(tǒng)的內(nèi)存模型,因此會由于不同平臺上內(nèi)存模型的差異,需要針對不同平臺來編寫代碼。
主內(nèi)存與工作內(nèi)存
Java 內(nèi)存模型的主要目標是定義程序中各個變量的訪問規(guī)則,即在虛擬機中將變量存儲到內(nèi)存和從內(nèi)存中讀取變量這樣的底層細節(jié)。這里說的變量和 Java 代碼中的變量有所區(qū)別,它包括了實例字段、靜態(tài)字段和構(gòu)成數(shù)組對象的元素,但不包括變量和方法參數(shù),因為后者是線程私有的,不會被共享。為了獲得較好的執(zhí)行性能,Java 內(nèi)存模型并沒有限制執(zhí)行引擎使用處理器的特定寄存器或緩存來和主內(nèi)存進行交互,也沒有限制 JIT 編譯器進行代碼執(zhí)行順序這類優(yōu)化措施。
Java 內(nèi)存模型規(guī)定了所有的變量都存儲在主內(nèi)存,每條線程都有自己單獨的工作內(nèi)存,線程的工作內(nèi)存中保存了被該線程使用到的變量的主內(nèi)存的副本拷貝,線程對變量的所有操作都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存,線程間變量值的傳遞均需要通過主內(nèi)存來完成。
內(nèi)存間交互操作
關(guān)于主內(nèi)存與工作內(nèi)存間具體的交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步回主內(nèi)存之類的細節(jié),Java 內(nèi)存模型定義了以下 8 種操作來完成,虛擬機實現(xiàn)時必須保證下面的每一種操作都是原子的、不可再分的。
這 8 種操作分別是:lock(鎖定)、unlock(解鎖)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(存儲)、write(寫入)。
對 volatile 型變量的特殊規(guī)則
volatile 是 Java 虛擬機提供的最輕量級的同步機制。當一個變量被定義為 volatile 后,它將具備兩種特性:
第一是保證此變量對所有線程的可見性,這里的「可見性」是指當一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的。普通變量則做不到這一點,需要通過主內(nèi)存來在線程間傳遞數(shù)據(jù)。比如,線程 A 修改了一個普通的變量值,然后向主內(nèi)存進行回寫,另一條線程 B 在 A 線程回寫完成之后再從主內(nèi)存進行讀寫操作,新變量值才會對線程 B 可見。
第二是禁止指令重排優(yōu)化。普通變量僅僅會保證方法的執(zhí)行過程中所有依賴賦值結(jié)果的地方能夠獲取到正確的結(jié)果,而不能保證變量賦值操作的順序與程序代碼中的執(zhí)行順序一致。因為在一個線程的方法執(zhí)行過程中無法感知到這點,這也就是 Java 內(nèi)存模型中描述的所謂的「線程內(nèi)表現(xiàn)為串行的語義」。
對 long 和 double 型變量的特殊規(guī)則
Java 內(nèi)存模型要求 lock、unlock、read、load、assign、use、store、writer 這 8 個操作都具有原子性,但對于 64 位數(shù)據(jù)類型(long 和 double),在模型中特別定義了一條相對寬松的規(guī)定:允許虛擬機將沒有被 volatile 修飾的 64 位數(shù)據(jù)的讀寫操作劃分為兩次 32 位的操作來進行,即允許虛擬機實現(xiàn)選擇可以不保證 64 位數(shù)據(jù)類型的 load、store、read 和 write 這 4 個操作的原子性。這點就是所謂的 long 和 double 的非原子協(xié)定。
如果有多個線程共享一個未聲明為 volatile 的 long 或 double 類型的變量,并且同時對它們進行讀取和修改操作,那么某些線程可能會讀取到一個錯誤的值。好在這種情況非常罕見,主流商業(yè)虛擬機中也都把對 long 和 double 的操作視為原子性,因此在實際開發(fā)中無需使用 volatile 來修飾變量。
原子性、可見性和有序性
Java 內(nèi)存模型是圍繞著在并發(fā)過程中如何處理原子性、可見性和有序性 3 個特質(zhì)來建立的。
原子性(Atomicity)
由 Java 內(nèi)存模型來直接保證原子性變量操作,包括 read、load、assign、use、store 和 write ,我們大致可以認為基本數(shù)據(jù)類型的訪問讀寫是具備原子性的。如果應用場景需要一個更大范圍的原子性保證,Java 內(nèi)存模型還提供了 lock 和 unlock 操作來滿足這種需求,盡管虛擬機未把 lock 和 unlock 操作直接開放給用戶使用,但是卻提供了更高層次的字節(jié)碼指令 monitorenter 和 monitorexit 來隱式地使用這兩個操作,這兩個字節(jié)碼指令反映到 Java 代碼中就是 synchronized 關(guān)鍵字,因此被 synchronize 修飾的方法或代碼塊之間的操作是具備原子性的。
可見性(Visibility)
可見性是指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 內(nèi)存模型是通過在變量修改后將新值同步回主內(nèi)存,在變量讀取前從主內(nèi)存刷新變量值這種依賴主內(nèi)存作為傳遞媒介的方式來實現(xiàn)可見性的,無論是普通變量還是 volatile 變量都是如此,普通變量與 volatile 變量的區(qū)別是, volatile 的規(guī)則保證了新值能立即同步到主內(nèi)存,以及每次使用前立即從主內(nèi)存刷新。因此,可以說 volatile 保證了多線程操作變量的可見性,而普通變量則不能保證這一點。除了 volatile 外,Java 還有兩個關(guān)鍵字 synchronized 和 final 。synchronized 同步塊的可見性是由「對一個變量執(zhí)行 unlock 操作前,必須先把此變量同步回主內(nèi)存中(執(zhí)行 store、write 操作)」這條規(guī)則獲得的;final 的可見性是指“:被 final 修飾的字段在構(gòu)造器中一旦初始化完成,并且構(gòu)造器沒有「this」的引用傳遞出去,那在其他線程中就能看見 final 字段的值。
有序性(Ordering)
Java 程序中天然的有序性可以總結(jié)為:如果在本線程內(nèi),所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指「線程內(nèi)表現(xiàn)為串行的語義」,后半句是指「指令重排序」現(xiàn)象和「工作內(nèi)存和主內(nèi)存同步延遲」現(xiàn)象。Java 語言提供了 volatile 和 synchronized 兩個關(guān)鍵字來保證線程之間操作的有序性,volatile 關(guān)鍵字本身就包含了禁止指令重排的語義,而 synchronized 則是由「一個變量在同一時刻只允許一條線程對其進行 lock 操作」這條規(guī)則獲得的,這條規(guī)則決定了持有同一個鎖的兩個同步塊只能串行的進入。
先行發(fā)生原則
如果 Java 內(nèi)存模型中所有的有序性都僅僅靠 volatile 和 synchronized 來保證,那么有一些操作就會變得很繁瑣,但是我們在編寫 Java 并發(fā)代碼的時候并沒有感覺到這一點,這是因為 Java 語言中有一個「先行發(fā)生」(happens-before)原則。這個原則非常重要,它是判斷數(shù)據(jù)是否存在競爭、線程是否安全的主要依據(jù),依靠這個原則,我們可以通過幾條規(guī)則一攬子解決并發(fā)環(huán)境下兩個操作之間是否可能存在沖突的所有問題。
先行發(fā)生是 Java 內(nèi)存模型中定義的兩項操作之間的偏序關(guān)系,如果說操作 A 先行發(fā)生于操作 B,其實就是說在發(fā)生操作 B 之前,操作 A 產(chǎn)生的影響能被操作 B 觀察到,「影響」包括修改了內(nèi)存中共享變量的值、發(fā)送了消息、調(diào)用了方法等。
Java 內(nèi)存模型下有一些天然的先行發(fā)生關(guān)系,這些先行發(fā)生關(guān)系無需任何同步器協(xié)助就已存在,可以在編碼中直接使用。如果兩個兩個操作之間的關(guān)系不在此列,并且無法從下列規(guī)則推導出來,它們就沒有順序性保障,虛擬機就可以隨意的對它們進行重排序。
- 程序次序規(guī)則:在一個線程內(nèi),按照程序代碼順序,寫在前面的代碼先行發(fā)生寫在后面的代碼。準確的講,應該是控制流順序而不是程序代碼順序,因為要考慮分支、循環(huán)等結(jié)構(gòu);
- 管程鎖定規(guī)則:一個 unlock 操作先行發(fā)生于后面對于同一個鎖的 lock 操作;
- volatile 變量規(guī)則:對一個 volatile 變量的寫操作先行發(fā)生于后面對這個變量的讀操作,理解了這個原則我們就能理解為什么 DCL 單例模式中為什么要用 volatile 來標識實例對象了;
- 線程啟動規(guī)則:線程的 start() 方法先行發(fā)生于此線程的所有其它動作;
- 線程終止規(guī)則:線程中所有的操作都先行發(fā)生于對此線程的終止檢測;
- 程序中斷規(guī)則:對線程 interrupt() 的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷時間的發(fā)生;
- 對象終結(jié)規(guī)則:一個對象的初始化完成先行發(fā)生于它的 finalize() 的開始;
- 傳遞性:操作 A 先行發(fā)生于 B,B 先行發(fā)生于 C,那么 A 就先行發(fā)生于 C。
3.Java 與線程
談論 Java 中的并發(fā),通常都是和多線程相關(guān)的。這一小節(jié)我們就講講 Java 線程在虛擬機中的實現(xiàn)。
線程的實現(xiàn)
主流的操作系統(tǒng)都提供了線程實現(xiàn),Java 語言則提供了在不同硬件和操作系統(tǒng)平臺下對線程操作的統(tǒng)一處理,每個已經(jīng)執(zhí)行 start() 且還未結(jié)束的 Thread 類的實例就代表了一個線程。Thread 類所有關(guān)鍵方法都是 Native 的。Java API 中,一個 Native 方法往往意味著這個方法沒有使用或者無法使用平臺無關(guān)的手段來實現(xiàn)(當然也可能是為了執(zhí)行效率而使用 Native 方法,不過,通常最高效率的手段就是平臺相關(guān)的手段)。
實現(xiàn)線程主要有 3 種方式:使用內(nèi)核線程實現(xiàn)、使用用戶線程實現(xiàn)、使用用戶線程加輕量級進程混合實現(xiàn)。
Java 線程的實現(xiàn)
Java 線程在 JDK 1.2 之前是基于稱為「綠色線程」的用戶線程實現(xiàn)的。而在 JDK 1.2 中,線程模型替換為基于操作系統(tǒng)原生線程模型來實現(xiàn)。因此,在目前的 JDK 版本中,操作系統(tǒng)支持怎樣的線程模型,在很大程度上決定了 Java 虛擬機的線程是怎樣映射的,這點在不同的平臺上沒有辦法達成一致,虛擬機規(guī)范中也沒有限定 Java 線程需要使用哪種線程模型來實現(xiàn)。線程模型只對線程的并發(fā)規(guī)模和操作成本產(chǎn)生影響,對 Java 程序的編碼和運行過程來說,這些差異都透明的。
4.Java 線程調(diào)度
線程調(diào)度是指系統(tǒng)為線程分配處理器使用權(quán)的過程,主要調(diào)度方式有兩種,分別是協(xié)同式線程調(diào)度和搶占式線程調(diào)度。
協(xié)同式線程調(diào)度
如果是使用協(xié)同式調(diào)度的多線程系統(tǒng),線程的執(zhí)行時間由線程本身來控制,線程把自己的工作執(zhí)行完之后,要主動通知系統(tǒng)切換到另外一個線程上。協(xié)同式多線程的最大好處是實現(xiàn)簡單,而且由于線程要把自己的事情做完后才會進行線程切換,切換操作對線程自己是可知的,所有沒有線程同步的問題。但是它的壞處也很明顯:線程執(zhí)行時間不可控,甚至如果一個線程編寫有問題,一直不告訴操作系統(tǒng)進行線程切換,那么程序就會一直阻塞在那里。很久以前的 Windows 3.x 系統(tǒng)就是使用協(xié)同式來實現(xiàn)對進程多任務,相當不穩(wěn)定,一個進程堅持不讓出 CPU 執(zhí)行時間就可能導致整個系統(tǒng)崩潰。
搶占式線程調(diào)度
如果是使用搶占式調(diào)度的多線程系統(tǒng),那么每個線程將由系統(tǒng)來分配執(zhí)行時間,線程的切換不由線程本身來決定。在這種實現(xiàn)線程調(diào)度的方式下,線程的執(zhí)行實現(xiàn)是系統(tǒng)可控的,也不會有一個線程導致整個進程阻塞的問題,Java 使用的線程調(diào)度方式就是搶占式的。和前面所說的 Windows 3.x 的例子相對,在 Windows 9x/NT 內(nèi)核中就是使用搶占式來實現(xiàn)多進程的,當一個進程出了問題,我們還可以使用任務管理器把這個進程「殺掉」,而不至于導致系統(tǒng)崩潰。
5.狀態(tài)轉(zhuǎn)換
Java 語言定義了 5 種線程狀態(tài),在任意一個時間點,一個線程只能有且只有其中一種狀態(tài),它們分別是:
- 新建(New):創(chuàng)建后尚未啟動的線程處于這種狀態(tài);
- 運行(Runnable):Runnable 包括了操作系統(tǒng)線程狀態(tài)中的 Running 和 Ready,也就是處于此狀態(tài)的線程有可能正在執(zhí)行,也有可能正在等待著 CPU 為它分配執(zhí)行時間;
- 無限期等待(Waiting):處于這種狀態(tài)的線程不會被分配 CPU 執(zhí)行時間,它們要等待被其它線程顯式地喚醒;以下三種方法會讓線程進入無限期等待狀態(tài):
- 沒有設置 TimeOut 參數(shù)的 Object.wait();
- 沒有設置 TimeOut 參數(shù)的 Thread.join();
- LockSupport.park()。
- 限期等待(Timed Waiting):處于這種狀態(tài)的線程也不會被分配 CPU 執(zhí)行時間,不過無需等待被其它線程顯式地喚醒,在一定時間之后它們會由系統(tǒng)自動喚醒;以下方法會讓線程進入限期等待狀態(tài):
- Thread.sleep();
- 設置了 TimeOut 參數(shù)的 Object.wait();
- 設置了 TimeOut 參數(shù)的 Thread.join();
- LockSupport.parkNanos();
- LockSupport.parkUntil()。
- 阻塞(Blocked):線程被阻塞了,「阻塞狀態(tài)」和「等待狀態(tài)」的區(qū)別是:「阻塞狀態(tài)」在等待著獲取一個排他鎖,這個事件將在另一個線程放棄這個鎖的時候發(fā)生;而「等待狀態(tài)」則是在等待一段時間,或者喚醒動作的發(fā)送。在程序等待進入同步區(qū)域時,線程將進入這種狀態(tài);
- 結(jié)束(Terminated):線程已經(jīng)結(jié)束執(zhí)行。
上述 5 中狀態(tài)遇到特定事件發(fā)生的時候?qū)ハ噢D(zhuǎn)換,如下圖:
二、線程安全與鎖優(yōu)化
本文的主題是高效并發(fā),但高效的前提是首先要保證并發(fā)的正確性和安全性,所以這一小節(jié)我們先從如何保證線程并發(fā)安全說起。
1.Java 線程安全
那么什么是線程安全呢?可以簡單的理解為多線程對同一塊內(nèi)存區(qū)域操作時,內(nèi)存值的變化是可預期的,不會因為多線程對同一塊內(nèi)存區(qū)域的操作和訪問導致內(nèi)存中存儲的值出現(xiàn)不可控的問題。
Java 語言中的線程安全
如果我們不把線程安全定義成一個非此即彼的概念(要么線程絕對安全,要么線程絕對不安全),那么我們可以根據(jù)線程安全的程度由強至弱依次分為如下五檔:
- 不可變;
- 絕對線程安全;
- 相對線程安全;
- 線程兼容;
- 線程對立。
線程安全的實現(xiàn)方法
雖然線程安全與否與編碼實現(xiàn)有著莫大的關(guān)系,但虛擬機提供的同步和鎖機制也起到了非常重要的作用。下面我們就來看看虛擬機層面是如何保證線程安全的。
同步互斥
互斥同步是常見的一種并發(fā)正確性保障的手段。同步是指在多個線程并發(fā)訪問共享數(shù)據(jù)時,保證共享數(shù)據(jù)在同一時間只被一個線程使用。而互斥是實現(xiàn)同步的一種手段。Java 中最基本的互斥同步手段就是 synchronized 關(guān)鍵字,synchronized 關(guān)鍵字在經(jīng)過編譯之后,會在同步塊的前后分別形成 monitorenter 和 monitorexit 這兩個字節(jié)碼指令,這兩個字節(jié)碼都需要一個 reference 類型的參數(shù)來指明要鎖定和解鎖的對象。如果 Java 程序中的 synchronized 明確指明了對象參數(shù),那就是這個對象的 reference;如果沒有,那就根據(jù) synchronized 修飾的是實例方法還是類方法,去取對應的對象實例或 class 對象來作為鎖對象。
根據(jù)虛擬機規(guī)范的要求,在執(zhí)行 monitorenter 指令時,首先要嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前線程已經(jīng)擁有了那個對象的鎖,就把鎖的計數(shù)器加 1;相應的,在執(zhí)行monitorexit 指令時將鎖計數(shù)器減 1,當鎖計數(shù)器為 0 時,鎖就被釋放。如果獲取鎖對象失敗,當前線程就要阻塞等待,直到對象鎖被另一個線程釋放為止。
另外要說明的一點是,同步塊在已進入的線程執(zhí)行完之前,會阻塞后面其它線程的進入。由于 Java 線程是映射到操作系統(tǒng)原生線程之上的,如果要阻塞或者喚醒一個線程,都需要操作系統(tǒng)來幫忙完成,這就需要從用戶態(tài)轉(zhuǎn)換到內(nèi)核態(tài),線程狀態(tài)轉(zhuǎn)換需要耗費很多的處理器時間。對于簡單的同步塊(如被 synchronized 修飾的 getter() 和 setter() 方法),狀態(tài)轉(zhuǎn)換消耗的時間可能比用戶代碼消耗的時間還要長。所以 synchronized 是 Java 中一個重量級的操作,因此我們只有在必要的情況下才應該使用它。當然虛擬機本身也會做相應的優(yōu)化,比如在操作系統(tǒng)阻塞線程前加入一段自旋等待過程,避免頻繁的用戶態(tài)到內(nèi)核態(tài)的轉(zhuǎn)換過程。這一點我們在介紹鎖優(yōu)化的時候再細聊。
非阻塞同步
互斥同步最大的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也成為阻塞同步。從處理問題的方式上來說,互斥同步是一種悲觀的并發(fā)策略,認為只要不去做正確的同步措施(例如加鎖),就肯定會出問題,無論共享數(shù)據(jù)是否會出現(xiàn)競爭,它都要進行加鎖(當然虛擬機也會優(yōu)化掉一些不必要的鎖)。隨著硬件指令集的發(fā)展,我們有了另外一個選擇:基于沖突檢查的樂觀并發(fā)策略。通俗的說,就是先進行操作,如果沒有其他線程競爭,那操作就成功了;如果共享數(shù)據(jù)有其它線程競爭,產(chǎn)生了沖突,就采取其它的補救措施,這種樂觀的并發(fā)策略的許多實現(xiàn)都不需要把線程掛起,因此這種同步操作稱為非阻塞同步。
前面之所以說需要硬件指令集的發(fā)展,是因為我們需要操作和沖突檢測這兩個步驟具備原子性。
這個原子性靠什么來保證呢?如果這里再使用互斥同步來保證原子性就失去意義了,所以我們只能靠硬件來完成這件事,保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類指令常用的有:
- 測試并設置(Test-and-Set)
- 獲取并增加(Fetch-and-Increment)
- 交換(Swap)
- 比較并交換(Compare-and-Swap,簡稱 CAS)
- 加載鏈接/條件存儲(Load-Linked/Store-Conditional,簡稱 LL/SC)
前三條是之前的處理器指令集里就有的,后兩條是新增的。
CAS 指令需要 3 個操作數(shù),分別是內(nèi)存位置(在 Java 中可以簡單理解為變量的內(nèi)存地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 執(zhí)行指令時,當且僅當 V 符合舊預期值 A 時,處理器用新值 B 更新 V 的值,否則他就不執(zhí)行更新,但是無論是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,該操作由 sun.misc.Unsafe 類里的 compareAndSwapInt() 和 compareAndSwapLong() 等幾個方法包裝提供,虛擬機在內(nèi)部對這些方法做了特殊處理,即時編譯出來的結(jié)果就是一條平臺相關(guān)的處理器 CAS 指令,沒有方法的調(diào)用過程,或者可以認為是無條件內(nèi)聯(lián)進去了。
由于 Unsafe 類不是提供給用戶程序調(diào)用的類,因此如果不用反射,我們只能通過其他的 Java API 來間接使用,比如 J.U.C 包里的整數(shù)原子類,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操作。
盡管 CAS 看起來很美,但是這種操作卻無法覆蓋互斥同步的所有場景,并且 CAS 從語義上來說并不是完美的。如果一個變量 V 初次讀取的時候是 A 值,并且在準備賦值的時候檢查它仍然是 A 值,那我們就能說它的值沒有被其他線程修改過嗎?如果在這段時間內(nèi)曾經(jīng)被改為了 B,后來又被改回為 A,那 CAS 操作就會認為它從來沒有被改變過。這個漏洞稱為 CAS 操作的「ABA」問題。
為了解決「ABA」問題,J.U.C 包提供了一個帶有標記的原子引用類 AtomicStamoedReference,它可以通過控制變量值的版本來保證 CAS 的正確性。不過這個類比較「雞肋」,大部分情況下 ABA 問題不會影響程序并發(fā)的正確性,如果需要解決 ABA 問題,改用傳統(tǒng)的互斥同步可能會比原子類更高效。
無同步方案
要保證線程安全不一定要進行同步,如果一個方法本來就不涉及共享數(shù)據(jù),那它自然無需任何同步措施,因此會有一些代碼天生就是線程安全的,其中就包括下面要說的可重入代碼和線程本地存儲。
可重入代碼(Reentrant Code):也叫純代碼,可以在代碼執(zhí)行的任何時候中斷它,轉(zhuǎn)而去執(zhí)行另一端代碼(包括遞歸調(diào)用自己),而在重新獲得控制權(quán)后,原來的程序不會出現(xiàn)任何錯誤??芍厝氪a有一些共同特征,例如不依賴存儲在堆上的數(shù)據(jù)和公用的系統(tǒng)資源,用到的狀態(tài)量都由參數(shù)傳入、不調(diào)用非可重入的方法等。如果一個方法的返回結(jié)果可以預測,只要輸入相同,就能返回相同的輸出,那它就是可重入代碼,當然也就是線程安全的。
線程本地存儲(Thread Local Storage):也就是說這個數(shù)據(jù)是線程獨有的,ThreadLocal 就是用來實現(xiàn)線程本地存儲的。
2.鎖優(yōu)化
HotSpot 虛擬機開發(fā)團隊花費了很大的精力實現(xiàn)了各種鎖優(yōu)化,比如自旋鎖與自適應自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等。
自旋鎖與自適應自旋
自旋鎖前面我們在聊互斥同步的時候就提到過,互斥同步對性能最大的影響就是阻塞的實現(xiàn),掛起線程和恢復線程都涉及到了用戶態(tài)到內(nèi)核態(tài)的轉(zhuǎn)換,這種狀態(tài)的轉(zhuǎn)換會給系統(tǒng)并發(fā)性能帶來很大的壓力。但是大多數(shù)場景下,共享數(shù)據(jù)的鎖定狀態(tài)只會持續(xù)很短的一段時間,為了這短暫的時間去掛起和恢復線程顯得不那么劃算。如果物理機有一個以上的處理器,能讓兩個或以上的線程同時并行處理,我們就可以讓后面請求鎖的那個線程「稍等一下」,但是不放棄處理器的執(zhí)行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只需要執(zhí)行一個空轉(zhuǎn)的循環(huán)(自旋),這就是所謂的自旋鎖。
自旋等待雖然避免了線程切換的開銷,但是它要占用處理器的時間。如果鎖被占用的時間很短,那么自旋等待的效果當然很好;反之,如果鎖被占用的時間很長,那么自旋的線程就會白白消耗處理器資源,反而形成負優(yōu)化。所以自旋等待必須有個限度,但是這個限度如果設置一個固定值并不是最有選擇,因此虛擬機開發(fā)團隊設計了自適應自旋鎖,讓自旋等待的時間不再固定,而是由前一次在同一個鎖上自旋的時間及鎖的擁有者的狀態(tài)來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運行,那么虛擬機就會認為這次自旋也有可能會成功,會將自旋等待的時間延長。如果對于某個鎖,自旋等待很少成功獲得過,那在以后要獲取這個鎖的時候就會放棄自旋。有了自適應自旋,隨著程序運行和性能監(jiān)控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越準確。
鎖消除
即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數(shù)據(jù)競爭的鎖就會進行鎖消除。所消除的主要判定依據(jù)來源于逃逸分析的數(shù)據(jù)支持,如果判定一段代碼中,堆上的所有數(shù)據(jù)都不會逃逸出去從而被其它線程訪問到,那就可以把它們當做棧上數(shù)據(jù)對待,認為它們是線程私有的,同步加鎖自然就沒必要了。
鎖粗化
我們在編碼時,總是推薦將同步塊的作用范圍限制到最小,只在共享數(shù)據(jù)的實際作用域中才進行同步,這樣是為了使得需要的同步操作數(shù)量盡可能變小,如果存在競爭,那等待鎖的線程也能盡快拿到鎖。通常,這樣做是正確的,但是如果一系列的連續(xù)操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作是出現(xiàn)在循環(huán)體中,那即使沒有線程競爭,頻繁的進行互斥同步也會導致不必要的性能損耗。那加鎖出現(xiàn)在循環(huán)體中來舉例,虛擬機遇到這種情況,就會把加鎖同步的范圍擴展(粗化)到循環(huán)體外,這樣只要加鎖一次就可以了,這就是鎖粗化。
關(guān)于輕量級鎖和偏向鎖這里就不再介紹,如果大家有興趣可以留言反饋,我在單獨發(fā)文介紹。