Java併發編程-Volatile和Syncronized關鍵字

Java併發編程學習分享的目標

  • 瞭解Java併發編程中常用的工具和類用途與用法
  • 瞭解Java併發編程工具的實現原理與設計思路
  • 瞭解併發編程中遇到的常見問題與解決方案
  • 瞭解如何根據實際情景選擇更合適的工具完成高效的設計方案

學習分享團隊
團隊:培優-技術中心-平臺研發部-運營研發團隊
Java併發編程分享小組:@沈健 @曹偉偉 @張俊勇 @田新文 @張晨
本章分享人:@沈健

本章課程簡介

Volatile關鍵字
Synchronized關鍵字

Volatile篇

目標:搞清楚Java併發工具, JMM, MESI以及硬件之間的關係
volatile01.png

Volatile的用法

修飾變量

private volatile long value;

Volatile的作用

  1. 用於保證變量在全局可見
  2. 防止指令重排導致的問題

可能有的同學就要問了,爲什麼要保證變量全局可見性,不保證全局可見性會出什麼問題呢,接下來我用一個例子解釋:
下面是一個簡單的田徑比賽的場景,主線程是發令員,10個子線程是10個運動員,發令員發出指令之後比賽纔開始,哪位同學預測一下下面代碼執行完之後會發生什麼結果。

package com.company;
 
public class Main {
    boolean start = false;
 
    public static void main(String[] args) {
        new Main().test();
    }
    public void test() {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                int wait = 0;
                while (!start) {
                    wait++;
                }
                System.out.println(Thread.currentThread().getName()
                        + "run after second: " + wait);
            });
            threads[i].start();
        }
        start = true;
        try {
            Thread.sleep(20000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我本地運行的結果如下:
volatile02.png
可以看到只有線程9感知到了指令,觸發了響應的動作。

這是爲什麼呢?原因是由於各層緩存的存在, 相當於Java每個線程會有自己的一份內存數據,因此主線程對標誌位的修改對於子線程來說並不總是可見的。這種情況就可以用volatile關鍵字優化了,添加了volatile關鍵字之後,執行結果如下:
volatile03.png

Java內存模型的細節,我們後面再詳細講述

未解之謎:爲什麼線程組倒數第二個線程總是執行的最快的。

好的,解釋完可見性了,可能又有的同學(說這個同學是不是你)就要問了,爲什麼要注意防止指令重排問題呢,不保證會出什麼問題呢,接下來我用一個例子解釋:

package com.company;
 
public class RearrangeTest {
    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            new RearrangeTest().test();
        }
    }
    public void test() {
        ReorderExample example = new ReorderExample();
        Thread write = new Thread(() -> {
            example.writer();
        });
        Thread read = new Thread(() -> {
            example.reader();
        });
        write.start();
        read.start();
    }
    class ReorderExample {
        int a = 0;
        boolean flag = false;
        public void writer() {
            a = 1;                     // 1
            flag = true;               // 2
        }
 
        public void reader() {
            if (flag) {                // 3
                int i =  a * a;        // 4
                if (i != 1) {
                    System.out.println(i);
                }
            }
        }
    }
}

在這個例子中,由於指令重排的問題,執行過後的值是不確定的。

那麼可見性問題是怎麼來的呢,接下來我在JMM模型與MESI中介紹。

JMM模型與MESI

上一小節中,我們介紹了指令重排與變量可見性兩個問題,這一小節,我們來剖析一下導致這兩個問題的原因。

首先是第一個問題,爲什麼會有可見性的問題。這個就要談到內存模型了。JMM是Java內存模型的簡稱,主要目的是用於控制了一個線程的寫入何時應該對於另一個線程可見。在JMM模型中,各線程之間共享的變量會放在主內存中,每個線程會拷貝一份內存到線程私有內存,線程中對共享變量的修改只會在私有內存中生效,並不會對其他線程可見。這就是可見性問題的來源,如下圖所示:
volatile04.png
同時JMM模型中提供了多種機制,來空制變量在多個線程間的可見性,這個問題我們在volatile原理小節詳細介紹。

下面我們還是介紹一下JMM這套系統是怎麼實現的,JMM真的會拷貝一份內存到本地嗎,如果我們啓動幾十個線程,豈不是要拷貝幾十分。這樣做未免也太浪費內存了。其實JMM只是一個虛擬的概念,並不是真實存在的。JMM抽象的唯一目的是給開發者提供一個保證,在什麼情況下,變量的修改一定會對其他線程可見。這套機制是通過處理器緩存,寫緩存,寄存器,編譯器以及其他硬件配合完成的。相當於在MESI模型的基礎上進行了一次封裝。

接下來我們再往下研究一層,看看最底層的MESI協議

緩存一致性協議給緩存行(通常爲64字節)定義了個狀態:獨佔(exclusive)、共享(share)、修改(modified)、失效(invalid),用來描述該緩存行是否被多處理器共享、是否修改。所以緩存一致性協議也稱MESI協議。

  • 獨佔(exclusive):僅當前處理器擁有該緩存行,並且沒有修改過,是最新的值。
  • 共享(share):有多個處理器擁有該緩存行,每個處理器都沒有修改過緩存,是最新的值。
  • 修改(modified):僅當前處理器擁有該緩存行,並且緩存行被修改過了,一定時間內會寫回主存,會寫成功狀態會變爲S。
  • 失效(invalid):緩存行被其他處理器修改過,該值不是最新的值,需要讀取主存上最新的值。

協議協作如下:

  • 一個處於M狀態的緩存行,必須時刻監聽所有試圖讀取該緩存行對應的主存地址的操作,如果監聽到,則必須在此操作執行前把其緩存行中的數據寫回CPU。
  • 一個處於S狀態的緩存行,必須時刻監聽使該緩存行無效或者獨享該緩存行的請求,如果監聽到,則必須把其緩存行狀態設置爲I。
  • 一個處於E狀態的緩存行,必須時刻監聽其他試圖讀取該緩存行對應的主存地址的操作,如果監聽到,則必須把其緩存行狀態設置爲S。
  • 當CPU需要讀取數據時,如果CPU緩存中沒有緩存, 則會從主緩存中讀取,並置爲E狀態,如果其緩存行的狀態是I的,則需要從內存中讀取,並把自己狀態變成S,如果不是I,則可以直接讀取緩存中的值,但在此之前,必須要等待其他CPU的監聽結果,如其他CPU也有該數據的緩存且狀態是M,則需要等待其把緩存更新到內存之後,再讀取。
  • 當CPU需要寫數據時,只有在其緩存行是M或者E的時候才能執行,否則需要發出特殊的RFO指令(Read Or Ownership,這是一種總線事務),通知其他CPU置緩存無效(I),這種情況下會性能開銷是相對較大的。在寫入完成後,修改其緩存狀態爲M。

下面通過一張圖來說明:
volatile05.png

上面就是MESI的內容了,總的來說Java併發依賴於JMM,JMM依賴於MESI,MESI依賴於硬件。

假設一個場景,如果JMM某語義需要某變量在多線程間可見,則Java編譯器會在生成的代碼中插入一條操作MESI CPU緩存的指令,借用MESI來實現其語義。
對於這個過程,我再下面volatile原理小節介紹。

Volatile的原理

public class VolatileCompileTest {
    volatile int v1 = 0;
    int a = 0;
 
    public void write() {
        a = v1;
        v1 = v1 + 1;
    }
 
    public int read() {
        v1 = 0;
        return a;
    }
 
    public static void main(String[] args) {
        VolatileCompileTest ins = new VolatileCompileTest();
        for (int i = 0; i < 1000 * 1000; i++) {
            ins.write();
        }
        System.out.println(ins.v1);
    }
}

如上圖所示代碼中,當操作了一個加了volatile關鍵字的時候會發生什麼呢?我們可以將代碼的彙編結果輸出來看一下:
volatile06.png

下面是不加volatile關鍵字的彙編代碼:
volatile07.png

