Java並發編程變量可見性避免指令重排使用詳解

引言

上一篇文章講的是線程本地存儲 ThreadLocal,講究的是讓每個線程持有一份數據副本,大傢各自訪問各自的,就不用爭搶瞭。

那怎麼保證程序裡一個線程對共享變量的修改能立馬被其他線程看到瞭?這時候有人會說瞭,加鎖呀,前面不就是因為加鎖成本太高才使用的 ThreadLocal的嗎?怎麼又說回去瞭?

其實CPU每個核心也都是有緩存的,今天要講的volatile能保證變量在多線程間的可見性,本文我們會對變量可見性、指令重排、Happens Before 原則以及 Volatile 對這些特性提供的支持和在程序裡的使用進行講解,本文大綱如下:

變量的可見性

一個線程對共享變量的修改,另外一個線程能夠立刻看到,稱為變量的可見性。

在單核系統中,所有的線程都是在一顆 CPU 上執行,CPU 緩存與內存的數據一致性容易解決。但是多核系統中,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決瞭,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。

比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對於線程 B 而言不具備可見性。

Java 裡可以使用 volatile 關鍵字修飾成員變量,來保證成員在線程間的可見性。讀取 volatile 修飾的變量時,線程將不會從所在CPU的緩存,而是直接從系統的主存中讀取變量值。同理,向一個 volatile 修飾的變量寫入值的時候,也是直接寫入到主存。

下面我們再來看一下,當不使用 volatile 時,多線程使用共享變量時的可見性問題。

Java 變量的可見性問題

Java 的 volatile 關鍵字能夠保證變量更改的跨線程可見,在一個多線程應用程序中,為瞭提高性能,線程會把變量從主存拷貝到線程所在CPU信息的緩存上再操作。如果程序運行在多核機器上,多個線程可能會運行在不同的CPU 上,也就意味著不同的線程可能會把變量拷貝到不同的 CPU 緩存上。

因為CPU緩存的讀寫速度遠高於主存,所以線程會把數據從主存讀到 CPU 緩存,數據的更新也是是先更新CPU 緩存中的副本,再刷回主存,除非有(匯編指令)強制要求否則不會每次更新都把數據刷回主存。

對於非 volatile 修飾的變量,Java 無法保證 JVM 何時會把數據從主存讀取到 CPU 緩存,或將數據從 CPU 緩存寫入主內存。

這在多線程環境下可能會導致問題,想象一下這樣一種情況,有多個線程可以訪問一個共享對象,該對象包含一個聲明如下的計數器變量。

public class SharedObject {
    public volatile int counter = 0;
}

假設在我們的例子中隻有線程1 會更新計數器 counter 的值,線程1 和線程2 都會時不時的讀取 counter 的值。 如果 counter 未被聲明為 volatile 的,則無法保證變量 counter 的值何時會從 CPU 緩存寫回主存。這意味著,CPU 緩存中的計數器變量值可能與主內存中的不同。比如像下圖這樣:

線程2 訪問 counter 的值的結果是 0 ,沒有看到變量 counter 最新的值。這是因為 counter 它最新的值還在CPU1 的緩存中,還沒有被線程1 寫回到主內。

上面這個例子描述的情況,就是所謂“可見性”問題:一個線程的更新對其他線程是不可見的。

Volatile 的可見性保證

Java 的 volatile 關鍵字旨在解決變量可見性問題。通過將上面例子中的 counter 變量聲明為 volatile的,所有對counter 變量的寫入都將立即寫回主存,所以對 counter 變量的讀取都會先將變量從主存讀到CPU緩存 (相當於每次都從主存讀取)。

把 counter 變量聲明成 volatile 隻需要在定義中加上 volatile 關鍵字即可

public class SharedObject {
    public volatile int counter = 0;
}

完整的 volatile 可見性保證

實際上,volatile 的可見性保證超出瞭 volatile 修飾的變量本身。它的可見性保證規則如下:

  • 如果線程 A 寫入一個 volatile 變量,而線程 B 隨後讀取瞭同一個 volatile 變量,那麼線程 A 在寫入 volatile 變量之前,對線程 A 可見(更新可見)的所有變量,在線程 B 讀取 volatile 變量之後也將對線程 B 可見。
  • 如果線程 A 讀取一個 volatile 變量,那麼在讀取 volatile 變量時,線程 A 可見的所有變量也將從主存中重新讀取。

我們通過例程解釋一下這兩個規則。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate() 方法寫入三個變量,其中隻有變量 days 是 volatile 的。 完整的 volatile 可見性保證意味著,當一個新值被寫入到變量 days 時,該線程可見的所有變量也會被寫入主內。這意味著,當一個新值被寫入變量 days 時,years 和 months 的值也會被寫入主存。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

而對於 volatile 變量的讀取來說,在上面例程的 totalDays 方法中,當讀取 days 變量的值的時候,除瞭會從主存中重新讀取變量 days 的值外,其他兩個未被 volatile 修飾的變量 years 和 months 也會被從主存中重新讀取到CPU緩存。通過上述讀取順序,可以確保看到 days、months 和 years 的最新值。

指令重排

在指定的語義保持不變的情況下,出於性能原因,JVM 和 CPU 可能會對程序中的指令進行重新排序。比如說,下面這幾個指令:

int a = 1;
int b = 2;
a++;
b++;

這些指令可以重新排序為以下序列

int a = 1;
a++;
int b = 2;
b++;

然而,對於存在被聲明為 volatile 的變量的程序而言,我們傳統理解的指令重排會導致嚴重的問題,還以上面使用過的例程來描述一下這個問題。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦當 update() 方法將新值寫入 days 變量時,新寫入的 years 和 months 的值也將被寫入主存。但是,如果 JVM 重排指令,把程序變成下面這樣會怎樣呢?

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

重排後變成瞭先對 days 進行賦值,根於完整可見性的第一條規則,當寫入 days 變量時,months 和 years 變量的值也會被寫入主存。但是指令重排後,變量 days 的賦值這一次是在新值寫入 months 和 years 之前發生的。因此,它們的新值不會正確地對其他線程可見。

顯然,重新排序的指令的語義已經改變,不過 Java 內部會有解決方案防止此類問題的發生。

volatile 的 Happens Before 保證

為瞭解決上面例子裡指令重排導致的問題,除瞭可見性保證之外,Java 的 volatile 關鍵字還提供瞭“happens-before”保證。

  • 如果原來位於寫 volatile 變量之前的非 volatile 變量的讀寫,在指令重排時,不允許這些指令出現在 volatile 變量的寫入指令之後。但是原來在 volatile 變量寫入之後的對其他變量的讀寫指令,在重排時,是允許出現在寫 volatile 變量之前的–即從後變前允許,從前變後不行。
  • 如果原來位於讀 volatile 變量之後的對非 volatile 變量的讀寫,在指令重排時,不允許出現在讀 volatile 變量之前。

上面的 Happens-Before 保證確保瞭 volatile 在程序發生指令重排時也能提供正確的可見性保證。

volatile 不能保證原子性

雖然 volatile 關鍵字保證瞭對 volatile 修飾的變量的所有讀取都直接從主存中讀取,對 volatile 變量的所有寫入都會寫入到主存中,但 volatile 不能保證原子性。

在前面共享計數器的例子中,我們設置瞭一個前提–隻有線程1 會更新計數器 counter 的值,線程1 和線程2 會時不時的讀取 counter 的值。在這個前提下,把 counter 變量聲明成 volatile 的足以確保線程 2 始終能看到線程1最新寫入的值。

事實上,當寫入變量的新值不依賴先前的值(比如累加)的時候,多個線程都向同一個 volatile 變量寫入時,是能保證向主存中寫入的是正確的值的。但是,如果需要首先讀取 volatile 變量的值,並基於該值為 volatile 變量生成一個新值,那麼 volatile 就不能保證變量正確的可見性瞭。讀取 volatile 變量和寫入新值之間的這個短短的時間間隔,在多線程並發寫入的情況下也是會產生 Data Racing 的。

想象一下,如果線程 1 將值為 0 的 counter 變量讀取到運行它的 CPU 的緩存中,將其遞增到 1,在線程1把 counter 的值寫回主存之前,線程 2 可能正好也從主內存中把 counter 變量讀到瞭運行它的 CPU 緩存中,讀取到的 counter 變量的值也是 0,然後線程 2 也對 counter 變量進行遞增的操作。

線程 1 和線程 2 現在實際上已經不同步瞭。理論上 counter 變量從 0 經過兩次遞增應該變成 2,但實際上每個線程在其 CPU 緩存中的 counter 變量的值為 1,即使線程最終將 counter 變量的值寫回主存,它的值也是不對的。

那麼,如何做到線程安全呢?有兩種方案:

  • volatile + synchronized
  • 使用原子類替代 volatile

原子類後面到 J.U.C 相關的章節的時候再去學習。

什麼時候適合使用 volatile

如果 volatile 修飾符使用恰當的話,它比 synchronized 的使用和執行成本更低,因為它不會引起線程上下文的切換和調度。但是要註意 volatile 是無法替代 synchronized ,因為 volatile 無法保證操作的原子性。

通常來說,使用 volatile 必須具備以下 2 個條件:

  • 對變量的寫操作不依賴於當前值
  • volatile 變量沒有包含在具有其他變量的表達式中

示例:雙重鎖實現線程安全的單例模式

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

volatile 的原理

使用 volatile 關鍵字時,程序對應的匯編代碼在對應位置會多出一個 lock 前綴指令。lock 前綴指令實際上相當於一個內存屏障(也稱內存柵欄),內存屏障會提供 3 個功能:

  • 它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
  • 它會強制將對緩存的修改操作立即寫入主存;
  • 如果是寫操作,它會導致其他 CPU 中對應的緩存行無效。

註意 volatile 的性能問題

讀取和寫入 volatile 變量都會直接訪問主存,讀寫主存比訪問 CPU 緩存更慢得多,不過使用 volatile 變量還可以防止指令重排,這是一種正常的性能增強技術。因此,我們隻應該在確實需要變量的可見性和防止指令重排時,再使用 volatile 變量。

以上就是Java並發編程變量可見性避免指令重排使用詳解的詳細內容,更多關於Java並發變量可見性避免指令重排的資料請關註WalkonNet其它相關文章!

推薦閱讀: