java並發編程(2)--volatile(轉)


轉載:http://ifeve.com/volatile/

作者:方 騰飛

花名清英,並發網(ifeve.com)創始人,暢銷書《Java並發編程的藝術》作者,螞蟻金服技術專家。目前工作於支付寶微貸事業部,關注互聯網金融,並發編程和敏捷實踐。

  1. Volatile是輕量級的synchronized,它在多處理器開發中保證了共享變量的“可見性”。
  2. 可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。
  3. 共享變量:在多個線程之間能夠被共享的變量被稱為共享變量。共享變量包括所有的實例變量,靜態變量和數組元素。他們都被存放在堆內存中,volatile只作用域共享變量。
  4. 內存屏障(Memory Barriers):是一組處理器指令,用於實現對內存操作的順序限制。
  5. 緩沖行(Cache line):緩存中可以分配的最小存儲單位。處理器填寫緩存線時會加載整個緩存線,需要使用多個主內存讀周期。
  6. 原子操作(Atomic operations):不可中斷的一個或一系列操作。
  7. 緩存行填充(cache line fill):當處理器識別到從內存中讀取操作數是可緩存的,處理器讀取整個緩存行到適當的緩存(L1,L2,L3的或所有)。
  8. 緩存命中(cache hit):如果進行高速緩存行填充操作的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操作數,而不是從內存。
  9. 寫命中(write hit):當處理器將操作數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,如果存在一個有效的緩存行,則處理器將這個操作數寫回到緩存,而不是寫會到內存,這個操作被稱為寫命中。
  10. 寫缺失(write misses the cache):一個有效的緩存行被寫入到不存在的內存區域。

Volatile的官方定義

java語言規范第三版中對volatile的定義如下:java編程語言允許線程訪問共享變量,為了確保共享變量能被准確和一致的更新,線程應該確保通過排它鎖單獨獲得這個變量。java語言提供了volatile,在某些情況下比鎖更加方便。如果一個字段被聲明或volatile,java線程內存模型確保所有線程看到這個變量值是一致的。

為什么要用volatile

volatile變量修飾符如果使用恰當的話,它比synchronized的使用和執行成本會更低,因為它不會引起線程上下文的切換和調度。

 volatile的實現原理

在x86處理器下通過工具獲取JIT編譯器生成的匯編指令來看看對Volatile進行寫操作CPU會做什么事情。

有volatile變量修飾的共享變量進行寫操作的時候會多第二行匯編代碼,通過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會發生兩件事情:

  • 將當前處理器緩存行的數據寫回到系統內存
  • 這個寫回內存的操作會引起在其他cpu里緩存了該地址的數據無效

處理器為了提高處理速度,不知和內存進行通訊,而是先將系統內存的數據讀到內部緩存(L1,L2或其他)后再進行操作,但操作完之后不知道何時會寫到內存,如果對聲明了volatile變量進行寫操作,jvm就會向處理器發送一條lock前綴的指令,將這個變量所在的緩存行的數據寫回到系統內存。但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會出問題,所以在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對象的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存里把數據讀到處理器緩存里。

這兩件事情在IA-32軟件開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述。

Lock前綴指令會引起處理器緩存回寫到內存。Lock前綴指令導致在執行指令期間,聲言處理器的 LOCK# 信號。在多處理器環境中,LOCK# 信號確保在聲言該信號期間,處理器可以獨占使用任何共享內存。(因為它會鎖住總線,導致其他CPU不能訪問總線,不能訪問總線就意味着不能訪問系統內存),但是在最近的處理器里,LOCK#信號一般不鎖總線,而是鎖緩存,畢竟鎖總線開銷比較大。在8.1.4章節有詳細說明鎖定操作對處理器緩存的影響,對於Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#信號。但在P6和最近的處理器中,如果訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK#信號。相反地,它會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操作被稱為“緩存鎖定”,緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據

