10-Java中共享記憶體可見性以及synchronized和volatile關鍵字

黑夜中的小迷途 發表於 2021-10-02
Java

Java中共享變數的記憶體可見性

  • 我們首先來看一下在多執行緒下處理共享變數時Java的記憶體模型,如圖所示

    image

    Java記憶體模型規定,將所有的變數都存放在主存中,當執行緒使用變數的時候,會把主記憶體裡面的變數賦值到自己的工作區間或者叫工作記憶體,執行緒讀寫變數時操作的是自己的工作記憶體中的變數,Java記憶體模型是一個抽象的概念,那麼在實際中執行緒的工作記憶體是什麼呢?

    image

    圖中顯示的是一個雙核CPU系統架構,每一個核都有自己的控制器和運算器,其中控制器包含一組暫存器和操作控制器,運算器執行算術邏輯運算。每一個核都有自己的一級快取。

    當一個執行緒操作共享變數的時,它首先從主存複製共享變數到自己的工作記憶體(私有記憶體)中,然後對工作記憶體的變數進行處理,處理完之後將變數值更新到主存中。假如執行緒A和執行緒B同時處理一個共享變數,會出現什麼情況呢?我們使用上圖2-5所示的CPU架構,假設執行緒A和B使用不同的CPU執行,並且當前兩級cache都為空,那麼由於這個時候cache的存在,將會導致記憶體不可見問題:

    1. 執行緒A首先獲取到共享變數X的值,由於兩級cache都沒有命中,所以載入主記憶體中X的值,假如為0。然後把X=0值快取到兩級cache中,執行緒A修改X=1,然後將其寫入兩級cache中,並且重新整理到主存中。執行緒A操作完畢後,執行緒A所在的CPU的兩級cache和主存中的X都為1。
    2. 執行緒B獲取到X的值,首選一級快取沒有命中,然後看二級快取,二級快取命中了,所以返回了一個X=1;到這裡一切都是正常的,因為這時候主記憶體中X=1,然後執行緒B修改X=2,並將其放到執行緒B所在的一級cache和二級cache中,最後更新主存中X=2。
    3. 執行緒A再次要修改X的值,獲取時一級快取中命中,並且X=1,到這裡問題就出現了,明明執行緒B已經把X修改為2了,為何執行緒A讀取X的值還是1呢?這就是共享變數的記憶體不可見問題。也就是執行緒B寫入的值對執行緒A不可見。那麼如何解決共享變數執行緒不可見的問題呢?這裡就需要使用java中的volatile關鍵字解決這個問題,下面會講到。

Java中Synchronized關鍵字

  • synchronized關鍵字介紹

    synchronized塊是Java提供的一種原子性內建鎖,Java中的每一個物件都可以看成一個同步鎖來使用。這些Java內建的使用者看不到的鎖被稱為內建鎖,也叫監視器鎖。執行緒的執行程式碼塊在進入synchronized程式碼塊前會自動的獲取到內部鎖,這時候其他執行緒訪問該同步程式碼塊會被阻塞掛起。拿到內部鎖的執行緒會在正常退出同步程式碼塊或者丟擲異常後或者在同步程式碼塊內呼叫了該內建鎖資源的wait系列方法時會釋放該內建鎖。內建鎖是排它鎖,也就是當一個執行緒獲取到這個鎖之後,其他執行緒必須等待該執行緒釋放鎖後才能獲得該鎖。

  • synchronized的記憶體語義

    前面介紹了共享變數記憶體可見性問題主要是由於執行緒當中工作記憶體所導致的。下面我們來講解synchronized的一個記憶體語義,這個記憶體語義就是解決共享變數記憶體可見性問題。進入synchronized塊的記憶體語義是把synchronized塊內使用到的變數從執行緒的工作記憶體中清除,這樣在synchronized塊內使用到該變數時候就不會從工作記憶體中取,而是直接從主存中取,退出synchronized塊的記憶體語義是把sunchronized塊對共享變數的修改重新整理到主存中。其實這也是加鎖和釋放鎖的概念。當獲取鎖後會清空本地記憶體中將會用到的共享變數,在使用這些共享記憶體會從主存中載入,在釋放鎖時會將本地記憶體中修改的共享變數重新整理到主存中。synchronized除了用來解決共享變數記憶體不可見問題,還可以用來實現原子性操作。另外注意的是,synchronized關鍵字會不會引起執行緒上下文切換並帶來執行緒排程開銷。

Java中volatile關鍵字

  • 上面介紹的是使用鎖的方式可以解決共享變數記憶體不可見問題。但是使用鎖太笨重,因此它會帶來執行緒上下文切換問題。對於解決記憶體可見性問題,Java還提供了一種弱形式的同步,也就是使用volatile關鍵字,該關鍵字確保一個變數的更新對其他執行緒馬上可見。當一個變數被宣告為volatile時,執行緒在寫入變數的時候不會把值快取到暫存器或者其他地方,而是會把值重新整理返回到主存中。當其他執行緒讀取該共享變數的時候,會直接從主存中重新獲取到最新值。而並不是使用工作記憶體中的值。voltile記憶體語義和synchronized語義有相似之處,當執行緒寫入volatile變數值的時候就等於執行緒退出synchronized同步塊(把寫入工作記憶體中共享變數的值同步到主記憶體),讀取volatile變數值時就相當於進入進入同步程式碼塊(先清空本地記憶體中共享變數值,再從主存中獲取到最新值)。

  • 下面使用volatile關鍵字解決記憶體可見性問題的例子,如下程式碼中的共享變數value就是不安全的,因為這裡沒有適當的同步措施。

    public class ThreadNotSafeInteger {
        private int value;
    
        public int getValue() {
            return value;
        }
    
        public void setValue(int value) {
            this.value = value;
        }
    }
    
  • 首先來看使用synchronized關鍵字進行同步的方式

    public class ThreadNotSafeInteger {
        private int value;
    
        public synchronized int getValue() {
            return value;
        }
    
        public synchronized void setValue(int value) {
            this.value = value;
        }
    }
    
  • 然後使用volatile進行同步

    public class ThreadNotSafeInteger {
        private volatile int value;
    
        public int getValue() {
            return value;
        }
    
        public void setValue(int value) {
            this.value = value;
        }
    }
    
  • 在這裡使用volatile和synchronized是等價的。都解決的共享記憶體變數value不可見問題。但是前者是獨佔鎖,其他執行緒呼叫會被阻塞等待,同時還存線上程上下文切換個執行緒重現排程的開銷。這也是使用鎖方式不好的地方。後者使用的是非阻塞演算法,不會造成執行緒上下文切換的開銷。

Java中原子性操作

  • 所謂原子操作,是指執行一系列操作要麼一次性全部執行完,要麼全部都不執行。如果不能保證操作室原子性操作,那麼就會出現執行緒安全問題,如下:

    public class ThreadNotSafeCount {
        private Long value;
    
        public Long getValue() {
            return value;
        }
    
        public void setValue(Long value) {
            this.value = value;
        }
    
        private void inc() {
            ++value;
        }
    }
    

    首先執行javac ThreadNotSafeCount.java命令

    然後執行javap -c ThreadNotSafeCount.class命令

    Compiled from "ThreadNotSafeCount.java"
    public class com.heiye.learn2.ThreadNotSafeCount {
      public com.heiye.learn2.ThreadNotSafeCount();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public java.lang.Long getValue();
        Code:
           0: aload_0
           1: getfield      #2                  // Field value:Ljava/lang/Long;
           4: areturn
    
      public void setValue(java.lang.Long);
        Code:
           0: aload_0
           1: aload_1
           2: putfield      #2                  // Field value:Ljava/lang/Long;
           5: return
    }
    
  • 我們該如何保證多個操作的原子性呢?最簡單的辦法就是使用synchronized關鍵字進行同步,程式碼如下

    public class ThreadNotSafeCount {
        private Long value;
    
        public synchronized Long getValue() {
            return value;
        }
    
        public synchronized void setValue(Long value) {
            this.value = value;
        }
    
        private synchronized void inc() {
            ++value;
        }
    }
    

    使用synchronized關鍵字的確可以實現執行緒安全性,即記憶體可見性和原子性,但是synchronized是獨佔鎖,內有獲取到內部鎖的執行緒會被阻塞掉,但是getValue()只是讀操作,多個執行緒同時呼叫這個方法並不會引發執行緒安全問題,但是加了synchronized關鍵字後,同一時間只能有一個執行緒可以呼叫,這顯然是不合理的,沒有必要。也許會有這樣一個疑惑,可以不可把這個方法上的synchronized關鍵字去掉呢?答案是不能的,因為這裡是靠synchronized來實現共享記憶體可見性的,那麼有沒有什麼更好的辦法呢?,答案是有的,下面講到的在內部使用非阻塞CAS演算法實現的原子性操作類AtomicLong就是一個不錯的選擇。