Java內存模型中的同步原語(volatile、synchronized、final)

目錄


一、Java內存模型的基礎
二、Java內存模型中的順序一致性
三、Java內存模型中的happens-before
四、同步原語(volatile、synchronized、final)
五、雙重檢查鎖定與延遲初始化
六、Java內存模型綜述
java

volatile的內存語義


volatile的特性

先看一下下面的例子:程序員

class VolatileExample{
    volatile long v1 = 0L;
    
    public void set(long l){
        v1 = l;
    }
    
    public void getAndIncrement(){
        v1++;
    }
    
    public long get(){
        return v1;
    }
}
複製代碼

這段代碼等價於下面的:安全

class VolatileExample{
    long v1 = 0L;
    
    public synchronized void set(long l){
        v1 = l;
    }
    
    public void getAndIncrement(){
        v1++;
    }
    
    public synchronized long get(){
        return v1;
    }
}
複製代碼

如上面程序所示,一個volatile變量的單個讀/寫操做,與一個普通變量的讀/寫操做都是使用同一個鎖來同步,他們之間的執行效果相同。數據結構

鎖的happends-before規則保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這意味着對一個volatile變量的讀,老是能看到(任意線程)對這個bolatile變量最後的寫入。多線程

鎖的語義決定了臨界區代碼的執行具備原子性。這意味着,即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。app

簡而言之,volatile變量自身具備如下特性:
一、可見性:對一個volatile變量的讀,老是能看到(任意線程)對這個bolatile變量最後的寫入。
二、原子性:對任意單個volatile變量的讀/寫操做具備原子性,但相似於volatile++這種複合操做不具備原子性。框架

volatile寫-讀創建的happens-before關係

從jdk5開始,volatile變量的寫-讀能夠實現線程之間的通訊。函數

從內存語義的角度來講,volatile的寫-讀與鎖的釋放-獲取有相同的效果:volatile寫和鎖的釋放有相同的語義;volatile讀與鎖的獲取有相同的語義。post

以下代碼:性能

private  int  count;  //普通變量
private  volatile  boolean falg;  //volatile 修飾的變量
//寫操做
public void writer(){
    count=1;   // 1
    falg=true;  //2
}
// 讀操做
public void reader(){
    if(falg){                   //3
        int  sum=count+1;       // 4
    }
}
複製代碼

假設有兩個線程:線程A調用讀方法, 線程B調用寫方法。根據happens-before規則,這個過程的創建分爲三類:
1)程序次序規則: 1 happens-before 2,3 happens-before 4
2)volatile規則:2 happens-before 3 。對一個volatile變量的寫操做先行發生於後面對這個變量的讀操做
3)傳遞規則: 1 happens-before 4 ;

轉換爲圖形化的表現形式以下:

若是falg不是volatile修飾的,那麼操做1和操做2之間沒有數據依賴性,處理器可能會對這兩個操做進行重排序,這時線程A正好執行先執行了操做2,而後這時線程B搶先執行了操做3, 發現爲true就執行if語句裏的代碼, 獲得值可能就是1,而不是咱們所預想的輸出sum=2。

volatile寫-讀的內存語義

volatile寫操做:當對一個volatile共享變量寫操做時,JAVA內存模型會當前線程對應的更新的後的本地內存中的值強制刷新到主內存中。
volatile讀操做:當讀一個volatile共享變量時,JAVA內存模型會把當前線程對應的本地內存標記爲無效,而後線程會從主內存中加載最新的值到工做內存中進行操做。

線程A寫一個volatile變量,其實就是新城A向接下來要讀取這個共享變量的某個線程,發送了一個信號,告訴它我已經修改了共享變量,你的工做內存的值要被標記無效。
線程B讀一個volatile變量,其實就是接收了以前線程A發出的修改共享變量的信號。
對一個volatile變量的寫操做,隨後對這個變量的讀操做,其實就是兩個線程之間的進行了通信。

volatile內存語義的實現

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。下面是基於保守策略的JAVA內存模型內存屏障的插入策略:

在每一個volatile寫以前插入一個StoreStore屏障
在每一個volatile寫操做的後面插入一個StoreLoad屏障
在每一個volatile讀操做的後面插入一個LoadLoad屏障
在每一個volatile讀操做的後面插入一個LoadStore屏障

volatile寫插入內存屏障後生成的指令序列示意圖以下:

爲何要加強volatile的內存語義

在JDK5以前的舊的內存模型中,雖然不容許volatile變量之間重排序,但舊的java內存模型容許volatile變量與普通變量重排序。示意圖以下:

在上圖上,1和2之間沒有數據依賴關係時,1和2就可能會被重排序。其結果就是:讀線程B執行4時,不必定能看到寫線程A在執行1操做對共享變量的修改。

所以,爲了提供一種比鎖更輕量級的線程之間通訊的機制,JDK5以後就加強了volatile的內存語義:嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和鎖的釋放-獲取具備相同的內存語義。

因爲volatile僅僅保證單個volatile變量的讀/寫具備原子性,而鎖的互斥執行的特性能夠確保對整個臨界區代碼的執行具備原子性。在功能上,鎖比volatile更強大;在可伸縮性和執行性能上,volatile更具備優點。

鎖(synchronized)的內存語義


鎖的釋放-獲取創建的happens-before關係

到這裏就記得一句話:鎖除了讓臨界區代碼互斥執行,還可讓釋放鎖的線程向同一個獲取鎖的線程發送消息

線程A獲取了鎖,執行完相應代碼,而後線程B才能去獲取到鎖,在線程B獲取到鎖的時候,線程A釋放鎖以前全部可見的共享變量都馬上對線程B可見。

鎖的釋放和獲取的內存語義

當線程釋放鎖時,JAVA內存模型會把該線程對應的本地內存中的共享變量刷新到主內存中,當另外一個線程獲取鎖的時候,JAVA內存模型會將該線程對應的本地內存設置爲無效,因此該線程必須從主內存中讀取共享變量,這就使得前一個線程在釋放鎖以後共享變量必然對另外一個線程可見。以下圖:

總結: 1)線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了消息。
2)線程B獲取一個鎖,實質上是線程B接收到了以前某個線程發出的消息。
3)線程A釋放鎖,隨後線程B獲取這個鎖,這個過程本質上是線程A經過主內存向線程B發送消息。

鎖內存語義的實現

在分析synchronized內存語義實現以前,先來看下可重入鎖(Reentrantlock)的實現例子:

加鎖和釋放鎖的方法以下:

public void lock() {
    sync.lock();
}
 
// sync.lock()實現
final void lock() {
    acquire(1);
}
 
public void unlock() {
    sync.release(1);
}
複製代碼

lock方法和unlock方法的具體實現都代理給了sync對象,來看一下sync對象的定義:

abstract static class Sync extends AbstractQueuedSynchronizer {...}
複製代碼

這裏能夠看到,Reentrantlock的實現依賴於Java同步器框架AbstractQueuedSynchronizer(本文簡稱之爲AQS)。AQS使用一個整型的volatile變量(命名爲state)來維護同步狀態,後面會有代碼。

static final class FairSync extends Sync public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
複製代碼

FairSync():公平鎖
NonfairSync():非公平鎖

公平鎖和非公平鎖的區別就是獲取鎖的規則不一樣,好比早晨起來買早餐,公平鎖就是你們都一個個排隊等待買,非公平鎖就是你來的時候正好前一我的買完走了,而你去插隊購買了。

先來看下公平鎖:

上面lock方法和unlock方法的具體實現都是由acquire和release方法完成的,而FairSync類中並無定義acquire方法和release方法,這兩個方法都是在Sync的父類AQS類中實現的。

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
     public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
複製代碼

咱們能夠看出,獲取鎖和釋放鎖的具體操做是在tryAcquire和tryRelease中實現的,而tryAcquire和tryRelease在父類AQS中只是接口,具體實現留給子類Sync。也是真正的加鎖,釋放的邏輯。

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); //獲取鎖的真正開始,首先讀取volatile變量state
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc); //寫state
                return true;
            }
            return false;
        }
複製代碼
protected final boolean tryRelease(int releases) {
            int c = getState() - releases; //讀取state
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);  //釋放鎖的最後,寫volatile變量state
            return free;
        }
複製代碼

公平鎖在釋放鎖的最後寫volatile變量state;在獲取鎖時首先讀這個volatile變量。根據volatile的happens-before規則(一個volatile變量的寫操做發生在這個volatile變量隨後的讀操做以前),釋放鎖的線程在寫volatile變量以前可見的共享變量,在獲取鎖的線程讀取同一個volatile變量後將當即變的對獲取鎖的線程可見。

非公平鎖的釋放和公平鎖徹底同樣,因此這裏僅僅分析非公平鎖的獲取。

final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
      protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
複製代碼

CAS:若是當前狀態值等於預期值,則以原子方式將同步狀態設置爲給定的更新值。此操做具備volatile讀和寫的內存語義。

這裏咱們分別從編譯器和處理器的角度來分析,CAS如何同時具備volatile讀和volatile寫的內存語義。前文咱們提到過,編譯器不會對volatile讀與volatile讀後面的任意內存操做重排序;編譯器不會對volatile寫與volatile寫前面的任意內存操做重排序。組合這兩個條件,意味着爲了同時實現volatile讀和volatile寫的內存語義,編譯器不能對CAS與CAS前面和後面的任意內存操做重排序。

書上還對X86處理器就爲cmpxchg指令加上lock前綴(lock cmpxchg).lock說明以下
1)確保對內存的讀-改-寫操做原子執行。
2)禁止該指令與以前和以後的讀和寫指令重排序。
3)把寫緩衝區中的全部數據刷新到內存中。

如今對公平鎖和非公平鎖的內存語義作個總結:
1)公平鎖和非公平鎖釋放時,最後都要寫一個volatile變量state。
2)公平鎖獲取時,首先會去讀這個volatile變量。
3)非公平鎖獲取時,首先會用CAS更新這個volatile變量,這個操做同時具備volatile讀和volatile寫的內存語義。

下圖爲我理解的圖:

從本文對ReentrantLock的分析能夠看出,鎖釋放-獲取的內存語義的實現至少有下面兩種方式:
1)利用volatile變量的寫-讀所具備的內存語義。
2)利用CAS所附帶的volatile讀和volatile寫的內存語義。

不管是公平仍是非公平性,這也解釋了Lock接口的實現類能實現和synchronized內置鎖同樣的內存數據可見性。

concurrent包的實現

因爲java的CAS同時具備 volatile 讀和volatile寫的內存語義,所以Java線程之間的通訊如今有了下面四種方式:
1)A線程寫volatile變量,隨後B線程讀這個volatile變量。
2)A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
3)A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
4)A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。

Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操做,這是在多處理器中實現同步的關鍵。同時,volatile變量的讀/寫和CAS能夠實現線程之間的通訊。把這些特性整合在一塊兒,就造成了整個concurrent包得以實現的基石。若是咱們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:
一、首先,聲明共享變量爲volatile;
二、而後,使用CAS的原子條件更新來實現線程之間的同步;
三、同時,配合以volatile的讀/寫和CAS所具備的volatile讀和寫的內存語義來實現線程之間的通訊。
AQS,非阻塞數據結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從總體來看,concurrent包的實現示意圖以下:

final的內存語義


final域的重排序規則

對於final域,編譯器和處理器要遵照兩個重排序規則

1> 在構造函數內對一個final域的寫入,與隨後把這個構造函數的引用賦值給一個引用變量,兩個操做不能重排序

2> 初次讀一個包含final域對象的引用,和隨後初次讀這個final域,這兩個操做不能重排序

class FinalExample{
	int i;//普通變量
	final int j;//final變量
	static FinalExample obj;
	public FinalExample(){//構造函數
		i = 1;//寫普通域
		j = 2;//寫final域
	}
	public static void writer(){//線程A寫執行
		obj = new FinalExample();
	}
	public static void read(){//線程B讀執行
		FinalExample fe = obj;//讀取包含final域對象的引用
		int a = fe.i;//讀取普通變量
		int b = fe.j;//讀取final變量
	}
}
複製代碼

寫final域的重排序規則

寫final域的操做不能重排序到構造函數以外,包含兩個方面

1> JMM禁止編譯器將寫final域的操做重排序到構造函數外

2> 編譯器會在final域的寫入以後,構造函數return前,插入一個StoreStore屏障,這個屏障禁止處理器把final域的寫重排序到構造函數以外

writer方法的調用,首先會構造一個實例,在將這個實例賦給一個引用,假設線程B讀沒有重排序的話

線程A中發生 寫普通域的操做重排序到構造函數外面,讀線程讀取構造函數的引用,並去讀普通域的值,就會讀取到普通域的初值,而final域因爲它的重排序特性,對final域的寫入並不會重排序到構造函數外,這樣讀線程讀取構造函數的引用是,就能正確讀取到final域初始化後的值。

結論就是: 在一個對象的引用對一個線程可見前,能保證final變量被正確初始化,而普通域不具備這個特性,由於普通域的寫入可能會重排序到構造函數外.也就是在多線程環境下,拿到一個對象的引用後,可能會出現它的普通屬性的變量尚未被正確初始化的狀況。

讀final域的重排序規則

讀取一個final域的引用和隨後讀取這個final域,不能重排序

在多線程環境下,線程A執行writer方法中,final的寫重排序規則,保證final域被其餘線程初始化時候必定是正確初始化的,線程B執行reader方法,若是讀取final域的操做重排序到讀取包含final域的對象的引用以前,final變量都尚未被初始化,這是一個錯誤的讀取操做,顯然,當final引用讀取以後,若是這個引用不爲空,可以保證final變量被初始化過,這個讀取就沒有問題

結論:多線程環境下,final域的讀取操做會重排序讀取在包含final域的引用以後,可是普通域的讀取操做可能排在,引用的前面。

final域爲引用類型

當final域是引用類型時,寫final域的重排序規則對編譯器和處理器增長下面約束:在構造函數內對一個final引用的對象的成員域的寫入,和隨後把這個構造函數的引用賦給一個引用變量,這二者之間不能重排序。

class FinalReferenceExample{
	final int[] intArray;//爲引用類型的final
	static FinalReferenceExample obj;
	public FinalReferenceExample(){//構造函數
		intArray = new int[1];//1
		intArray[0] = 1;//2
	}
	public static void writerOne(){//寫線程A執行
		obj = new FinalReferenceExample();//3
	}
	public static void writerTwo(){//寫線程B執行
		obj.intArray[0]=2;//4
	}
	public static void reader(){//讀線程C執行
		if(obj!=null){//5
			int temp = obj.intArray[0];//6
		}
}
複製代碼

如今假設一種可能,寫線程A執行完畢,寫線程B執行,讀線程C執行 寫線程A執行,根據前面final域的重排序規則,操做1對final域的寫入和操做2對final域的寫入,不會重排序到操做3對象的引用賦給一個引用變量後面,也就是讀線程C至少能夠看到 intArray[0]爲1,

而線程B的寫入和線程C存在數據競爭,讀線程C可能看不到線程B對intArray的寫入,若是想要看到,須要同步來保證內存可見性。

final引用爲什麼不能從構造函數內「溢出」

寫final域的重排序規則保證,在引用變量爲任意線程可見以前,final域已經被正確初始化了,而且還要保證: 在構造函數內部,不能讓這個對象的引用對其餘線程可見,也就是對象引用不能在構造函數內溢出。

public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;

public FinalReferenceEscapeExample () {
    i = 1;                              //1寫final域
    obj = this;                          //2 this引用在此「逸出」
}

public static void writer() {
    new FinalReferenceEscapeExample ();
}

public static void reader {
    if (obj != null) {                     //3
        int temp = obj.i;                 //4
    }
}
}
複製代碼

假設一個線程A執行writer()方法,另外一個線程B執行reader()方法。這裏的操做2使得對象還未完成構造前就爲線程B可見。即便這裏的操做2是構造函數的最後一步,且即便在程序中操做2排在操做1後面,執行read()方法的線程仍然可能沒法看到final域被初始化後的值,由於這裏的操做1和操做2之間可能被重排序。以下圖:

從上圖咱們能夠看出:在構造函數返回前,被構造對象的引用不能爲其餘線程可見,由於此時的final域可能尚未被初始化。在構造函數返回後,任意線程都將保證能看到final域正確初始化以後的值。

final語義在處理器中的實現

如今咱們以x86處理器爲例,說明final語義在處理器中的具體實現。

上面咱們提到,寫final域的重排序規則會要求譯編器在final域的寫以後,構造函數return以前,插入一個StoreStore障屏。讀final域的重排序規則要求編譯器在讀final域的操做前面插入一個LoadLoad屏障。

因爲x86處理器不會對寫-寫操做作重排序,因此在x86處理器中,寫final域須要的StoreStore障屏會被省略掉。一樣,因爲x86處理器不會對存在間接依賴關係的操做作重排序,因此在x86處理器中,讀final域須要的LoadLoad屏障也會被省略掉。也就是說在x86處理器中,final域的讀/寫不會插入任何內存屏障!

爲何要加強final的語義

在舊的Java內存模型中 ,最嚴重的一個缺陷就是線程可能看到final域的值會改變。好比,一個線程當前看到一個整形final域的值爲0(還未初始化以前的默認值),過一段時間以後這個線程再去讀這個final域的值時,卻發現值變爲了1(被某個線程初始化以後的值)。最多見的例子就是在舊的Java內存模型中,String的值可能會改變(參考文獻2中有一個具體的例子,感興趣的讀者能夠自行參考,這裏就不贅述了)。

爲了修補這個漏洞,JSR-133專家組加強了final的語義。經過爲final域增長寫和讀重排序規則,能夠爲java程序員提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造函數中沒有「逸出」),那麼不須要使用同步(指lock和volatile的使用),就能夠保證任意線程都能看到這個final域在構造函數中被初始化以後的值。

總結


保證代碼順序執行是靠插入內存屏障,禁止重排序從而保證程序代碼是按順序執行的(臨界區的代碼也不能重排序),由於JAVA內存模型是共享內存模型,在一個線程釋放完鎖以前,共享變量的值已經刷新在主內存中了,而上一個線程通訊下一個線程獲取鎖的時候,主內存中的共享變量已是最新的了,下一個線程會將本地內存設置爲無效,而後從新去主內存讀值,這樣保證了線程之間的可見性和原子性。(volatile只能保證單個volatile變量具備原子性,複合操做不確保)

相關文章
相關標籤/搜索