一個處理器的緩存回寫到內存會導致其他處理器的緩存無效。IA-32處理器和Intel 64處理器使用MESI(修改,獨占,共享,無效)控制協議去維護內部緩存和其他處理器緩存的一致性。在多核處理器系統中進行操作的時候,IA-32 和Intel 64處理器能嗅探其他處理器訪問系統內存和它們的內部緩存。它們使用嗅探技術保證它的內部緩存,系統內存和其他處理器的緩存的數據在總線上保持一致。例如在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處理共享狀態,那么正在嗅探的處理器將無效它的緩存行,在下次訪問相同內存地址時,強制執行緩存行填充。

 


 

以下轉載:http://www.cnblogs.com/dolphin0520/p/3920373.html, 作者海子

java內存模型

 

一.內存模型的相關概念

  大家都知道,計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到數據的讀取和寫入。由於程序運行過程中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,由於CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。因此在CPU里面就有了高速緩存。

  也就是,當程序在運行過程中,會將運算需要的數據從主存復制一份到CPU的高速緩存當中,那么CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之后,再將高速緩存中的數據刷新到主存當中。舉個簡單的例子,比如下面的這段代碼:

1
i = i +  1 ;

   當線程執行這個語句時,會先從主存當中讀取i的值,然后復制一份到高速緩存當中,然后CPU執行指令對i進行加1操作,然后將數據寫入高速緩存,最后將高速緩存中i最新的值刷新到主存當中。

  這個代碼在單線程中運行是沒有任何問題的,但是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不同的CPU中,因此每個線程運行時有自己的高速緩存(對單核CPU來說,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。本文我們以多核CPU為例。

  比如同時有2個線程執行這段代碼,假如初始時i的值為0,那么我們希望兩個線程執行完之后i的值變為2。但是事實會是這樣嗎?

  可能存在下面一種情況:初始時,兩個線程分別讀取i的值存入各自所在的CPU的高速緩存當中,然后線程1進行加1操作,然后把i的最新值1寫入到內存。此時線程2的高速緩存當中i的值還是0,進行加1操作之后,i的值為1,然后線程2把i的值寫入內存。

  最終結果i的值是1,而不是2。這就是著名的緩存一致性問題。通常稱這種被多個線程訪問的變量為共享變量。

  也就是說,如果一個變量在多個CPU中都存在緩存(一般在多線程編程時才會出現),那么就可能存在緩存不一致的問題。

  為了解決緩存不一致性問題,通常來說有以下2種解決方法:

  1)通過在總線加LOCK#鎖的方式

  2)通過緩存一致性協議

  這2種方式都是硬件層面上提供的方式。

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。因為CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。比如上面例子中 如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在總線上發出了LCOK#鎖的信號,那么只有等待這段代碼完全執行完畢之后,其他CPU才能從變量i所在的內存讀取變量,然后進行相應的操作。這樣就解決了緩存不一致的問題。

  但是上面的方式會有一個問題,由於在鎖住總線期間,其他CPU無法訪問內存,導致效率低下。

  所以就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置為無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那么它就會從內存重新讀取。

 

 

在java虛擬機規范中視圖定義一種java內存模型(java memory model,jmm)來屏蔽各個硬件平台和操作系統的內存訪問差異,以實現讓java程序在各種平台下都能達到一致的內存訪問效果。它定義了程序中變量的訪問規則,就是定義了程序的執行次序。注意,為了獲得較好的性能,java內存模型並沒有限制執行引擎使用處理器的寄存器或者告訴緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java內存模型中,也會存在緩存一致性問題和指令重排序問題。

java內存模型規定所有的變量都是存在主存當中(類似前面說的物理內存),每個線程都有自己的工作內存(類似前面的高速緩存)。線程對變量的所有操作都必須在工作內存中進行,而不能直接對主存進行操作。每個線程不能訪問其他線程的工作內存。舉個簡單的例子:在java中,執行下面的語句:

i=10;

  執行線程必須先在自己的工作線程中對變量i所在的緩存行進行賦值操作,然后再寫入主存中。而不是直接將數值10寫入主存當中。

  那么,java語言本身對原子性、可見性以及有序性提供了哪些保證呢?

 

二.並發編程中的三個概念

1.原子性

在java中,對基本數據類型的變量的讀取和賦值是原子性操作,即這些操作是不可被中斷的,要么執行,要么不執行。

例子:

x=10; //1
y=x;//2
x++;//3
x = x+1;//4

  乍一看,有些同學可能會說上面的4個語句中的操作都是原子操作。其實只有語句1是原子操作,其他三個都不是原子性操作。

  • 語句1是直接將數值10賦值給x,也 就是說線程執行這個語句的會直接將數值10寫入到工作內存中。
  • 語句2世紀上包含2個操作,它先要去讀取x的值,再講x的值寫入工作內存,雖然讀取x的值以及將x的值寫入工作內存這個兩個操作都是原子性的,但合起來就不是原子性操作了。
  • 同樣的,x++和x=x+1包括3個操作:讀取x的值,進行加1操作,寫入新的值。

  所以上面4個語句只有語句1的操作具備原子性。也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。不過這里有一點需要注意:在32位平台下,對64位數據的讀取和賦值是需要通過兩個操作來完成的,不能保證其原子性。但是好像在最新的JDK中,JVM已經保證對64位數據的讀取和賦值也是原子性操作了。

  從上面可以看出,Java內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大范圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。

2.可見性

對於可見性,java提供了volatile關鍵字來保證可見性。當一個共享變量被volatile修飾時,它會保證修改的值會立即更新到主存,當有其他線程讀取時,它會去內存中讀取新值。

而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。

另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。

3.有序性

在java內存模型中,允許編譯器和處理器對指令進行重新排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程並發執行的正確性。

在java里,可以通過volatile關鍵字來保證一定的有序性。另外可以通過synchronized和locl來保證有序性。

另外,java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠保證的有序性,這個通常也成為happens-before原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那么她們就不能保證有序性,虛擬機可以隨意地對她們進行重新排序。

下面具體介紹happens-befoe原則(先行發生原則):

  • 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作
  • 鎖定規則:一個unlock操作先行發生於后面對同一個鎖lock操作。
  • volatile變量規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作。
  • 傳遞規則:如果操作a線程發生於操作b,而操作b又先行發生於操作c,則可以得出操作a先行發生於操作c。
  • 線程啟動規則:Thread對象的start()方法先行發生於此線程的每一個操作。
  • 線程中斷規則:對於線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷時間的發生。
  • 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始
  • 這8條原則摘自《深入理解java虛擬機》。前4條規則是比較重要,后4條顯而易見。

 

下面我們來解釋一下前4條規則:

  對於程序次序規則來說,我的理解就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生於書寫在后面的操作”,這個應該是程序看起來執行的順序是按照代碼順序執行的,因為虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。

  第二條規則也比較容易理解,也就是說無論在單線程中還是多線程中,同一個鎖如果出於被鎖定的狀態,那么必須先對鎖進行了釋放操作,后面才能繼續進行lock操作。

  第三條規則是一條比較重要的規則,也是后文將要重點講述的內容。直觀地解釋就是,如果一個線程先去寫一個變量,然后一個線程去進行讀取,那么寫入操作肯定會先行發生於讀操作。

  第四條規則實際上就是體現happens-before原則具備傳遞性。

 

 volatile關鍵字的兩層語義

  • 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
  • 禁止進行指令重排序
//線程1
boolean stop = false;
while(!stop){
    doSomething();
}
 
//線程2
stop = true;

  這段代碼是很典型的一段代碼,很多人在中斷線程時可能會采用這種標記辦法。但事實上,這段代碼會完全運行正確嗎?即一定會將線程中斷嗎?不一定,也許在大多時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程。(親自測驗,如果不設計stop的讀取,則一直循環)

  

在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那么線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。

  那么當線程2更改了stop變量的值之后,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那么線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

  但是用volatile修飾之后就變得不一樣了:

  • 使用volatile關鍵字會強制將修改的值立即寫入主存。
  • 使用volatile關鍵字后,當線程2進行修改時,會導致線程1的工作內存中緩存比阿娘stop的緩存行無效(反映到硬件層是cpu的L1或L2等緩存中對應的緩存行無效);
  • 由於線程1的工作緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。
  • 那么在線程2修改stop值時(當然這里包括2個操作,修改線程2工作內存中的值,然后將修改后的值寫入內存),會使得線程1的工作內存中緩存變量stop的緩存行無效,然后線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之后,然后去對應的主存讀取最新的值。那么線程1讀取到的就是最新的正確的值。

volatile不能保證原子性

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>2)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

  上述代碼的結果是一個不確定的值。

在前面已經提到過,自增操作是不具備原子性的,它包括讀取變量的原始值、進行加1操作、寫入工作內存。那么就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

  假如某個時刻變量inc的值為10,

  線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然后線程1被阻塞了;

  然后線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量inc的緩存行無效,所以線程2會直接去主存讀取inc的值,發現inc的值時10,然后進行加1操作,並把11寫入工作內存,最后寫入主存。

  然后線程1接着進行加1操作,由於已經讀取了inc的值,注意此時在線程1的工作內存中inc的值仍然為10,所以線程1對inc進行加1操作后inc的值為11,然后將11寫入工作內存,最后寫入主存。

  那么兩個線程分別進行了一次自增操作后,inc只增加了1。

博文看到這里有個疑問,緩存寫到主存會引起其他緩存中的地址失效,那么線程2寫入inc=11的時候,線程1中的inc的值就無效,這里究竟發生了什么?所以這里的問題暫時先這樣理解:volatile只會在讀取的時候去讀取新的值,如果這一輪的計算已經讀取了,然后這個值盡管是錯誤了,但這次操作並不會重新讀取,這次操作的仍舊是錯誤的值。

采用synchronized和Lock以及使用AtomicInteger都可以保證自增的原子性。

public class Test {
    public  AtomicInteger inc = new AtomicInteger();
     
    public  void increase() {
        inc.getAndIncrement();
    }
    
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

  在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作類,即對基本數據類型的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。atomic是利用CAS來實現原子性操作的(Compare And Swap),CAS實際上是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。

volatile能保證一定的有序性

volatile關鍵字禁止指令重排序有兩層意思:

  • 當程序執行到volatile變量的讀操作或寫操作,在其前面的操作的更改肯定全部已經執行,且結果已經對后面的操作可見,其后面的操作肯定還沒進行;
  • 在進行指令優化時,不能將volatile變量訪問的語句放在其后面執行,也不能把volatile變量后的語句放到其前面執行

可能上面說的比較繞,舉個簡單的例子:

//x、y為非volatile變量
//flag為volatile變量
 
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;         //語句4
y = -1;       //語句5

 由於flag變量為volatile變量,那么在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5后面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

  並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

  前面舉這個例子的時候,提到有可能語句2會在語句1之前執行,那么久可能導致context還沒被初始化,而線程2中就使用未初始化的context去進行操作,導致程序出錯。

  這里如果用volatile關鍵字對inited變量進行修飾,就不會出現這種問題了,因為當執行到語句2時,必定能保證context已經初始化完畢。

 volatile的應用場景

synchronized關鍵字是防止多個線程同時執行一段代碼,那么就會很影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

  1. 對變量的寫操作不依賴於當前值
  2. 該變量沒有包含在具有其他變量的不變式中

實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。

  事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程序在並發時能夠正確執行。

  下面列舉幾個Java中使用volatile的幾個場景。

1.狀態標記量

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

  2.規定執行前后順序

volatile boolean inited = false;
//線程1:
context = loadContext();  
inited = true;            
 
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

  3.double check

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM