詭異的並發之可見性


我們都知道,隨着祖國越來越繁榮昌盛,隨着科技的進步,設備的更新換代,計算機體系結構、操作系統、編譯程序都在不斷地改革創新,但始終有一點是不變的(我對鴨血粉絲的熱愛忠貞不渝):那就是下面三者的性能耗時:CPU < 內存 < I/O

但也正因為這些改變,也就在並發程序中出現了一些詭異的問題,而其中最昭著的三大問題就是:可見性、有序性、原子性。

今天我們就主要來學習一下三者中的可見性。

零、可見性的闡述

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

在單核時代,所有線程都在一個CPU上執行,所以一個線程的寫,一定是對其它線程可見的。就好比,一個總經理下面就一個項目負責人。

此時,項目經理查看到任務G后,分配給員工A和員工B,那么這個任務的進度就能隨時掌握在項目經理手中了;每個員工都能從項目經理處得知最新的項目進度。

而在多核時代后,每個CPU都有自己的緩存,這就出現了可見性問題。

此時,兩個項目經理同時查看到任務G后,各自分配給自己下屬員工,那么這個任務的進度就只能掌握在各自項目經理手中了,因為所有員工的工作進度並不是匯報給同一個項目經理;那么,每個員工只能得知自己項目組員工的工作進度,並不能得知其他項目組的工作進度。所以,當多個項目經理在做同一個任務時,就可能出現任務配比不均、任務進度拖延、任務重復進行等多種問題。

總結上面的例子來講,就是因為進度的不及時更新,導致數據不是最新,導致決策失誤。所以,我們隱約可以看出,內存並不直接與Cpu打交道,而是通過高速緩存與Cpu打交道。

cpu  <——> 高速緩存  <———>  內存

通過一張圖片來表示就是(多核):

下文我們的闡述,若無特殊說明,都是基於多核的。

一、導致共享變量在線程之間不可見的原因:

可見性問題都是由Cpu緩存不一致為並發編程帶來,而其中的主要有下面三種情況:

1.1、線程交叉執行

線程交叉執行多數情況是由於線程切換導致的,例如下圖中的線程A在執行過程中切換到線程B執行完成后,再切換回線程A執行剩下的操作;此時線程B對變量的修改不能對線程A立即可見,這就導致了計算結果和理想結果不一致的情況。

1.2、重排序結合線程交叉執行

例如下面這段代碼

    int a = 0;    //行1
    int b = 0;    //行2
    a = b + 10;   //行3
    b = a + 9;    //行4

如果行1和行2在編譯的時候改變順序,執行結果不會受到影響;

如果將行3和行4在變異的時候交換順序,執行結果就會受到影響,因為b的值得不到預期的19;

由圖知:由於編譯時改變了執行順序,導致結果不一致;而兩個線程的交叉執行又導致線程改變后的結果也不是預期值,簡直雪上加霜!

1.3、共享變量更新后的值沒有在工作內存及主存間及時更新

因為主線程對共享變量的修改沒有及時更新,子線程中不能立即得到最新值,導致程序不能按照預期結果執行。

例如下面這段代碼:

package com.itquan.service.share.resources.controller;

import java.time.LocalDateTime;

/**
 * @author :mmzsblog
 * @description:共享變量在線程間的可見性測試
 */
public class VisibilityDemo {

    // 狀態標識flag
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        System.out.println(LocalDateTime.now() + "主線程啟動計數子線程");
        new CountThread().start();

        Thread.sleep(1000);
        // 設置flag為false,使上面啟動的子線程跳出while循環,結束運行
        VisibilityDemo.flag = false;
        System.out.println(LocalDateTime.now() + "主線程將狀態標識flag被置為false了");
    }

    static class CountThread extends Thread {
        @Override
        public void run() {
            System.out.println(LocalDateTime.now() + "計數子線程start計數");
            int i = 0;
            while (VisibilityDemo.flag) {
                i++;
            }
            System.out.println(LocalDateTime.now() + "計數子線程end計數,運行結束:i的值是" + i);
        }
    }

}

運行結果是:

從控制台的打印結果可以看出,因為主線程對flag的修改,對計數子線程沒有立即可見,所以導致了計數子線程久久不能跳出while循環,結束子線程。

歡迎關注公眾號"Java學習之道",查看更多干貨!

對於這種情況,當然不能忍,所以就引出了下一個問題:如何解決線程間不可見性

二、如何解決線程間不可見性

為了保證線程間可見性我們一般有3種選擇:

2.1、volatile:只保證可見性

volatile關鍵字能保證可見性,但也只能保證可見性,在此處就能保證flag的修改能立即被計數子線程獲取到。

此時糾正上面例子出現的問題,只需在定義全局變量的時候加上volatile關鍵字

    // 狀態標識flag
    private static volatile boolean flag = true;

2.2、Atomic相關類:保證可見性和原子性

將標識狀態flag在定義的時候使用Atomic相關類來進行定義的話,就能很好的保證flag屬性的可見性以及原子性。

此時糾正上面例子出現的問題,只需在定義全局變量的時候將變量定義成Atomic相關類

    // 狀態標識flag
    private static AtomicBoolean flag = new AtomicBoolean(true);

不過值得注意的一點是,此時原子類相關的方法設置新值和得到值的放的是有點變化,如下:

    // 設置flag的值
    VisibilityDemo.flag.set(false);
    
    // 獲取flag的值
    VisibilityDemo.flag.get()

2.3、Lock: 保證可見性和原子性

此處我們使用的是Java常見的synchronized關鍵字。

此時糾正上面例子出現的問題,只需在為計數操作i++添加synchronized關鍵字修飾

    synchronized (this) {
        i++;
    }

通過上面三種方式,我們都能得到類似如下的期望結果:

然而,接下來我們要對其中的volatilesynchronized關鍵字做一番較為詳細的解釋。歡迎關注公眾號"Java學習之道",查看更多干貨!

三、可見性-volatile

Java內存模型對volatile關鍵字定義了一些特殊的訪問規則,當一個變量被volatile修飾后,它將具備兩種特性,或者說volatile具有下列兩層語義:

  • 第一、保證了不同線程對這個變量進行讀取時的可見性。即一個線程修改了某個變量的值, 這個新值對其他線程來說是立即可見的。 (volatile解決了線程間共享變量的可見性問題)。
  • 第二、禁止進行指令重排序, 阻止編譯器對代碼的優化。

針對第一點,volatile保證了不同線程對這個變量進行讀取時的可見性,具體表現為:

  • 1: 使用 volatile 關鍵字會強制將在某個線程中修改的共享變量的值立即寫入主內存。
  • 2: 使用 volatile 關鍵字的話, 當線程 2 進行修改時, 會導致線程 1 的工作內存中變量的緩存行無效(反映到硬件層的話, 就是 CPU 的 L1或者 L2 緩存中對應的緩存行無效);

附一張CPU緩存模型圖:

  • 3: 由於線程 1 的工作內存中變量的緩存行無效,所以線程1再次讀取變量的值時會去主存讀取。基於這一點,所以我們經常會看到文章中或者書本中會說volatile 能夠保證可見性。

綜上所述:就是用volatile修飾的變量,對這個變量的讀寫,不能使用 CPU 緩存,必須從內存中讀取或者寫入。

使用volatile無法保障線程安全,那么volatile的作用是什么呢?

其中之一:(對狀態量進行標記,保證其它線程看到的狀態量是最新值)

volatile關鍵字是Java虛擬機提供的最輕量級的同步機制,很多人由於對它理解不夠(其實這里你想理解透的話可以看看happens-before原則),而往往更願意使用synchronized來做同步。

四、可見性synchronized

4.1、作用域

synchronized關鍵字的作用域有二種:

  • 1)是某個對象實例內,synchronized aMethod(){}可以防止多個線程同時訪問這個對象的synchronized方法。

    如果一個對象有多個synchronized方法,只要一個線程訪問了其中的一個synchronized方法,其它線程不能同時訪問這個對象中任何一個synchronized方法。

    這時,不同的對象實例的synchronized方法是不相干擾的。也就是說,其它線程照樣可以同時訪問相同類的另一個對象實例中的synchronized方法。

    因為當修飾非靜態方法的時候,鎖定的是當前實例對象。

  • 2)是某個類的范圍,synchronized static aStaticMethod{}防止多個線程同時訪問這個類中的synchronized static 方法。它可以對類的所有對象實例起作用。

    因為當修飾靜態方法的時候,鎖定的是當前類的 Class 對象。

4.2、可用於方法中的某個區塊中

除了方法前用synchronized關鍵字,synchronized關鍵字還可以用於方法中的某個區塊中,表示只對這個區塊的資源實行互斥訪問。

用法是:

synchronized(this){
    /*區塊*/
}

它的作用域是當前對象;

4.3、不能繼承

synchronized關鍵字是不能繼承的,也就是說,基類的方法

synchronized f(){
    // 具體操作
} 

在繼承類中並不自動是

synchronized f(){
    // 具體操作  
}

而是變成了

f(){
    // 具體操作
}

繼承類需要你顯式的指定它的某個方法為synchronized方法;

綜上3點所述:synchronized關鍵字主要有以下這3種用法:

  • 修飾實例方法:作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
  • 修飾靜態方法:作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
  • 修飾代碼塊:指定加鎖對象,對給定對象加鎖,進入同步代碼塊前要獲得給定對象的鎖

這三種用法就基本保證了共享變量在讀取的時候,讀取到的是最新的值。

4.4、JVM關於synchronized的兩條規定:

  • 線程解鎖前,必須把共享變量的最新值刷新到主內存

  • 線程加鎖時,將清空工作內存中共享變量的值,從而是使用共享變量時,需要從主內存中重新讀取最新的值(注意:加鎖與解鎖是同一把鎖)

從上面的這兩條規則也可以看出,這種方式保證了內存中的共享變量一定是最新值。

但我們在使用synchronized保證可見性的時候也要注意以下幾點:

  • A.無論synchronized關鍵字加在方法上還是對象上,它取得的鎖都是對象;而不是把一段代碼或函數當作鎖――而且同步方法很可能還會被其他線程的對象訪問。
  • B.每個對象只有一個鎖(lock)與之相關聯。Java 編譯器會在 synchronized 修飾的方法或代碼塊前后自動加上加鎖 lock() 和解鎖 unlock(),這樣做的好處就是加鎖 lock() 和解鎖 unlock() 一定是成對出現的,畢竟忘記解鎖 unlock() 可是個致命的 Bug(意味着其他線程只能死等下去了)。
  • C.實現同步是要很大的系統開銷作為代價的,甚至可能造成死鎖,所以盡量避免無謂的同步控制。

以上內容就是我對並法中的可見性的一點理解與總結了,下期我們接着敘述並發中的有序性。

參考文章:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM