1. 程式人生 > >死磕 java同步系列之volatile解析

死磕 java同步系列之volatile解析

問題

(1)volatile是如何保證可見性的?

(2)volatile是如何禁止重排序的?

(3)volatile的實現原理?

(4)volatile的缺陷?

簡介

volatile可以說是Java虛擬機器提供的最輕量級的同步機制了,但是它並不容易被正確地理解,以至於很多人不習慣使用它,遇到多執行緒問題一律使用synchronized或其它鎖來解決。

瞭解volatile的語義對理解多執行緒的特性具有很重要的意義,所以彤哥專門寫了一篇文章來解釋volatile的語義到底是什麼。

語義一:可見性

前面介紹Java記憶體模型的時候,我們說過可見性是指當一個執行緒修改了共享變數的值,其它執行緒能立即感知到這種變化。

關於Java記憶體模型的講解請參考【死磕 java同步系列之JMM(Java Memory Model)】。

而普通變數無法做到立即感知這一點,變數的值線上程之間的傳遞均需要通過主記憶體來完成,比如,執行緒A修改了一個普通變數的值,然後向主記憶體回寫,另外一條執行緒B只有在執行緒A的回寫完成之後再從主記憶體中讀取變數的值,才能夠讀取到新變數的值,也就是新變數才能對執行緒B可見。

在這期間可能會出現不一致的情況,比如:

(1)執行緒A並不是修改完成後立即回寫;

(線路A修改了變數x的值為5,但是還沒有回寫,執行緒B從主記憶體讀取到的還舊值0)

(2)執行緒B還在用著自己工作記憶體中的值,而並不是立即從主記憶體讀取值;

(執行緒A回寫了變數x的值為5到主記憶體中,但是執行緒B還沒有讀取主記憶體的值,依舊在使用舊值0在進行運算)

基於以上兩種情況,所以,普通變數都無法做到立即感知這一點。

但是,volatile變數可以做到立即感知這一點,也就是volatile可以保證可見性。

java記憶體模型規定,volatile變數的每次修改都必須立即回寫到主記憶體中,volatile變數的每次使用都必須從主記憶體重新整理最新的值。

volatile的可見性可以通過下面的示例體現:

public class VolatileTest {
    // public static int finished = 0;
    public static volatile int finished = 0;

    private static void checkFinished() {
        while (finished == 0) {
            // do nothing
        }
        System.out.println("finished");
    }

    private static void finish() {
        finished = 1;
    }

    public static void main(String[] args) throws InterruptedException {
        // 起一個執行緒檢測是否結束
        new Thread(() -> checkFinished()).start();

        Thread.sleep(100);

        // 主執行緒將finished標誌置為1
        finish();

        System.out.println("main finished");

    }
}

在上面的程式碼中,針對finished變數,使用volatile修飾時這個程式可以正常結束,不使用volatile修飾時這個程式永遠不會結束。

因為不使用volatile修飾時,checkFinished()所在的執行緒每次都是讀取的它自己工作記憶體中的變數的值,這個值一直為0,所以一直都不會跳出while迴圈。

使用volatile修飾時,checkFinished()所在的執行緒每次都是從主記憶體中載入最新的值,當finished被主執行緒修改為1的時候,它會立即感知到,進而會跳出while迴圈。

語義二:禁止重排序

前面介紹Java記憶體模型的時候,我們說過Java中的有序性可以概括為一句話:如果在本執行緒中觀察,所有的操作都是有序的;如果在另一個執行緒中觀察,所有的操作都是無序的。

前半句是指執行緒內表現為序列的語義,後半句是指“指令重排序”現象和“工作記憶體和主記憶體同步延遲”現象。

關於Java記憶體模型的講解請參考【死磕 java同步系列之JMM(Java Memory Model)】。

普通變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲得正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致,因為一個執行緒的方法執行過程中無法感知到這點,這就是“執行緒內表現為序列的語義”。

比如,下面的程式碼:

// 兩個操作在一個執行緒
int i = 0;
int j = 1;

上面兩句話沒有依賴關係,JVM在執行的時候為了充分利用CPU的處理能力,可能會先執行int j = 1;這句,也就是重排序了,但是線上程內是無法感知的。

看似沒有什麼影響,但是如果是在多執行緒環境下呢?

我們再看一個例子:

public class VolatileTest3 {
    private static Config config = null;
    private static volatile boolean initialized = false;

    public static void main(String[] args) {
        // 執行緒1負責初始化配置資訊
        new Thread(() -> {
            config = new Config();
            config.name = "config";
            initialized = true;
        }).start();

        // 執行緒2檢測到配置初始化完成後使用配置資訊
        new Thread(() -> {
            while (!initialized) {
                LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
            }

            // do sth with config
            String name = config.name;
        }).start();
    }
}

class Config {
    String name;
}

這個例子很簡單,執行緒1負責初始化配置,執行緒2檢測到配置初始化完畢,使用配置來幹一些事。

在這個例子中,如果initialized不使用volatile來修飾,可能就會出現重排序,比如在初始化配置之前把initialized的值設定為了true,這樣執行緒2讀取到這個值為true了,就去使用配置了,這時候可能就會出現錯誤。

(此處這個例子只是用於說明重排序,實際執行時很難出現。)

通過這個例子,彤哥相信大家對“如果在本執行緒內觀察,所有操作都是有序的;在另一個執行緒觀察,所有操作都是無序的”有了更深刻的理解。

所以,重排序是站在另一個執行緒的視角的,因為在本執行緒中,是無法感知到重排序的影響的。

而volatile變數是禁止重排序的,它能保證程式實際執行是按程式碼順序執行的。

實現:記憶體屏障

上面講了volatile可以保證可見性和禁止重排序,那麼它是怎麼實現的呢?

答案就是,記憶體屏障。

記憶體屏障有兩個作用:

(1)阻止屏障兩側的指令重排序;

(2)強制把寫緩衝區/快取記憶體中的資料回寫到主記憶體,讓快取中相應的資料失效;

關於“記憶體屏障”的知識點,各路大神的觀點也不完全一致,所以這裡彤哥也就不展開講述了,感興趣的可以看看下面的文章:

(注意,公眾號不允許外發連結,所以只能辛苦複製連結到瀏覽器中閱讀了,而且還可能需要TECH上網)

(1) Doug Lea的《The JSR-133 Cookbook for Compiler Writers》

http://g.oswego.edu/dl/jmm/cookbook.html

Doug Lea 就是java併發包的作者,大牛!

(2)Martin Thompson的《Memory Barriers/Fences》

https://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html

Martin Thompson 專注於把效能提升到極致,專注於從硬體層面思考問題,比如如何避免偽共享等,大牛!

它的部落格地址就是上面這個地址,裡面有很多底層的知識,有興趣的可以去看看。

(3)Dennis Byrne的《Memory Barriers and JVM Concurrency》

https://www.infoq.com/articles/memory_barriers_jvm_concurrency

這是InfoQ英文站上面的一篇文章,我覺得寫的挺好的,基本上綜合了上面的兩種觀點,並從彙編層面分析了記憶體屏障的實現。

目前國內市面上的關於記憶體屏障的講解基本不會超過這三篇文章,包括相關書籍中的介紹。

我們還是來看一個例子來理解記憶體屏障的影響:

public class VolatileTest4 {
    // a不使用volatile修飾
    public static long a = 0;
    // 消除快取行的影響
    public static long p1, p2, p3, p4, p5, p6, p7;
    // b使用volatile修飾
    public static volatile long b = 0;
    // 消除快取行的影響
    public static long q1, q2, q3, q4, q5, q6, q7;
    // c不使用volatile修飾
    public static long c = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (a == 0) {
                long x = b;
            }
            System.out.println("a=" + a);
        }).start();

        new Thread(()->{
            while (c == 0) {
                long x = b;
            }
            System.out.println("c=" + c);
        }).start();

        Thread.sleep(100);

        a = 1;
        b = 1;
        c = 1;
    }
}

這段程式碼中,a和c不使用volatile修飾,b使用volatile修飾,而且我們在a/b、b/c之間各加入7個long欄位消除偽共享的影響。

關於偽共享的相關知識,可以檢視彤哥之前寫的文章【雜談 什麼是偽共享(false sharing)?】。

在a和c的兩個執行緒的while迴圈中我們獲取一下b,你猜怎樣?如果把long x = b;這行去掉呢?執行試試吧。

彤哥這裡直接說結論了:volatile變數的影響範圍不僅僅只包含它自己,它會對其上下的變數值的讀寫都有影響。

缺陷

上面我們介紹了volatile關鍵字的兩大語義,那麼,volatile關鍵字是不是就是萬能的了呢?

當然不是,忘了我們記憶體模型那章說的一致性包括的三大特性了麼?

一致性主要包含三大特性:原子性、可見性、有序性。

volatile關鍵字可以保證可見性和有序性,那麼volatile能保證原子性麼?

請看下面的例子:

public class VolatileTest5 {
    public static volatile int counter = 0;

    public static void increment() {
        counter++;
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        IntStream.range(0, 100).forEach(i->
                new Thread(()-> {
                    IntStream.range(0, 1000).forEach(j->increment());
                    countDownLatch.countDown();
                }).start());

        countDownLatch.await();

        System.out.println(counter);
    }
}

這段程式碼中,我們起了100個執行緒分別對counter自增1000次,一共應該是增加了100000,但是實際執行結果卻永遠不會達到100000。

讓我們來看看increment()方法的位元組碼(IDEA下載相關外掛可以檢視):

0 getstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
3 iconst_1
4 iadd
5 putstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
8 return

可以看到counter++被分解成了四條指令:

(1)getstatic,獲取counter當前的值併入棧

(2)iconst_1,入棧int型別的值1

(3)iadd,將棧頂的兩個值相加

(4)putstatic,將相加的結果寫回到counter中

由於counter是volatile修飾的,所以getstatic會從主記憶體重新整理最新的值,putstatic也會把修改的值立即同步到主記憶體。

但是中間的兩步iconst_1和iadd在執行的過程中,可能counter的值已經被修改了,這時並沒有重新讀取主記憶體中的最新值,所以volatile在counter++這個場景中並不能保證其原子性。

volatile關鍵字只能保證可見性和有序性,不能保證原子性,要解決原子性的問題,還是隻能通過加鎖或使用原子類的方式解決。

進而,我們得出volatile關鍵字使用的場景:

(1)運算的結果並不依賴於變數的當前值,或者能夠確保只有單一的執行緒修改變數的值;

(2)變數不需要與其他狀態變數共同參與不變約束。

說白了,就是volatile本身不保證原子性,那就要增加其它的約束條件來使其所在的場景本身就是原子的。

比如:

private volatile int a = 0;

// 執行緒A
a = 1;

// 執行緒B
if (a == 1) {
    // do sth
}

a = 1;這個賦值操作本身就是原子的,所以可以使用volatile來修飾。

總結

(1)volatile關鍵字可以保證可見性;

(2)volatile關鍵字可以保證有序性;

(3)volatile關鍵字不可以保證原子性;

(4)volatile關鍵字的底層主要是通過記憶體屏障來實現的;

(5)volatile關鍵字的使用場景必須是場景本身就是原子的;

彩蛋

關於“記憶體屏障”的三篇文章,考慮到有的同學無法TECH上網,彤哥專門把這三篇下載下來整理了一下。

關注我的公眾號“彤哥讀原始碼”,後臺回覆“volatile”,下載這三篇資料。


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。

相關推薦

java同步系列volatile解析

問題 (1)volatile是如何保證可見性的? (2)volatile是如何禁止重排序的? (3)volatile的實現原理? (4)volatile的缺陷? 簡介 volatile可以說是Java虛擬機器提供的最輕量級的同步機制了,但是它並不容易被正確地理解,以至於很多人不習慣使用它,遇到多執行緒問題一律

java同步系列synchronized解析

問題 (1)synchronized的特性? (2)synchronized的實現原理? (3)synchronized是否可重入? (4)synchronized是否是公平鎖? (5)synchronized的優化? (6)synchronized的五種使用方式? 簡介 synchronized關鍵字是Ja

java同步系列ReentrantLock原始碼解析(一)——公平鎖、非公平鎖

問題 (1)重入鎖是什麼? (2)ReentrantLock如何實現重入鎖? (3)ReentrantLock為什麼預設是非公平模式? (4)ReentrantLock除了可重入還有哪些特性? 簡介 Reentrant = Re + entrant,Re是重複、又、再的意思,entrant是enter的名詞或

java同步系列ReentrantLock原始碼解析(二)——條件鎖

問題 (1)條件鎖是什麼? (2)條件鎖適用於什麼場景? (3)條件鎖的await()是在其它執行緒signal()的時候喚醒的嗎? 簡介 條件鎖,是指在獲取鎖之後發現當前業務場景自己無法處理,而需要等待某個條件的出現才可以繼續處理時使用的一種鎖。 比如,在阻塞佇列中,當佇列中沒有元素的時候是無法彈出一個元素

java同步系列ReentrantReadWriteLock原始碼解析

問題 (1)讀寫鎖是什麼? (2)讀寫鎖具有哪些特性? (3)ReentrantReadWriteLock是怎麼實現讀寫鎖的? (4)如何使用ReentrantReadWriteLock實現高效安全的TreeMap? 簡介 讀寫鎖是一種特殊的鎖,它把對共享資源的訪問分為讀訪問和寫訪問,多個執行緒可以同時對共享

java同步系列Semaphore原始碼解析

問題 (1)Semaphore是什麼? (2)Semaphore具有哪些特性? (3)Semaphore通常使用在什麼場景中? (

java同步系列StampedLock原始碼解析

問題 (1)StampedLock是什麼? (2)StampedLock具有什麼特性? (3)StampedLock是否支援可重入

java同步系列CyclicBarrier原始碼解析——有圖有真相

問題 (1)CyclicBarrier是什麼? (2)CyclicBarrier具有什麼特性? (3)CyclicBarrier與

java同步系列Phaser原始碼解析

問題 (1)Phaser是什麼? (2)Phaser具有哪些特性? (3)Phaser相對於CyclicBarrier和Count

java同步系列開篇

討論 關註 使用 避免死鎖 更新數據 讀寫 上下文切換 monit 缺點 簡介 同步系列,這是彤哥想了好久的名字,本來是準備寫鎖相關的內容,但是java中的CountDownLatch、Semaphore、CyclicBarrier這些類又不屬於鎖,它們和鎖又有很多共同點,

java同步系列JMM(Java Memory Model)

簡介 Java記憶體模型是在硬體記憶體模型上的更高層的抽象,它遮蔽了各種硬體和作業系統訪問的差異性,保證了Java程式在各種平臺下對記憶體的訪問都能達到一致的效果。 硬體記憶體模型 在正式講解Java的記憶體模型之前,我們有必要先了解一下硬體層面的一些東西。 在現代計算機的硬體體系中,CPU的運算速度是非常快

java同步系列自己動手寫一個鎖Lock

問題 (1)自己動手寫一個鎖需要哪些知識? (2)自己動手寫一個鎖到底有多簡單? (3)自己能不能寫出來一個完美的鎖? 簡介 本篇文章的目標一是自己動手寫一個鎖,這個鎖的功能很簡單,能進行正常的加鎖、解鎖操作。 本篇文章的目標二是通過自己動手寫一個鎖,能更好地理解後面章節將要學習的AQS及各種同步器實現的原理

java同步系列AQS起篇

問題 (1)AQS是什麼? (2)AQS的定位? (3)AQS的實現原理? (4)基於AQS實現自己的鎖? 簡介 AQS的全稱是AbstractQueuedSynchronizer,它的定位是為Java中幾乎所有的鎖和同步器提供一個基礎框架。 AQS是基於FIFO的佇列實現的,並且內部維護了一個狀態變數sta

java同步系列ReentrantLock VS synchronized——結果可能跟你想的不一樣

問題 (1)ReentrantLock有哪些優點? (2)ReentrantLock有哪些缺點? (3)ReentrantLock

java同步系列AQS終篇(面試)

問題 (1)AQS的定位? (2)AQS的重要組成部分? (3)AQS運用的設計模式? (4)AQS的總體流程? 簡介 AQS的全稱是AbstractQueuedSynchronizer,它的定位是為Java中幾乎所有的鎖和同步器提供一個基礎框架。 在之前的章節中,我們一起學習了ReentrantLock、R

java同步系列mysql分散式鎖

問題 (1)什麼是分散式鎖? (2)為什麼需要分散式鎖? (3)mysql如何實現分散式鎖? (4)mysql分散式鎖的優點和缺點? 簡介 隨著併發量的不斷增加,單機的服務遲早要向多節點或者微服務進化,這時候原來單機模式下使用的synchronized或者ReentrantLock將不再適用,我們迫切地需要一

java同步系列zookeeper分散式鎖

(2)zookeeper分散式鎖有哪些優點? (3)zookeeper分散式鎖有哪些缺點? 簡介 zooKeeper是一個分散式的,開放原始碼的分散式應用程式協調服務,它可以為分散式應用提供一致性服務,它是Hadoop和Hbase的重要元件,同時也可以作為配置中心、註冊中心運用在微服務體系中。 本章我們將介

java同步系列redis分散式鎖進化史

(2)redis分散式鎖有哪些優點? (3)redis分散式鎖有哪些缺點? (4)redis實現分散式鎖有沒有現成的輪子可以使用? 簡介 Redis(全稱:Remote Dictionary Server 遠端字典服務)是一個開源的使用ANSI C語言編寫、支援網路、可基於記憶體亦可持久化的日誌型、Key-

java同步系列終結篇

腦圖 下面是關於同步系列的一份腦圖,列舉了主要的知識點和問題點,看過本系列文章的同學可以根據腦圖自行回顧所學的內容,也可以作為面試前的準備。 如果有需要高清無碼原圖的同學,可以關注公眾號“彤哥讀原始碼”,回覆“sync”領取。 總結 所謂同步,就是保證多執行緒(包括多程序)對共享資源的讀寫能夠安全有效的執