1. 程式人生 > >深入理解JVM(執行緒部分) Note

深入理解JVM(執行緒部分) Note

硬體的效率與一致性

由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?

Java記憶體模型

Java虛擬機器規範中試圖定義一種Java記憶體模型 (Java Memory Model,JMM)來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數(Variables)與Java程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的 ,不會被共享,自然就不會存在競爭問題。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中。每條執行緒還有自己的工作記憶體(Working Memory,可與前面講的處理器快取記憶體類比),執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數 。

Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的。

有不少讀者會對這段描述中的“拷貝副本”提出疑問,如“假設執行緒中訪問一個10MB的物件,也會把這10MB的記憶體複製一份拷貝出來嗎?”,事實上並不會如此,這個物件的引用、物件中某個線上程訪問到的欄位是有可能存在拷貝的,但不會有虛擬機器實現成把整個物件拷貝A一次。

volatile

i++。即使編譯出來只有一條位元組碼指令,也並不意味執行這條指令就是一個原子操作。一條位元組碼指令在解釋執行時,直譯器將要執行許多行程式碼才能實現它的語義。

volatile使用場景
下面的這類場景就很適合使用volatile變數來控制併發,當shutdown()

方法被呼叫時,能保證所有執行緒中執行的doWork()方法都立即停下來

volatile boolean shutdownRequested;
public void shutdown(){
shutdownRequested=true;
}
public void doWork(){
while(!shutdownRequested){
//do stuff
}
}

如果定義initialized變數時沒有使用volatile修飾,就可能會由於指令重排序的優化,導致位於執行緒A中最後一句的程式碼“initialized=true”被提前執行(這裡雖然使用Java作為虛擬碼,但所指的重排序優化是機器級的優化操作,提前執行是指這句話對應的彙編程式碼被提前執行)。

那為何說它禁止指令重排序呢?
從硬體架構上講,指令重排序是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理。但並不是說指令任意重排,CPU需要能正確處理指令依賴情況以保障程式能得出正確的執行結果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)* 2與A* 2+10顯然不相等,但指令3可以重排到指令1、2之前或者中間,只要保證CPU執行後面依賴到A、B值的操作時能獲取到正確的A和B值即可。

效率
volatile變數讀操作的效能消耗與普通變數幾乎沒有什麼差別,但是寫操作則可能會慢一些,因為它需要在原生代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下volatile的總開銷仍然要比鎖低。

其他
我們在編寫程式碼時一般不需要把用到的long和double變數專門宣告為volatile。

除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final

Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一個時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

讀者有沒有發現synchronized關鍵字在需要這3種特性的時候都可以作為其中一種的解決方案?看起來很“萬能”吧。的確,大部分的併發控制操作都能使用synchronized來完成。synchronized的“萬能”也間接造就了它被程式設計師濫用的局面,越“萬能”的併發控制,通常會伴隨著越
大的效能影響,這點我們將在第13章講解虛擬機器鎖優化時再介紹。

先行發生原則
判斷是否安全

private int value=0;
pubilc void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}

假設存線上程A和B,執行緒A先(時間上的先後)調了“setValue(1)”,然後執行緒B呼叫了同一個對的“getValue()”,那麼執行緒B收到的返回值是什麼?
這裡面的操作不是執行緒安全的。
那怎麼修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇:要麼把getter/setter方法都定義為synchronized方法,這樣就可以套用管程鎖定規則;要麼把value定義為volatile變數,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變數規則來實現先行發生關係。

Java與執行緒

併發不一定要依賴多執行緒(如PHP中很常見的多程序併發),但是在Java裡面談論併發,大多數都與執行緒脫不開關係。
我們注意到Thread類與大部分的Java API有顯著的差別,它的所有關鍵方法都是宣告為Native的。

實現執行緒主要有3種方式

  • 使用核心執行緒實現(作業系統的執行緒)
  • 使用使用者執行緒實現
    從廣義上來講,一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒(User Thread,UT)
    而狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也可以支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型
    Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄使用它。
  • 使用使用者執行緒加輕量級程序混合實現

Java執行緒在JDK 1.2之前,是基於稱為“綠色執行緒”(Green Threads)的使用者執行緒實現的,而在JDK 1.2中,執行緒模型替換為基於作業系統原生執行緒模型來實現。
對於Sun JDK來說,它的Windows版與Linux版都是使用一對一的執行緒模型實現的,一條Java執行緒就對映到一條輕量級程序之中,因為Windows和Linux系統提供的執行緒模型就是一對一的。

Java執行緒排程
執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種,分別是協同式執行緒排程(Cooperative Threads-Scheduling)和搶佔式執行緒排程(Preemptive Threads-Scheduling)。

如果使用協同式排程的多執行緒系統,執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上。協同式多執行緒的最大好處是實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以沒有什麼執行緒同步的問題。
Lua語言中的“協同例程”就是這類實現。它的壞處也很明顯:執行緒執行時間不可控制,甚至如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。

雖然Java執行緒排程是系統自動完成的,但是我們還是可以“建議”系統給某些執行緒多分配一點執行時間,另外的一些執行緒則可以少分配一點——這項操作可以通過設定執行緒優先順序來完成。Java語言一共設定了10個級別的執行緒優先順序(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在兩個執行緒同時處於Ready狀態時,優先順序越高的執行緒越容易被系統選擇執行。

狀態轉換
阻塞(Blocked):執行緒被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待著獲取到一個排他鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。

執行緒安全與鎖優化

在軟體業發展的初期,程式編寫都是以演算法為核心的,程式設計師會把資料和過程分別作為獨立的部分來考慮,資料代表問題空間中的客體,程式程式碼則用於處理這些資料,這種思維方式直接站在計算機的角度去抽象問題和解決問題,稱為面向過程的程式設計思想(順序)。與此相對的是,面向物件(歸類)的程式設計思想是站在現實世界的角度去抽象和解決問題,它把資料和行為都看做是物件的一部分,這樣可以讓程式設計師能以符合現實世界的思維方式來編寫和組織程式。

當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件是執行緒安全的。

Java語言中的執行緒安全

我們這裡討論的執行緒安全,就限定於多個執行緒之間存在共享資料訪問這個前提,因為如果一段程式碼根本不會與其他執行緒共享資料,那麼從執行緒安全的角度來看,程式是序列執行還是多執行緒執行對它來說是完全沒有區別的。

我們可以將Java語言中各種操作共享的資料分為以下5類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容和執行緒對立。

不可變
Java語言中,如果共享資料是一個基本資料型別,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。如果共享資料是一個物件,那就需要保證物件的行為不會對其狀態產生任何影響才行,如果讀者還沒想明白這句話,不妨想一想java.lang.String類的物件,它是一個典型的不可變物件,我們呼叫它的substring()、replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構造的字串物件。

保證物件行為不影響自己狀態的途徑有很多種,其中最簡單的就是把物件中帶有狀態的變數都宣告為final,這樣在建構函式結束之後,它就是不可變的.

絕對執行緒安全
如果說java.util.Vector是一個執行緒安全的容器,相信所有的Java程式設計師對此都不會有異議,因為它的add()、get()和size()這類方法都是被synchronized修飾的,儘管這樣效率很低,但確實是安全的。但是,即使它所有的方法都被修飾成同步,也不意味著呼叫它的時候永遠都不再需要同步手段了.
很明顯,儘管這裡使用到的Vector的get()、remove()和size()方法都是同步的,但是在多執行緒的環境中,如果不在方法呼叫端做額外的同步措施的話,使用這段程式碼仍然是不安全的,因為如果另一個執行緒恰好在錯誤的時間裡刪除了一個元素,導致序號i已經不再可用的話,再用i訪問陣列就會丟擲一個ArrayIndexOutOfBoundsException.
必須加入同步以保證Vector訪問的執行緒安全性

Thread removeThread=new Thread(new Runnable(){
@Override
public void run(){
synchronized(vector){
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
}
}
});
Thread printThread=new Thread(new Runnable(){
@Override
public void run(){
synchronized(vector){
for(int i=0;i<vector.size();i++){
System.out.println((vector.get(i)));
}
}
}
})

相對執行緒安全
對的執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單獨的操作是執行緒安全的,我們在呼叫的時候不需要做額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。
在Java語言中,大部分的執行緒安全類都屬於這種型別,例如Vector、HashTable、Collections的synchronizedCollection()方法包裝的集合等。

執行緒安全的實現方法(三種)

1.互斥同步
互斥同步(Mutual Exclusion&Synchronization)是常見的一種併發正確性保障手段。同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一個(或者是一些,使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式。因此,在這4個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令,這兩個位元組碼都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java程式中的synchronized明確指定了物件引數,那就是這個物件的reference;如果沒有明確指定,那就根據synchronized修飾的是例項方法還是類方法,去取對應的物件例項或Class物件來作為鎖物件。

根據虛擬機器規範的要求,在執行monitorenter指令時,首先要嘗試獲取物件的鎖。如果這個物件沒被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將鎖計數器減1,當計數器為0時,鎖就被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。

除了synchronized之外,我們還可以使用java.util.concurrent(下文稱J.U.C)包中的重入鎖(ReentrantLock)來實現同步,相比synchronized,ReentrantLock增加了一些高階功能,主要有以下3項:等待可中斷、可實現公平鎖,以及鎖可以繫結多個條件。

公平鎖
指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布林值的構造數要求使用公平鎖。

如果讀者的程式是使用JDK 1.6或以上部署的話,效能因素就不再是選擇
ReentrantLock的理由了,虛擬機器在未來的效能改進中肯定也會更加偏向於原生的synchronized,所以還是提倡在synchronized能實現需求的情況下,優先考慮使用synchronized來進行同步。

2.非阻塞同步
互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也稱為阻塞同步。
樂觀:基於衝突檢測的樂觀併發策略,通俗地說,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成功了;如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步。

使用AtomicInteger代替int後,程式輸出了正確的結果,一切都要歸功於incrementAndGet()方法的原子性。它的實現其實非常簡單。

/**
*Atomically increment by one the current value.
*@return the updated value
*/
public final int incrementAndGet(){
for(;){
int current=get();
int next=current+1;
if(compareAndSet(current,next))
return next;
}
}

CAS指令需要有3個運算元,分別是記憶體位置(在Java中可以簡單理解為變數的記憶體地址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,上述的處理過程是一個原子操作。

incrementAndGet()方法在一個無限迴圈中,不斷嘗試將一個比當前值大1的新值賦給自己。如果失敗了,那說明在執行“獲取-設定”操作的時候值已經有了修改,於是再次迴圈進行下一次操作,直到設定成功為止。

儘管CAS看起來很美,但顯然這種操作無法涵蓋互斥同步的所有使用場景,並且CAS從語義上來說並不是完美的,存在這樣的一個邏輯漏洞:如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然為A值,那我們就能說它的值沒有被其他執行緒改變過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA”問題。J.U.C包為了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變數值的版本來保證CAS的正確性。不過目前來說這個類比較“雞肋”,大部分情況下ABA問題不會影響程式併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

3.無同步方案
要保證執行緒安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享資料爭用時的正確性的手段,如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正確性,因此會有一些程式碼天生就是執行緒安全的,筆者簡單地介紹其中的兩類。

我們可以通過一個簡單的原則來判斷程式碼是否具備可重入性:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的資料,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是執行緒安全的。

可以通過java.lang.ThreadLocal類來實現執行緒本地儲存的功能。每一個執行緒的Thread物件中都有一個ThreadLocalMap物件,這個物件儲存了一組以ThreadLocal.threadLocalHashCode為鍵,以本地執行緒變數為值的K-V值對,ThreadLocal物件就是當前執行緒的ThreadLocalMap的訪問入口,每一個ThreadLocal物件都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以線上程K-V值對中找回對應的本地執行緒變數。

鎖優化

高效併發是從JDK 1.5到JDK 1.6的一個重要改進,HotSpot虛擬機器開發團隊在這個版本上花費了大量的精力去實現各種鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking)等,這些技術都是為了線上程之間更高效地共享資料,以及解決競爭問題,從而提高程式的執行效率。

自旋鎖與自適應自旋
自旋鎖 是一種 互斥鎖 的實現方式而已,相比一般的互斥鎖會在等待期間放棄cpu,自旋鎖(spinlock) 則是不斷迴圈並測試鎖的狀態,這樣就一直佔著cpu。
互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發效能帶來了很大的壓力。同時,虛擬機器的開發團隊也注意到在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快鎖。

自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是10次,使用者可以使用引數-XX:PreBlockSpin來更改。
在JDK 1.6中引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個迴圈。另外,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。


public class SpinLock {

  private AtomicReference<Thread> sign =new AtomicReference<>();

  public void lock(){
    Thread current = Thread.currentThread();
    while(!sign .compareAndSet(null, current)){
    }
  }

  public void unlock (){
    Thread current = Thread.currentThread();
    sign .compareAndSet(current, null);
  }
}

鎖消除
鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援(第11章已經講解過逃逸分析技術),如果判斷在一段程式碼中,堆上的所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把它們當做棧上資料對待,認為它們是執行緒私有的,同步加鎖自然就無須進行。

public String concatString(String s1,String s2,String s3){
return s1+s2+s3;
}

我們也知道,由於String是一個不可變的類,對字串的連線操作總是通過生成新的String物件來進行的,因此Javac編譯器會對String連線做自動優化。在JDK 1.5之前,會轉化為StringBuffer物件的連續append()操作,在JDK 1.5及以後的版本中,會轉化為StringBuilder物件的連續append()操作.

鎖粗化
原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在鎖競爭,那等待鎖的執行緒也能儘快拿到鎖。
大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

連續的append()方法就屬於這類情況。如果虛擬機器探測到有這樣一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部,以程式碼清單13-7為例,就是擴充套件到第一個append()操作之前直至最後一個append()操作之後,這樣只需要加鎖一次就可以了。

輕量級鎖
首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量
產生的效能消耗。CAS

偏向鎖
偏向鎖也是JDK 1.6中引入的一項鎖優化,它的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

偏向鎖的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。