Java進階筆記——你需要了解的volatile 關鍵字

前言

不管是在面試還是實際開發中 volatile 都是一個應該掌握的技能。

首先來看看為什麼會出現這個關鍵字。

內存可見性

由於 Java 內存模型(JMM)規定,所有的變量都存放在主內存中,而每個線程都有著自己的工作內存(高速緩存)。

線程在工作時,需要將主內存中的數據拷貝到工作內存中。這樣對數據的任何操作都是基於工作內存(效率提高),並且不能直接操作主內存以及其他線程工作內存中的數據,之後再將更新之後的數據刷新到主內存中。

這裡所提到的主內存可以簡單認為是堆內存,而工作內存則可以認為是棧內存

如下圖所示:

Java進階筆記——你需要了解的volatile 關鍵字

所以在併發運行時可能會出現線程 B 所讀取到的數據是線程 A 更新之前的數據。

顯然這肯定是會出問題的,因此 volatile 的作用出現了:

當一個變量被 volatile 修飾時,任何線程對它的寫操作都會立即刷新到主內存中,並且會強制讓緩存了該變量的線程中的數據清空,必須從主內存重新讀取最新數據。

volatile 修飾之後並不是讓線程直接從主內存中獲取數據,依然需要將變量拷貝到工作內存中。

內存可見性的應用

當我們需要在兩個線程間依據主內存通信時,通信的那個變量就必須的用 volatile 來修飾:

public class Volatile implements Runnable{

private static volatile boolean flag = true ;

@Override

public void run() {

while (flag){

}

System.out.println(Thread.currentThread().getName() +"執行完畢");

}

public static void main(String[] args) throws InterruptedException {

Volatile aVolatile = new Volatile();

new Thread(aVolatile,"thread A").start();

System.out.println("main 線程正在運行") ;

Scanner sc = new Scanner(System.in);

while(sc.hasNext()){

String value = sc.next();

if(value.equals("1")){

new Thread(new Runnable() {

@Override

public void run() {

aVolatile.stopThread();

}

}).start();

break ;

}

}

System.out.println("主線程退出了!");

}

private void stopThread(){

flag = false ;

}

}

主線程在修改了標誌位使得線程 A 立即停止,如果沒有用 volatile 修飾,就有可能出現延遲。

但這裡有個誤區,這樣的使用方式容易給人的感覺是:

對 volatile 修飾的變量進行併發操作是線程安全的。

這裡要重點強調,volatile 並不能保證線程安全性!

如下程序:

public class VolatileInc implements Runnable{

private static volatile int count = 0 ; //使用 volatile 修飾基本數據內存不能保證原子性

//private static AtomicInteger count = new AtomicInteger() ;

@Override

public void run() {

for (int i=0;i<10000 ;i++){

count ++ ;

//count.incrementAndGet() ;

}

}

public static void main(String[] args) throws InterruptedException {

VolatileInc volatileInc = new VolatileInc() ;

Thread t1 = new Thread(volatileInc,"t1") ;

Thread t2 = new Thread(volatileInc,"t2") ;

t1.start();

//t1.join();

t2.start();

//t2.join();

for (int i=0;i<10000 ;i++){

count ++ ;

//count.incrementAndGet();

}

System.out.println("最終Count="+count);

}

}

當我們三個線程(t1,t2,main)同時對一個 int 進行累加時會發現最終的值都會小於 30000。

這是因為雖然 volatile 保證了內存可見性,每個線程拿到的值都是最新值,但 count ++ 這個操作並不是原子的,這裡面涉及到獲取值、自增、賦值的操作並不能同時完成。

  • 所以想到達到線程安全可以使這三個線程串行執行(其實就是單線程,沒有發揮多線程的優勢)。
  • 也可以使用 synchronize 或者是鎖的方式來保證原子性。
  • 還可以用 Atomic 包中 AtomicInteger 來替換 int,它利用了 CAS 算法來保證了原子性。

指令重排

內存可見性只是 volatile 的其中一個語義,它還可以防止 JVM 進行指令重排優化。

舉一個偽代碼:

int a=10 ;//1

int b=20 ;//2

int c= a+b ;//3

一段特別簡單的代碼,理想情況下它的執行順序是:1>2>3。但有可能經過 JVM 優化之後的執行順序變為了 2>1>3。

可以發現不管 JVM 怎麼優化,前提都是保證單線程中最終結果不變的情況下進行的。

可能這裡還看不出有什麼問題,那看下一段偽代碼:

private static Map value ;

private static volatile boolean flag = fasle ;

//以下方法發生在線程 A 中 初始化 Map

public void initMap(){

//耗時操作

value = getMapValue() ;//1

flag = true ;//2

}

//發生在線程 B中 等到 Map 初始化成功進行其他操作

public void doSomeThing(){

while(!flag){

sleep() ;

}

//dosomething

doSomeThing(value);

}

這裡就能看出問題了,當 flag 沒有被 volatile 修飾時,JVM 對 1 和 2 進行重排,導致 value 都還沒有被初始化就有可能被線程 B 使用了。

所以加上 volatile 之後可以防止這樣的重排優化,保證業務的正確性。

指令重排的的應用

一個經典的使用場景就是雙重懶加載的單例模式了:

public class Singleton {

private static volatile Singleton singleton;

private Singleton() {

}

public static Singleton getInstance() {

if (singleton == null) {

synchronized (Singleton.class) {

if (singleton == null) {

//防止指令重排

singleton = new Singleton();

}

}

}

return singleton;

}

}

這裡的 volatile 關鍵字主要是為了防止指令重排。

如果不用 ,singleton = new Singleton();,這段代碼其實是分為三步:

  • 分配內存空間。(1)
  • 初始化對象。(2)
  • 將 singleton 對象指向分配的內存地址。(3)

加上 volatile 是為了讓以上的三步操作順序執行,反之有可能第二步在第三步之前被執行就有可能某個線程拿到的單例對象是還沒有初始化的,以致於報錯。

總結

volatile 在 Java 併發中用的很多,比如像 Atomic 包中的 value、以及 AbstractQueuedLongSynchronizer 中的 state 都是被定義為 volatile 來用於保證內存可見性。

將這塊理解透徹對我們編寫併發程序時可以提供很大幫助。


分享到:


相關文章: