天天看點

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

目錄

  • 1. 原子性
      • 問題分析
      • 解決方法
  • 2. 可見性
      • 退不出的循環
      • 解決方法
      • 可見性
  • 3. 有序性
      • 詭異的結果
      • 解決方法
      • 有序性了解
      • happens-before
  • 4. CAS 與 原子類
      • CAS
      • 樂觀鎖與悲觀鎖
      • 原子操作類
  • 5. synchronized 優化
      • 輕量級鎖
      • 鎖膨脹
      • 重量鎖
      • 偏向鎖
      • 其它優化
  • JMM 定義了一套在多線程讀寫共享資料時(成員變量、數組)時,對資料的可見性、有序性、和原子性的規則和保障。
  • 跟記憶體結構沒有關系

1. 原子性

  • 問題提出,兩個線程對初始值為 0 的靜态變量一個做自增,一個做自減,各做 5000 次,結果是 0 嗎?

代碼如下

public class Demo4_1 {

    static int i = 0;

    

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
           
                for (int j = 0; j < 50000; j++) {
                    i++;
                }
            
        });

        Thread t2 = new Thread(() -> {
            
                for (int j = 0; j < 50000; j++) {
                    i--;
                }
            
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(i);
    }
}
           

問題分析

  • 以上的結果可能是正數、負數、零。為什麼呢?因為 Java 中對靜态變量的自增,自減并不是原子操作。
  • JVM就會交錯的執行兩個線程

    例如對于

    i++

    而言(i 為靜态變量),實際會産生如下的 JVM 位元組碼指令:

getstatic i // 擷取靜态變量i的值

iconst_1 // 準備常量1

iadd // 加法

putstatic i // 将修改後的值存入靜态變量i

而對應 i-- 也是類似:

getstatic i // 擷取靜态變量i的值

iconst_1 // 準備常量1

isub // 減法

putstatic i // 将修改後的值存入靜态變量i

而 Java 的記憶體模型如下,完成靜态變量的自增,線程在工作記憶體,自減需要在主存和線程記憶體中進行資料交換:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  • 記憶體模型是将記憶體分為主記憶體和工作記憶體
  • 如果是

    單線程

    以上 8 行代碼是順序執行(不會交錯)沒有問題
  • 但多線程下這 8 行代碼可能交錯運作(為什麼會交錯?思考一下):

    出現負數的情況:

    JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
    出現正數的情況:
    JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

解決方法

synchronized

(同步關鍵字)

文法

synchronized( 對象 ) {
要作為原子操作代碼
}
           

用 synchronized 解決并發問題:

public class Demo4_1 {

    static int i = 0;

    static Object obj = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (obj) {
                for (int j = 0; j < 50000; j++) {
                    i++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (obj) {
                for (int j = 0; j < 50000; j++) {
                    i--;
                }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(i);
    }
}
           
  • 如何了解呢:你可以把 obj 想象成一個房間,線程 t1,t2 想象成兩個人。

    當線程 t1 執行到 synchronized(obj) 時就好比 t1 進入了這個房間,并反手鎖住了門,在門内執行count++ 代碼。

  • 這時候如果 t2 也運作到了 synchronized(obj) 時,它發現門被鎖住了,隻能在門外等待。
  • 當 t1 執行完 synchronized{} 塊内的代碼,這時候才會解開門上的鎖,從 obj 房間出來。t2 線程這時才可以進入 obj 房間,反鎖住門,執行它的 count-- 代碼。
注意:上例中 t1 和 t2 線程必須用 synchronized 鎖住

同一個 obj 對象

,如果 t1 鎖住的是 m1 對象,t2 鎖住的是 m2 對象,就好比兩個人分别進入了兩個不同的房間,沒法起到同步的效果。

2. 可見性

退不出的循環

先來看一個現象,main 線程對 run 變量的修改對于 t 線程不可見,導緻了 t 線程無法停止:

public class Demo {
    private static int i = 0;
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // ....
            }
        });
        t.start();
        Thread.sleep(1000);
        run = false; // 線程t不會如預想的停下來
    }

}
           

為什麼呢?分析一下:

  1. 初始狀态, t 線程剛開始從主記憶體讀取了 run 的值到工作記憶體。
    JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  2. 因為 t 線程要頻繁從主記憶體中讀取 run 的值,

    JIT 編譯器

    會将 run 的值緩存至自己工作記憶體中的

    高速緩存

    中,減少對主存中 run 的通路,提高效率
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  1. 1 秒之後,main 線程修改了 run 的值,并同步至主存,而 t 是從自己工作記憶體中的高速緩存中讀取這個變量的值,結果永遠是舊值
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

解決方法

volatile

(易變關鍵字)

它可以用來修飾成員變量和靜态成員變量,他可以避免線程從自己的工作緩存中查找變量的值,必須到主存中擷取它的值,線程操作 volatile 變量都是直接操作主存

注意這個關鍵字不能保證原子性

可見性

前面例子展現的實際就是可見性,它保證的是在多個線程之間,一個線程對 volatile 變量的修改對另一個線程可見, 不能保證原子性,僅用在一個寫線程,多個讀線程的情況: 上例從位元組碼了解是這樣的:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  • 多用在多個人寫,多個人讀資料,保證讀取的資料是最新的
  • 比較一下之前我們将線程安全時舉的例子:兩個線程一個 i++ 一個 i-- ,隻能保證看到最新值,不能解決指令交錯
    JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
注意 synchronized 語句塊

既可以保證代碼塊的原子性,也同時保證代碼塊内變量的可見性

。但缺點是synchronized是屬于

重量級操作

性能相對更低

如果在前面示例的死循環中加入 System.out.println() 會發現即使不加 volatile 修飾符,線程 t 也能正确看到對 run 變量的修改了,想一想為什麼?

因為println()使用了synchronized關鍵字

3. 有序性

詭異的結果

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

I_Result 是一個對象,有一個屬性 r1 用來儲存結果,問,可能的結果有幾種?

有同學這麼分析

  • 情況1:線程1 先執行,這時 ready = false,是以進入 else 分支結果為 1
  • 情況2:線程2 先執行 num = 2,但沒來得及執行 ready = true,線程1 執行,還是進入 else 分支,結果為1
  • 情況3:線程2 執行到 ready = true,線程1 執行,這回進入 if 分支,結果為 4(因為 num 已經執行過了)

但我告訴你,結果還有可能是 0

  • 線程2 執行 ready = true,切換到線程1,進入 if 分支,相加為 0,再切回線程2 執行num = 2
  • 這種現象叫做

    指令重排

    ,是 JIT 編譯器在運作時的一些優化,這個現象需要通過大量測試才能複現:
  • 借助 java 并發壓測工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress

mvn archetype:generate -DinteractiveMode=false -

DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-testarchetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0

建立 maven 項目,提供如下測試類

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
    int num = 0;
    boolean ready = false;
    @Actor
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    } @
            Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}
           

執行

mvn clean install

java -jar target/jcstress.jar

會輸出我們感興趣的結果,摘錄其中一次結果:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

* 可以看到,出現結果為 0 的情況有1000多次,雖然次數相對很少,但畢竟是出現了

解決方法

volatile 修飾的變量,可以禁用指令重排

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

結果:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

有序性了解

JVM 會在不影響正确性的前提下,可以調整語句的執行順序,思考下面一段代碼

static int i;
static int j;
// 在某個線程内執行如下指派操作
i = ...; // 較為耗時的操作
j = ...;
           

可以看到,至于是先執行 i 還是 先執行 j ,對最終的結果不會産生影響。是以,上面代碼真正執行時,既可以是

i = ...; // 較為耗時的操作
j = ...;
           

也可以是

j = ...;
i = ...; // 較為耗時的操作
           
  • 這種特性稱之為

    『指令重排』

    ,多線程下『指令重排』會影響正确性,例如著名的 double-checked locking 模式實作單例
public final class Singleton {
        private Singleton() { }
        private static Singleton INSTANCE = null;
        public static Singleton getInstance() {
            // 執行個體沒建立,才會進入内部的 synchronized代碼塊
            if (INSTANCE == null) {
                synchronized (Singleton.class) {
                   // 也許有其它線程已經建立執行個體,是以再判斷一次
                    if (INSTANCE == null) {
                        INSTANCE = new Singleton();
                    }
                }
            } 
            return INSTANCE;
        }
    }
           

以上的實作特點是:

  • 懶惰執行個體化
  • 首次使用 getInstance() 才使用 synchronized 加鎖,後續使用時無需加鎖

但在多線程環境下,上面的代碼是有問題的, INSTANCE = new Singleton() 對應的位元組碼為:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

