你真的瞭解 volatile 關鍵字嗎?

来源:https://www.cnblogs.com/wupeixuan/archive/2019/10/31/11769011.html
-Advertisement-
Play Games

今天,讓我們一起來探討 Java 併發編程中的知識點:volatile 關鍵字 本文主要從以下三點講解 volatile 關鍵字: 1. volatile 關鍵字是什麼? 2. volatile 關鍵字能解決什麼問題?使用場景是什麼? 3. volatile 關鍵字實現的原理? volatile 關 ...


今天,讓我們一起來探討 Java 併發編程中的知識點:volatile 關鍵字

本文主要從以下三點講解 volatile 關鍵字:

  1. volatile 關鍵字是什麼?
  2. volatile 關鍵字能解決什麼問題?使用場景是什麼?
  3. volatile 關鍵字實現的原理?

volatile 關鍵字是什麼?

在 Sun 的 JDK 官方文檔是這樣形容 volatile 的:

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes. A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.

也就是說,如果一個變數加了 volatile 關鍵字,就會告訴編譯器和 JVM 的記憶體模型:這個變數是對所有線程共用的、可見的,每次 JVM 都會讀取最新寫入的值並使其最新值在所有 CPU 可見。volatile 可以保證線程的可見性並且提供了一定的有序性,但是無法保證原子性。在 JVM 底層 volatile 是採用記憶體屏障來實現的。

通過這段話,我們可以知道 volatile 有兩個特性:

  1. 保證可見性、不保證原子性
  2. 禁止指令重排序

原子性和可見性

原子性是指一個操作或多個操作要麼全部執行並且執行的過程不會被任何因素打斷,要麼都不執行。性質和資料庫中事務一樣,一組操作要麼都成功,要麼都失敗。看下麵幾個簡單例子來理解原子性:

i == 0;       //1
j = i;        //2
i++;          //3
i = j + 1;    //4

在看答案之前,可以先思考一下上面四個操作,哪些是原子操作?哪些是非原子操作?

答案揭曉:

1——是:在Java中,對基本數據類型的變數賦值操作都是原子性操作(Java 有八大基本數據類型,分別是byte,short,int,long,char,float,double,boolean)
2——不是:包含兩個動作:讀取 i 值,將 i 值賦值給 j
3——不是:包含了三個動作:讀取 i 值,i+1,將 i+1 結果賦值給 i
4——不是:包含了三個動作:讀取 j 值,j+1,將 j+1 結果賦值給 i

也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。

註:由於以前的操作系統是 32 位, 64 位數據(long 型,double 型)在 Java 中是 8 個位元組表示,一共占用 64 位,因此需要分成兩次操作採用完成一個變數的賦值或者讀取操作。隨著 64 位操作系統越來越普及,在 64 位的 HotSpot JVM 實現中,對64 位數據(long 型,double 型)做原子性處理(由於 JVM 規範沒有明確規定,不排除別的 JVM 實現還是按照 32 位的方式處理)。

在單線程環境中我們可以認為上述步驟都是原子性操作,但是在多線程環境下,Java 只保證了上述基本數據類型的賦值操作是原子性的,其他操作都有可能在運算過程中出現錯誤。為此在多線程環境下為了保證一些操作的原子性引入了鎖和 synchronized 等關鍵字。

上面說到 volatile 關鍵字保證了變數的可見性,不保證原子性。原子性已經說了,下麵說下可見性。

可見性其實和 Java 記憶體模型的設定有關:Java 記憶體模型規定所有的變數都是存在主存(線程共用區域)當中,每個線程都有自己的工作記憶體(私有記憶體)。線程對變數的所有操作都必須在工作記憶體中進行,而不直接對主存進行操作。並且每個線程不能訪問其他線程的工作記憶體。

舉個簡單慄子:

比如上面 i++ 操作,在 Java 中,執行 i++ 語句:

執行線程首先從主存中讀取 i(原始值)到工作記憶體中,然後在工作記憶體中執行運算 +1 操作(主存的 i 值未變),最後將運算結果刷新到主存中。

數據運算是在執行線程的私有記憶體中進行的,線程執行完運算後,並不一定會立即將運算結果刷新到主存中(雖然最後一定會更新主存),刷新到主存動作是由 CPU 自行選擇一個合適的時間觸發的。假設數值未更新到主存之前,當其他線程去讀取時(而且優先讀取的是工作記憶體中的數據而非主存),此時主存中可能還是原來的舊值,就有可能導致運算結果出錯。

以下代碼是測試代碼:

package com.wupx.test;

/**
 * @author wupx
 * @date 2019/10/31
 */
public class VolatileTest {

    private boolean flag = false;

    class ThreadOne implements Runnable {
        @Override
        public void run() {
            while (!flag) {
                System.out.println("執行操作");
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("任務停止");
        }
    }

    class ThreadTwo implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(2000L);
                System.out.println("flag 狀態改變");
                flag = true;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        VolatileTest testVolatile = new VolatileTest();
        Thread thread1 = new Thread(testVolatile.new ThreadOne());
        Thread thread2 = new Thread(testVolatile.new ThreadTwo());
        thread1.start();
        thread2.start();
    }
}

上述結果有可能線上程 2 執行完 flag = true 之後,並不能保證線程 1 中的 while 能立即停止迴圈,原因在於 flag 狀態首先是線上程 2 的私有記憶體中改變的,刷新到主存的時機不固定,而且線程 1 讀取 flag 的值也是在自己的私有記憶體中,而線程 1 的私有記憶體中 flag 仍未 false,這樣就有可能導致線程仍然會繼續 while 迴圈。運行結果如下:

執行操作
執行操作
執行操作
flag 狀態改變
任務停止

避免上述不可預知問題的發生就是用 volatile 關鍵字修飾 flag,volatile 修飾的共用變數可以保證修改的值會在操作後立即更新到主存裡面,當有其他線程需要操作該變數時,不是從私有記憶體中讀取,而是強制從主存中讀取新值。即一個線程修改了某個變數的值,這新值對其他線程來說是立即可見的。

指令重排序

一般來說,處理器為了提高程式運行效率,可能會對輸入代碼進行優化,它不保證程式中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程式最終執行結果和代碼順序執行的結果是一致的。

比如下麵的代碼

int i = 0;             
boolean flag = false;
i = 1;        // 1
flag = true;  // 2

代碼定義了一個 int 型變數,定義了一個 boolean 類型變數,然後分別對兩個變數進行賦值操作。從代碼順序上看,語句 1 是在語句 2 前面的,那麼 JVM 在真正執行這段代碼的時候會保證語句 1 一定會在語句 2 前面執行嗎?不一定,為什麼呢?這裡可能會發生指令重排序(InstructionReorder)。

語句 1 和語句 2 誰先執行對最終的程式結果並沒有影響,那麼就有可能在執行過程中,語句 2 先執行而語句 1 後執行。

但是要註意,雖然處理器會對指令進行重排序,但是它會保證程式最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下麵一個例子:

int a = 10;     // 1
int r = 2;      // 2
a = a + 3;      // 3
r = a * a;      // 4

這段代碼執行的順序可能是 1->2->3->4 或者是 2->1->3->4,但是 3 和 4 的執行順序是不會變的,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令 Instruction2 必須用到 Instruction1 的結果,那麼處理器會保證 Instruction1 會在 Instruction2 之前執行。

雖然重排序不會影響單個線程內程式執行的結果,但是多線程呢?下麵看一個例子:

// 線程1
String config = initConfig();    // 1
boolean inited = true;           // 2
 
// 線程2
while(!inited){
       sleep();
}
doSomeThingWithConfig(config);

上面代碼中,由於語句 1 和語句 2 沒有數據依賴性,因此可能會被重排序。假如發生了重排序,線上程 1 執行過程中先執行語句 2,而此時線程 2 會以為初始化工作已經完成,那麼就會跳出 while 迴圈,去執行 doSomeThingWithConfig(config) 方法,而此時 config 並沒有被初始化,就會導致程式出錯。

從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性。

那麼 volatile 關鍵字修飾的變數禁止重排序的含義是:

  1. 當程式執行到 volatile 變數的讀操作或者寫操作時,在其前面的操作肯定已經全部進行,且對後面的操作可見,在其後面的操作肯定還沒有進行
  2. 在進行指令優化時,不能將 volatile 變數之前的語句放在對 volatile 變數的讀寫操作之後,也不能把 volatile 變數後面的語句放到其前面執行

舉個慄子:

x=0;             // 1
y=1;             // 2
volatile z = 2;  // 3
x=4;             // 4
y=5;             // 5

變數z為 volatile 變數,那麼進行指令重排序時,不會將語句 3 放到語句 1、語句 2 之前,也不會將語句 3 放到語句 4、語句 5 後面。但是語句 1 和語句 2、語句 4 和語句 5 之間的順序是不作任何保證的,並且 volatile 關鍵字能保證,執行到語句 3 時,語句 1 和語句 2 必定是執行完畢了的,且語句 1 和語句 2 的執行結果是對語句 3、語句 4、語句 5是可見的。

回到之前的例子:

// 線程1
String config = initConfig();   // 1
volatile boolean inited = true; // 2
 
// 線程2
while(!inited){
       sleep();
}
 
doSomeThingWithConfig(config);

之前說這個例子提到有可能語句2會在語句1之前執行,那麼就可能導致執行 doSomThingWithConfig() 方法時就會導致出錯。

這裡如果用 volatile 關鍵字對 inited 變數進行修飾,則可以保證在執行語句 2 時,必定能保證 config 已經初始化完畢。

volatile 應用場景

synchronized 關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程式執行效率,而 volatile 關鍵字在某些情況下性能要優於 synchronized,但是要註意 volatile 關鍵字是無法替代 synchronized 關鍵字的,因為 volatile 關鍵字無法保證操作的原子性。通常來說,使用 volatile 必須具備以下三個條件:

  1. 對變數的寫入操作不依賴變數的當前值,或者能確保只有單個線程更新變數的值
  2. 該變數不會與其他狀態變數一起納入不變性條件中
  3. 在訪問變數時不需要加鎖

上面的三個條件只需要保證是原子性操作,才能保證使用 volatile 關鍵字的程式在高併發時能夠正確執行。建議不要將 volatile 用在 getAndOperate 場合,僅僅 set 或者 get 的場景是適合 volatile 的。

常用的兩個場景是:

  1. 狀態標記量
volatile boolean flag = false;

while (!flag) {
    doSomething();
}

public void setFlag () {
    flag = true;
}

volatile boolean inited = false;
// 線程 1
context = loadContext();
inited = true;

// 線程 2
while (!inited) {
    sleep();
}
doSomethingwithconfig(context);
  1. DCL雙重校驗鎖-單例模式
public class Singleton {
    private volatile static Singleton instance = null;

    private Singleton() {
    }

    /**
     * 當第一次調用getInstance()方法時,instance為空,同步操作,保證多線程實例唯一
     * 當第一次後調用getInstance()方法時,instance不為空,不進入同步代碼塊,減少了不必要的同步
     */
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

推薦閱讀:設計模式-單例模式

使用 volatile 的原因在上面解釋重排序時已經講過了。主要在於 instance = new Singleton(),這並非是一個原子操作,在 JVM 中這句話做了三件事情:

  1. 給 instance分配記憶體
  2. 調用 Singleton 的構造函數來初始化成員變數
  3. 將 instance 對象指向分配的記憶體庫存空間(執行完這步 instance 就為非 null 了)

但是 JVM 即時編譯器中存在指令重排序的優化,也就是說上面的第二步和第三步順序是不能保證的,最終的執行順序可能是 1-2-3,也可能是 1-3-2。如果是後者,線程 1 在執行完 3 之後,2 之前,被線程 2 搶占,這時 instance 已經是非 null(但是並沒有進行初始化),所以線程 2 返回 instance 使用就會報空指針異常。

volatile 特性是如何實現的呢?

前面講述了關於 volatile 關鍵字的一些使用,下麵我們來探討一下 volatile 到底如何保證可見性和禁止指令重排序的。

在《深入理解Java虛擬機》這本書中說道:

觀察加入volatile關鍵字和沒有加入 volatile 關鍵字時所生成的彙編代碼發現,加入 volatile 關鍵字時,會多出一個 lock 首碼指令。

接下來舉個慄子:

volatile 的 Integer 自增(i++),其實要分成 3 步:

  1. 讀取 volatile 變數值到 local
  2. 增加變數的值
  3. 把 local 的值寫回,讓其它的線程可見

這 3 步的 JVM 指令為:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

lock 首碼指令實際上相當於一個記憶體屏障(也叫記憶體柵欄),記憶體屏障會提供 3 個功能:

  1. 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成(滿足禁止重排序)
  2. 它會強制將對緩存的修改操作立即寫入主存(滿足可見性)
  3. 如果是寫操作,它會導致其他 CPU 中對應的緩存行無效(滿足可見性)

volatile 變數規則是 happens-before(先行發生原則)中的一種:對一個變數的寫操作先行發生於後面對這個變數的讀操作。(該特性可以很好解釋 DCL 雙重檢查鎖單例模式為什麼使用 volatile 關鍵字來修飾能保證併發安全性)

總結

變數聲明為 volatile 類型時,編譯器與運行時都會註意到這個變數是共用的,不會將該變數上的操作與其他記憶體操作一起重排序。volatile 變數不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取 volatile 類型的變數時總會返回最新寫入的值。

在訪問 volatile 變數時不會執行加鎖操作,也就不會使執行線程阻塞,因此 volatile 變數是比 sychronized 關鍵字更輕量級的同步機制。

加鎖機制既可以確保可見性和原子性,而 volatile 變數只能確保可見性。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 場景 Ubuntu Server 16.04 LTS上怎樣安裝下載安裝Nginx並啟動: https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/102828075 在上面安裝完Nginx後,在 /usr/local/nginx/下有個co ...
  • 場景 Ubuntu Server 16.04 LTS上怎樣安裝下載安裝Nginx並啟動: https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/102828075 上面在安裝完Nginx後,來看看Nginx的常用的命令。 註: 博客: h ...
  • 場景 Ubuntu Server 16.04 LTS上怎樣安裝下載安裝Nginx並啟動: https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/102828075 在上面進行安裝Nginx前配置檢查時提示: checking for C ...
  • 場景 Linux-安裝 Ubuntu Server 16.04 X64(圖文教程詳細版): https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/93790806 Nginx入門簡介和反向代理、負載均衡、動靜分離理解: https://b ...
  • 安裝RabbitMQ,可以選擇使用Docker或者在CentOS下安裝RabbitMQ。這裡就不介紹這些高大上的安裝,就介紹在本地安裝吧。 第一步、下載並安裝erlang 為什麼要先下載安裝erlang呢?因為RabbitMQ服務端代碼是使用併發式語言Erlang編寫的,安裝RabbitMQ的前提是 ...
  • 事件起因 昨天有同事找我到,說他搭建的 XXL JOB 任務調度系統不能工作了,調用總是出錯(服務端返回 500)希望我能幫忙處理一下,不過說實話我也沒有搭建過 XXL JOB 的經驗,但是既然同事請求了,就只能硬著頭皮幫忙一起看下,解決的過程還算比較順利,但是發現網上這塊的資料很少,所以打算把解決 ...
  • 項目中要用到RabbitMQ,領導讓我先瞭解一下。在之前的公司中,用到過消息隊列MQ,阿裡的那款RocketMQ,當時公司也做了簡單的技術分享,自己也看了一些博客。自己在有道雲筆記上,做了一些整理,但後來也就擱在那了。現在有時間,就對MQ的一些簡單的概念做下整理吧。 RabbitMQ的一些介紹,請參 ...
  • 前段時間我朋友介紹我看一套b站Java教程 說這個教程是b站口碑最好的 我去看了一段時間 確實講的非常好 這套是求知講堂出的 網址:https://www.bilibili.com/video/av61604219 大家一起學習 有看過評價下這套教程 覺得他是不是b站最好的Java教程 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...