聊聊Java多線程之內存可見性 可見性(Visibility)

可見性(Visibility)

線程可見性簡介

線程之間的可見性是指當一個線程修改一個變量,另外一個線程可以馬上得到這個修改值。

假設我們有2個線程:A爲讀線程,讀取一個共享變量的值,並根據讀取到的值來判斷下一步執行邏輯;B爲寫線程,對一個共享變量進行寫入。很有可能B線程寫入的值對於A線程是不可見的。

兩個線程間的不可見性

我們用一個例子來表示這種線程間變量不可見的情況。Nonvisibility中的示例包含兩個共享數據的線程。Cancel線程將更新標誌,Work線程將一直循環直到讀取到Cancel線程更新標誌:



public class NonVisibilityDemo1 {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static int flag = -1;
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag = 1;
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}


/**
Output:
WorkThread start
CancelThread set flag=1
...
...
...
程序一直運行不退出
**/

這個程序可能會一直循環下去,因爲Work線程可能讀取不到Cancel線程對於flag的寫入而永遠等待。

線程間的不可見性是怎樣產生的

要理解線程間對共享變量的不可見性,需要大概理解CPU的工作流程。

先了解我們使用的程序變量可能存儲的位置:

  • 每個CPU有寄存器,CPU對變量的運算需要從寄存器中讀寫變量
  • 除寄存器(Register)外還有高速緩存子系統(Cache),寫緩衝器(Store Buffer),無效化隊列 (Invalidate Queue)
  • 計算機的主內存

也就是說,我們對一個變量的讀寫操作,可能要途徑:主內存->Cache->Store Buffer->寄存器->CPU。

那麼可能產生變量不可見的情況就會有:

  1. 每個處理器都有自己的寄存器,不同的線程可能運行在不同的CPU上,例如線程A在CPU-1中運行更改了變量V的值從0到1,於此同時(瞬時),線程2在CPU-2中讀取變量V的值仍然是0,這時對線程A對變量的操作則對線程B體現了不可見性。

  2. CPU對變量操作之後,需要將對該變量的更新寫入到主內存中,而處理器對主內存並不是直接訪問,而是通過該CPU的寫緩衝器(Store Buffer)中,還沒到達該處理器的Cache中,這時一個CPU的Store Buffer是無法於另一個CPU共享該變量的更新的,這樣也產生了不可見性。

雖然一個CPU的Cache是不可以被另一個CPU直接讀取的,但是處理器可以通過緩存一致性協議(Cache Coherence Protocol)來讀取其他處理器的Cache中的數據,並且將數據同步該處理器的Cache中,這個過程稱之爲緩存同步。並且爲了保證可見性,需要將CPU對變量做的更新最終寫入到該CPU的高速緩存或者主內存中,這個過程稱爲沖刷處理器緩存
即通過沖刷處理器緩存來保證CPU對變量的更新沖刷到Cache中,通過緩存同步將對變量的更新同步到其他的處理器。

如何解決線程間不可見性

爲了保證線程間可見性我們必須要保證對共享數據的寫操作和讀操作都是同步的,也就是寫操作線程和讀操作線程都需要在同一個鎖上進行同步。我們一般有3種方式去保持同步:

  • volatile:只保證可見性
  • Atomic相關類:保證可見性和原子性
  • Lock: 保證可見性和原子性

使用volatile關鍵字來解決可見性問題

Java提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。
在訪問volatile變量時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。

我們嘗試更改上一個示例,使用volatile關鍵字來修飾共享數據ShareData.flag

public class VisibilityByVolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static volatile  int flag = -1;
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag = 1;
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread set flag=1
程序運行結束
**/

由於對ShareData.flag使用了volatile關鍵字進行了修飾,程序可以正常結束,並且讀線程可以正常的訪問到寫線程對共享數據flag的修改從而正常結束。

使用AtomicInteger類來解決可見性問題

我們再嘗試更改上一個示例,使用AtomicInteger類來包裝共享數據ShareData.flag:


public class VisibilityByAtomicDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag.get() == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static AtomicInteger flag = new AtomicInteger(-1);
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag.set(1);
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread setFlag flag=1
程序運行結束
**/

由於ShareData.flag使用的類型是AtomicInteger,寫線程對flag的修改對於讀線程是可見的,這樣寫線程可以讀取到flag被更新爲1並正常退出。

使用synchronized來解決可見性問題

使用synchronized關鍵字對操作加鎖也可以保證線程間的可見性,並且保證操作的原子性。內置鎖可以用於確保某個線程以一種可預測的方式來查看另一個線程的執行結果。加鎖的含義不僅僅侷限於互斥行爲,還包括內存可見性。
我們再構造一個示例來說明synchronized關鍵字所起的作用。首先我們還是需要2個線程,一個讀線程,一個寫線程,然後把讀寫操作封裝到ShareData中,然後觀察在沒有synchronized關鍵字修飾時程序
的運行情況。


public class VisibilityBySynchronizedDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.getFlag() == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    private static  int flag = -1;

    public static synchronized int getFlag() {
        return flag;
    }

    public static synchronized void setFlag(int value) {
        flag = value;
    }
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.setFlag(1);
        System.out.println("CancelThread setFlag flag=" + ShareData.getFlag());
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread setFlag flag=1
程序結束
**/

由於getFlag()setFlag()方法都使用了synchronized關鍵字修飾,保證了原子性和可見性,程序正常結束。

小結

在Java平臺中,如何保證可見性呢?也就是我們上面提到的

  • volatile:只保證可見性
  • Atomic相關類:保證可見性和原子性
  • Lock: 保證可見性和原子性

其實無論對於上述的何種方式,其本質都是會使相應的CPU進行刷新處理器緩存動作,來保證共享變量的可見性。

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