JAVA併發包的Volatile和CAS如何不用鎖保證線程安全?

前言

從JDK1.5以後,引入了java.util.concurrent併發包,其中java.util.concurrent.atomic包,方便在無鎖的情況下,進行原子操作。在JUC中大部分都是利用volatile關鍵字+CAS在不用鎖的情況來保證線程安全的。本篇文章把這兩個知識點給大家一個清晰的解析,只有掌握了關鍵字volatile和CAS機制,你才能對JUC包有一個徹底的理解。

 

 

 

Java的內存模型JMM

1.1、Java的內存模型(JMM)

要想徹底明白volatile到底是幹什麼的,你必須知道Java的內存模型(JMM)。網上有很多關於對JMM定義的描述,如果我在按照他們的列出來,那麼這一篇文章就變了味道,所以我用自己理解的去闡述Java內存模型,不會用長篇大論去介紹概念,而是依據例子去闡述,我覺得更有意義。

我們知道,共享變量屬於所有的線程共享的,爲了提高性能,每一個線程都會保存一份共享變量的副本,就是說每一個線程都會從主存中複製一份共享變量到自己的工作內存中去。舉例說明:


 

有一個全局變量count=0,線程1和線程2同時將count+1

上面是一個非常簡單的例子,如果對JMM不熟悉的同學很容易脫口而出最終結果爲2,但是在多線程下的環境下真的就是我們期望的結果嗎?答案是不一定,可能就會出現不同的現象了。

第一個現象:線程1首先獲取到CPU的執行權,


 

1:線程1首先獲取CPU的執行權,所以從主存中獲取count=0,然後複製一份到自己的工作內存中去。
2:線程1將工作內存中的count+1,此時工作內存count=1,還未來得及刷新到主存中,這時線程2獲取了CPU的執行權
3:線程2獲取CPU的執行權,所以也從主存中獲取count=0,然後複製一份到自己的工作內存中去。
4:線程2將工作內存中的count+1,此時工作內存count=1。
5:是線程1首先刷新到主存中,還是線程2首先刷新到主存中,這個不確定。

上面線程1和線程2兩個線程的工作內存的count都是1,但是它們什麼時候刷新到主存中,無法確定,可能是線程1首先將count=1刷新到主存中,也可能是線程2首先將count=1刷新到主存中,不管哪一個線程首先將它的工作內存中count刷新到主存中,那此時主存也會count=1,這個結果與我們想象的不一樣。

第二個現象:線程2首先獲取到CPU的執行權,


 

1:線程2首先獲取CPU的執行權,所以從主存中獲取count=0,然後保存到自己的工作內存中
2:線程2的count副本+1,此時count=1,但是還未來得及刷新到主存中,線程1獲取了CPU的執行權。
3:線程1獲取CPU執行權後,會從主存中拷貝一份count=0,到自己的工作內存中去。
4:線程1的count副本+1,此時count=1.
5:是線程2首先刷新到主存中,還是線程1首先刷新到主存中,這個不確定

上面兩種現象不管是線程1首先獲取CPU執行權,還是線程2獲取CPU執行權,最終的結果是一樣的,那就是count=1。這個結果並不是我們要的結果,導致出現這個結果的原因就是並不知道工作內存中的值什麼時間纔會刷新到主存中去

第三種現象:線程1首先獲取到CPU執行權,然後count+1,並刷新到主存中後線程2才獲取CPU的執行權。


 

1:線程1首先獲取CPU的執行權,從主存中複製一份count=0到自己的工作內存中去。
2:線程1將工作內存的count+1,此時count=1
3:在線程2獲取CPU執行權之前,線程1就將自己工作內存count=1刷新到主存中去。
4:此時主存中的count=1
5:線程2獲取CPU的執行權,從主存中複製一份count=1到自己的工作內存中去。
6:線程2將工作內存count+1,此時count=2
7:在適當的某個時候,線程2把count=2刷新到主存中去。

第四種線程:線程2首先獲取到CPU的執行權,然後count+1,並刷新到主存中後線程1才獲取到CPU的執行權。


 

1:線程2首先獲取CPU的執行權,從主存中複製一份count=0到自己的工作內存中。
2:線程2將工作內存中count+1,此時count=1
3:在線程1獲取CPU之前,線程2將工作內存count=1刷新到主存中
4:此時主存中的count=1
5:線程1說去CPU的執行權,從主存中複製一份count=1到工作內存中。
6:線程1將工作內存中的count+1,此時count=2
7:在適當的某個時候,線程1把count=2刷新到主存中去。

第三個和第四個現象是我們想要的結果,當另一個線程獲取CPU執行權之前,前一個線程已經把修改的count刷新到主存中去了。

通過上面的四個現象,我們可以總結如下特點:


 

1:線程不能直接操作主存中的共享變量,而是複製一份副本到自己的工作內存,並對這個副本進行操作。
2:每個線程對副本的修改,在刷新到主存之前,其他線程是看不到的。
3:每個線程對工作內存中的副本進行修改後,至於什麼時候刷新到主存中,這個不確定。

從上面的一個實例中引出了JMM的可見性問題,在併發情況下,一個線程對共享變量的修改可能對其他線程並不可見,導致計算的值和我們想象的不一致,所以在多線程下,要想線程安全必須要解決可見性的問題。

二、Java內存模型的重排序問題

上面我們說了多線程併發修改共享變量可能會出現可見性的問題,爲了性能,Java內存模型可能對代碼進行重排序,這種重排序在單線程下不會影響最終的結果,但是在多線程下就會出現問題.

舉例1:


 

有3個變量:
int a = 0;
int b = 1;
int c = 2;

上面這3個變量既沒有數據依賴,也沒有控制依賴,所以爲了提高性能,編譯器可能對這3段代碼進行重排序,代碼的執行結果有以下幾種情況:


 

第一種情況:a->b->c
第二種情況:a->c->b
第三種情況:b->a->c
第四種情況:b->c->a
第五種情況:c->a->b
第六種情況:c->b->a

這種重排序在單線程下不會影響最終的結果,但是在多線程情況下就存在不確定因素了。

剛纔我提到了數據依賴和控制依賴,那這兩種是什麼情況呢?解釋如下:

數據依賴性:寫後讀、寫後寫、讀後寫


 

第一種:寫後讀,例如:int a = 1;int b = a;
第二種:寫後寫,例如:int a = 1;a = 2;
第三種:讀後寫,例如:int a = b; b=1;

上面三種情況都存在數據依賴性,如果對它們進行重排序,會導致結果錯誤,所以編譯器不會對它們進行重排序,在單線程情況下結果是正確的,但是在多線程情況下可能就有問題了。

舉例如下:


 

class MyTest1{
private int a = 0;
private int b = 0;
public void test1(){
a = 1;//$1
}
public void test2(){
b = a;//$2
}
public int getA() {
return a;
}
public int getB() {
return b;
}
}
class Test{
public static void main(String[] args) throws InterruptedException {
MyTest1 mt = new MyTest1();
CountDownLatch cd = new CountDownLatch(1);
CountDownLatch cd1 = new CountDownLatch(2);
new Thread(()->{
try {
cd.await();
mt.test1();
cd1.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(()->{
try {
cd.await();
mt.test1();
cd1.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
cd.countDown();
cd1.await();
System.out.println("a="+mt.getA());
System.out.println("b="+mt.getB());
}
}

上面的CountDownLatch是併發包的一個類,大家先理解能夠爲能夠使一個或這多個線程等待其他線程完成後在執行就可以了,上面這個代碼線程1執行test1(),線程2執行test2().那最終結果會怎樣的?

第一種結果:

a = 1

b = 0

第二種結果:

a = 1

b = 1

上面兩種執行結果不同,說明在多線程下,數據依賴雖然保證單線程結果正確,但是在多線程下就有不確定因素了。


 

數據依賴:
int a = 1;
int b = a;
變量b依賴變量a的結果,所以編譯器不會對這兩行代碼進行重排序。
-------------------------------------------------
控制依賴:
int a = 0;
boolean b = false
public void test1(){
a=1;//$1
b=true;//$2
}
if(b){//$3
int c = a+a;//$4
}
上面的操作$3和操作$4就存在控制依賴了。

當程序中存在控制依賴時,會影響指令序列執行的並行度,編譯器通過猜測來克服這個問題,線程2可以提前讀取並計算a+a,然後把結果保存到一個名爲重排序緩衝中。當$3中b==true時,就把結果寫入到c中,但是在單線程下雖然可以重排序,但是不會破壞結果,但是在多線程下就不一定了。

舉例說明如下:


 

class MyTest{
private int a = 0;
private boolean b = false;
private int c = 0;
public void test1(){
a = 1;//$1
b = true;//$2
}
public void test2(){
if(b){//$3
c = a+a;//$4
}
}
public int getA() {
return a;
}
public boolean isB() {
return b;
}
public int getC() {
return c;
}
}
class Test{
public static void main(String[] args) throws InterruptedException {
MyTest mt = new MyTest();
CountDownLatch cd = new CountDownLatch(1);
CountDownLatch cd1 = new CountDownLatch(2);
new Thread(()->{
try {
cd.await();
mt.test1();
cd1.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(()->{
try {
cd.await();
mt.test1();
cd1.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
cd.countDown();
cd1.await();
System.out.println("a="+mt.getA());
System.out.println("b="+mt.getB());
}
}

第一種執行結果:

a = 1

b = true

c = 0

第二種執行結果:

a = 1

b = true

c = 2

通過上面的講解,我們知道了爲了提高性能,在單線程下的重排序不會影響結果,但是在多線程下結果就不確定了。所以在多線程下如果要想保證線程安全,需要對有序性進行保證,禁止指令重排序。

三、Java原子性的問題

要想在多線程下安全的修改一個共享變量,保證可見性和有序性的同時,也要保證原子性,就是一個操作是不可分的,必須是連續的,要麼成功,要麼失敗,例如i++這種操作就不是連續的,它包含以下幾個操作:


 

1:讀:首先讀取i
2:改:將i進行++
3:寫:然後寫修改後的值寫入

可以看出上面的一個i++操作涉及到3個內容,它不符合原子行不可分割的特點,這樣在多線程情況下就有不確定的因素了,所以如果只保證可見性和有序性,不保證原子性仍無法保證線程安全。

通過上面的講解,我們知道要想保證線程安全,必須符合可見性、有序性、原子性三個特點,那麼在Java中是怎樣去保證這三個特點的呢?這一篇文章我們看看通過關鍵字volatile和CAS來保證線程安全的。

四、關鍵字volatile

首先我先寫出來關鍵字volatile的作用,如下:


 

1:volatile能保證可見性
2:volatile能保證有序性

上面我列出了volatile的作用,但是可以看出來volatile並不保證原子性,接下來我開始分析它是怎樣保證可見性和有序性的。在分析之前總結volatile的特點


 

1:任何對volatile變量的寫,JMM會立刻把工作內存的值被刷新到主存中
2:任何對volatile變量的讀,都會從主存中拷貝一份最新的數據到自己的工作內存中去。

我還是用舉例的方法來解釋上面的兩個特點:


 

假設有一個成員變量:
private volatile int a = 0;
//$1:表示任何對volatile的寫,JMM會立刻把值刷新到主存中
public void write(){
a = 1;
}
//$2:表示任何對volatile的讀,JMM會把當前線程的工作內存對應的副本置爲無效,然後從主存中讀取一份。
public void read(){
int b = a;
}
假設線程1首先執行write()方法,然後線程2執行read()方法。

$1用圖表示如下:

$2用圖表示如下:

上面分析了volatile的寫和讀的特性,通過這個特性能夠解決多線程下可見性的問題,volatile除了能解決可見性的問題,同時也能禁止指令重排序,它是通過加如內存屏障來保證指令重排序的,從而能保證有序性,因爲內存屏障牽涉到JVM的一些特性,這裏就不在展開講了,如果有機會,我會有一個專題用來介紹JVM,到那時我會着重講解以下,這裏只是讓大家瞭解以下volatile既能保證可見性又能保證有序性。

講到這,大家思考以下volatile能保證線程安全嗎?以我的感覺,這個不一定。


 

1:如果是對單個變量的讀或者寫,能夠保證線程安全,也可以認爲volatile具有原子性。
2:如果是複合操作,比如i++,這種就保證不了原子性了,從而也無法保證線程安全。

所以volatile只能保證可見性有序性,並不能保證原子性,對單個變量的操作能保證原子性,只是一個特殊而已,並不能說volatile就具有原子性,所以只利用volatile去保證線程安全是遠遠不夠的,還需要一個方法去保證原子性,這樣才能複合線程安全的特性,進而才能保證多線程下對共享變量的安全操作。那怎樣才能保證原子性呢?請繼續看下面的CAS機制。

五、CAS機制

上面講解了volatile擁有了線程安全的兩個特性,但是缺少原子性,所以無法保證線程安全,那麼CAS的出現就是解決原子性的問題的。

CAS是compare and swap的簡寫,從字面上理解就是比較和交換。它的定義如下:


 

CAS(V,E,N)
V:表示要更改的變量
E:表示變量的預期值
N:表示變量要更新的新值

它的原理就是通過比較預期值E和當前V的真正值是否相同,如果相同,則更新爲N,如果不相同,則自旋判斷,直到更新成功爲止。它的流程圖如下:

CAS能夠保證對一個變量的原子操作,CPU能夠保證這種原子操作,在Java中Unsafe對CAS進行了封裝。


 

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
---------------------------------
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
-------------------------------------
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
 

上面只是簡單的介紹了CAS是幹什麼的,CAS能夠保證對一個變量的原子操作,它的一些概念和深入的地方就不在闡述了。

上面通過對JMM的介紹,從而引出了多線程安全的3個特性:原子性、可見性、有序性,也可通過關鍵字volatile+CAS來保證對一個變量的安全操作,併發包JUC中大部分都是利用這種機制處理的,如果你學會了,那接下來的併發包中的內容就很容易理解了。接下來我們就一起進入併發包的學習。

 

關注公衆號:JAVA取經之旅

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章