volatile域的語義及其實現

0.背景-緩存一致性

根據維基百科的定義:
在一個共享內存多處理器系統中,每一個處理器都有一個單獨的緩存,能夠有不少共享數據副本:一個在主內存中,一個在每一個請求它的處理器的本地緩存中。 當一個數據副本被更改時,其餘副本必須反映該更改。 緩存一致性是確保共享操做數(數據)值的更改及時在整個系統中傳播的學科。
下面圖1是緩存不一致的示例圖,圖2是緩存一致的示例圖
緩存不一致
緩存一致
其實Java的volatile某種意義上也是來解決這種緩存不一致的狀況。html

更多緩存一致性的知識,能夠參看維基百科的詞條,也能夠看medium上的這篇文章java

1.JMM提供的volatile域的語義

1.1 可見性

根據JSR-133 FAQ中的說明,volatile字段是用於在線程之間傳遞狀態的特殊字段。 每次讀取volatile時,都會看到任意一個線程對該volatile的最後一次寫入。 實際上,程序員將volatile字段指定爲不能接受因爲緩存或重排序而致使的「過期」值的字段。 禁止編譯器和運行時在寄存器中分配它們。 它們還必須確保在寫入後將其從高速緩存(cache)中刷新到主存(memory),以便它們能夠當即對其餘線程可見。 一樣,在讀取volatile字段以前,必須使高速緩存無效,以即可以看到主內存中的值而不是本地處理器高速緩存中的值。 git

也就是說每次讀取volatile都是從主存讀取,寫入也會刷新到主存,於是保證了不一樣線程拿到的都是最新值,即保證了共享資源對各個CPU上的線程的可見性,這其實就是保證了緩存一致性。程序員

1.2. 重排序限制

在舊的內存模型下(Java1.5以前),對volatile變量的訪問不能相互重排序,但能夠與nonvolatile變量訪問一塊兒重排序。 這破壞了volatile字段做爲從一個線程到另外一線程發信號通知狀態的一種手段。github

在新的內存模型下(Java1.5及以後),volatile變量不能相互從新排序仍然爲true。區別在於,如今對它們周圍的正常字段訪問進行重排序再也不那麼容易了。編程

寫入一個volatile 字段具備與monitor釋放相同的存儲效果,而從一個volatile 字段中讀取具備與monitor獲取相同的存儲效果。windows

實際上,因爲新的內存模型對volatile 字段訪問與其餘字段訪問(不管是否爲易失性)的從新排序施加了更嚴格的約束,所以當線程A寫入volatile 字段f時,對線程A可見的任何內容,這些內容在線程B讀取f時均可見。緩存

這是一個如何使用易失性字段的簡單示例:性能優化

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

假定一個線程在調用writer方法,而另外一個在調用reader方法。在writer方法中對v的寫操做,會將對x的寫操做也更新到主存中,而對v的讀操做則從主存中獲取該值。數據結構

所以,若是reader方法看到v的值爲true,那麼也保證能夠看到在它以前發生的對42的寫入。

在舊的內存模型下,狀況並不是如此。若是v不是volatile,則編譯器能夠對writer方法中的寫入進行重排序,而reader方法對x的讀取可能會看到0。關於重排序的示例,能夠參見這篇文章

有效地,volatile的語義已獲得實質性加強,幾乎達到了同步(synchronization)的水平。出於可見性目的,對volatile 字段的每次讀取或寫入都像 "half" a synchronization (半同步)同樣。

重要說明:請注意,兩個線程訪問相同的volatile變量很重要,以便正確設置 happens-before 關係。在線程A寫入volatile字段f時,對線程A的可見的一切,並不必定對讀取volatile字段 g以後的線程B可見。

釋放和獲取必須「匹配」(即在相同的volatile 字段上執行)以具備正確的語義。

1.3.若是x爲volatile域,那麼x++ 是原子操做嗎?

首先先解釋一下什麼是原子操做:

An atomic operation is an operation that will always be executed without any other process being able to read or change state that is read or changed during the operation

原子操做是這樣一個操做,該操做執行期間讀取或改變的狀態不會被任何其餘進程讀取或改變。

1.3.1 與預期不符

假如咱們有下面的代碼:

package volatileTest;

import juc.CountDownLatch;

/**
 * * @Author: cuixin
 * * @Date: 2020/8/5 19:25
 */
public class VolatileAdder {
    private  volatile int x;
    public  void add(){
        //不是原子操做
        x++;
    }
    public  int get(){
        return x;
    }


    public static void main(String[] args) throws Exception
    {
        VolatileAdder instance = new VolatileAdder();
        int taskNum = 2;
        CountDownLatch countDownLatch = new CountDownLatch(taskNum);
        for(int i=0; i<taskNum; i++){
            new Thread(new Task(instance, countDownLatch)).start();  
        }
        countDownLatch.await();
        System.out.println(instance.get());
    }

    private static class Task implements Runnable{
        private VolatileAdder adder;
        private CountDownLatch latch;
        Task(VolatileAdder adder,  CountDownLatch latch){
            this.adder = adder;
            this.latch = latch;
        }

        @Override
        public void run() {
            for (int i = 0; i < 100000; i++)
            {
                adder.add();
            }
            latch.countDown();
        }
    }
}

(注:這裏的使用CountDownLatch只是爲了確保,兩個線程運行完任務後,主線程纔會調用instance.get(),輸出x的值。)
咱們運行上面的程序,發現結果並非預想的200000,要比這個值小一些(若是在你的機器上不是,你能夠適當調大run方法中的循環次數)。

1.3.2 jvm指令層面看看x++

下面咱們先從jvm指令層面看看x++是否是原子的。

執行

javac volatileTest/VolatileAdder.java 

javap -v  volatileTest/VolatileAdder > volatileTest/VolatileAdder.disasm

拿到jvm層面反彙編代碼,查看volatileTest/VolatileAdder.disasm 文件,能夠發現 add 方法裏面的一行 x++,用的四行 jvm 指令實現的。以下圖:
valatileAdder-add

對上面標紅四條JVM指令說明一下:

getfield 獲取字段x的值並放入操做數棧頂,

iconst_1 將1放入操做數棧棧頂;

iadd 從操做數棧頂取出兩個元素相加並將結果放回到棧頂;

putfield 從操做數棧頂拿到上面的相加結果,並賦值給字段x。

因爲一個 ++ 操做須要四條 JVM 指令,那麼就可能存在下面這種執行序列,此時至關於少作了一次++操做。

線程A 線程B
getfield
getfield
iconst_1
iadd
putfield
iconst_1
iadd
putfield

因爲線程A執行 ++x操做期間,混雜着線程B 執行++x操做,因此說這不是原子操做。

那麼如何解決呢,若是多線程下須要++操做,不妨使用Atomic相關類替代(預告,後面文章會介紹使用及原理)。

若是你還不放心,覺得上面的jvm對應的機器指令不必定也有這麼多。

1.3.3 從機器指令看x++

首先嚐試運行下面的命令,將字節碼文件轉換成本地機器指令文件。

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly volatileTest/VolatileAdder> volatileTest/VolatileAdder.native

這時候在個人機器上報了一個Could not load hsdis-amd64.dll; library not loadable; PrintAssembly is disabled的錯誤。

這個根據不一樣的操做系統和 cpu 上面的報錯會有所不一樣,你能夠按照這個地址本身編譯來解決上面的問題,也能夠本身搜搜看有沒有現成的(好比,我用的就是別人弄好的文件),而後放到了JAVA_HOME/bin路徑下,再執行就不報錯了。

VolatileAdder.native中搜索 'add' ,能夠看到 x++,也是由四條機器指令實現的,一樣的道理再一次說明了x++不是原子操做。

在這裏插入圖片描述

2.內存屏障 memory barrier

2.1 概念

下面的這幾段介紹來自維基百科

A memory barrier, also known as a membar, memory fence or fence instruction, is a type of barrier instruction that causes a central processing unit (CPU) or compiler to enforce an ordering constraint on memory operations issued before and after the barrier instruction. This typically means that operations issued prior to the barrier are guaranteed to be performed before operations issued after the barrier.

內存屏障,也稱爲 membar,,memory fence或 fence instruction,是一種屏障指令,它使中央處理單元(CPU)或編譯器對於在屏障指令以前和以後發出的存儲器操做執行一種排序約束。

這一般意味着能夠保證在屏障以前發佈的操做能夠在屏障以後發佈的操做以前執行。

Memory barriers are necessary because most modern CPUs employ performance optimizations that can result in out-of-order execution. This reordering of memory operations (loads and stores) normally goes unnoticed within a single thread of execution, but can cause unpredictable behaviour in concurrent programs and device drivers unless carefully controlled. The exact nature of an ordering constraint is hardware dependent and defined by the architecture's memory ordering model. Some architectures provide multiple barriers for enforcing different ordering constraints.

內存屏障是必需的,由於大多數現代CPU都採用了性能優化,這些性能優化可能會致使亂序執行。

一般在單個執行線程中不會注意到這種內存操做(load和store)的從新排序,可是除非仔細控制,不然可能在併發程序和設備驅動程序中引發不可預測的行爲。

排序約束的確切性質取決於硬件,並由體系結構的內存排序模型定義。某些體系結構爲執行不一樣的排序約束提供了多個內存屏障。

Memory barriers are typically used when implementing low-level machine code that operates on memory shared by multiple devices. Such code includes synchronization primitives and lock-free data structures on multiprocessor systems, and device drivers that communicate with computer hardware.

當實如今多個設備共享的內存上運行的低級機器代碼時,一般使用內存屏障。此類代碼包括多處理器系統上的同步原語和無鎖數據結構,以及與計算機硬件進行通訊的設備驅動程序。

2.2 Intel 64的內存屏障指令及內存排序限制

2.2.1 內存屏障指令

上面主要是說了Java 內存模型提供的 volatile 語義,那麼這些語義是如何實現的呢?

其實上面 VolatileAdder.native文件已經給出了答案,關鍵就在lock addl前面的lock前綴

image2020-8-7_14-28-45.png
經過查看英特爾®64和IA-32架構軟件開發人員手冊卷2A, 能夠找到 lock 的說明,下面是節選:

image2020-8-6_20-41-13.png
使處理器的LOCK#信號在執行伴隨的指令的過程當中被聲明(將指令轉換爲原子指令)。在多處理器環境中,LOCK#信號可確保在斷言該信號時,該處理器擁有對任何共享內存的獨佔使用。

也就是上面在 addl 添加前綴 lock ,這會致使該處理器執行addl時擁有對任何共享內存的獨佔使用。

其實x86-64中相似的內存屏障還有不少,好比mfencelfence, cpuid 等。

好比下面是Intel 64中mfence的節選說明:

Performs a serializing operation on all load-from-memory and store-to-memory instructions that were issued prior the MFENCE instrunction.
This serializing operation guarantees that every load and store instruction that preceds the MFENCE instruction in program order becomes globally visible before any load or store instruction that follows the MFENCE instruction.

對在MFENCE指令以前發出的全部 load-from-memory 和 store-to-memory 執行序列化操做。此序列化操做可確保,按照程序順序在 MFENCE 指令以前的每一個 load 和 store 指令,對於 MFENCE 指令以後的任何 load 或store指令都是全局可見的。

2.2.2 內存排序限制

這裏有個文件是關於Intel® 64內存排序的說明,你們也能夠看下。

2.3 Java內存模型

上面這只是關於Intel® 64相關的內存屏障指令和內存排序的說明,每一個CPU架構都不一樣呢?是否是有點絕望。。。嗯,還好有大神

下面是Doug Lea整理的關於不一樣處理器相關的內存屏障指令和原子指令。
在這裏插入圖片描述

你們必定要去看看Doug Lea寫的這篇「The JSR-133 Cookbook for Compiler Writers」。
看了以後JVM會確保生成的機器指令會在volatile字段周圍插入合適的內存屏障指令,從而實現JSR-133定義的volatile語義。 上面給出的示例VolatileExample就會在以下位置插入內存屏障指令StoreStore和LoadLoad。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;   
    //在這之間插入StoreStore屏障, 等價於在v的值true刷到主存以前,先將x的值42刷到主存。
    v = true;
  }

  public void reader() {
   //在獲取v的值以後插入LoadLoad屏障,等價於先從主存加載v的值,若是v的值爲true,再從主存加載x的值。
   if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

讀完這篇文章能夠發現,能夠看到不一樣CPU架構提供不一樣的內存屏障指令(主要由硬件工程師實現)和內存排序限制;爲了對上層隱藏各類CPU架構的不一樣,Doug Lea基於此又提出了JVM層面的LoadLoad,StoreStore等內存屏障(由JVM實現者實現);而後JVM實現者則提供統一的Java內存模型(Java語言規範 第八版 17章);而後咱們這些普通的Java開發者就在這統一的Java內存模型上寫跨平臺的應用

這裏是否是有點像搭積木同樣,一層層落上去,一層層地抽象上去。雖然按理說普通的Java開發者只須要熟悉Java內存模型便可編寫併發程序,可是爲了更好地理解如何使用Java內存模型提供的語義,爲了更好地將本身的理解遷移到其餘編程語言,理解這些底層的機制十分有必要。

3.總結

這篇文章首先是推薦的緩存一致性的文章,給你們一個背景。而後主要是對volatile的語義進行了介紹,並設計示例VolatileAdder從JVM指令和機器指令兩個層面來講明volatile域++操做不是原子操做。

下面有針對示例VolatileAdder的機器代碼中的lock addl指令進行了說明,進而引出Intel64內存屏障指令和內存排序限制,而後JVM對不一樣CPU架構進行封裝抽象提供了統一的Java內存模型給普通開發者。

是否是沒有想到,一個看起來簡簡單單的volatile,後面居然隱藏了那麼多祕密。

4.參考

https://en.wikipedia.org/wiki...
https://docs.oracle.com/javas...
http://gee.cs.oswego.edu/dl/j...
https://www.cs.umd.edu/~pugh/...
https://en.wikipedia.org/wiki...
https://docs.oracle.com/javas...
https://wiki.openjdk.java.net...
https://jpbempel.github.io/20...
https://www.infoq.com/article...
https://jpbempel.github.io/20...

相關文章
相關標籤/搜索