JUC併發編程學習(十七) -5分鐘搞懂volatile

基本概念

先補充一下概念,java內存模型中的可見性、原子性和有序性。

可見性

百度百科的講解,是指對象間的可見性,含義是一個對象能夠看到或者引用另一個對象的能力。

可見性,是指線程之間的可見性,一個線程修改的值對另外一個線程是可見的。可以將可見性理解爲一種通知機制也就是A線程修改了值,其他線程立馬就知道修改的結果。比如:用volatile修飾的變量,就會具有可見性。volatile修飾的變量不允許線程內部緩存和重排序,即直接修改內存。所以對其他線程是可見的。但是這裏需要注意一個問題,volatile只能讓被他修飾內容具有可見性,但不能保證它具有原子性。比如 volatile int a = 0;之後有一個操作 a++;這個變量a具有可見性,但是a++ 依然是一個非原子操作,也就是這個操作同樣存在線程安全問題。

在 Java 中 volatile、synchronized 和 final 實現可見性。

原子性

原子是組成世界萬物的最小單位,具有不可分割性。

由 Java 內存模型來直接保證的原子性變量操作包括 read、load、assign、use、store 和 write。大致可以認爲基本數據類型的操作是原子性的。同時 lock 和 unlock 可以保證更大範圍操作的原子性。而 synchronize 同步塊操作的原子性是用更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式操作的。

一個操作是原子操作,那麼我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

在 Java 中 synchronized 和在 lock、unlock 中操作保證原子性。

有序性

如果在線程內被觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有操作都是無序的。前半句指線程內表現爲串行的語義,後半句是指“指令重排”現象和“工作內存與主內存同步延遲”現象。

Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 是因爲其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。

Volatile原理

java提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。

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

在這裏插入圖片描述
當對非volatile變量進行讀寫的時候,每個線程先從內存中拷貝變量到線程對應的CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個變量可以拷貝到不同的CPU 緩存中。

而聲明變量是volatile的,JVM保證了每次讀取變量都是從內存中讀,跳過了CPU catche這一步。

當一個變量定義爲 volatile 之後,將具備兩種特性:

  1. 保證此變量對所有的線程的可見性,這裏的“可見性”,如本文開頭所述,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存(詳見:Java內存模型)來完成。
  2. 禁止指令重排序優化。有volatile修飾的變量,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個內存屏障(指令重排序時不能把後面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,並不需要內存屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。

接下來會去分別驗證volatile的特性。

volatile 可見性

1.num變量未使用volatile前,雖然主內存中的num值變了,但是沒有通知到線程num值已更改,導致線程一直在死循環。

package com.jp.test002;

import java.util.concurrent.TimeUnit;

public class VolatileDemo1 {


        // volatile 讀取的時候去主內存中讀取在最新值!
        private  static int num = 0;

        public static void main(String[] args) throws InterruptedException { // Main線程

            new Thread(()->{
                /**
                 *  @description:
                 *  由於num添加了volatile,所以線程每次讀取都會去主內存中讀取
                 *  @author yangxj
                 *  @date 2020/2/26 17:54
                 */
                while (num==0){

                }
            }).start();

            TimeUnit.SECONDS.sleep(1);

            num = 1;
            System.out.println(num);

        }
}

在這裏插入圖片描述
2.添加volutile保證可見性

package com.jp.test002;

import java.util.concurrent.TimeUnit;

public class VolatileDemo1 {


    private  volatile static  int num=0;

    public static void main(String[] args) {

        //1.線程
        new Thread(()->{
            //從主內存中不斷讀取num值
            while (num==0){
       
            }

        }).start();


        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //改變主線程內存中的num值
        num=1;
        System.out.println(num);
    }
}

在這裏插入圖片描述

volatile不保證原子性

原子性的特點 : 不可分割,要麼同時成功,要麼同時失敗。

//驗證不保證原子性
public class VolatileDemo2 {

    private volatile static int num=0;


    public static void add(){
        num++;
    }

    public static void main(String[] args) {

        //理論上,計算num的結果應該爲20*1000=20000
        for (int i=1;i<=20;i++){
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }

        //main線程,判斷上面  所有線程執行完成,只剩下主線程、和gc線程
        while (Thread.activeCount()>2){
            //線程放棄當前分得的 CPU 時間,但是不使線程阻塞
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + " " + num);
    }

}

上面代碼,for循環結算理論上num的計算結果應該爲20000,但是每次計算的結果卻都不相同
在這裏插入圖片描述
這是什麼原因呢,因爲numm++ 不是原子操作,而volatile也不保證原子性操作。我們可以通過javap命令查看一下,字節碼執行過程。

首先找到,VolatileDemo2生成的字節碼文件
在這裏插入圖片描述
然後在當前目錄使用CMD命令,輸入命令

javap -c  VolatileDemo2.class

可以看到如下:
在這裏插入圖片描述

volatile 不保證原子性解決方案
1.使用同步關鍵字synchronized,保證每一次只有一個線程能操作值

public class VolatileDemo2 {

    private   volatile static int num=0;


    public synchronized static void add(){
        num++;
    }

    public static void main(String[] args) {

        //理論上,計算num的結果應該爲20*1000=20000
        for (int i=1;i<=20;i++){
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }

        //main線程,判斷上面  所有線程執行完成,只剩下主線程、和gc線程
        while (Thread.activeCount()>2){
            //線程放棄當前分得的 CPU 時間,但是不使線程阻塞
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + " " + num);
    }

}

2.使用lock鎖


import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class VolatileDemo2 {

    private   volatile static int num=0;


    private static  Lock lock=new ReentrantLock();


    public  static void add(){
        //2.加鎖的方式
        lock.lock();
        num++;
        lock.unlock();
    }

    public static void main(String[] args) {

        //理論上,計算num的結果應該爲20*1000=20000
        for (int i=1;i<=20;i++){
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }

        //main線程,判斷上面  所有線程執行完成,只剩下主線程、和gc線程
        while (Thread.activeCount()>2){
            //線程放棄當前分得的 CPU 時間,但是不使線程阻塞
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + " " + num);
    }

}

3.使用原子性類工具java.util.concurrent.atomic
在這裏插入圖片描述

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileDemo2 {

    private  static AtomicInteger num=new AtomicInteger();


    public static void add(){
        num.getAndIncrement(); // 等價於 num++
    }

    public static void main(String[] args) {

        //理論上,計算num的結果應該爲20*1000=20000
        for (int i=1;i<=20;i++){
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }

        //main線程,判斷上面  所有線程執行完成,只剩下主線程、和gc線程
        while (Thread.activeCount()>2){
            //線程放棄當前分得的 CPU 時間,但是不使線程阻塞
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + " " + num);
    }

}

volatile 禁止指令重排

指令重排: 計算機在執行程序之後,爲了提高性能,編譯器和處理器會進行指令重排!

指令重排:程序最終執行的代碼,不一定是按照你寫的順序來的!

int x = 11;  // 語句1
int y = 12;  // 語句2
x = y + 5;   // 語句3
y = x*x ;    // 語句4

我們所期望的:1234 但是可能執行的時候回變成 2134 1324


可不可能是4123?不能,因爲4要依賴於1.

處理器在進行指令重排的時候,需要考慮:數據之間的依賴性。

a,b,x,y初始值都是0.

線程A 線程B
x=a y=b
b=1 a=2

正常的結果是,x=0,y=0。如果出現指令重排,就會出現如下效果

線程A 線程B
b=1 a=2
x=a y=b

指令重排得到了詭異結果: x=2,y=1

volatile實現禁止指令重排原理:內存屏障。

內存屏障: 作用於CPU的指令,主要作用兩個:

  1. 保證特定的操作執行順序;
  2. 保證某些變量的內存可見性(利用volatile實現了可見性)。

在這裏插入圖片描述
內存屏障使用最多的地方是在單例模式中。

總結

volatile是可以保持可見性,不可以保證原子性的,由於內存屏障,可以避免指令重排的現象發生。

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