rsi地址的變量即是this的地址,0xc(%rsi) 代表的就是變量v1,可以看到在加了volatile關鍵字之後,v1計算完之後,添加了一條lock addl指令。

這條指令的作用就是控制CPU將當前處理器緩存行寫回系統內存,同時會將緩存標誌標記爲M,這時候其他處理器的緩存會探到這一狀態而將自己緩存對應行標記爲I,下次讀取就會重新從主存讀取了。

以上即是MESI層的實現,接下來我們說一下JMM在MESI上的封裝
對於可見性問題,JMM的解決方式就是通過編譯代碼中插入lock指令解決

對於重排序問題,JMM抽象的對Javaer提供了兩個承諾:happens-before和as-if-serial規則

happens-before規則
happens-before規則定義如下:
如果一個操作A happens-before 另一個操作B,則A在B之前執行,並且執行結果對B可見
如果兩個操作存在happens-before關係這並不意味着程序一定會按照指定的關係來執行,只要執行過後的結果一直,JMM是允許這種排序的
有哪些操作可以控制happens-before規則呢,JSR-133中定義的Java內存模型中定義瞭如下happens-before規則
volatile08.png

as-if-serial規則
編譯器編譯完代碼之後以及jit編譯之後所產生的代碼單線程執行時與編程者變寫的代碼之間的原始指令可以不同,但是執行結果必須一致

爲了實現上面的約定,Java編譯器會在字節碼中插入如下屏障:

  1. 在每個volatile寫操作的前面插入一個StoreStore屏障,防止前面的寫操作與volatile寫重排序,防止另一個線程裏對兩個變量以相反的順序寫
  2. 在每個volatile寫操作的後面插入一個StoreLoad屏障,防止後面的讀和volatile寫操作重排序
  3. 在每個volatile讀操作的後面插入一個LoadLoad屏障,防止volatile後面的讀和volatile讀重排序
  4. 在每個volatile讀操作的後面插入一個LoadStore屏障,防止volatile後面的寫和volatile的讀重排序
    當然這些屏障也是虛擬的存在,只是爲了知道編譯器在生成代碼時怎麼處理指令重排序問題,以保證在任何平臺上任意程序中都能得到正確的volatile語義

下面以一個例子說明:
volatile09.png

volatile10.png

學習Volatile設計理念, 我總結了三條原則
用最小的代價完成任務

有了鎖,我們爲什麼還需要volatile呢,這是因爲volatile不需要加鎖,在某些情況下volatile會取得比鎖更高的效率。在我們設計方案的過程中,每個問題都會有很多可行方案,很多時候我們需要在這些可行方案中選擇代價最小的一個。這個能力使我們成爲開發專家的路上很重要的一個能力,需要我們不斷的學習總結。

public class VisibilityTest1 {
    boolean start = false;
 
    public synchronized boolean isStart() {
        return start;
    }
 
    public synchronized void setStart(boolean start) {
        this.start = start;
    }
 
    public static void main(String[] args) {
        new VisibilityTest1().test();
    }
    public void test() {
        Thread[] threads = new Thread[100];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                int wait = 0;
                while (!isStart()) {
                    wait++;
                }
                System.out.println(Thread.currentThread().getName()
                        + " run after second: " + wait);
            });
            threads[i].start();
        }
        setStart(true);
        try {
            Thread.sleep(20000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

瞭解底層原理才能寫出極致的性能

併發大師Doug Lea在JDK1.7開發LinkedTransferQueue時,在使用volatile變量時,爲了提高效率,在類中追加了很多無用的對象,代碼如下:
volatile11.png

Doug Lea爲什麼這麼做呢,原因是當時主要的處理器緩存行都是64個字節,將節點追加到64個字節,每個節點就恰好佔據了一個緩存行,防止了同時讀寫某個緩存行時會出現的高速緩存鎖定現象降低效率。

當然,對於我們大部分編程過程中我們是不需要去追求這麼極端的。但是,必須認識到,業內流行的軟件,很多都是追求極限的性能才成爲行業內公認的技術選型的,如kafka的零拷貝技術,clickHouse的向量運算等等。

Synchronized篇

提到Synchronized關鍵字,想比大家都不陌生。可以說是Java編程領域老工具人了,從Java語言發佈一直服務到現在,而且還在不斷進化中。

**章節目標:**瞭解如何根據實際情況選擇合適的鎖,如果和設計一個自適應的鎖

爲什麼需要Synchronized

volatile可以解決可見性與指令重排,但是確無法解決程序運行過程中的原子性問題,舉個例子說:

private volatile long value;
 
value = 1000; // 線程安全的
 
value++; // 非線程安全

因爲對於value++,轉換爲字節碼以後,可以看到, 程序其實做了多個操作, 而這些操作的執行過程中是有可能發生線程切換, 導致執行結果不準確的。

Synchronized的用法

那麼,哪位同學可以說出Synchronized關鍵字的5種用法呢:

  • 修飾方法
  • 修飾代碼塊
public synchronized void test1() {
    start++;
}
 
public void test2() {
    synchronized (this) {
        start++;
    }
}

Synchronized原理

在討論Synchronized關鍵資源裏之前,我們先來觀測一下,添加了Synchronized關鍵字之後,會有什麼現象:

public synchronized void test1();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=5, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field start:J
         5: lconst_1
         6: ladd
         7: putfield      #2                  // Field start:J
        10: return
      LineNumberTable:
        line 6: 0
        line 7: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/company/SyncTest1;
 
  public void test2();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=5, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field start:J
         9: lconst_1
        10: ladd
        11: putfield      #2                  // Field start:J
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
      Exception table:
         from    to  target type
             4    16    19   any
            19    22    19   any

我們可以看到,對於直接在方法中加Synchronized關鍵字的方法,編譯成字節碼之後會有一個ACC_SYNCHRONIZED的flag,對於在代碼塊中加Synchronized關鍵字的方法,編譯出來的字節碼中,加入了monitorenter和monitorexit指令。

那麼這兩種方式有什麼不同呢,其實這兩種方法效果是一樣的,區別是在方法上添加Synchronized關鍵字時,是由JVM幫我們調用的monitorenter和monitorexit。

再觀察我們的monitorenter和monitorexit指令,可以看到這兩個操作是消費一個參數,產生0個參數,

  • 修飾方法,參數爲方法對象
  • 修飾靜態方法,參數爲類對象
  • 修飾代碼塊,參數爲顯示指定的對象
    也就是說,monitor是和對象關聯的,一個對象只會有一個monitor,也就是說下面的兩個例子裏,write和read方法是互斥的
public class SynchronizedExample1 {
    int a = 0;
    public synchronized void write(int i) {
        a = i;
    }
    public synchronized int read() {
        return a;
    }
}
public class SynchronizedExample2 {
    int a = 0;
    public void write(int i) {
        synchronized (Integer.valueOf(1)) {
            a = i;
        }
    }
    public synchronized int read() {
        synchronized (Integer.valueOf(1)) {
            return a;
        }
    }
}

每個Java對象都有唯一的一個monitor相關聯,monitor對象結構如下圖所示:
volatile12.png
_owner指的是正在獲取鎖的線程,這裏是用cas來做的
_waitSet 指的是調用了Object.wait之後出於等待狀態的線程集合
_EntryList指的是等待狀態的線程集合

Synchronized2.png

接下來我給大家解釋一下,調用monitorenter或monitorexit時爲什麼要帶一個對象參數呢?因爲Synchronized鎖狀態是存儲在對象頭中的。

下面我給大家說一下java對象頭的組成,以64位機爲例。
Synchronized3.png

如圖所示,是一個SynchronizedExample1對象在堆中所佔內存,內存中前8個字節,鎖數據就是存放在MarkWork裏。MarkWord的詳細結構如下:
Synchronized4.png
可以看到,在不同鎖情況下,markwork中的內容是不一樣的。但是各種等級的鎖下,都會有兩個bit的lock區域,這兩個bit是用來標記鎖的種類的。也就是說,當monitor檢查了63-64bit之後,就可以知道目前該對象上加的是什麼鎖了,確定鎖的種類,就可以確定markwork中數據的分佈,進行對應的處理了。例如,如果經確定目前鎖的狀態是偏向鎖,則就可以讀取1~54bit的數據,找到當前獲取偏向鎖的線程id,來確定是直接放行還是升級成輕量級鎖。
Synchronized5.png
上表就是所種類和標誌位對應關係了。可以看到未鎖定和偏向鎖是一樣的都是01,怎麼區分兩個鎖呢,靠的就是上上個圖裏的biase_lock標誌位。

下面這一小節,我們來講一下Synchronized鎖升級的過程。

Synchronized升級過程

Synchronized6.png

學習Synchronized設計理念, 我總結了三條原則
如果可以不使用鎖,那就不要使用鎖。
爲什麼Synchronized首先加的是偏向鎖呢,因爲偏向鎖基本相當於無鎖,假如臨界區代碼運行在單線程的情況下,使用偏向鎖就可以實現近乎不加鎖的性能.。

大家看看下面的例子里程序有什麼問題,有什麼優化方法:

public class SynchronizedExample3 {
    static int a = 0;
    private static Object lock = new Object();
    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (Thread t : threads) {
            t = new Thread(() -> {
                synchronized (lock) {
                    a += new Random().nextInt(10);
                }
            });
            t.start();
        }
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("result is: " + a);
    }
}

低併發情況下使用樂觀鎖, 高併發情況下使用悲觀鎖
在研究Synchronized鎖升級的時候,我們可以看到,Synchronized使用的是多種鎖結合使用的方式來達到不同應用場景下都能達到較高的效率。當臨界區數據被多個線程同時訪問的時候,Synchronized首先做的是將鎖升級爲輕量級鎖。如果臨界區數據競爭不是很激烈,通過有限次cas就可以獲取鎖,那麼就可以免除加互斥鎖的開銷,因爲互斥鎖的開銷往往是很大的。但是如果臨界區競爭特別激烈,多次獲取樂觀鎖仍不能獲取的情況下,樂觀鎖的開銷就很大了,因爲線程要一直空自旋,佔用了大量CPU資源。Synchronized的做法是將鎖升級成重量級鎖,也就是悲觀鎖。這給我們帶來的啓示是,不同鎖適應於不同的運行條件,競爭不激烈,很快就可以獲取鎖的情況下,用樂觀鎖會好一些,競爭比較激烈,要長時間才能獲取鎖的場合下,用悲觀鎖合適一些。

通用工具爲了追求通用性往往會做很多自適應工作,如果我們要追求極致性需要在通用組件的基礎上針對實際應用情況做針對性優化
從Synchronized的升級過程我們可以看到,作爲一個通用組件,Synchronized爲了在不同應用場景下都能獲得較好的性能,做了很多額外的工作。但是如果我們明確知道我們的應用場景,那麼這些額外的工作就不僅不回提高性能反而會消耗性能了,例如如果我們是一個併發比較激烈的場景,或者大任務的場景,則輕量級鎖cas的過程就應該避免了,應該直接使用重量級的鎖。我們可以在jvm啓動的時候通過參數關閉輕量級鎖的環節。

課後練習

用CAS實現一個和Synchronized類似的鎖,目標:

  • 在單線程運行時,效率與無鎖類似
  • 在弱併發的情況下,效率優於直接加互斥鎖
  • 在強併發的情況下,效率優於樂觀鎖

參考資料 & 擴展閱讀
《Java併發編程的藝術》

🎉🔥好未來技術交流羣建立啦!!🔥🎉

爲了給大家提供更好、更快的即時交流平臺~

好未來技術交流微信羣在今天正式成立‼️‼️‼️

 

在這裏...

有定期的線上、線下福利活動等您參與!

齊聚行業內各個方向的技術大牛!!

還有好未來技術線一手的招聘資訊!!!

如果你還有其他想要的,歡迎隨時私聊小編📢

🌟你想要的小編都有🌟

還愣着幹嘛⁉️⁉️  衝🦆!!!

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