您好,登錄后才能下訂單哦!
本篇內容介紹了“java中的volatile關鍵字是什么”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
今天,讓我們一起來探討 Java 并發編程中的知識點:volatile 關鍵字
本文主要從以下三點講解 volatile 關鍵字:
volatile 關鍵字是什么?
volatile 關鍵字能解決什么問題?使用場景是什么?
volatile 關鍵字實現的原理?
在 Sun 的 JDK 官方文檔是這樣形容 volatile 的:
The Java programming language provides a secondmechanism, volatile fields, that is more convenient than locking for somepurposes. A field may be declared volatile, in which case the Java Memory Modelensures that all threads see a consistent value for the variable.
也就是說,如果一個變量加了 volatile 關鍵字,就會告訴編譯器和 JVM 的內存模型:這個變量是對所有線程共享的、可見的,每次 JVM 都會讀取最新寫入的值并使其最新值在所有 CPU 可見。volatile 可以保證線程的可見性并且提供了一定的有序性,但是無法保證原子性。在 JVM 底層 volatile 是采用內存屏障來實現的。
通過這段話,我們可以知道 volatile 有兩個特性:
保證可見性、不保證原子性
禁止指令重排序
原子性是指一個操作或多個操作要么全部執行并且執行的過程不會被任何因素打斷,要么都不執行。性質和數據庫中事務一樣,一組操作要么都成功,要么都失敗。看下面幾個簡單例子來理解原子性:
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 關鍵字修飾的變量禁止重排序的含義是:
當程序執行到 volatile 變量的讀操作或者寫操作時,在其前面的操作肯定已經全部進行,且對后面的操作可見,在其后面的操作肯定還沒有進行
在進行指令優化時,不能將 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 已經初始化完畢。
synchronized 關鍵字是防止多個線程同時執行一段代碼,那么就會很影響程序執行效率,而 volatile 關鍵字在某些情況下性能要優于 synchronized,但是要注意 volatile 關鍵字是無法替代 synchronized 關鍵字的,因為 volatile 關鍵字無法保證操作的原子性。通常來說,使用 volatile 必須具備以下三個條件:
對變量的寫入操作不依賴變量的當前值,或者能確保只有單個線程更新變量的值
該變量不會與其他狀態變量一起納入不變性條件中
在訪問變量時不需要加鎖
上面的三個條件只需要保證是原子性操作,才能保證使用 volatile 關鍵字的程序在高并發時能夠正確執行。建議不要將 volatile 用在 getAndOperate 場合,僅僅 set 或者 get 的場景是適合 volatile 的。
常用的兩個場景是:
狀態標記量
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);
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 中這句話做了三件事情:
給 instance分配內存
調用 Singleton 的構造函數來初始化成員變量
將 instance 對象指向分配的內存庫存空間(執行完這步 instance 就為非 null 了)
但是 JVM 即時編譯器中存在指令重排序的優化,也就是說上面的第二步和第三步順序是不能保證的,最終的執行順序可能是 1-2-3,也可能是 1-3-2。如果是后者,線程 1 在執行完 3 之后,2 之前,被線程 2 搶占,這時 instance 已經是非 null(但是并沒有進行初始化),所以線程 2 返回 instance 使用就會報空指針異常。
前面講述了關于 volatile 關鍵字的一些使用,下面我們來探討一下 volatile 到底如何保證可見性和禁止指令重排序的。
在《深入理解Java虛擬機》這本書中說道:
觀察加入volatile關鍵字和沒有加入 volatile 關鍵字時所生成的匯編代碼發現,加入 volatile 關鍵字時,會多出一個 lock 前綴指令。
接下來舉個栗子:
volatile 的 Integer 自增(i++),其實要分成 3 步:
讀取 volatile 變量值到 local
增加變量的值
把 local 的值寫回,讓其它的線程可見
這 3 步的 JVM 指令為:
mov 0xc(%r10),%r8d ; Load inc %r8d ; Increment mov %r8d,0xc(%r10) ; Store lock addl $0x0,(%rsp) ; StoreLoad Barrier
lock 前綴指令實際上相當于一個內存屏障(也叫內存柵欄),內存屏障會提供 3 個功能:
它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成(滿足禁止重排序)
它會強制將對緩存的修改操作立即寫入主存(滿足可見性)
如果是寫操作,它會導致其他 CPU 中對應的緩存行無效(滿足可見性)
volatile 變量規則是 happens-before(先行發生原則)中的一種:對一個變量的寫操作先行發生于后面對這個變量的讀操作。(該特性可以很好解釋 DCL 雙重檢查鎖單例模式為什么使用 volatile 關鍵字來修飾能保證并發安全性)
變量聲明為 volatile 類型時,編譯器與運行時都會注意到這個變量是共享的,不會將該變量上的操作與其他內存操作一起重排序。volatile 變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取 volatile 類型的變量時總會返回最新寫入的值。
在訪問 volatile 變量時不會執行加鎖操作,也就不會使執行線程阻塞,因此 volatile 變量是比 sychronized 關鍵字更輕量級的同步機制。
加鎖機制既可以確保可見性和原子性,而 volatile 變量只能確保可見性。
“java中的volatile關鍵字是什么”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。