從Java內存模型的角度思考線程安全與併發

併發的兩個關鍵問題

1、線程之間如何通信

2、線程之間如何同步

  通信是指線程之間以何種機制來交換信息,在命令式編程中,通信機制有兩種:共享內存和消息傳遞;JAVA的併發採用的是共享內存,線程之間的通信總是隱式進行。

  同步指程序中用於控制不同線程間操作發生相對順序的機制,在共享內存併發模型中,同步是顯式進行的。

JAVA的內存模型

1、共享變量:分配在堆內存中的元素都是共享變量,包括實例域、靜態域、數組元素。

2、非共享變量:分配在棧上的都是非共享變量,主要指的是局部變量。該變量爲線程私有,不會在線程之間共享,也不存在內存可見性的問題。

1、圖中的主內存用於存儲共享變量,主內存是所有線程所共有的。

2、本地內存是一個抽象概念,不像主內存是真實存在的,每一個線程都有一個本地內存,用於存放該線程所使用的共享變量的副本。

如果線程A和線程B需要進行通信,則必須經過以下兩個過程:

1、線程A把本次內存中修改過的共享變量刷新到主內存中。

2、線程B到主內存中去讀取線程A已經更新過的共享變量

這個過程與計算機網絡的7層模型的過程特性類似,都必須先經過從上到下,再經過底層的物理鏈路,最後從下到上完成一次通信。

 一、內存間交互操作

內存間交互主要指工作內存(本地內存)與主內存之間的交互,即一個變量如何從主內存拷貝到工作內存,如何從工作內存刷新到主內存的一些實現細節。JAVA內存模型定義以下八種操作來完成:

1、lock:作用於主內存,把一個變量標識爲某個線程獨佔狀態。

2、unlock:作用於主內存,把一個處於鎖定狀態的變量釋放,釋放後變量可以被其他線程鎖定。

3、read:作用於主內存,把一個變量從主內存傳輸到工作內存中,用於後面的load操作。

4、load:作用於工作內存,把read操作從主內存中得到的變量值放入工作內存的變量副本中。

5、use:作用於工作內存,把變量值傳遞給執行引擎,每當虛擬機需要使用變量的字節碼指令時將會執行這個操作。

6、assign:作用於工作內存,把從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到需要給該變量賦值的字節碼指令時執行這個操作。

7、store:作用於工作內存,把工作內存中的一個變量值傳到的主內存,以便後續的write操作。

8、write:作用於主內存,把store操作從工作內存中獲取的值賦值給主內存中的變量

對於這8個操作,有如下的一個原則:

1、不允許read和load,store和write操作單獨出現。

2、不允許一個線程丟棄它最近的assign操作,即變量在工作內存中的更新需要同步到主內存中。

3、不允許線程無原因地(沒有發生過任何assign操作)把數據同步到主內存。

4、一個新的變量只能在主內存中產生,不能在工作內存中直接使用未被初始化的變量。

5、一個變量在同一時刻只能被一個線程lock,並且lock和unlock需要成對出現。

6、如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要執行load或者assgin操作。

7、對一個變量執行unclock之前,必須把此變量同步到主內存中。

二、重排序

重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段。重排序分爲3類:

1、編譯器優化重排序:編譯器在不改變單線程語義的前提下,可以重新安排語句的執行順序。

2、指令級並行的重排序:如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

3、內存系統重排序:由於處理器使用緩存和緩衝區,使得加載和存儲操作看上去可能是亂序執行。

從Java源代碼到最終實際執行的指令序列,會經過下面三種重排序:

數據依賴性:

a = 1;     //1
a = 2;    //2

上面展示的是寫後寫的操作,其中1、2兩步的順序打亂,會改變程序執行的結果;這種就是數據有依賴性性。

double pi = 3.14;             //A
double r = 1.0;               //B
double area = pi * r * r;     //C

這裏可以看到,A和C、B和C有數據依賴,其中A和B沒有數據依賴,這樣編譯器和處理器就可以對AB進行重排序。

上面AB這種場景就是符合as-if-serial語義的:不管怎麼進行重排序,單線程程序的執行結果不能被改變。編譯器和處理器在重排序的時候都必須遵守as-if-serial語義。

三、內存屏障

