首頁 > Java > java教程 > Java基礎之volatile應用實例分析

Java基礎之volatile應用實例分析

WBOY
發布: 2023-05-28 11:23:39
轉載
1290 人瀏覽過

Java基礎之volatile應用實例分析

問:請談談你對volatile的理解?
答:volatile是Java虛擬機器提供的輕量級的同步機制,它有3個特性:
1)保證可見性
2)不保證原子性
3)禁止指令重排

剛學完java基礎,如果有人問你什麼是volatile?它有什麼作用的話,相信一定非常懵逼…
可能看了答案,也完全不明白,什麼是同步機制?什麼是可見性?什麼是原子性?什麼是指令重排?

1、volatile保證可見性

1.1、什麼是JMM模型?

要想理解什麼是可見性,首先要先理解JMM。

JMM(Java記憶體模型,Java Memory Model)本身就是一種抽象的概念,並非真實存在。它描述的是一組規則或規範,透過這組規範,定了程式中各個變數的存取方法。 JMM關於同步的規定:
1)線程解鎖前,必須把共享變數的值刷新回主記憶體;
2)線程加鎖前,必須讀取主記憶體的最新值到自己的工作記憶體;
3)加上鎖解鎖是同一把鎖;

由於JVM運行程式的實體是線程,創建每個線程時,JMM會為其建立一個工作記憶體(有些地方稱為堆疊空間) ,工作記憶體是每個執行緒的私有資料區域。

Java記憶體模型規定所有變數都儲存在主內存,主記憶體是共享記憶體區域,所有執行緒都可以存取。

但執行緒對變數的操作(讀取、賦值等)必須在工作記憶體中進行。首先需要將變數從主內存複製到工作內存,進行操作後再寫回主內存。

看了上面對JMM的介紹,可能還是優點懵,接下來用一個賣票系統來進行舉例:

1)如下圖,此時賣票系統後端只剩下1張票,並已讀入主內存:ticketNum=1。
2)此時網路上有多個使用者都在搶票,那麼此時就有多個線程同時都在進行買票服務,假設此時有3個線程都讀入了目前的票數:ticketNum =1,那麼接著就會買票。
3)假設線程1先搶占到cpu的資源,先買好票,並在自己的工作內存中將ticketNum的值改為0:ticketNum=0,然後再寫回到主內存中。

此時,線程1的用戶已經買到票了,那麼線程2,線程3此時應該不能再繼續買票了,因此需要係統通知線程2,線程3,ticketNum此時已經等於0了:ticketNum=0。如果有這樣的通知操作,你就可以理解為就具有可見性。

Java基礎之volatile應用實例分析

透過上面對JMM的介紹和舉例,可以簡單總結下。

JMM記憶體模型的可見性是指,多執行緒存取主記憶體的某一個資源時,如果某一個執行緒在自己的工作記憶體中修改了該資源,並寫回主內存,那麼JMM記憶體模型應該要通知其他執行緒來從新取得最新的資源,來確保最新資源的可見性。

1.2、volatile保證可見性的程式碼驗證

在第1.1節中,我們基本上了解可見性的定義,現在我們可以使用程式碼驗證這個定義。經實踐證明,使用volatile確實能夠保證可見性。

1.2.1、無可見性程式碼驗證

先驗證下,不使用volatile,是不是就是沒有可見性。

package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{
    int number = 0;

    public void add10() {
        this.number += 10;
    }}public class VolatileVisibilityDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动一个线程修改myData的number,将number的值加10
        new Thread(
                () -> {
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 正在执行");
                    try{
                        TimeUnit.SECONDS.sleep(3);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    myData.add10();
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 更新后,number的值为" + myData.number);
                }
        ).start();

        // 看一下主线程能否保持可见性
        while (myData.number == 0) {
            // 当上面的线程将number加10后,如果有可见性的话,那么就会跳出循环;
            // 如果没有可见性的话,就会一直在循环里执行
        }

        System.out.println("具有可见性!");
    }}
登入後複製

運行結果如下圖,可以看到雖然執行緒0已經將number的值改為了10,但是主執行緒還是在循環中,因為此時number不具有可見性,系統不會主動通知。
Java基礎之volatile應用實例分析

1.2.1、volatile保證可見性驗證

在上面程式碼的第7行給變數number加入volatile後再次測試,如下圖,此時主線程成功退出了循環,因為JMM主動通知了主執行緒更新number的值了,number已經不為0了。
Java基礎之volatile應用實例分析

2、volatile不保證原子性

2.1 什麼是原子性?

理解了上面說的可見性之後,再來理解下什麼叫原子性?

原子性是指無法分割或打斷,維持完整性的特性。換句話說,當一個執行緒正在執行某個操作時,它不能被任何因素中斷。要嘛同時成功,要嘛同時失敗。

還是有點抽象,接下來舉個例子。

如下圖,創建了一個測試原子性的類別:TestPragma。編譯後的程式碼表明,add方法內對n的增加是透過三個指令來完成的。

因此可能存在线程1正在执行第1个指令,紧接着线程2也正在执行第1个指令,这样当线程1和线程2都执行完3个指令之后,很容易理解,此时n的值只加了1,而实际是有2个线程加了2次,因此这种情况就是不保证原子性。
Java基礎之volatile應用實例分析

2.2 不保证原子性的代码验证

在2.1中已经进行了举例,可能存在2个线程执行n++的操作,但是最终n的值却只加了1的情况,接下来对这种情况再用代码进行演示下。

首先给MyData类添加一个add方法

package com.koping.test;class MyData {
    volatile int number = 0;

    public void add() {
        number++;
    }}
登入後複製

然后创建测试原子性的类:TestPragmaDemo。验证number的值是否为20000,需要测试通过20个线程分别对其加1000次后的结果。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);

    }}
登入後複製

运行结果如下图,最终number的值仅为18410。
可以看到即使加了volatile,依然不保证有原子性。
Java基礎之volatile應用實例分析

2.3 volatile不保证原子性的解决方法

上面介绍并证明了volatile不保证原子性,那如果希望保证原子性,怎么办呢?以下提供了2种方法

2.3.1 方法1:使用synchronized

方法1是在add方法上添加synchronized,这样每次只有1个线程能执行add方法。

结果如下图,最终确实可以使number的值为20000,保证了原子性。

但在实际业务逻辑方法中,很少只有一个类似于number++的单行代码,通常会包含其他n行代码逻辑。现在为了保证number的值是20000,就把整个方法都加锁了(其实另外那n行代码,完全可以由多线程同时执行的)。所以就优点杀鸡用牛刀,高射炮打蚊子,小题大做了。

package com.koping.test;class MyData {
    volatile int number = 0;

    public synchronized void add() {
      // 在n++上面可能还有n行代码进行逻辑处理
        number++;
    }}
登入後複製

Java基礎之volatile應用實例分析

2.3.2 方法1:使用JUC包下的AtomicInteger

给MyData新曾一个原子整型类型的变量num,初始值为0。

package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData {
    volatile int number = 0;

    volatile AtomicInteger num = new AtomicInteger();

    public void add() {
        // 在n++上面可能还有n行代码进行逻辑处理
        number++;
        num.getAndIncrement();
    }}
登入後複製

让num也同步加20000次。可以将原句重写为:使用原子整型num可以确保原子性,如下图所示:在执行number++时不会发生竞态条件。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);
        System.out.println("num值加了20000次,此时number的实际值是:" + myData.num);

    }}
登入後複製

Java基礎之volatile應用實例分析

3、volatile禁止指令重排

3.1 什么是指令重排?

在第2节中理解了什么是原子性,现在要理解下什么是指令重排?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排:
源代码–>编译器优化重排–>指令并行重排–>内存系统重排–>最终执行指令

处理器在进行重排时,必须要考虑指令之间的数据依赖性。

单线程环境中,可以确保最终执行结果和代码顺序执行的结果一致。

但是多线程环境中,线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测

看了上面的文字性表达,然后看一个很简单的例子。
比如下面的mySort方法,在系统指令重排后,可能存在以下3种语句的执行情况:
1)1234
2)2134
3)1324
以上这3种重排结果,对最后程序的结果都不会有影响,也考虑了指令之间的数据依赖性。

public void mySort() {
    int x = 1;  // 语句1
    int y = 2;  // 语句2
    x = x + 3;  // 语句3
    y = x * x;  // 语句4}
登入後複製

3.2 单线程单例模式

看完指令重排的简单介绍后,然后来看下单例模式的代码。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试
        System.out.println("单线程的情况测试开始");
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println("单线程的情况测试结束\n");
    }}
登入後複製

首先是在单线程情况下进行测试,结果如下图。可以看到,构造方法只执行了一次,是没有问题的。
Java基礎之volatile應用實例分析

3.3 多线程单例模式

接下来在多线程情况下进行测试,代码如下。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }

        // DCL(Double Check Lock双端检索机制)//        if (instance == null) {//            synchronized (SingletonDemo.class) {//                if (instance == null) {//                    instance = new SingletonDemo();//                }//            }//        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}
登入後複製

在多线程情况下的运行结果如下图。可以看到,多线程情况下,出现了构造方法执行了2次的情况。
Java基礎之volatile應用實例分析

3.4 多线程单例模式改进:DCL

在3.3中的多线程单里模式下,构造方法执行了两次,因此需要进行改进,这里使用双端检锁机制:Double Check Lock, DCL。即加锁之前和之后都进行检查。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {//        if (instance == null) {//            instance = new SingletonDemo();//        }

        // DCL(Double Check Lock双端检锁机制)
        if (instance == null) {  // a行
            synchronized (SingletonDemo.class) {
                if (instance == null) {  // b行
                    instance = new SingletonDemo();  // c行
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}
登入後複製

在多次运行后,可以看到,在多线程情况下,此时构造方法也只执行1次了。
Java基礎之volatile應用實例分析

3.5 多執行緒單例模式改進,DCL版存在的問題

要注意的是3.4中的DCL版的單例模式依然不是100%準確的! ! !

是不是不太明白為什麼3.4DCL版單例模式不是100%準確的原因
是不是不太明白在3.1講完指令重排的簡單理解後,為什麼突然要講多執行緒的單例模式

因為3.4DCL版單例模式可能會因為指令重排而導致問題,雖然該問題出現的可能性可能是千萬分之一,但是該程式碼依然不是100%準確的。 如果要保證100%準確,那麼需要加入volatile關鍵字,增加volatile可以禁止指令重排

接下來分析下,為什麼3.4DCL版單例模式不是100%準確?

檢視instance = new SingletonDemo();編譯後的指令,可以分為以下3步:
1)分配物件記憶體空間:memory = allocate();
2)初始化物件: instance(memory);
3)設定instance指向分配的記憶體位址:instance = memory;

由於步驟2和步驟3不存在資料依賴關係,因此可能出現執行132步驟的情況。
例如執行緒1執行了步驟13,還沒有執行步驟2,此時instance!=null,但是物件還沒有初始化完成;
如果此時執行緒2搶占到cpu,然後發現instance!=null,然後直接回傳使用,就會發現instance為空,就會出現異常。

這就是指令重排可能導致的問題,因此要確保程式100%正確就需要加volatile禁止指令重排。

3.6 volatile保證禁止指令重排的原理

在3.1中簡單介紹了下執行重排的含義,然後透過3.2-3.5,借助單例模式來舉例說明多執行緒情況下,為什麼要使用volatile的原因,因為可能存在指令重排導致程式異常。

接下來就介紹下volatile能保證禁止指令重排的原理。

首先要了解一個概念:記憶體屏障(Memory Barrier),又稱為記憶體柵欄。它是CPU指令,有2個作用:
1)保證特定操作的執行順序;
2)保證某些變數的記憶體可見性;

由於編譯器和處理器都能執行指令重排。如果在指令之間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說,透過插入記憶體屏障,禁止在記憶體屏障前後的指令執行重排需最佳化

記憶體屏障的另一個作用是強制刷出各種CPU的快取數據,因此任何CPU上的執行緒都能讀取到這些數據的最新版本

Java基礎之volatile應用實例分析

以上是Java基礎之volatile應用實例分析的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:yisu.com
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板