並發編程(一)—— volatile關鍵字和 atomic包


Java內存模型

       JMM(java內存模型)

  java虛擬機有自己的內存模型(Java Memory Model,JMM),JMM可以屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓java程序在各種平台下都能達到一致的內存訪問效果。

  JMM決定一個線程對共享變量的寫入何時對另一個線程可見,JMM定義了線程和主內存之間的抽象關系:共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存保存了被該線程使用到的主內存的副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。這三者之間的交互關系如下

 

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

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

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。這就是著名的緩存一致性問題。通常稱這種被多個線程訪問的變量為共享變量。

 

並發編程中的三個概念

  在並發編程中,我們通常會遇到以下三個問題:原子性問題,可見性問題,有序性問題。我們先看具體看一下這三個概念:

1.原子性

  原子性:即一個操作或者多個操作 要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行。

  一個很經典的例子就是銀行賬戶轉賬問題:

  比如從賬戶A向賬戶B轉1000元,那么必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

  試想一下,如果這2個操作不具備原子性,會造成什么樣的后果。假如從賬戶A減去1000元之后,操作突然中止。然后又從B取出了500元,取出500元之后,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。

  所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

2.可見性

  可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

  舉個簡單的例子,看下面這段代碼:

1 //線程1執行的代碼
2 int i = 0;
3 i = 10;
4  
5 //線程2執行的代碼
6 j = i;

假若執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值加載到CPU1的高速緩存中,然后賦值為10,那么在CPU1的高速緩存當中i的值變為10了,卻沒有立即寫入到主存當中。

此時線程2執行 j = i,它會先去主存讀取i的值並加載到CPU2的緩存當中,注意此時內存當中i的值還是0,那么就會使得j的值為0,而不是10。

這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。

3.有序性

  有序性:即程序執行的順序按照代碼的先后順序執行。舉個簡單的例子,看下面這段代碼:

int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;          //語句2

從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什么呢?這里可能會發生指令重排序(Instruction Reorder)。

一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那么就有可能在執行過程中,語句2先執行而語句1后執行。

但是重排序也需要遵守一定規則:

  1.重排序操作不會對存在數據依賴關系的操作進行重排序。

    比如:a=1;b=a; 這個指令序列,由於第二個操作依賴於第一個操作,所以在編譯時和處理器運行時這兩個操作不會被重排序。

  2.重排序是為了優化性能,但是不管怎么重排序,單線程下程序的執行結果不能被改變

    比如:a=1;b=2;c=a+b這三個操作,第一步(a=1)和第二步(b=2)由於不存在數據依賴關系,所以可能會發生重排序,但是c=a+b這個操作是不會被重排序的,因為需要保證最終的結果一定是c=a+b=3。

 

volatile關鍵字

   volatile是Java提供的一種輕量級的同步機制。同synchronized相比(synchronized通常稱為重量級鎖),volatile更輕量級。

  一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之后,那么就具備了兩層語義:

  1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。

  2)禁止進行指令重排序。

1、共享變量的可見性

public class TestVolatile {
    
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo();
        new Thread(td).start();
        while(true){
            if(td.isFlag()){
                System.out.println("------------------");
                break;
            }
        }
    }

}

class ThreadDemo implements Runnable {
    private  boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
        }
        flag = true;
        System.out.println("flag=" + isFlag());
    }

    public boolean isFlag() {
        return flag;
    }
}

上面這個例子,開啟一個多線程去改變flag為true,main 主線程中可以輸出"------------------"嗎?

  答案是NO! 

  這個結論會讓人有些疑惑,可以理解。開啟的線程雖然修改了flag 的值為true,但是還沒來得及寫入主存當中,此時main里面的 td.isFlag()還是false,但是由於 while(true)  是底層的指令來實現,速度非常之快,一直循環都沒有時間去主存中更新td的值,所以這里會造成死循環!運行結果如下:

此時線程是沒有停止的,一直在循環。

如何解決呢?只需將 flag 聲明為volatile,即可保證在開啟的線程A將其修改為true時,main主線程可以立刻得知:

  第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

  第二:使用volatile關鍵字的話,當開啟的線程進行修改時,會導致main線程的工作內存中緩存變量flag的緩存行無效(反映到硬件層的話,就是CPU的L1緩存中對應的緩存行無效);

  第三:由於線程main的工作內存中緩存變量flag的緩存行無效,所以線程main再次讀取變量flag的值時會去主存讀取。

volatile具備兩種特性,第一就是保證共享變量對所有線程的可見性。將一個共享變量聲明為volatile后,會有以下效應:

  1.當寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量強制刷新到主內存中去;

  2.這個寫會操作會導致其他線程中的緩存無效。

 

2、禁止進行指令重排序

這里我們引用上篇文章單例里面的例子

 1 class Singleton{
 2     private volatile static Singleton instance = null;
 3 
 4     private Singleton() {
 5     }
 6      
 7     public static Singleton getInstance() {
 8         if(instance==null) {
 9             synchronized (Singleton.class) {
10                 if(instance==null)
11                     instance = new Singleton();
12             }
13         }
14         return instance;
15     }
16 }

instance = new Singleton(); 這段代碼可以分為三個步驟:
1、memory = allocate() 分配對象的內存空間
2、ctorInstance() 初始化對象
3、instance = memory 設置instance指向剛分配的內存

但是此時有可能發生指令重排,CPU 的執行順序可能為:

1、memory = allocate() 分配對象的內存空間
3、instance = memory 設置instance指向剛分配的內存
2、ctorInstance() 初始化對象

在單線程的情況下,1->3->2這種順序執行是沒有問題的,但是如果是多線程的情況則有可能出現問題,線程A執行到11行代碼,執行了指令1和3,此時instance已經有值了,值為第一步分配的內存空間地址,但是還沒有進行對象的初始化;