內存屏障又稱內存柵欄,是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題。由於現在操作系統都是多處理器,而每一個處理器都有自己的緩存,並且這些緩存都不是實時與內存進行交互。這樣就會導致不同CPU上緩存的數據不一致問題,在多線程的程序中,就會出現一些異常行爲。而操作系統底層就提供了內存屏障來解決這些問題。目前有4種屏障:

1、LoadLoad屏障:

  對於語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。

2、StoreStore屏障:

  對於語句Store1; StoreStore; Store2,在Store2及後續寫入操作之前,保證Store1的寫入操作對其他處理器可見。

3、LoadStore屏障:

  對於語句Load1; LoadStore; Store2,在Store2及後續寫入操作之前,保證Load1要讀取的數據被讀取完畢。

4、StoreLoad屏障:

  對於語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。

java對內存屏障的使用有以下常見的兩種:

1、使用volatile修飾變量,則對變量的寫操作,會插入StoreLoad屏障。

2、使用Synchronized關鍵字包住的代碼區域,當線程進入到該區域讀取變量的信息時,保證讀到的是最新的值,這是因爲在同步區內對變量的寫入操作,在離開同步區時將當前線程內數據刷新到主內存中。而對數據的讀取也不能從緩存中獲取,只能從主內存中讀取,保證了數據的有效性。這就是插入了StoreStore屏障。

四、volatile內存語義與實現

關鍵字volatile是java虛擬機提供的輕量級的同步機制,只能用來修改變量,在多線程情況下保證了變量的可見性,但是不保證變量的原子性。volatitle修飾的變量具有以下兩種特性:

1、可見性

  保證此變量對所有線程的可見性,這裏的可見性是指當一個線程修改了該變量的值,修改後的值對其他所有線程都是立即可知的。而普通變量則做不到這一點,因爲普通變量需要將修改的值從工作內存同步到主內存後才能被其他線程可見。

  volatile變量也可以在各個線程的工作內存中存在數據不一致的情況,但是由於每次使用之前都要進行刷新,執行引擎看不到不一致的情況,因此可以認爲不存在不一致的問題。

前面提高volatile不能保證原子性,下面看這段代碼:

複製代碼
    
public class VolatileTest
{
    private static volatile int rac = 0;
    
    private static final int threadCnt = 20;
    
    public static void increase()
    {
        rac++;
    }
    
    public static void main(String[] args)
    {
        Thread[] thread = new Thread[threadCnt];
        for(int i=0; i<threadCnt; i++)
        {
            thread[i] = new Thread(new Runnable()
            {

                @Override
                public void run()
                {
                    for(int j=0; j < 10000; j++)
                    {
                        increase();
                    }
                    
              }});
            thread[i].start();
        }
        
        while(Thread.activeCount() > 1)
        {
            Thread.yield();
        }
        
        System.out.println(rac);
    }
}
複製代碼

  這段代碼如果是正確併發的話,得到的結果應該是200000,但是我們得到的結果都是比這個值要小,且每次的結果都不一樣。(這裏每條線程的自增操作的次數要足夠大,10000就可以,因爲如果太小,20個線程就會順序執行沒有併發,得到的是正確的結果,不會出現我們要製造的那種場景)。

我們利用下面的命令,得到increase方法的字節碼如下:

可以看到在自增運算rac++是由四條字節碼指令構成,這裏就可以知道爲什麼會出現異常的原因了:第一步中getstatic指令將rac的值取到操作棧頂時,volatile保證了rac的值在此時是正確的,但是在執行第二、三步的時候,其他線程可能已經將rac的值增加了,那麼在棧頂的rac的值就是過期的數據,在最後調用putstatic指令將rac同步到主內存中時,rac的值就偏小;

2、禁止指令重排序優化

五、happens-before

JMM對兩種不同性質的重排序,採取了不同的策略:

1、對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。

2、對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不作要求(JMM允許這種重排序)

 

happens-before規則:

1、一個線程中的每個操作happens-before於該線程中的任意後續操作。

2、對一個鎖的解鎖happens-before與隨後對這個鎖的加鎖。

3、對一個volatile域的寫happens-before於任意後續對這個volatile域的讀。

4、如果A happens-before B,且B happens-before C, 那麼A happens-before C

5、如果線程A執行操作ThreadB.start()(啓動B線程),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。

6、如果線程A執行操作ThreadB.join()並且成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。

參考:http://www.cnblogs.com/dongguacai/p/5970076.html

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