併發程式設計(一)—— 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)。
一般來說,處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。在此我向大家推薦一個架構學習交流圈:609164807 幫助突破瓶頸 提升思維能力
比如上面的程式碼中,語句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關鍵字會強制將修改的值立即寫入主存;
在此我向大家推薦一個架構學習交流圈:609164807 幫助突破瓶頸 提升思維能力
第二:使用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); 在此我向大家推薦一個架構學習交流圈:609164807 幫助突破瓶頸 提升思維能力 //開啟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為執行緒工作記憶體值,在此我向大家推薦一個架構學習交流圈:609164807 幫助突破瓶頸 提升思維能力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漏洞。