volatile的理解

轉自https://mp.weixin.qq.com/s/s9h13tepy9d2wRrn5EPpFQ

筆者近期看到一篇對volatile理解特別好的文章,特記錄下來以便自己以後查看

一.可見性

如何理解可見性,還是來看個會出現死循環的例子:

(注意:運行時請加上jvm參數:-server,while循環內不要有標準輸出):

public class Task implements Runnable{
    boolean running =true;
    int i=0;

    @Override
    public void run(){
        while(running){
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        Task task=new Task();
        Thread th=new Thread(task);
        th.start();
        Thread.sleep(10);
        task.running=fasle;
        Thread.sleep(100);
        System.out.println(task.i);
        System.out.println("程序停止");
    }
}

這是爲什麼呢?先來看看java的內存模型,如下圖:

java內存分爲工作內存和主存

工作內存:即java線程的本地內存,是單獨給某個線程分配的,存儲局部變量等,同時也會複製主存的共享變量作爲本地的副本,目的是爲了減少和主存通信的頻率,提高效率。主存:存儲類成員變量等。

可見性是指的是線程訪問變量是否是最新值。

局部變量不存在可見性問題,而共享內存就會有可見性問題,因爲本地線程在創建的時候,會從主存中讀取一個共享變量的副本,且修改也是修改副本,且並不是立即刷新到主存中去,那麼其他線程並不會馬上共享變量的修改。

因此,線程B修改共享變量後,線程A並不會馬上知曉,就會出現上述死循環的問題。

解決共享變量可見性問題,需要用volatile關鍵字修飾。

如下圖代碼就不會出現死循環:

public class Task2 implements Runnable{

    volatile boolean running=true;
    int i=0;

    @Override
    public void run(){
        while(running){
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        Task2 task=new Task2();
        Thread th=new Thread(task);
        th.start();
        Thread.sleep(1);
        task.running=false;
        Thread.sleep(1000);
        System.out.println(task.i);
        System.out.println("程序停止");
    }
}

那麼爲什麼能解決死循環的問題呢?

可見性的特性總結爲以下2點:

對volatile變量的寫會立即刷新到主存

對volatile變量的讀會讀主存中的新值

可以用如下圖更清晰的描述:

如此一來,就不會出現死循環了。

爲了能更深刻的理解volatile的語義,我們來看下面的時序圖,回答這2個問題:

問題1:t2時刻,如果線程A讀取running變量,會讀取到false,還是等待線程B執行完呢?

答案是否定的,volatile並沒有鎖的特性。

問題2:t4時刻,線程A是否一定能讀取到線程B修改後的最新值

答案是肯定的,線程A會從重新從主存中讀取running的最新值。

 

還有一種辦法也可以解決死循環的問題:

public class Task implements Runnable{

    volatile boolean running=true;
    int i=0;

    @Override
    public void run(){
        while(this.isRunning()){
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        Task task=new Task();
        Thread th=new Thread(task);
        th.start();
        Thread.sleep(10);
        task.setRunning(false);
        System.out.println(task.i);
        System.out.println("程序停止");
    }

    public synchronized booble isRunning(){
        return running;
    }

    public synchronized void setRunning(boolean running){
        this.running=running;
    }
}

雖然running變量上沒有volatile關鍵字修飾,但是讀和寫running都是同步方法.

同步塊存在如下語義

進入同步塊,訪問共享變量會去讀取主存

退出同步塊,本地內存對共享變量的修改會立即刷新到主存

因此上述代碼不會出現死循環。

 

二.volatile變量的原子性

我看了很多文章,有些文章甚至是出版的書籍都說volatile不是原子的,他們舉的例子是i++操作,i++本身不是原子操作,是讀並寫,我這裏要講的原子性指的是寫操作,原子性的特別總結爲2點:

對一個volatile變量的寫操作,只有所有步驟完成,才能被其它線程讀取到。

多個線程對volatile變量的寫操作本質上是有先後順序的。也就是說併發寫沒有問題。

這樣說也許讀者感覺不到和非volatile變量有什麼區別,我來舉個例子:

//線程1初始化User
User user;
user = new User();
//線程2讀取user
if(user!=null){
user.getName();
}

在多線程併發環境下,線程2讀取到的user可能未初始化完成,具體來看User user = new User的語義:

分配對象的內存空間

初始化對線

設置user指向剛分配的內存地址

步驟2和步驟3可能會被重排序,流程變爲

1->3->2

這些線程1在執行完第3步而還沒來得及執行完第2步的時候,如果內存刷新到了主存,那麼線程2將得到一個未初始化完成的對象。因此如果將user聲明爲volatile的,那麼步驟2,3將不會被重排序。

下面我們來看一個具體案例,一個基於雙重檢查的懶加載的單例模式實現:

public class Singleton{
    private static Singleton instance;

    private Singleton(){};

    public static Singleton getInstance(){
        if(instance==null){//步驟1
            synchronized(Singleton.class){//步驟2
                if(instance==null){//步驟3
                    instance=new Singleton();//步驟4
                }
            }
        }
        return instance;
    }
}

這個單例模式看起來很完美,如果instance爲空,則加鎖,只有一個線程進入同步塊完成對象的初始化,然後instance不爲空,那麼後續的所有線程獲取instance都不用加鎖,從而提升了性能。

但是我們剛纔講了對象賦值操作步驟可能會存在重排序,即當前線程的步驟4執行到一半,其它線程如果進來執行到步驟1,instance已經不爲null,因此將會讀取到一個沒有初始化完成的對象。

但如果將instance用volatile來修飾,就完全不一樣了,對instance的寫入操作將會變成一個原子操作,沒有初始化完,就不會被刷新到主存中。

修改後的單例模式代碼如下:

public class Singleton{
    private static volatile Singleton instance;

    private Singleton(){};

    public static Singleton getInstance(){
        if(instance==null){//步驟1
            synchronized(Singleton.class){//步驟2
                if(instance==null){//步驟3
                    instance=new Singleton();//步驟4
                }
            }
        }
        return instance;
    }
}

三.對volatile理解的誤區

很多人會認爲對volatile變量的所有操作都是原子性的,比如自增i++

這其實是不對的。

看如下代碼:

public class VolatileDemo implements Runnable{
    volatile inti=0;

    @Override
    public void run(){
        for(int j=0;j<10000;j++){
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        VolatileDemo task=new VolatileDemo();
        Thread t1=new Thread(task);
        Thread t2=new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(task.i);
    }
}

如果i++的操作是線程安全的,那麼預期結果應該是i=20000

然而運行的結果是:11349

說明i++存在併發問題,i++語義是i=i+1

分爲2個步驟

讀取i=0

計算i+1=1,並重新賦值給i

那麼可能存在2個線程同時讀取到i=0,並計算出結果i=1然後賦值給i,那麼就得不到預期結果i=2。

這個問題說明了2個問題:

i++這種操作不是原子操作

volatile 並不會有鎖的特性

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章