Java併發機制及鎖的實現原理
Java併發程式設計概述
併發程式設計的目的是為了讓程式執行得更快,但是,並不是啟動更多的執行緒就能讓程式最大限度地併發執行。在進行併發程式設計時,如果希望通過多執行緒執行任務讓程式執行得更快,會面臨非常多的挑戰,比如上下文切換的問題、死鎖的問題,以及受限於硬體和軟體的資源限制問題,本章會介紹幾種併發程式設計的挑戰以及解決方案。
上下文切換
即使是單核處理器也支援多執行緒執行程式碼,CPU通過給每個執行緒分配CPU時間片來實現這個機制。時間片是CPU分配給各個執行緒的時間,因為時間片非常短,所以CPU通過不停地切換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。CPU通過時間片分配演算法來迴圈執行任務,當前任務執行一個時間片後會切換到下一個任務。但是,在切換前會儲存上一個任務的狀態,以便下次切換回這個任務時,可以再載入這個任務的狀態。所以任務從儲存到再載入的過程就是一次上下文切換。
多執行緒一定快嗎
下面的程式碼演示序列和併發執行並累加操作的時間,請分析:下面的程式碼併發執行一定比序列執行快嗎?測試結果(具體資料與執行環境相關):public class ConcurrentTest { private static final long count=10001; public static void main(String[] args) throws InterruptedException{ concurrency(); serial(); } private static void concurrency() throws InterruptedException{ long start=System.currentTimeMillis(); Thread thread=new Thread(new Runnable(){ @Override public void run(){ int a=0; for(long i=0;i<count;i++){ a++; } } }); thread.start(); int b=0; for(long i=0;i<count;i++){ b--; } thread.join(); long time=System.currentTimeMillis()-start; System.out.println("concurrency:"+time); } private static void serial(){ long start=System.currentTimeMillis(); int a=0; for(long i=0;i<count;i++){ a++; } int b=0; for(long i=0;i<count;i++){ b--;; } long time=System.currentTimeMillis()-start; System.out.println("serial:"+time); } }
迴圈次數 |
序列執行耗時/ms |
併發執行/ms |
1萬 |
1 |
2 |
一百萬 |
7 |
4 |
一億 |
172 |
90 |
如何減少上下文切換
減少上下文切換的方法有無鎖併發程式設計、CAS演算法、使用最少執行緒和使用協程。
無鎖併發程式設計:多執行緒競爭鎖時,會引起上下文切換,所以多執行緒處理資料時,可以用一些辦法來避免使用鎖,如將資料的ID按照Hash演算法取模分段,不同的執行緒處理不同段的資料。
CAS演算法:Java的Atomic包使用CAS演算法來更新資料,而不需要加鎖。
使用最少執行緒:避免建立不需要的執行緒,如果任務很少 ,但是建立了很多的執行緒來處理,這樣會造成大量執行緒都處於等待狀態。
協程:在單執行緒裡實現多工的排程,並在單執行緒裡維護多個任務間的切換。
死鎖
鎖是一個非常有用的工具,運用場景非常多,因為它使用起來非常簡單,而且易於理解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦產生死鎖,就會造成系統功能不可用。我們先看一段程式碼,這段程式碼會引起死鎖,使執行緒threadA和執行緒threadB相互等待對方釋放鎖。
public class DeadLockDemo {
private static String A="A";
private static String B="B";
public static void main(String[] args){
new DeadLockDemo().deadLock();
}
private void deadLock(){
Thread threadA=new Thread(new Runnable(){
@Override
public void run(){
synchronized(A){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(B){
System.out.println("AB");
}
}
}
});
Thread threadB=new Thread(new Runnable(){
@Override
public void run(){
synchronized(B){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(A){
System.out.println("BA");
}
}
}
});
threadA.start();
threadB.start();
}
}
一旦出現死鎖,業務是可以感知的,因為不能繼續提供服務了。那麼,這個時候我們需要通過dump執行緒檢視到底是哪個執行緒出現了問題。
1、執行上述程式
2、在命令列下執行命令:jps -l
檢視執行在虛擬機器上的程序,找到程序的本地虛擬機器唯一ID(5024)。
3、在命令列下執行命令:jstack -l 5204
生成虛擬機器當前時刻的執行緒快照。
不難發現,兩個執行緒都已經鎖定了(Locked)一個String物件,同時又都在等待加鎖(waiting to lock)另外一個執行緒已經鎖定的一個String物件。因此產生了死鎖(deadlock)。
避免死鎖的常見方法
1、避免一個執行緒同時獲取多個鎖
2、避免一個執行緒在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源。
3、嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
資源限制的挑戰
(1)什麼是資源限制
資源限制是指在進行併發程式設計時,程式的執行速度受限於計算機硬體資源或軟體資源。
例如,伺服器的頻寬只有2Mb/s,某個資源的下載速度是1Mb/s每秒,系統啟動10個執行緒下載資源,下載速度不會變成10Mb/s,所以在進行併發程式設計時,要考慮這些資源的限制。硬體資源限制有頻寬的上傳/下載速度、硬碟讀寫速度和CPU的處理速度。軟體資源限制有資料庫的連線數和socket連線數等。
(2)資源限制引發的問題
在併發程式設計中,將程式碼執行速度加快的原則是將程式碼中序列執行的部分變成併發執行,但是如果將某段序列的程式碼併發執行,因為受限於資源,仍然在序列執行,這時候程式不僅不會加快執行,反而會更慢,因為增加了上下文切換和資源排程的時間。
(3)如何解決資源限制的問題
對於硬體資源限制,可以考慮使用叢集並行執行程式。既然單機的資源有限制,那麼就讓程式在多機上執行。比如使用ODPS、Hadoop或者自己搭建伺服器叢集,不同的機器處理不同的資料。可以通過“資料ID%機器數”,計算得到一個機器編號,然後由對應編號的機器處理這筆資料。對於軟體資源限制,可以考慮使用資源池將資源複用。比如使用連線池將資料庫和Socket連線複用,或者在呼叫對方webservice介面獲取資料時,只建立一個連線。
(4)在資源限制情況下進行併發程式設計
如何在資源限制的情況下,讓程式執行得更快呢?方法就是,根據不同的資源限制調整程式的併發度,比如下載檔案程式依賴於兩個資源——頻寬和硬碟讀寫速度。有資料庫操作時,涉及資料庫連線數,如果SQL語句執行非常快,而執行緒的數量比資料庫連線數大很多,則某些執行緒會被阻塞,等待資料庫連線。
執行緒安全
併發處理的廣泛應用是使得Amdahl定律代替摩爾定律成為計算機效能發展源動力的根本原因,也是人類壓榨計算機運算能力的最有力武器。但是我們必須保證併發的安全性,在此基礎上實現的高效併發才有意義。一般而言,併發的安全性也就是我們常說的執行緒安全。
Java語言中的執行緒安全
按照執行緒安全的安全程度由強到弱,將Java語言中的共享資料的分為如下5類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容和執行緒對立。
1、不可變
在Java語言中(JDK 1.5之後),不可變(Immutable)的物件一定是執行緒安全的。無論是物件的方法實現還是方法的呼叫者,都不需要額外採取任何的執行緒安全保障措施。只要一個不可變物件被正確的構建出來,那麼其外部的可見狀態永遠也不會改變,永遠也不會看到它在多執行緒之中處於不一致的狀態。“不可變”帶來的安全性是最簡答和最純粹的。
在Java語言中,如果共享資料是一個基本資料型別,那麼只需要在定義時使用final關鍵字修飾它就可以保證它是不可變的。
如果共享資料是一個物件,那就需要保證物件的行為不會對其狀態產生任何影響才行。例如,java.lang.String類的物件,它就是一個典型的不可變物件,我們呼叫它的substring() 、replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構建的字串物件。
保證物件行為不影響自己狀態的途徑有很多種,其中最簡單的就是把物件中帶有狀態的變數都宣告為final,這樣在建構函式結束之後,它就是不可變的。例如,java.lang.Integer建構函式,它通過將內部狀態變數value定義為final來保障狀態不變。
private final int value;
public Integer(int value) {
this.value = value;
}
2、絕對執行緒安全
當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或在呼叫方進行其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那麼這個物件就是絕對執行緒安全的。一個類要達到絕對的執行緒安全,往往需要付出很大的代價,甚至有時候是不切實際的代價。在Java API中標註自己是執行緒安全的類,大多數都不是絕對執行緒安全的。
比如說java.util.Vector是一個執行緒安全的容器,相信大家都不會有異議。因為它的add(),get()和size()這類方法都被synchronized修飾,儘管這樣效率低下,但確實是執行緒安全的。但是,即使它所有的方法都被修飾成同步的,也不意味著呼叫它的時候永遠都不再需要同步手段了。
public class VectorTest {
private static Vector<Integer> vector=new Vector<Integer>();
public static void main(String[] args){
while(true){
for(int i=0;i<10;i++){
vector.add(i);
}
Thread removeThread=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
}
});
Thread printThread=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<vector.size();i++){
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
//不要同時產生過多的執行緒,否則會導致作業系統假死
while(Thread.activeCount()>20);
}
}
}
儘管這裡使用的Vector的get()、remove()和size()方法都是同步的,但是在多執行緒環境下,如果不在方法呼叫端做額外的同步操作的話,使用這段程式碼仍然不是執行緒安全的,因為如果另一個執行緒恰好在錯誤的時間刪除了一個元素,導致列印執行緒中的序列i已經不再可用的話,再用序列i訪問陣列就會丟擲一個ArrayIndeOutOfBoundsException。
如果要保證這段程式碼的執行緒安全,我們可以將程式碼改為:
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));
}
}
}
});
3、相對執行緒安全
相對執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單獨的操作是執行緒安全的,我們在呼叫的時候不需要做額外的保護措施。但是,對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的手段來保證呼叫的正確性。在Java語言中,大部分的執行緒安全類都是屬於這種型別。例如Vector、HashTable和利用Collections的synchronizedCollection()方法包裝的集合。
4、執行緒相容
執行緒相容是指物件本身並不是執行緒安全的,但是可以通過在呼叫端正確的使用同步手段來保證物件在併發環境下可以安全的使用。我們平常說的一個類不是執行緒安全的,絕大多數時候指的是這一種情況。Java API中大部分類都是屬於執行緒相容的,例如ArrayList和HashMap。
5、執行緒對立
執行緒對立是指,無論呼叫端是否採用同步手段,都無法在多執行緒環境中併發使用。這樣的情況通常是有害的,應當儘量避免。
執行緒安全的實現方法
1、互斥同步
互斥同步是最常見的一種併發正確性保障手段。同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只能被一個執行緒使用。而互斥是實現同步的一種手段,臨界區、互斥量和訊號量都是主要的互斥實現方式。互斥是方法,同步時目的。
2、非阻塞同步
互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也成為阻塞同步。從處理問題的方式上說,互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施(例如加鎖),那就會出現問題。
隨著硬體指令集的發展(比如,現代處理器對CAS(Compare and Swap)指令的支援),我們有了另一個選擇:基於衝突檢測的樂觀併發策略,通俗的講,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成功了。如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷的嘗試,直到成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步。
3、無同步方案
要保證執行緒安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享資料爭用時的正確性的手段,如果一個方法不涉及共享資料,那它自然就無需任何同步措施保證正確性。
可重入程式碼:可重入程式碼有一些共同的特徵,例如不依賴儲存在堆上的資料和公共的系統資源、用到的狀態量都由引數傳入、不呼叫不可重入方法等。如果一個方法,它的返回結果是可預測的,只要輸入了相同的資料,就都能返回相同的結果,就是可重入的。
執行緒本地儲存:如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行?如果能保證,我們就可以把共享資料的可見範圍限定在同一個執行緒之內,這樣就無須同步也能保證執行緒之間不出現資料爭用的問題。例如,Web互動模式中的“一個請求對應一個伺服器執行緒”的處理方式,這種處理方式使得很多Web服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。例如,ThreadLocal在Spring事務管理中的應用。
Java鎖的實現
在Java SE 1.6中,鎖一共有4中狀態,級別從低到高依次是:無鎖、偏向鎖、輕量級鎖和重量級鎖。
輕量級鎖
輕量級鎖是JDK 1.6之中加入的新型鎖機制,它名字中的“輕量級”是相對於使用操作系統互斥量來實現的傳統鎖而言的,因此傳統鎖的機制就稱為重量級鎖。首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。要理解輕量級鎖,必須先介紹虛擬機器的物件記憶體佈局中的物件頭。
Java物件頭
如果物件是陣列型別,則虛擬機器用3個字寬儲存物件頭,如果是非陣列型別,則用2字寬儲存物件頭。在32位虛擬機器中,1字寬等於4位元組,即32bit。在64位虛擬機器中,一字寬等於8位元組,即64bit。
Java物件頭裡的Mark Word裡預設儲存物件的HashCode、分代年齡和鎖標記位。32位JVM的Mark Word的預設儲存結構如下:
在執行期間,Mark Word裡儲存的資料會隨著鎖標誌位和偏向鎖標誌位的變化而變化。Mark Word可能變化為儲存以下4種資料:
在64位虛擬機器下,Mark Word是64bit大小的,其儲存結構如下:
輕量級鎖的加鎖過程
在程式碼進入同步快的時候,如果此同步物件沒有被鎖定,(鎖標誌位為“01”狀態),虛擬機器首先將在當前執行緒的棧楨中建立一個鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝。 然後,虛擬將使用CAS操作嘗試將物件的MarkWord更新為指向LockWord的指標,如果這個更新操作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位將轉換為“00”,即表示此物件處於輕量級鎖定狀態。如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧楨(棧楨中的Lock Word),如果是說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊執行了。否則說明這個鎖物件已經被其他執行緒佔用了。如果有兩條或兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,此時Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也就要進入阻塞狀態。
輕量級鎖的解鎖過程
如果物件Mark Word仍然指向當前執行緒的鎖記錄(Lock Record),就用CAS操作把物件的Mark Word用當前執行緒的LockRecord(加鎖之前Mark Word的拷貝)進行替換,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他執行緒嘗試獲取該鎖,鎖就會膨脹為重量級鎖。
一旦鎖升級為重量級鎖,就不再恢復到輕量級鎖狀態。在重量級鎖狀態下,其他執行緒試圖獲取鎖時,都會被阻塞,當持有鎖的執行緒釋放鎖後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的鎖競爭。
輕量級鎖能提升程式同步效能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。
偏向鎖
偏向鎖也是JDK 1.6中引入的一項鎖優化,它的目的是消除資料在無競爭情況下的同步操作,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步操作都消除掉,連CAS操作都不做了。 偏向鎖的“偏”,就是偏心的偏,它的意思就是這個鎖會偏向於第一個獲取它的執行緒。偏向鎖的加鎖過程
假如當前虛擬機器啟用了偏向鎖,那麼,當鎖物件第一次被執行緒獲取的時候,虛擬機器會將物件頭中的鎖標誌位設為“01”,即偏向鎖模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的MarkWord之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,只需要簡單測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖,如果測試成功虛擬機器可以不再進行任何同步操作。如果測試失敗,說明有另外一個執行緒嘗試獲取這個鎖,偏向鎖模式宣告結束,執行偏向鎖的撤銷。偏向鎖的撤銷
撤銷過程:再次檢測Mark Word的偏向鎖標識位(不是鎖標識位)是否設定為1,即當前物件是否支援偏向鎖。如果沒有,則升級為輕量級鎖,如果有,則繼續嘗試使用CAS將物件頭的偏向鎖指向當前執行緒。 偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。關閉偏向鎖
偏向鎖在Java 6和java 7裡是預設啟用的,如果通過虛擬機器引數關閉偏向鎖,那麼程式預設進入輕量級鎖狀態。
鎖優化
自旋鎖與自適應自旋鎖
互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發效能帶來了很大的壓力。同時虛擬機器團隊也注意到在許多應用中,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器上有兩個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否能夠很快釋放鎖。為了讓執行緒等待,我們只需要讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。
自旋鎖在JDK 1.6中預設開啟。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長,那麼自旋的執行緒會白白浪費處理器資源,反而帶來效能上的浪費。因此,自旋等待的時間必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲取鎖,就應該使用傳統的方式來掛起執行緒了。
在JDK 1.6中引入了自適應的自旋鎖,自適應意味著自選的時間不再固定了,而是由上一次在同一個鎖上自旋時間以及鎖的擁有者的狀態來決定。
鎖消除
鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。
例如,如下程式碼(看起來沒有同步的程式碼)
public String concatString(String s1,String s2,String s3){
return s1+s2+s3;
}
我們知道,由於String是一個不可變的類,對字串的連線操作總是通過生成新的String物件來進行的,因此Javac編譯器會對String連線做自動優化。在JDK 1.5之後,會轉化成StringBuffer物件的連續append()操作。
public String concatString(String s1,String s2,String s3){
StringBuffer sb=new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
現在大家還認為這段程式碼沒有同步嗎?每個StringBuffer.append()方法都有一個同步塊,鎖就是sb物件。虛擬機器觀察變數sb,很快就會發現它的動態作用域限制在concatString()方法內部,其他執行緒無法訪問它,因此雖然這裡有鎖,但是可以被完全的消除掉,在即時編譯後,這段程式碼就會忽略掉所有的同步而直接執行了。
鎖粗化
原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。
大部分情況下,上面的原則是對的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作時出現在迴圈體中,那及時沒有鎖競爭,頻繁的進行互斥同步操作也會導致不必要的效能損耗。
連續的StringBuffer.append()方法就屬於這類情況,如果虛擬機器探測到有這樣一串零碎的操作都對同一個物件加鎖,將會把加鎖同步不範圍(粗化)到整個操作序列的外部(例如,第一個append()操作之前知道最後一個appen()操作之後,這樣只需要加鎖一次就可以了)。
原子操作
原子操作就是“不可被中斷的一個或一些列操作”。在併發程式設計中,原子操作可以說是最常見的一個術語。處理器如何實現原子操作
首先處理器會自動保證基本的記憶體操作的原子性,處理器保證從系統記憶體中讀取或者寫入一個位元組是原子的,意思就是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的記憶體地址。 1、使用匯流排鎖保證原子性 如果多個處理器同時對共享變數進行讀後寫操作(自增操作i++就是典型的讀後寫操作),那麼共享變數就會被多個處理器同時進行操作,這樣讀後寫操作就不是原子性的了,操作完之後共享變數的值會和期望的不一致。例如,初始化共享變數i=1,我們進行兩次i++操作,我們期望的結果是3,但是可能結果是2。所謂匯流排鎖就是使用處理器提供的一個LOCK#訊號,當一個處理器需要加鎖操作共享記憶體時,該處理器在總線上輸出此訊號,那麼其他處理器的請求將被阻塞住,那麼該處理器就可以獨佔共享記憶體。
2、使用快取鎖定保證原子性
在同一時刻,我們只需要保證對某個記憶體地址的操作是原子性的即可,但匯流排鎖把CPU和記憶體之間的通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖定的開銷非常大,目前處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。
所謂快取鎖定是指,在最新的處理器中,如果訪問的記憶體區域已經快取在處理器內部,則不會聲言LOCK#訊號。相反地,它會鎖定這塊記憶體區域的快取並回寫到記憶體,並使用快取一致性機制來確保修改的原子性,此操作被稱為“快取鎖定”,快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料。一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。
Java如何實現原子操作
1、使用迴圈CAS實現原子操作
所謂CAS,簡單說來就是,CAS有3個運算元,記憶體值V,舊的預期值A,要修改的新值B。當且僅當預期值A和記憶體值V相同時,將記憶體值V修改為B。這是一種樂觀鎖的思路,它相信在它修改之前,沒有其它執行緒去修改它;類似資料庫中樂觀鎖的版本控制機制。
JVM中的CAS操作是利用了處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是迴圈進行CAS操作直到成功為止。
public class CASCounting {
private static AtomicInteger atomicI=new AtomicInteger(0);
private static int i=0;
private static int si=0;
//用CAS保證自增操作原子性
private static void safeCount(){
for(;;){
int current=atomicI.get();
int next=current+1;
boolean suc=atomicI.compareAndSet(current, next);
if(suc){
break;
}
}
}
//非原子性操作
private static void unsafeCount(){
i++;
}
//使用加鎖同步保證原子性
private synchronized static void synCount(){
si++;
}
public static void main(String[] args){
int count=1000;
Thread[] threads=new Thread[count];
long start=System.currentTimeMillis();
for(int i=0;i<count;i++){
threads[i]=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<1000;i++){
//safeCount();
//unsafeCount();
synCount();
}
}
});
}
for(int i=0;i<count;i++){
threads[i].start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
//System.out.println(atomicI.get());
//System.out.println(i);
System.out.println(si);
System.out.println(System.currentTimeMillis()-start);
}
}
輸出結果分別是:978018——93;1000000——110;1000000——218;
CAS實現原子操作的三大問題
ABA問題:因為CAS需要操作值的時候,檢查值有沒有變化,如果沒有變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號,在變數前面追加一個版本號,每次變數更新的時候把版本號加1。
迴圈時間長開銷大:自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。
只能保證一個共享變數的原子操作:對於多個共享變數的操作,迴圈CAS就無法保證操作的原子性了,這個時候就可以用鎖。
2、使用鎖機制實現原子操作
鎖機制保證只有獲得鎖的執行緒才能夠操作鎖定的記憶體區域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了迴圈CAS,即當一個執行緒想要進入同步塊的時候使用迴圈CAS的方式來獲取鎖,當它退出同步塊的時候,使用迴圈CAS釋放鎖。
Java併發機制的實現
Java記憶體模型
Java執行緒之間的通訊由Java記憶體模型(JMM,Java Memory Model)控制,JMM決定了一個執行緒的共享變數的寫入何時對另一個執行緒可見。從抽象角度來看,JMM定義了執行緒和記憶體之間的抽象關係,執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體(也稱工作記憶體),本地記憶體是JMM的一個抽象概念,並不是真實存在的。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。Java記憶體模型的抽象示意圖如下:
重排序
在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分3種
1、編譯器優化的重排序,編譯器在不改變單執行緒程式語義的前提下,可以重排序語句的執行順序。
2、指令集並行的重排序,現代處理器採用了指令集並行技術(流水線技術)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
3、記憶體系統的重排序,由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去時在亂序執行。
重排序對多執行緒的影響
public class ReorderExample {
int a=0;
boolean flag=false;
public void writer(){
a=1; //1
flag=true; //2
}
public void reader(){
if(flag){ //3
int i=a*a; //4
}
}
}
flag變數是個標記,用來標識變數a是否已被寫入。這裡假設有兩個執行緒A和B,A首先執行writer()方法,隨後B執行緒接著執行reader()方法。執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入呢?
答案是:不一定能看到。
由於操作1和操作2沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什麼效果?請看下面的程式執行時序圖:
操作1和操作2做了重排序。程式執行時,執行緒A首先寫標記變數flag,隨後執行緒B讀這個變數。由於條件判斷為真,執行緒B將讀取變數a。此時,變數a還沒有被執行緒A寫入,在這裡多執行緒程式的語義被重排序破壞了!
Volatile
實現原理
如果對聲明瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。1、Lock字首指令會引起處理器快取回寫到記憶體
2、一個處理器的快取回寫到記憶體會導致其他處理器的快取無效
Volatile的特性
一個volatile變數的單個讀/寫操作,與一個普通變數的讀/寫操作都使用同一個鎖來同步,他們之間的執行效果相同。
public class VolatileFeature {
volatile long vl=0l;
public void set(long l){
vl=l;
}
public void getAndIncrement(){
vl++;
}
public long get(){
return vl;
}
}
等價於
public class VolatileFeature {
long vl=0l;
public synchronized void set(long l){
vl=l;
}
//由於volatile變數的自增操作是一個複合操作,不能保證原子性
public void getAndIncrement(){
long temp=get();
temp+=1l;
set(temp);
}
public synchronized long get(){
return vl;
}
}
volatile寫-讀的記憶體語意
當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體(工作記憶體)中的共享變數重新整理到貯存。
當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主存中讀取共享變數。
使用volatile需要注意的問題
1、volatile關鍵字不能保證volatile變數複合操作的原子性
public class VolatileCounting {
private static volatile int count=0;
private static void addCount(){
count++;
}
public static void main(String[] args){
int threadCount=1000;
Thread[] threads=new Thread[threadCount];
for(int i=0;i<threadCount;i++){
threads[i]=new Thread(new Runnable(){
@Override
public void run(){
for(int j=0;j<1000;j++){
addCount();
}
}
});
}
for(int i=0;i<threadCount;i++){
threads[i].start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(count);
}
}
輸出:996840
2、對64位long和double型變數的非原子性協定
java記憶體模型,允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行。如果有多個執行緒共享一個並未宣告為volatile的long或double型別的變數,並且同時對他們進行讀取或修改操作,那麼某些執行緒可能會讀取到一個即非原值,也不是其他執行緒修改值的“中間值”。
long和double佔用的位元組數都是8,也就是64bits。在64位作業系統上,JVM中double和long的賦值操作是原子操作。但是在32位作業系統上對64位的資料的讀寫要分兩步完成,每一步取32位資料。這樣對double和long的賦值操作就會有問題:如果有兩個執行緒同時寫一個變數記憶體,一個程序寫低32位,而另一個寫高32位,這樣將導致獲取的64位資料是失效的資料。因此需要使用volatile關鍵字來防止此類現象。volatile本身不保證獲取和設定操作的原子性,僅僅保持修改的可見性。但是java的記憶體模型保證宣告為volatile的long和double變數的get和set操作是原子的。
public class LongVolatile {
private static long value;
private static void set0(){
value=0;
}
private static void set1(){
value=-1;
}
public static void main(String[] args) {
System.out.println(Long.toBinaryString(-1));//-1的64位表示
System.out.println(pad(Long.toBinaryString(0),64));//0的64位表示
Thread t0=new Thread(new Runnable(){
@Override
public void run(){
set0();
}
});
Thread t1=new Thread(new Runnable(){
@Override
public void run(){
set1();
}
});
t0.start();
t1.start();
long temp;
while ((temp = value) == -1 || temp == 0) {
//如果靜態成員value的值是-1或0,說明兩個執行緒操作沒有交叉
}
System.out.println(pad(Long.toBinaryString(temp), 64));
System.out.println(temp);
t0.interrupt();
t1.interrupt();
}
// 將0擴充套件
private static String pad(String s, int targetLength) {
int n = targetLength - s.length();
for (int x = 0; x < n; x++) {
s = "0" + s;
}
return s;
}
}
在32位作業系統上,我們開啟兩個執行緒,對long型別的共享變數,不停的進行賦值操作,而主執行緒檢測是否產生“中間值”。結果肯呢為:
1111111111111111111111111111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000011111111111111111111111111111111
4294967295
或者
1111111111111111111111111111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000000
1111111111111111111111111111111100000000000000000000000000000000
-4294967296
如果我們將變數宣告為volatile或者將程式在64位作業系統上執行,那麼程式將進入死迴圈。由於賦值操作具有原子性,不會出現所謂的中間結果。
3、volatile可以禁止重排序
例如,利用volatile可以實現雙重檢驗鎖的單例模式。
Synchronized
實現原理
在多執行緒併發程式設計中synchronized一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,隨著Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了。本文已經較為詳細的介紹了Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的效能消耗而引入的偏向鎖和輕量級鎖,以及鎖的儲存結構和升級過程。 先來看下利用synchronized實現同步的基礎:Java中的每一個物件都可以作為鎖。具體表現為以下3種形式。
·對於普通同步方法,鎖是當前例項物件。
·對於靜態同步方法,鎖是當前類的Class物件。
·對於同步方法塊,鎖是Synchonized括號裡配置的物件。
鎖的釋放和獲取的記憶體語義
當執行緒釋放鎖時,JVM會把該執行緒對應的本地記憶體(工作記憶體)中的共享變數重新整理到主記憶體中。
當執行緒獲取鎖時,JVM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。
對比鎖釋放-獲取的記憶體語義與volatile寫-讀的記憶體語義,可以看出:鎖釋放與volatile寫有相同的記憶體語義;鎖獲取與volatile讀有相同的記憶體語義。
final
final域的記憶體語義
寫final域的重排序規則可以確保:在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化了,而普通域不具有這個保障。
讀final域的重排序規則可以確保:在讀取一個物件的final之前,一定會先讀取包含這個final域的物件的引用。
內容源自:
《深入理解java虛擬機器》
《Java併發程式設計的藝術》