Java多線程之內存可見性和原子性詳解:Synchronized和Volatile

一、 背景

能否保證 synchronized volatile
原子性 ×
可見性

二、 可見性介紹

2.1 可見性介紹

2.1.1 定義

  • 可見性:一個線程對共享變量值的修改,能夠及時地被其他線程看到
  • 共享變量:如果一個變量在多個線程的工作內存中都存在副本,那麼這個變量就是這幾個線程的共享變量

2.1.2 要實現共享變量的可見性,必須保證2點:

  • 線程修改後的共享變量值能夠及時從工作內存刷新到主內存中
  • 其他線程能夠及時把共享變量的最新值從主內存更新到自己的工作內存中

2.1.3 可見性的實現方式

Java語言層面支持的可見性實現方式:

  • synchronized
  • volatile

2.2 Java內存模型(JMM)

2.2.1 Java內存模型(Java Memory Model)描述了Java程序中各種變量(線程共享變量)的訪問規則,以及在JVM中將變量存儲到內存和從內存中讀取出變量這樣的底層細節。

  • 所有的變量都存儲在主內存中
  • 每個線程都有自己獨立的工作內存,裏面保存該線程使用到的變量的副本(主內存中該變量的一份拷貝) 

2.2.2 兩條規定

  • 線程對共享變量的所有操作都必須在自己的工作內存中進行,不能直接從主內存中讀寫
  • 不同線程之間無法直接訪問其他線程工作內存中的變量,線程間變量值的傳遞需要通過主內存來完成

2.2.3 共享變量可見性實現的原理

  • 原本主內存中x=0,線程1和線程2工作內存中x=0
  • 線程1修改當前工作內存1中的x=1,然後刷新都主內存
  • 線程2將主內存中的x讀取到自己的工作內存2中,然後再從工作內存讀取到寄存器上進行操作 

三、synchronized實現可見性

3.1 synchronized實現可見性原理

3.1.1 synchronized能夠實現:

  • 原子性(通過同步來保證)
  • 可見性

3.1.2 JMM關於synchronized的兩條規定

  • 線程解鎖前,必須把共享變量的最新值刷新到主內存中
  • 線程加鎖時,將清空工作內存中共享變量的值,從而使用共享變量時需要從主內存中重新讀取最新的值(注意:加鎖與解鎖需要是同一把鎖)

==》線程解鎖前對共享變量的修改在下次加鎖時對其他線程可見

3.1.3 線程執行互斥代碼的過程

①獲得互斥鎖
②清空工作內存
③從主內存拷貝變量的最新副本到工作內存
④執行代碼
⑤將更改後的共享變量的值刷新到主內存
⑥釋放互斥鎖

3.1.4 相關知識點:指令重排序

  • 重排序:代碼書寫的順序與實際執行的順序不同,指令重排序是編譯器或處理器爲了提高程序性能而做的優化
①編譯器優化的重排序(編譯器優化)
②指令級並行重排序(處理器優化)
③內存系統的重排序(處理器優化)--JMM
  • 類似例子
(1)代碼順序
int number = 1;
int result = 0;

(2)實際編譯後的代碼
int result = 0;
int number = 1;

3.1.5 相關知識點:as-if-serial

  • as-if-serial:無論如何重排序,程序執行的結果應該與代碼順序執行的結果一致(Java編譯器、運行時和處理器都會保證Java在單線程下遵循as-if-serial語義)
  • 例子
int num1 = 1;          //第1行代碼
int num2 = 2;          //第2行代碼
int sum = num1 + num2; //第3行代碼
  • 單線程:第1、2行的順序可以重排,但第3行不能
  • 重排序不會給單線程帶來內存可見性問題
  • 多線程中程序交錯執行時,重排序可能會造成內存可見性問題

3.2 synchronized實現可見性代碼

public class SynchronizedDemo {
    //共享變量
    private boolean ready = false;
    private int result = 0;
    private int number = 1;
    //寫操作
    public void write() {
        ready = true; //1.1
        number = 2;  //1.2
    }
    //讀操作
    public void read() {
        if (ready) { //2.1
            result = number * 3; //2.2
        }
        System.out.println("result的值爲:" + result);
    }


    //內部線程類
    private class ReadWriteThread extends Thread {
        private boolean flag;
        public ReadWriteThread(boolean flag) {
            this.flag = flag;
        }

        @Override
        public void run() {
            if (flag) {
                //構造方法中傳入true,執行寫操作
                write();
            } else {
                //構造方法中傳入false,執行讀操作
                read();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo syncDemo = new SynchronizedDemo();
        //啓動線程執行寫操作
        syncDemo.new ReadWriteThread(true).start();
        //啓動線程執行讀操作
        syncDemo.new ReadWriteThread(false).start();
    }
}
  • 分析可能結果
(1)執行順序
1.1->2.1->2.2->1.2
result的值爲:3

(2)執行順序
1.2->2.1->2.2(不執行)->1.1
result的值爲:0

3.2.1 可見性分析

導致共享變量在線程間不可見的原因 synchronized解決方案
線程的交叉執行 原子性(同步執行,保證只有一個線程在執行)
重排序結合線程交叉執行 原子性(同步執行,保證只有一個線程在執行)
共享變量更新後的值沒有在工作內存與主內存間及時更新 可見性

3.2.2 加synchronized的安全代碼

//寫操作
    public synchronized void write() {
        ready = true; //1.1
        number = 2;  //1.2
    }
    //讀操作
    public synchronized void read() {
        if (ready) { //2.1
            result = number * 3; //2.2
        }
        System.out.println("result的值爲:" + result);
    }

四、volatile實現可見性

  • 能夠保證volatile變量的可見性
  • 不能保證volatile變量複合操作的原子性

4.1 volatile能夠保證可見性

4.1.1 volatile如何實現內存可見性:

深入來說:通過加入內存屏障禁止重排序優化來實現的。

  • volatile變量執行寫操作時,會在寫操作後加入一條store屏障指令
  • volatile變量執行讀操作時,會在讀操作前加入一條load屏障指令

通俗地講:volatile變量在每次被線程訪問時,都強制從主內存中重讀該變量的值; 而當該變量發生變化時,又會強制線程將最新的值刷新到主內存。 這樣任何時刻,不同的線程總能看到該變量的最新值

4.1.2 線程寫volatile變量的過程:

  • (1)改變線程的工作內存中volatile變量副本的值
  • (2)將改變後的副本的值從工作內存刷新到主內存

4.1.3 線程讀volatile變量的過程

  • (1)從主內存中讀取volatile變量的最新值到線程的工作內存中
  • (2)從工作內存中讀取volatile變量的副本

4.2 volatile不能保證原子性

  • volatile不能保證volatile變量複合操作的原子性
private int number = 0;
number++;  //不是原子操作

1、讀取number的值
2、將number的值加1
3、寫入最新的number的值


//加入synchronized,只有一個線程執行,變爲原子操作
synchronized(this) {
  number++;
}

4.2.1 分析

  • 假設當前number=5
number=5
1、線程A讀取number=5
2、線程B讀取number=5
3、線程B執行+1操作
4、線程B寫入最新的number值
(線程B工作內存:number=6
  主內存:number=6)
5、而此時,線程A工作內存中:number=5(由於線程A沒有重新做讀操作,所以工作內存中還是5)
6、線程A執行+1操作(5+1)
7、線程A寫入最新的number值(寫入A的工作內存,然後再刷新到主內存)
(
線程A工作內存:number=6
主內存:number=6
)
AB執行了2次+1操作,但實際上只加了1

4.2.2 保證number自增操作的原子性(解決方案):

  • 使用synchronized關鍵字
  • 使用ReentrantLock(java.until.concurrent.locks包下)
  • 使用AtomicInteger(java.util.concurrent.atomic包下)

(1)synchronized示例

public class SynchronizedIncreaseDemo {
    private int number = 0;

    public int getNumber() {
        return number;
    }

    public void increase() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized(this) {
            number++;
        }

    }

    public static void main(String[] args) {
        SynchronizedIncreaseDemo volatileDemo = new SynchronizedIncreaseDemo();
        for (int i = 0; i < 500; i++) {
            new Thread(() -> volatileDemo.increase()).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("number: " + volatileDemo.getNumber());
    }
}

//---------輸出結果-------
每次都是500

(2)ReentrantLock示例

public class LockIncreaseDemo {
    private Lock lock = new ReentrantLock();
    private int number = 0;

    public int getNumber() {
        return number;
    }

    public void increase() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        lock.lock();
        try {
            number++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        LockIncreaseDemo volatileDemo = new LockIncreaseDemo();
        for (int i = 0; i < 500; i++) {
            new Thread(() -> volatileDemo.increase()).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("number: " + volatileDemo.getNumber());
    }
}
//---------輸出結果-------
每次都是500

(3)AtomicInteger示例

public class AtomicIncreaseDemo {
    private AtomicInteger number = new AtomicInteger();

    public AtomicInteger getNumber() {
        return number;
    }

    public void increase() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        number.getAndIncrement();
    }

    public static void main(String[] args) {
        AtomicIncreaseDemo volatileDemo = new AtomicIncreaseDemo();
        for (int i = 0; i < 500; i++) {
            new Thread(() -> volatileDemo.increase()).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("number: " + volatileDemo.getNumber().get());
    }
}

4.3 volatile適用場合

要在多線程中安全得使用volatile變量,必須同時滿足:

4.3.1、對變量的寫入操作不依賴其當前值(當前的值與上一次的值沒有關係)

  • 不滿足:number++、count=count*5
  • 滿足:boolean變量、記錄溫度變化的變量等

4.3.2、該變量沒有包含在具有其他變量的不變式中

  • 不滿足:不變式low<up(low和up都是volatile變量,volatile不保證原子性。當代碼包含其他共享變量,如果被其他線程執行,那麼值就會發生改變)
  • 關於不變式,可以參考該文章:如何正確使用 Volatile 變量

4.4 synchronized和volatile比較

  • volatile不需要加鎖,比synchronized更輕量級,不會阻塞線程
  • 從內存可見性角度看,volatile讀相當於加鎖(讀操作前加入一條load屏障指令),volatile寫相當於解鎖(寫操作後加入一條store屏障指令)
  • synchronized既能保證可見性,又能保證原子性;而volatile只能保證可見性,無法保證原子性

五、常用操作

5.1 synchronized+volatile+雙重檢查鎖(double checked locking)

  • 假設現在有2個線程,線程A和線程B,線程A先執行getToken,走到1.1,檢測到token爲空,則執行synchronized加鎖進入1.2做初始化token邏輯,賦值了token=“ABCDE”
  • 如果此時線程B進來,執行getToken,由於token變量是volatile的,線程A修改後立刻刷新到主內存了。此時線程B進來就直接從主內存讀取,拿到了最新的token值=ABCDE,就返回了
public class DoubleCheckDemo {
    private volatile String token = "";
    public String getToken() {
        if (token.isEmpty()) {
            synchronized (DoubleCheckDemo.class) {
                if (token.isEmpty()) {
                    //做相應的token邏輯
                    token = "ABCDE";
                }
            }
        }
        return token;
    }

    public static void main(String[] args) {
        DoubleCheckDemo demo = new DoubleCheckDemo();
        new Thread(() -> System.out.println(demo.getToken())).start();
        new Thread(() -> System.out.println(demo.getToken())).start();
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章