此時線程B執行到了第8行代碼處,此時instance已經有值了則return instance,線程B 使用instance的時候,就會出現異常。

這里可以使用 volatile 來禁止指令重排序。

 

從上面知道volatile關鍵字保證了操作的可見性和有序性,但是volatile能保證對變量的操作是原子性嗎?

下面看一個例子:

package com.mmall.concurrency.example.count;
import java.util.concurrent.CountDownLatch;

/**
 * @author: ChenHao
 * @Description:
 * @Date: Created in 15:05 2018/11/16
 * @Modified by:
 */
public class CountTest {
    // 請求總數
    public static int clientTotal = 5000;
    public static volatile int count = 0;

    public static void main(String[] args) throws Exception {
        //使用CountDownLatch來等待計算線程執行完
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        //開啟clientTotal個線程進行累加操作
        for(int i=0;i<clientTotal;i++){
            new Thread(){
                public void run(){
                    count++;//自加操作
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待計算線程執行完
        countDownLatch.await();
        System.out.println(count);
    }
}

執行結果:

針對這個示例,一些同學可能會覺得疑惑,如果用volatile修飾的共享變量可以保證可見性,那么結果不應該是5000么?

問題就出在count++這個操作上,因為count++不是個原子性的操作,而是個復合操作。我們可以簡單講這個操作理解為由這三步組成:

  1.讀取count

  2.count 加 1

  3.將count 寫到主存

  所以,在多線程環境下,有可能線程A將count讀取到本地內存中,此時其他線程可能已經將count增大了很多,線程A依然對過期的本地緩存count進行自加,重新寫到主存中,最終導致了count的結果不合預期,而是小於5000。

那么如何來解決這個問題呢?下面我們來看看

 

Atomic包

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

package com.mmall.concurrency.example.count;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author: ChenHao
 * @Description:
 * @Date: Created in 15:05 2018/11/16
 * @Modified by:
 */
public class CountTest {
    // 請求總數
    public static int clientTotal = 5000;
    public static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        //使用CountDownLatch來等待計算線程執行完
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        //開啟clientTotal個線程進行累加操作
        for(int i=0;i<clientTotal;i++){
            new Thread(){
                public void run(){
                    count.incrementAndGet();//先加1,再get到值
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待計算線程執行完
        countDownLatch.await();
        System.out.println(count);
    }
}

執行結果:

下面我們來看看原子類操作的基本原理

 1 public final int incrementAndGet() {
 2      return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
 3 }
 4 
 5 public final int getAndAddInt(Object var1, long var2, int var4) {
 6     int var5;
 7     do {
 8         var5 = this.getIntVolatile(var1, var2);
 9     } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
10 
11     return var5;
12 }
13 
14 /***
15 * 獲取obj對象中offset偏移地址對應的整型field的值。
16 * @param obj 包含需要去讀取的field的對象
17 * @param obj中整型field的偏移量
18 */
19 public native int getIntVolatile(Object obj, long offset);
20 
21 /**
22 * 比較obj的offset處內存位置中的值和期望的值,如果相同則更新。此更新是不可中斷的。
23 * 
24 * @param obj 需要更新的對象
25 * @param offset obj中整型field的偏移量
26 * @param expect 希望field中存在的值
27 * @param update 如果期望值expect與field的當前值相同,設置filed的值為這個新值
28 * @return 如果field的值被更改返回true
29 */
30 public native boolean compareAndSwapInt(Object obj, long offset, int expect, int update);

首先介紹一下什么是Compare And Swap(CAS)?簡單的說就是比較並交換。

CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值與預期原值相匹配,那么處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。CAS 有效地說明了“我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。” Java並發包(java.util.concurrent)中大量使用了CAS操作,涉及到並發的地方都調用了sun.misc.Unsafe類方法進行CAS操作。

 我們來分析下incrementAndGet的邏輯:

  1.先獲取當前的value值

  2.調用compareAndSet方法來來進行原子更新操作,這個方法的語義是:

    先檢查當前value是否等於obj中整型field的偏移量處的值,如果相等,則意味着obj中整型field的偏移量處的值 沒被其他線程修改過,更新並返回true。如果不相等,compareAndSet則會返回false,然后循環繼續嘗試更新。

第一次count 為0時線程A調用incrementAndGet時,傳參為 var1=AtomicInteger(0),var2為var1 里面 0 的偏移量,比如為8090,var4為需要加的數值1,var5為線程工作內存值,do里面會先執行一次,通過getIntVolatile 獲取obj對象中offset偏移地址對應的整型field的值此時var5=0;while 里面compareAndSwapInt 比較obj的8090處內存位置中的值和期望的值var5,如果相同則更新obj的值為(var5+var4=1),此時更新成功,返回true,則 while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));結束循環,return var5。

當count 為0時,線程B 和線程A 同時讀取到 count ,進入到第 8 行代碼處,線程B 也是取到的var5=0,當線程B 執行到compareAndSwapInt時,線程A已經執行完compareAndSwapInt,已經將內存地址為8090處的值修改為1,此時線程B 執行compareAndSwapInt返回false,則繼續循環執行do里面的語句,再次取內存地址偏移量為8090處的值為1,再去執行compareAndSwapInt,更新obj的值為(var5+var4=2),返回為true,結束循環,return var5。

 

CAS的ABA問題

  當然CAS也並不完美,它存在"ABA"問題,假若一個變量初次讀取是A,在compare階段依然是A,但其實可能在此過程中,它先被改為B,再被改回A,而CAS是無法意識到這個問題的。CAS只關注了比較前后的值是否改變,而無法清楚在此過程中變量的變更明細,這就是所謂的ABA漏洞。 

 

 

 

 

 

 


免責聲明!

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



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