1. 程式人生 > >併發程式設計-(3)Java記憶體模型和volatile

併發程式設計-(3)Java記憶體模型和volatile

目錄

1、記憶體模型概念

我們都知道,計算機在執行程式時,每條指令都是在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。這就是快取一致性問題。通常稱這種被多個執行緒訪問的變數為共享變數

  也就是說,如果一個變數在多個CPU中都存在快取(一般在多執行緒程式設計時才會出現),那麼就可能存在快取不一致的問題。

為了解決快取不一致性問題,通常來說有以下2種解決方法:

  1. 通過在匯流排加LOCK#鎖的方式
  2. 通過快取一致性協議

  這2種方式都是硬體層面上提供的方式。

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。比如上面例子中 如果一個執行緒在執行 i = i +1,如果在執行這段程式碼的過程中,在總線上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

  但是上面的方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。

  所以就出現了快取一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

2、多執行緒的特性

1.1、原子性

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

一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。這2個操作必須要具備原子性才能保證不出現一些意外的問題。

我們操作資料也是如此,比如i = i+1;其中就包括,讀取i的值,計算i,寫入i。這行程式碼在Java中是不具備原子性的,則多執行緒執行肯定會出問題,所以也需要我們使用同步和lock這些東西來確保這個特性了。

原子性其實就是保證資料一致、執行緒安全一部分,

1.2、可見性

當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

若兩個執行緒在不同的cpu,那麼執行緒1改變了i的值還沒重新整理到主存,執行緒2又使用了i,那麼這個i值肯定還是之前的,執行緒1對變數的修改執行緒沒看到這就是可見性問題。

1.3、有序性

程式執行的順序按照程式碼的先後順序執行。

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

int a = 10; //語句1

int r = 2; //語句2

a = a + 3; //語句3

r = a*a; //語句4

則因為重排序,他還可能執行順序為 2-1-3-4,1-3-2-4
但絕不可能 2-1-4-3,因為這打破了依賴關係。
顯然重排序對單執行緒執行是不會有任何問題,而多執行緒就不一定了,所以我們在多執行緒程式設計時就得考慮這個問題了。

2、Java記憶體模型

2.1、JMM和JVM

  • JMM:Java記憶體模型,多執行緒相關,主要決定一個執行緒對共享變數寫操作是,其他執行緒是否可見。
  • JVM:Java虛擬機器模型,其中jvm結構中主要談到的是方法區、堆、棧、老年代、新生代的這些問題。

2.2、Java記憶體模型(JMM)

  • 主記憶體:共享變數

  • 本地記憶體:共享變數副本

2.2.1、案例

package com.fly.thread_demo.demo_2;

/**
 * volatile 關鍵字
 */
public class VolatileDemo extends Thread{

    private  Boolean flag = true;

    public void setFlag(Boolean flag){
        this.flag = flag;
    }

    @Override
    public void run(){
        System.out.println("執行緒開始...");
        while (flag){

        }
        System.out.println("執行緒結束...");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo vd = new VolatileDemo();
        vd.start();
        Thread.sleep(3000);
        vd.setFlag(false);
        Thread.sleep(1000);
        System.out.println(vd.flag);
    }
}

因為我在主執行緒中flag設定為false,但是子執行緒一直拿到的是本地記憶體中的副本,所以就算我們把flag設定為false,子執行緒依舊會一直執行下去。

https://blog.csdn.net/ft305977550/article/details/78769573 (System.out.println對執行緒安全的影響)

解決方案:private volatile Boolean flag = true; 在共享變數前加關鍵字volatile,修飾

2.2.2、volatile作用

  1. 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。
  2. 禁止進行指令重排序。

上述案例如果把flag用volatile修飾過後就不一樣了,第一:使用volatile關鍵字會強制將修改的值立即寫入主存;第二:使用volatile關鍵字的話,當主執行緒進行修改時,會導致子執行緒的工作記憶體中快取變數flag的快取行無效(反映到硬體層的話,就是CPU的L1或者L2快取中對應的快取行無效);第三:由於子執行緒的工作記憶體中快取變數flag的快取行無效,所以子執行緒再次讀取變數flag的值時會去主存讀取。那麼在主執行緒修改flag值時(當然這裡包括2個操作,修改主執行緒工作記憶體中的值,然後將修改後的值寫入記憶體),會使得子執行緒的工作記憶體中快取變數flag的快取行無效,然後子執行緒讀取時,發現自己的快取行無效,它會等待快取行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。那麼子執行緒讀取到的就是最新的正確的值。

2.3、重排序

2.3.1、什麼是重排序

int a = 1;  //1
int b = 2;  //2
int c = a+b;//3

編譯器重排序的典型就是通過調整指令順序,在不改變程式語義的前提下,儘可能的減少暫存器的讀取、儲存次數,充分複用暫存器的儲存值。重排序只會對沒有依賴關係的指令進行重排序,並不會對有依賴關係的進行重排序

比如,我們第1行 和第2行 沒有任何關係,所以先執行1 還是2 都不會影響語義,但是第3行就不一樣了,他依賴1,2行,所以編譯器不會對第3行就行重排序

2.3.2、重排序如何影響執行緒安全

package com.fly.thread_demo.demo_2;

/**
 * 重排序
 */
public class SortThreadDemo {
    /**
     * 共享變數
     */
    private int a = 0;
    private boolean flag = false;

    /**
     * 執行緒1
     */
    public void write(){
        a = 1;          //1
        flag = true;    //2
    }

    /**
     * 執行緒2
     */
    public void read(){
        if(flag){       //3
            a  = a + a ;//4
        }
    }

}

我們假設有兩個執行緒 分別執行 write 方法和read方法,如果沒有重排序,當flag是true是時候,a的值肯定是1,這個時候第4行程式碼結果是a = 2;但是第1行和第2行程式碼沒有依賴關係,可能會發生重排序,如果發生了重排序,先執行了2 ,這個時候執行緒1掛起了,這個時候a=0;flag = true;執行緒2在執行的時候a = 0;這樣就產生執行緒安全問題了,同樣volatile可以禁止重排序,就不會發生這個問題了。

private volatile int a = 0;
private volatile boolean flag = false;

2.4、總結

  1. volatile可以保證執行緒的可見性,但是不能保證原子性,可以禁止重排序。
  2. synchronized 既可以保證可見性,又可以保證原子性。