其中 4 7 兩步的順序不是固定的,也許 jvm 會優化為:先将引用位址指派給 INSTANCE 變量後,再執行構造方法,如果兩個線程 t1,t2 按如下時間序列執行:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  • 這時 t1 還未完全将構造方法執行完畢,如果在構造方法中要執行很多初始化操作,那麼 t2 拿到的是将是一個未初始化完畢的單例
  • 對 INSTANCE 使用

    volatile

    修飾即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才會真正有效

happens-before

happens-before 規定了哪些寫操作對其它線程的讀操作可見,它是可見性與有序性的一套規則總結,抛開以下 happens-before 規則,JMM 并不能保證一個線程對共享變量的寫,對于其它線程對該共享變量的讀可見

  • 線程解鎖 m 之前對變量的寫,對于接下來對 m 加鎖的其它線程對該變量的讀可見
static int x;
static Object m = new Object();
new Thread(() -> {
    synchronized (m) {
        x = 10;
    }
}, "t1").start();
new Thread(() -> {
    synchronized (m) {
        System.out.println(x);
    }
}, "t2").start();
           
  • 線程對 volatile 變量的寫,對接下來其它線程對該變量的讀可見
    JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

4. CAS 與 原子類

CAS

  • CAS 即

    Compare and Swap

    ,它展現的一種樂觀鎖的思想,比如多個線程要對一個共享的整型變量執行 +1 操作
  • 結合

    volatile

    ,保證拿到的資料是最新的
  • 提倡無鎖開發
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

擷取共享變量時,為了保證該變量的可見性,需要使用 volatile 修飾。結合 CAS 和 volatile 可以實作無鎖并發,适用于競争不激烈、多核 CPU 的場景下。

  • 因為沒有使用 synchronized,是以線程不會陷入阻塞,這是效率提升的因素之一
  • 但如果競争激烈,可以想到重試必然頻繁發生,反而效率會受影響

CAS 底層依賴于一個

Unsafe

類來直接調用作業系統底層的 CAS 指令,下面是直接使用 Unsafe 對象進行線程安全保護的一個例子(需要根據反射的方式調用)

樂觀鎖與悲觀鎖

  • CAS

    是基于樂觀鎖的思想:最樂觀的估計,不怕别的線程來修改共享變量,就算改了也沒關系,我吃虧點再重試呗。
  • synchronized

    是基于悲觀鎖的思想:最悲觀的估計,得防着其它線程來修改共享變量,我上了鎖你們都别想改,我改完了解開鎖,你們才有機會。

原子操作類

  • juc(java.util.concurrent)中提供了原子操作類,可以提供線程安全的操作,例如:AtomicInteger、AtomicBoolean等,它們底層就是采用 CAS 技術 + volatile 來實作的。
  • 可以使用 AtomicInteger 改寫之前的例子:
public class Demo {
    // 建立原子整數對象
    private static AtomicInteger i = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {


        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndIncrement(); // 擷取并且自增 i++
                // i.incrementAndGet(); // 自增并且擷取 ++i
            }
        });
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndDecrement(); // 擷取并且自減 i--
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }


}
           

5. synchronized 優化

Java HotSpot 虛拟機中,每個對象都有對象頭(包括 class 指針和 Mark Word)。Mark Word 平時存儲這個對象的

哈希碼

分代年齡

,當加鎖時,這些資訊就根據情況被替換為

标記位

線程鎖記錄指針

重量級鎖指針

線程ID

等内容

輕量級鎖

如果一個對象雖然有多線程通路,但多線程通路的時間是錯開的(也就是沒有競争),那麼可以使用輕量級鎖來優化。這就好比:

  • 學生(線程 A)用課本占座,上了半節課,出門了(CPU時間到),回來一看,發現課本沒變,說明沒有競争,繼續上他的課。 如果這期間有其它學生(線程 B)來了,會告知(線程A)有并發通路,線程A 随即更新為重量級鎖,進入重量級鎖的流程。
  • 而重量級鎖就不是那麼用課本占座那麼簡單了,可以想象線程 A 走之前,把座位用一個鐵栅欄圍起來假設有兩個方法同步塊,利用同一個對象加鎖
static Object obj = new Object();
        public static void method1() {
            synchronized( obj ) {
                // 同步塊 A
                method2();
            }
        } 
        public static void method2() {
            synchronized( obj ) {
            // 同步塊 B
            }
        }
           
  • 每個線程都的棧幀都會包含一個鎖記錄的結構,内部可以存儲鎖定對象的 Mark Word
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

鎖膨脹

如果在嘗試加輕量級鎖的過程中,CAS 操作無法成功,這時一種情況就是有其它線程為此對象加上了輕量級鎖(有競争),這時需要進行鎖膨脹,将輕量級鎖變為重量級鎖。

static Object obj = new Object();
        public static void method1() {
            synchronized( obj ) {
            // 同步塊
            }
        }
           
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

重量鎖

重量級鎖競争的時候,還可以使用自旋來進行優化,如果目前線程自旋成功(即這時候持鎖線程已經退出了同步塊,釋放了鎖),這時目前線程就可以避免阻塞。

在 Java 6 之後自旋鎖是自适應的,比如對象剛剛的一次自旋操作成功過,那麼認為這次自旋成功的可能性會高,就多自旋幾次;反之,就少自旋甚至不自旋,總之,比較智能。

  • 自旋會占用 CPU 時間,單核 CPU 自旋就是浪費,多核 CPU 自旋才能發揮優勢。
  • 好比等紅燈時汽車是不是熄火,不熄火相當于自旋(等待時間短了劃算),熄火了相當于阻塞(等待時間長了劃算)
  • Java 7 之後不能控制是否開啟自旋功能

自旋重試成功的情況:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

自旋重試失敗的情況:

JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化
  • 自旋重試多次後,會終止自旋
  • 自旋的次數是自适應的,并不是固定值,如果經常自旋成功,自旋的次數會增加

偏向鎖

優化鎖重入

輕量級鎖在沒有競争時(就自己這個線程),每次重入仍然需要執行 CAS 操作。Java 6 中引入了偏向鎖來做進一步優化:隻有第一次使用 CAS 将線程 ID 設定到對象的 Mark Word 頭,之後發現這個線程 ID是自己的就表示沒有競争,不用重新 CAS.

  • 撤銷偏向需要将持鎖線程更新為輕量級鎖,這個過程中所有線程

    需要暫停(STW)

  • 通路對象的 hashCode 也會撤銷偏向鎖
  • 如果對象雖然被多個線程通路,但沒有競争,這時偏向了線程 T1 的對象仍有機會重新偏向 T2,重偏向會重置對象的 Thread ID
  • 撤銷偏向和重偏向都是批量進行的,以類為機關
  • 如果撤銷偏向到達某個門檻值,整個類的所有對象都會變為不可偏向的
  • 可以主動使用 -XX:-UseBiasedLocking 禁用偏向鎖

可以參考這篇論文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

假設有兩個方法同步塊,利用同一個對象加鎖

static Object obj = new Object();
        public static void method1() {
            synchronized( obj ) {
                // 同步塊 A
                method2();
            }
        } 
        public static void method2() {
            synchronized( obj ) {
            // 同步塊 B
            }
        }
           
JVM--記憶體模型1. 原子性2. 可見性3. 有序性4. CAS 與 原子類5. synchronized 優化

其它優化

1. 減少上鎖時間

  • 同步代碼塊中盡量短

2. 減少鎖的粒度

将一個鎖拆分為多個鎖提高并發度

,例如:

  • ConcurrentHashMap
  • LongAdder 分為 base 和 cells 兩部分。沒有并發争用的時候或者是 cells 數組正在初始化的時候,會使用 CAS 來累加值到 base,有并發争用,會初始化 cells 數組,數組有多少個 cell,就允許有多少線程并行修改,最後将數組中每個 cell 累加,再加上 base 就是最終的值
  • LinkedBlockingQueue 入隊和出隊使用不同的鎖,相對于LinkedBlockingArray隻有一個鎖效率要高

3.鎖粗化

  • 多次循環進入同步塊不如同步塊内多次循環 另外 JVM 可能會做如下優化,把多次 append 的加鎖操作粗化為一次(因為都是對同一個對象加鎖,沒必要重入多次)

4. 鎖消除

  • JVM 會進行代碼的逃逸分析,例如某個加鎖對象是方法内局部變量,不會被其它線程所通路到,這時候就會被即時編譯器忽略掉所有同步操作。

5. 讀寫分離

  • CopyOnWriteArrayList ConyOnWriteSet