溫故知新-多線程-深入刨析volatile關鍵詞



摘要

本文主要涉及Java中的volatile,將從volatile的作用開啓,再分析volatile實現的從而深刻立即理解volatile的作用;最後通過《volatile DCL單例需不需要加volatile?》這樣一個問題結束volatile的溫習;

volatile的作用

我在前幾篇的文章編程語言&性能優化已經提到了volatile的作用;
概括一下就是:

  1. 線程可見
  2. 防止指令重排

volatile如何解決線程可見?

  • 下面的代碼flag只有加了volatile修飾後,纔會打印出here,很好驗證;
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
	new Thread(() - > {
		while (flag) {
		  // do nothing
		}
		log.info("here");
	}, "name").start();
	Thread.sleep(1000);
	flag = false;
}

CPU Cache

CPU緩存的出現主要是爲了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因爲CPU運算速度要比內存讀寫速度快得多,舉個例子:

  • 一次主內存的訪問通常在幾十到幾百個時鐘週期
  • 一次L1高速緩存的讀寫只需要1~2個時鐘週期
  • 一次L2高速緩存的讀寫也只需要數十個時鐘週期
    這種訪問速度的顯著差異,導致CPU可能會花費很長時間等待數據到來或把數據寫入內存。

它的工作簡要原理如下:

  • 當CPU要讀取一個數據時,首先從緩存中查找,如果找到就立即讀取並送給CPU處理;
  • 如果沒有找到,就用相對慢的速度從內存中讀取並送給CPU處理,同時把這個數據所在的數據塊調入緩存中,可以使得以後對整塊數據的讀取都從緩存中進行,不必再調用內存。

爲了充分發揮CPU的計算性能和吞吐量,現代CPU引入了一級緩存(一級數據緩存Data Cache,D-Cache和一級指令緩存InstructionCache,I-Cache)、二級緩存和三級緩存,結構如下圖所示:cpu三級緩存

  • CPU到各緩存和內存之間的大概速度:速度
  • 在Linux中可以通過如下命令查看CPU Cache:
    查看CPU Cache
    這裏的index0和index1對應着L1 D-Cache和L1 I-Cache。

CPU Cache & 主內存

關係

當系統運行時,CPU執行計算的過程如下

  • 程序以及數據被加載到主內存
  • 指令和數據被加載到CPU緩存
  • CPU執行指令,把結果寫到高速緩存
  • 高速緩存中的數據寫回主內存

單核處理,問題不是很大,但是在多核的情況下,問題就來了;
問題
eg:

  • 核0讀取了一個字節,根據局部性原理,它相鄰的字節同樣被被讀入核0的緩存
  • 核3做了上面同樣的工作,這樣核0與核3的緩存擁有同樣的數據
  • 核0修改了那個字節,被修改後,那個字節被寫回核0的緩存,但是該信息並沒有寫回主存
  • 核3訪問該字節,由於核0並未將數據寫回主存,數據不同步

緩存一致性協議

爲了解決這一問題,CPU製造商規定了一個緩存一致性協議。

每個CPU都有一級緩存,但是,我們卻無法保證每個CPU的一級緩存數據都是一樣的。 所以同一個程序,CPU進行切換的時候,切換前和切換後的數據可能會有不一致的情況。那麼這個就是一個很大的問題了。 如何保證各個CPU緩存中的數據是一致的。就是CPU的緩存一致性問題。

如何解決呢?

  1. 總線鎖

一種處理一致性問題的辦法是使用Bus Locking(總線鎖)。當一個CPU對其緩存中的數據進行操作的時候,往總線中發送一個Lock信號。 這個時候,所有CPU收到這個信號之後就不操作自己緩存中的對應數據了,當操作結束,釋放鎖以後,所有的CPU就去內存中獲取最新數據更新。
用鎖,那麼性能問題就來了,所以出現了MESI;

  1. MESI

MESI是保持一致性的協議。它的方法是在CPU緩存中保存一個標記位,這個標記位有四種狀態:
M: Modify,修改緩存,當前CPU的緩存已經被修改了,即與內存中數據已經不一致了;
E: Exclusive,獨佔緩存,當前CPU的緩存和內存中數據保持一致,而且其他處理器並沒有可使用的緩存數據;
S: Share,共享緩存,和內存保持一致的一份拷貝,多組緩存可以同時擁有針對同一內存地址的共享緩存段;
I: Invalid,失效緩存,這個說明CPU中的緩存已經不能使用了。

CPU的讀取遵循下面幾點,來保證CPU的效率

  • 如果緩存狀態是I,那麼就從內存中讀取,否則就從緩存中直接讀取。
  • 如果緩存處於M或E的CPU讀取到其他CPU有讀操作,就把自己的緩存寫入到內存中,並將自己的狀態設置爲S。
  • 只有緩存狀態是M或E的時候,CPU纔可以修改緩存中的數據,修改後,緩存狀態變爲M。
  • Volatile的彙編碼

生成彙編參考文章: 從彙編看Volatile的內存屏障

Java代碼如下

public class VolatileTest {
    private static volatile Integer flag = 0;
    public static void main(String[] args) {
        flag++;
    }
}

生成彙編

flag彙編:
  0x00000001156e7d65: movb   $0x0,(%rsi,%rbx,1)
  0x00000001156e7d69: lock addl $0x0,(%rsp)     ;*putstatic flag
                                                ; - com.yangsc.juc.VolatileTest::main@16 (line 21)
--- 
flag2彙編:
  0x00000001156e7e58: mov    0x38(%rsp),%rbx
  0x00000001156e7e5d: movb   $0x0,(%rdi,%rbx,1)  ;*putstatic flag2
                                                ; - com.yangsc.juc.VolatileTest::main@38 (line 22)                                             

有volatile修飾的共享變量進行寫操作時會多出第二行彙編代碼,該句代碼的意思是對原值加零,其中相加指令addl前有lock修飾。通過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會引發兩件事情:

  1. 將當前處理器緩存行的數據寫回到系統內存。
    Lock前綴指令導致在執行指令期間,聲言處理器的LOCK# 信號。在多處理器環境中,LOCK# 信號確保在聲言該信號期間,處理器可以獨佔任何共享內存(因爲它會鎖住總線,導致其他CPU不能訪問總線,也就不能訪問系統內存,在Intel486和Pentium處理器中都是這種策略)。但是,在最近的處理器裏,LOCK# 信號一般不鎖總線,而是鎖緩存,因爲鎖總線開銷的比較大。在P6和目前的處理器中,如果訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK# 信號。相反,它會鎖定這塊區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操作被稱爲“緩存鎖定”,緩存一致性機制會阻止同時修改由兩個以上的處理器緩存的內存區域數據。

  2. 這個寫回內存的操作會使在其他CPU裏緩存了該內存地址的數據無效。
    IA-32處理器和Intel 64處理器使用MESI控制協議去維護內部緩存和其他處理器緩存的一致性。在多核處理器系統中進行操作的時候,IA-32和Intel 64處理器能嗅探其他處理器訪問系統內存和它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統內存和其他處理器的緩存的數據在總線上保持一致。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處於共享狀態,那麼正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強行執行緩存行填充。

volatile如何解決指令重排序?

計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排,一般分一下3種:源代碼 -> 編譯器優化的重排 -> 指令並行的重排 -> 內存系統的重排 -> 最終執行的指令

  • 單線程環境裏面確保程序最終執行結果和代碼順序執行的結果一致。(單線程沒有影響)

  • 處理器在進行重排序時必須考慮指令之間的數據依賴性。(int a=1; int b=0; 沒有依賴,可以進行指令重排)

  • 多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。

  • 驗證指令

public class VolatileTest2 {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(() -> {
                shortWait(100);
                a = 1;
                x = b;
            });

            Thread two = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start();
            two.start();
            one.join();
            two.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                log.info(result);
                break;
            } else {
                log.info(result);
            }
        }
    }
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}
  • 執行結果
    結果
    加上volatile修飾後直接結果,跑了一上午,沒有出現x、y爲0的情況
    private static volatile int x = 0, y = 0;
    private static volatile int a = 0, b = 0;

在這裏插入圖片描述

volatile 字節碼標記

當我們對一個變量用volatile修飾時,字節碼中會標記爲volatile,後續交由虛擬機處理;
在這裏插入圖片描述

volatile 虛擬機規範

內存屏障(Memory Barrier,或有時叫做內存柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題。Java編譯器也會根據內存屏障的規則禁止重排序。內存屏障可以被分爲以下幾種類型

  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。

hotspot實現方法,通過查資料瞭解到,大致有兩種方式實現:

  • 內存屏障sfence mfence lfence等指令,缺點:不一定跨平臺;
  • 鎖總線,好處:跨平臺;
    通過JDK的bytecodeinterpreter.cpp源碼可以看到,通過調用OrderAccess::fence()實現;
    fence
    再跳轉到fence函數,發現底層使用了lock,鎖總線的方式,這種方式好處就在於,跨平臺可移植性;
    fence

LOCK 用於在多處理器中執行指令時對共享內存的獨佔使用。 它的作用是能夠將當前處理器對應緩存的內容刷新到內存,並使其他處理器對應的緩存失效。 另外還提供了有序的指令無法越過這個內存屏障的作用。
這個跟上一篇《溫故知新-多線程-深入刨析CAS》提到的CAS也是一樣的原理,都是用了鎖;


至此,volatile的實現原理也都講完了,來看一下簡單的應用

volatile DCL單例需不需要加volatile?

  • code0 一個最簡單的單例,問題在於提前就實例化好了,佔用內存,一般叫做餓漢模式;
public class SingletonDemo {
    public static final SingletonDemo instance = new SingletonDemo();
    private SingletonDemo(){
    }
    public static SingletonDemo getInstance(){
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(()->{
                SingletonDemo singletonDemo = SingletonDemo.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
  • code1 懶漢模式,問題在於,這種方式鎖的粒度太大;
public class SingletonDemo1 {
    public static SingletonDemo1 instance;

    private SingletonDemo1() {
    }

    public static synchronized SingletonDemo1 getInstance() {
        if (instance == null) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance = new SingletonDemo1();
        }
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo1 singletonDemo = SingletonDemo1.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
  • code2 懶漢模式2(有問題的)
    因爲 if (instance == null) 和synchronized(SingletonDemo2.class)加鎖之間有時間間隙,所以這種方式行不通;
public class SingletonDemo2 {
    public static SingletonDemo2 instance;

    private SingletonDemo2() {
    }

    public static  SingletonDemo2 getInstance() {
        if (instance == null) {
            synchronized(SingletonDemo2.class){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                instance = new SingletonDemo2();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo2 singletonDemo = SingletonDemo2.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
  • code3 於是就有DCL的懶漢模式
    DCL是(Double Check Lock)的縮寫,也就是加鎖後再判斷一遍,保證過程是單例的,似乎一切都很完美,但要討論的問題是:“volatile DCL單例需不需要加volatile?”,繼續看;
public class SingletonDemo3 {
    public static SingletonDemo3 instance;

    private SingletonDemo3() {
    }

    public static SingletonDemo3 getInstance() {
        if (instance == null) {
            synchronized(SingletonDemo3.class){
                if (instance == null) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    instance = new SingletonDemo3();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo3 singletonDemo = SingletonDemo3.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}

回答這個問題:volatile DCL單例需不需要加volatile?我們需要知道new一個對象的過程,藉助idea插件:jclasslib is a bytecode viewer看一下new 一個對象的字節碼;
代碼如下:

public class ObjectLayout {
    public static void main(String[] args) {
        Object o = new Object();
    }
}

在這裏插入圖片描述
從圖中可以看到,new一個對象包括以下幾個步驟:

  1. new:在內存中new 一個對象
  2. dup:一個僞指令
  3. invokespecial:構造方法,初始化相關value;
  4. astore_1 將棧幀指向這個對象

根據我們上面文章所講講到的知識,可能會發生指令重排,如果3和4步驟發生指令重排,那麼就有可能拿到了一個半初始化的對象;
以SingletonDemo3舉例,可能會產生步驟1不爲null,但是2還沒進行,線程直接取走了一個半初始化的對象,這問題可能就會很嚴重了;

在這裏插入圖片描述


所以需要使用volatile修飾單例,防止指令重排 public static volatile SingletonDemo3 instance;

當然,單例的寫法還有其它,比如枚舉類等等,這不在本文的討論的範疇,可以搜索其它文章瞭解更多的單例寫法;

參考

Java volatile 關鍵字底層實現原理解析
理解CPU Cache
KVM之CPU虛擬化
從彙編看Volatile的內存屏障
就是要你懂Java中volatile關鍵字實現原理
JVM內存模型、指令重排、內存屏障概念解析


你的鼓勵也是我創作的動力

打賞地址

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