java多線程(volatile知識點)

懷揣夢想的閉眼聽風 發佈 2024-04-29T09:13:24.895072+00:00

Java的volatile關鍵字用於標記一個變量「應當存儲在主存」。更確切地說,每次讀取volatile變量,都應該從主存讀取,而不是從CPU緩存讀取。每次寫入一個volatile變量,應該寫到主存中,而不是僅僅寫到CPU緩存。

java的volatile關鍵字用於標記一個變量「應當存儲在主存」。更確切地說,每次讀取volatile變量,都應該從主存讀取,而不是從CPU緩存讀取。每次寫入一個volatile變量,應該寫到主存中,而不是僅僅寫到CPU緩存。

【變量可見性問題】

在一個多線程的應用中,線程在操作非volatile變量時,出於性能考慮,每個線程可能會將變量從主存拷貝到CPU緩存中。並且JVM並不保證會從主存中讀取數據到CPU緩存,或者將CPU緩存中的數據寫到主存中,如圖:如果只有線程1修改了(自增)counter變量,而線程1和線程2兩個線程都會在某些時刻讀取counter變量。如果counter變量沒有聲明成volatile,則counter的值不保證會從CPU緩存寫回到主存中。也就是說,CPU緩存和主存中的counter變量值並不一致。這就是「可見性」問題,線程看不到變量最新的值,因為其他線程還沒有將變量值從CPU緩存寫回到主存。一個線程中的修改對另外的線程是不可見的。

【volatile可見性】

Java的volatile關鍵字就是設計用來解決變量可見性問題。將counter變量聲明為volatile,則在寫入counter變量時,也會同時將變量值寫入到主存中。同樣的,在讀取counter變量值時,也會直接從主存中讀取。

將一個變量聲明為volatile,可以保證變量寫入時對其他線程的可見。在上面的場景中,一個線程(T1)修改了counter,另一個線程(T2)讀取了counter(但沒有修改它),將counter變量聲明為volatile,就能保證寫入counter變量後,對T2是可見的。然而,如果T1和T2都修改了counter的值,那麼就會出現問題了。事實上,多個線程都能寫入共享的volatile變量,主存中也能存儲正確的變量值,然而這有一個前提,變量新值的寫入不能依賴於變量的舊值。換句話說,就是一個線程寫入一個共享volatile變量值時,不需要先讀取變量值,然後以此來計算出新的值。

【擴展】

volatile的可見性保證並不是只對於volatile變量本身那麼簡單。可見性保證遵循以下規則:

  • 如果線程A寫入一個volatile變量,線程B隨後讀取了同樣的volatile變量,則線程A在寫入volatile變量之前的所有可見的變量值,在線程B讀取volatile變量後也同樣是可見的。
  • 如果線程A讀取一個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;
    }
}

update()方法寫入3個變量,其中只有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;
    }
}

totalDays()方法開始讀取days變量值到total變量。在讀取days變量值時,months和years的值也會同時從主存讀取。因此,按上面所示的順序讀取時,可以保證讀取到days、months、years變量的最新值。

可以將對volatile變量的讀寫理解為一個觸發刷新的操作,寫入volatile變量時,線程中的所有變量也都會觸發寫入主存。而讀取volatile變量時,也同樣會觸發線程中所有變量從主存中重新讀取。因此,應當儘量將volatile的寫入操作放在最後,而將volatile的讀取放在最前,這樣就能連帶將其他變量也進行刷新。上面的例子中,update()方法對days的賦值就是放在years、months之後,就是保證years、months也能將最新的值寫入到主存,如果是放在兩個變量之前,則days會寫入主存,而years、months則不會。反過來,totalDays()方法則將days的讀取放在最前面,就是為了能同時觸發刷新years、months變量值,如果是放後面,則years、months就可能還是從CPU緩存中讀取值,而不是從主存中獲取最新值。

【指令重排問題】

出於性能考慮,JVM和CPU是允許對程序中的指令進行重排的,只要保證(重排後的)指令語義一致即可。然而,指令重排面臨的一個問題就是對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;
    }
}

發生指令重排後,可能變化為:

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

在days被修改時,months、years的值也會寫入到主存,但這時進行寫入,months、years並不是新的值。java通過Happens-Before來保證。

【Happens-Before】

  • 如果有讀寫操作發生在寫入volatile變量之前,讀寫其他變量的指令不能重排到寫入volatile變量之後。寫入一個volatile變量之前的讀寫操作,對volatile變量是有happens-before保證的。注意,如果是寫入volatile之後,有讀寫其他變量的操作,那麼這些操作指令是有可能被重排到寫入volatile操作指令之前的。但反之則不成立。即可以把位於寫入volatile操作指令之後的其他指令移到寫入volatile操作指令之前,而不能把位於寫入volatile操作指令之前的其他指令移到寫入volatile操作指令之後。
  • 如果有讀寫操作發生在讀取volatile變量之後,讀寫其他變量的指令不能重排到讀取volatile變量之前。注意,如果是讀取volatile之前,有讀取其他變量的操作,那麼這些操作指令是有可能被重排到讀取volatile操作指令之後的。但反之則不成立。即可以把位於讀取volatile操作指令之前的指令移到讀取volatile操作指令之後,而不能把位於讀取volatile操作指令之後的指令移到讀取volatile操作指令之前。

【總結】

如果只有一個線程對volatile進行讀寫,而其他線程只是讀取變量,這時,對於只是讀取變量的線程來說,volatile就已經可以保證讀取到的是變量的最新值。如果沒有把變量聲明為volatile,這就無法保證。讀寫volatile變量會導致變量從主存讀寫。從主存讀寫比從CPU緩存讀寫更加「昂貴」。訪問一個volatile變量同樣會禁止指令重排,而指令重排是一種提升性能的技術。因此,你應當只在需要保證變量可見性的情況下,才使用volatile變量。

關鍵字: