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漏洞。