Java進階專題(十一) 探究JMM

道阻且長啊發表於2020-08-20

前言

​ JMM即java記憶體模型,JMM研究的就是多執行緒下Java程式碼的執行順序,共享變數的讀寫。它定義了Java虛擬機器在計算機記憶體中的工作方式。從抽象角度看,JMM定義了執行緒和主存之間的抽象關係:執行緒之前的共享變數儲存在主記憶體中,每個執行緒有個私有的本地記憶體,本地記憶體中儲存了該執行緒讀寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他硬體和編譯器優化。

​ 先丟擲兩個問題:

  1. 你寫的程式碼一定是實際執行的程式碼嗎?
  2. 程式碼的編寫順序,一定是實際執行的順序嗎?

參考文獻:

Java Language Specification Chapter 17. Threads and Locks

JSR-133: JavaTM Memory Model and Thread Specification

Doug Lea' s JSR-133 cookbook

書籍:《Java Concurrency in Practice》

併發測試框架:jcstress

多執行緒讀寫共享變數

問題演示

猜猜一下程式碼在多執行緒的情況下,會發生什麼樣的情況?

永遠的迴圈

boolean stop;
@Actor
public void a1() {
   while(!stop){
   }
}
@Signal
void a2() {
   stop = true;
}

加加減減

int balance = 10;
@Actor
public void deposit() {
   balance += 5;
}
@Actor
public void withdraw() {
   balance -= 5;
}
@Arbiter
public void query(I_Result r) {
   r.r1 = balance;
}

第四種可能

int a;
int b;
@Actor
public void actor1(II_Result r) {
   b = 1;
   r.r2 = a;
}
@Actor
public void actor2(II_Result r) {
   a = 2;
   r.r1 = b;
}

問題解密

迴圈問題-揭祕

為了方便測試,改造下程式碼:

package com.study.demo6;

import java.util.concurrent.TimeUnit;

public class WhileTest {
    static boolean stop;

    public static void a1() {
        while (true) {
            boolean b = stop;
            if (b) {
                break;
            }
        }
    }
    
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stop = true;
            System.out.println("stop>>>>>>>true!");
        }).start();
        a1();
    }
}

執行結果:

發現main主執行緒中,呼叫了啊a1()方法,子執行緒1秒後,對stop修改了true,按正常邏輯,死迴圈應該會break終止了,但是實際上執行,我們發現,一直在迴圈中,並未終止!

提示:

先用 -XX:+PrintCompilation 來檢視即時編譯情況(% 的含義 On-Stack-Replacement(OSR))

再嘗試用 -Xint 強制解釋執行

加加減減問題-解密

程式碼演示

package com.study.demo6;

import java.util.Arrays;
import java.util.List;

public class AddSubTest {
    static int balance = 10;

    private static void add(){
        balance+=5;
    }
    private static void sub(){
        balance-=5;
    }

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = Arrays.asList(new Thread(AddSubTest::add), new Thread(AddSubTest::sub));
        threadList.forEach(Thread::start);
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(balance);
    }
}

這回用一下ASM 工具,可以看到原始碼第10 行的 balance += 5 的位元組碼如下

LINENUMBER 8 L0
   GETSTATIC TestAddSub.balance : I
   ICONST_5
   IADD
   PUTSTATIC TestAddSub.balance : I

而第13 行的 balance -= 5 位元組碼如下

LINENUMBER 12 L0
   GETSTATIC TestAddSub.balance : I
   ICONST_5
   ISUB
   PUTSTATIC TestAddSub.balance : I

換成偽代後

    static int balance = 10;

    private static void add(){
        //balance+=5;
        int b = balance;
        b += 5;
        balance = b;
    }
    private static void sub(){
        //balance-=5;
        int c = balance;
        c -= 5;
        balance = c;
    }

可能出現的執行順序如下:

case1: 執行緒1和2序列

int b = balance; // 執行緒1
b += 5;          // 執行緒1
balance = b;     // 執行緒1
int c = balance; // 執行緒2
c -= 5;          // 執行緒2
balance = c;     // 執行緒2

case2:執行緒1和執行緒2同時拿到10,執行緒1執行完,執行緒2再執行完

int c = balance; // 執行緒2
int b = balance; // 執行緒1
b += 5;          // 執行緒1
balance = b;     // 執行緒1
c -= 5;          // 執行緒2
balance = c;     // 執行緒2

case3:執行緒1和執行緒2同時拿到10,執行緒2執行完,執行緒1再執行完

int b = balance; // 執行緒1
int c = balance; // 執行緒2
c -= 5;          // 執行緒2
balance = c;     // 執行緒2
b += 5;          // 執行緒1
balance = b;     // 執行緒1

第四種可能-揭祕

程式碼演示:

package com.study.demo6;

public class FourthResultTest {
    int a;
    int b;

    private void actor1(IIResult r){
        b=1;
        r.r2 = a;
    }

    private void actor2(IIResult r){
        a=2;
        r.r1 = b;
    }

}

可能出現的結果

case1:

b = 1;      // 執行緒1
r.r2 = a;   // 執行緒1
a = 2;      // 執行緒2
r.r1 = b;   // 執行緒2
// 結果 r1==1, r2==0

case2:

a = 2;      // 執行緒2
r.r1 = b;   // 執行緒2
b = 1;      // 執行緒1
r.r2 = a;   // 執行緒1
// 結果 r1==0, r2==2

case3:

a = 2;      // 執行緒2
b = 1;      // 執行緒1
r.r2 = a;   // 執行緒1
r.r1 = b;   // 執行緒2
// 結果 r1==1, r2==2

case4:這種結果是不是超乎你的預期了?這是因為可能是編譯器調整了指令執行順序

r.r2 = a;   // 執行緒1
a = 2;      // 執行緒2
r.r1 = b;   // 執行緒2
b = 1;      // 執行緒1
// 結果 r1==0, r2==0

思考為什麼

  1. 如果讓一個執行緒總是佔用CPU 是不合理的,所有任務排程器會讓執行緒分時使用CPU

  2. 編譯器以及硬體層面都會做層層優化,提升效能

  3. Compiler/JIT 優化

  4. Processor 流水線優化

  5. Cache 優化

編輯器優化

case1:

//優化前
x=1
y="universe"
x=2
//優化後
y="universe"
x=2

case2:

//優化前
for(i=0;i<max;i++){
       z += a[i]
   }
//優化後
t = z
for(i=0;i<max;i++){
       t += a[i]
   }
z = t

case3:

//優化前
if(x>=0){
y = 1;
// ...
}
//優化後
y = 1;
if(x>=0){
// ...
}

Processor優化

流水線在CPU 的一個時鐘週期內會執行多個指令的不同部分

非流水線操作

假設有三條指令

---|---|---|
1   2   3

每條指令執行花費300ps 時間,最後將結果存入暫存器需要20ps
一秒能執行的指令數為

流水線操作

仔細分析就會發現,可以把每個指令細分為三個階段

A|B|C|          // 1
 A|B|C|        // 2
   A|B|C|      // 3

增加一些暫存器,快取每一階段的結果,這樣就可以在執行 指令1-C 階段時,同時執行 指令2-B 以及 指令3-A
一秒能執行的指令數為

execute out of order

  • 在按序執行中,一旦遇到指令依賴的情況,流水線就會停滯
  • 如果採用亂序執行,就可以跳到下一個非依賴指令併發布它。這樣,執行單元就可以總是處於工作狀態,把
    時間浪費減到最少

快取優化

MESI (CPU快取一致性)協議 引入快取的副作用在於同一份資料可能儲存了副本,一致性該如何保證呢?

  • Modified - 要向其它CPU 傳送cache line 無效訊息,並等待ack
  • Exclusive - 獨佔、即將要執行修改
  • Shared - 共享、一般讀取時的初始狀態
  • Invalid - 一旦發現資料無效,需要重新載入資料

例子

就上文所說的第四種可能:r1 和r2 有沒有可能同時為0

r.r1 = b;   // 執行緒2 與 a = 2 重排
r.r2 = a;   // 執行緒1 與 a = 1 重排
b = 1;      // 執行緒1
a = 2;      // 執行緒2

下面從快取的角度分析,注意假定指令沒有重排

b = 1;      // 執行緒1 - 寫入 CPU-0 的 store buffer
a = 2;      // 執行緒2 - 寫入 CPU-1 的 store buffer
r.r1 = b;   // 執行緒2 - 馬上執行
r.r2 = a;   // 執行緒1 - 馬上執行
// 執行緒1 - 將 store buffer 中的 b = 1 寫入 cache, 晚了
// 執行緒2 - 將 store buffer 中的 a = 2 寫入 cache, 晚了

我們關注問題的點

​ 以上介紹了多執行緒讀寫共享變數可能發生的哪些問題?但對於程式設計師而言,我們不應當關注究竟是編譯器優化、Processor 優化、快取優化。否則,就好像開啟了潘多拉魔盒!

JMM記憶體模型

什麼是JMM

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. A high level, informal overview of the memory model shows it to be a set of rules for when writes by one thread are visible to another thread.

多執行緒下,共享變數的讀寫順序是頭等大事,記憶體模型就是多執行緒下對共享變數的一組讀寫規則

  • 共享變數值是否線上程間同步
  • 程式碼可能的執行順序
  • 需要關注的操作就有兩種Load、Store
    • Load 就是從快取讀取到暫存器中,如果一級快取中沒有,就會層層讀取二級、三級快取,最後才是Memory
    • Store 就是從暫存器運算結果寫入快取,不會直接寫入Memory,當Cache line 將被eject 時,會
      writeback 到Memory

JMM規範

規則一 Race Condition

​ 在多執行緒下,沒有關係依賴的程式碼,在操作共享變數時(至少有一個執行緒寫),並不能保證按編寫順序(Program Order)執行,這稱為發生了競態條件(Race Conditon)。

例如

有共享變數 x,執行緒 1 執行

r.r1 = y; 
r.r2 = x;

執行緒 2 執行

x = 1; 
y = 1;

最終的結果可能是 r11 而 r20

競態條件是為了更好的 data race free。

規則二 Syncronization Order

​ 若要保證多執行緒下,每個執行緒執行順序(Synchronization Order)按編寫順序(Program Order)執行,那麼必須使用 Synchronization Actions 來保證,這些 SA 有

  • lock,unlock

  • volatile 方式讀寫變數

  • VarHandle 方式讀寫變數

Synchronization Order 也稱之為 Total Order

例如

用 volatile 修飾共享變數 y,執行緒 1 執行

r.r1 = y; 
r.r2 = x;

執行緒 2 執行

x = 1; 
y = 1;

最終的結果就不可能是 r11 而 r20

SO並不是阻止多執行緒切換

錯誤的認識,執行緒 1 執行

synchronized(LOCK) { 
  r1 = x; //1 處 
  r2 = x; //2 處 
}

執行緒 2 執行

synchronized(LOCK) { 
  x = 1 
}

並不是說 //1 與 //2 處之間不能切換到執行緒 2,只是即使切換到了執行緒 2,因為執行緒 2 不能拿到 LOCK 鎖導致被阻塞,執行權又會輪到執行緒 1

volatile 只用了一半算 SO 嗎?

用例1

int x; 
volatile int y;

之後採用

x = 10; //1 處 
y = 20; //2 處

此時 //1 處程式碼絕不會重排到 //2 處之後(只寫了 volatile 變數)

用例 2

int x; 
volatile int y;

執行下面的測試用例

@Actor 
public void a1(II_Result r) { 
  y = 1; //1 處 
  r.r2 = x; //2 處 
}
@Actor 
public void a2(II_Result r) { 
  x = 1; //3 處 
  r.r1 = y; //4 處 
}

//1 //2 處的順序可以保證(只寫了 volatile 變數),但 //3 //4 處的順序卻不能保證(只讀了 volatile 變數),仍會出現 r1r20 的問題

有時會很迷惑人,例如下面的例子

用例3

@Actor 
public void a1(II_Result r) { 
  r.r2 = x; //1 處 
  y = 1; //2 處 
}
@Actor 
public void a2(II_Result r) { 
  r.r1 = y; //3 處 
  x = 1; //4 處 
}

這回 //1 //2 (只寫了 volatile 變數)//3 //4 處(只讀了 volatile 變數)的順序均能保證了,絕不會出現r1r21 的情況

​ 此外將用例 2 中兩個變數均用 volatile 修飾就不會出現 r1r20 的問題,因此也把全部都用 volatile 修飾稱為total order,部分變數用 volatile 修飾稱為 partial order

規則三 Happens Before

​ 若是變數讀寫時發生執行緒切換(例如,執行緒 1 寫入 x,切換至執行緒 2,執行緒 2 讀取 x)在這些邊界的處理上如果有action1 先於 action 2 發生,那麼程式碼可以按確定的順序執行,這稱之為 Happens-Before Order 規則(Happens-Before Order 也稱之為 Partial Order).

用公式表達就是:

含義為:如果 action1 先於 action2 發生,那麼 action1 之前的共享變數的修改對於 action2 可見,且程式碼按 PO順序執行

具體規則

其中 $T_{n}$ 代表執行緒,而 x 未加說明,是普通共享變數,使用 volatile 會單獨說明

1)執行緒的啟動和執行邊界

2)執行緒的結束和join邊界

3)執行緒的打斷和得知打斷的邊界

4)unlock lock 邊界

5)volatile write volatile read 邊界

6)傳遞性

規則四 Causality

Causality 即因果律:程式碼之間如存在依賴關係即使沒有加 SA 操作,程式碼的執行順序也是可以預見的

回顧一下

多執行緒下,沒有依賴關係的程式碼,在共享變數讀寫操作(至少有一個執行緒寫)時,並不能保證以編寫順序(Program Order)執行,這稱為發生了競態條件(Race Condition)

如果有一定的依賴關係呢?

@JCStressTest
@Outcome(id = {"0", "0"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(expect = Expect.FORBIDDEN, desc = "FORBIDDEN")
@State
public class Case {
    int x;
    int y;

    @Actor
    public void a1(IIResult r) {
        r.r1 = x;
        y = r.r1;
    }

    @Actor
    public void a2(IIResult r){
        r.r2 = y;
        x = r.r2;
    }
}

x 的值來自於 y,y 的值來自於 x,而二者的初始值都是 0,因此沒有可能有其他結果

規則五安全釋出

若要安全構造物件,並將其共享使用,需要用 final volatile 修飾其成員變數,並避免 this 溢位情況(靜態成員變數可以安全地釋出)

例如

class Holder{
    int x1;
    volatile int x2;

    public Holder(int x) {
        x1=x;
        x2=x;
    }
}

需要將它作為全域性使用

Holder f;

兩個執行緒,一個建立,一個使用

Holder holder;

@Actor
public void a1(){
  holder = new Holder(1);
}

@Actor
public void a2(IIResult r){
  Holder holder = this.holder;
  if (holder != null){
    r.r1 = holder.x1 +holder.x2;
  }else {
    r.r1 = -1;
  }
}

可能看見未構造完整的物件

同步動作

前面沒有詳細展開從規則 2 之後的講解,是因為要理解規則,還需理解底層原理,即記憶體屏障

記憶體屏障

LoadLoad

  • 防止 y 的 Load 重排到 x 的 Load 之前

    if(x) { 
      LoadLoad 
        return y 
    }
    
  • 意義:x == true 時,再去獲取 y,否則可能會由於重排導致 y 的值相對於 x 是過期的

LoadStore

  • 防止 y 的 Store 被重排到 x 的 Load 之前

StoreSotre

  • 防止 A 的 Store 被重排到 B 的 Store 之後

    A = x 
    StoreStore 
    B = true
    
  • 意義:在 B 修改為 true 之前,其它執行緒別想看到 A 的修改

    • 有點類似於 sql 中更新後,commit 之前,其它事務不能看到這些更新(B 的賦值會觸發 commit 並撤除屏障)

StoreLoad

  • 意義:屏障前的改動都同步到主存 ,屏障後的 Load 獲取主存最新資料,發生線上程切換時,並且使得藍色執行緒所有的寫操作寫入主存,使得紅色執行緒能讀取到最新資料
    • 防止屏障前所有的寫操作,被重排序到屏障後的任何的讀操作,可以認為此 store -> load 是連續的
    • 有點類似於 git 中先 commit,再遠端 poll,而且這個動作是原子的

如何記憶

  • LoadLoad + LoadStore = Acquire 即讓同一執行緒內讀操作之後的讀寫上不去,第一個 Load 能讀到主存最新值
  • LoadStore + StoreStore = Release 即讓同一執行緒內寫操作之前的讀寫下不來,後一個 Store 能將改動都寫入主存
  • StoreLoad 最為特殊,還能用線上程切換時,對變數的寫操作 + 讀操作做同步,只要是對同一變數先寫後讀,那麼屏障就能生效

Volatile

本質

事實上對 volatile 而言 Store-Load 屏障最為有用,簡化起見以後的分析省略部分其他屏障

作用

  • 保證單一變數的原子性
  • 控制了可能的執行路徑: 執行緒內按屏障有序,執行緒切換時按HB有序
  • 可見性:執行緒切換時若發生了讀寫則變數可見,順帶影響普通變數可見

volatile的用途

凡是需要cas操作的地方

比如AtomicInteger的原始碼

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
    private volatile int value; 
    
    // ...

    public final boolean compareAndSet(int expectedVal, int newVal) {
        return U.compareAndSetInt(this, VALUE, expectedVal, newVal);
    }
    
    // ...
}

AbstractQueuedSynchronizer的原始碼

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
    private transient volatile Node head;
    private transient volatile Node tail;
    private volatile int state;

    protected final int getState() {
        return state;
    }

    protected final boolean compareAndSetState(int e, int n) {
        return U.compareAndSetInt(this, STATE, e, n);
    }

    final void enqueue(Node node) {
        if (node != null) {
            for (; ; ) {
                Node t = tail;
                node.setPrevRelaxed(t);
                if (t == null) tryInitializeHead();

                else if (casTail(t, node)) {
                    t.next = node;
                    if (t.status < 0) LockSupport.unpark(node.waiter);
                    break;
                }
            }
        }
    }

    private void tryInitializeHead() {
        Node h = new ExclusiveNode(); // 頭
        if (U.compareAndSetReference(this, HEAD, null, h)) tail = h;
    }

    private boolean casTail(Node c, Node v) {
        return U.compareAndSetReference(this, TAIL, c, v);
    }
}

ConcurrentHashMap原始碼

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>, Serializable {
    /**
     * Table initialization and resizing control. When negative, the
     * table is being initialized or resized: -1 for initialization,
     * else -(1 + the number of active resizing threads). Otherwise,
     * when table is null, holds the initial table size to use upon
     * reation, or 0 for default. After initialization, holds the
     * next element count value upon which to resize the table.
     */
    private transient volatile int sizeCtl;
    /**
     * The array of bins. Lazily initialized upon first insertion.
     * Size is always a power of two. Accessed directly by iterators.
     */
    transient volatile Node<K, V>[] table;

    private final Node<K, V>[] initTable() {
        Node<K, V>[] tab;
        int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0) Thread.yield();
            else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }
    // ... 
}

volatile負責保證可見性,cas來保證原子

Synchronized

本質

起始synchronized本質就是通兩個JVM指令:monitorenter和monitorexit來實現了,我們可以通過下面一段程式碼的來研究下,其原理

package com;

public class SynchronizedTest {
    static int i = 0;
    public static void main(String[] args) {
        synchronized (SynchronizedTest.class){
            i++;
        }
    }
}

通過反編譯看下

 #......
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class com/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #3                  // Field i:I
         8: iconst_1
         9: iadd
        10: putstatic     #3                  // Field i:I
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return
#......

可以看到就是通過jvm指令monitorenter、monitorexit實現的,結合上圖,具體步驟如下:

我們知道synchronized是通加物件鎖來實現的,但是這個物件是否作為鎖而存在呢?

  1. 當執行緒1執行synchronized時,jvm呼叫monitorenter時,就會先作業系統申請一個作業系統的Moniter鎖(底層由c++實現的),並把其地址存放在LOCK物件頭中。
  2. 當執行緒1根據LOCK物件頭找到Moniter鎖,判斷owner是否被佔用,沒有被佔用,就會修改其值,等於持有了鎖。
  3. 大概執行緒2同樣會執行monitorenter指令,根據LOCK物件頭找到Moniter鎖,判斷owner是否被佔用,發現已經被佔用,首先會自旋嘗試獲取,一定次數沒獲取到,就會進入EntryList佇列等待,並從執行狀態變成阻塞狀態,執行緒3也是如此。
  4. 當執行緒1執行完畢或出現異常時就會執行monitorexit,釋放owner並喚醒EntryList中的被阻塞執行緒,具體都佇列頭還是佇列尾部去喚醒,這個根據具體演算法實現,這裡不做贅述。
  5. 假如執行緒2被喚醒就會去獲取owner是否空閒,空閒了就佔用,執行緒3依然處於阻塞狀態。

相關記憶體屏障

優化(JDK1.6之後)

  • 重量級
    • 當有競爭時,仍會向系統申請 Monitor 互斥鎖
  • 輕量級鎖
    • 如果執行緒加鎖、解鎖時間上剛好是錯開的,這時候就可以使用輕量級鎖,只是使用 cas 嘗試將物件頭替換為該執行緒的鎖記錄地址,如果 cas 失敗,會鎖重入或觸發重量級鎖升級
  • 偏向鎖
    • 打個比方,輕量級鎖就好比用課本佔座,執行緒每次佔座前還得比較一下,課本是不是自己的(cas),頻繁 cas 效能也會受到影響
    • 而偏向鎖就好比座位上已經刻好了執行緒的名字,執行緒【專用】這個座位,比 cas 更為輕量
    • 但是一旦其他執行緒訪問偏向物件,那麼比較麻煩,需要把座位上的名字擦去,這稱之為偏向鎖撤銷,鎖也升級為輕量級鎖
    • 偏向鎖撤銷也屬於昂貴的操作,怎麼減少呢,JVM 會記錄這一類物件被撤銷的次數,如果超過了 20 這個閾值,下次新執行緒訪問偏向物件時,就不用撤銷了,而是刻上新執行緒的名字,這稱為重偏向
    • 如果撤銷次數進一步增加,超過 40 這個閾值,JVM 會認為這一類物件不適合採用偏向鎖,會對它們禁用偏向鎖,下次新建物件會直接加輕量級鎖

無鎖與有鎖

  • synchronized 更為重量,申請鎖、鎖重入都要發起系統呼叫,頻繁呼叫效能會受影響

  • synchronized 如果無法獲取鎖時,執行緒會陷入阻塞,引起的執行緒上下文切換成本高

  • 雖然做了一系列優化,但輕量級鎖偏向鎖都是針對無資料競爭場景

  • 如果資料的原子操作時間較長,仍應該讓執行緒阻塞,無鎖適合的是短頻快的共享資料修改操作主要用於計數器停止標記、或是阻塞前的有限嘗試

VarHandle

目前無鎖問題實現

​ 目前Java 中的無鎖技術主要體現在以AtomicInteger 為代表的的原子操作類,它的底層使用Unsafe 實現,而Unsafe 的問題在於安全性和可移植性
​ 此外,volatile 主要使用了Store-Load 屏障來控制順序,這個屏障還是太強了,有沒有更輕量級的解決方法呢?

Varhandle快速上手

​ 在Java9 中引入了VarHandle,來提供更細粒度的記憶體屏障,保證共享變數讀寫可見性、有序性、原子性。提供了更好的安全性和可移植性,替代Unsafe 的部分功能

建立

public class TestVarHandle {
   int x;
   static VarHandle X;
   
   static {
       try {
           X = MethodHandles.lookup()
               .findVarHandle(TestVarHandle.class, "x", int.class);
       } catch (NoSuchFieldException | IllegalAccessException e) {
           e.printStackTrace();
       }
   }
}

讀寫

方法名 作用 說明
get 獲取值 與普通變數取值一樣,會重排、有不可見現象
set 設定值
getOpaque 獲取值 對其保護的變數,保證其不重排和可見性,但不使用屏障,不阻礙其它變數
setOpaque 設定值
getAcquire 獲取值 相當於get 之後加LoadLoad + LoadStore
setRelease 設定值 相當於set 之前加LoadStore + StoreStore
getVolatile 獲取值 語義同volatile,相當於獲取之後加LoadLoad + LoadStore
setVolatile 設定值 語義同volatile,相當於設定之前加LoadStore + StoreStore,設定之後加StoreLoad
compareAndSet 原子賦值 原子賦值,成功返回true,失敗返回false

更多安全問題

單個變數讀寫原子性

  • 64 位系統vs 32 位系統
    如果需要保證long 和double 在32 位系統中原子性,需要用volatile 修飾

  • JMM9 之前
    JMM9 32 位系統下double 和long 的問題,double 沒有問題,long 在-server -XX:+UnlockExperimentalVMOptions -XX:-AlwaysAtomicAccesses 才有問題

Object alignment

​ 你或許聽說過物件對齊,它的一個主要目的就是為了單個變數讀寫的原子性,可以使用jol 工具檢視java 物件的記憶體佈局

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.10</version>
</dependency>

測試類

public class TestJol {
   public static void main(String[] args) {
       String layout = ClassLayout.parseClass(Test.class).toPrintable();
       System.out.println(layout);
   }
   public static class Test {
       private byte a;
       private byte b;
       private byte c;
       private long e;
   }
}

開啟物件頭壓縮(預設)輸出

com.itheima.test.TestJol$Test object internals:
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0    12        (object header)                           N/A
12     1   byte Test.a                                    N/A
13     1   byte Test.b                                    N/A
14     1   byte Test.c                                    N/A
15     1        (alignment/padding gap)             
16     8   long Test.e                                    N/A
Instance size: 24 bytes
Space losses: 1 bytes internal + 0 bytes external = 1 bytes total

不開啟物件頭壓縮 -XX:-UseCompressedOops 輸出

com.itheima.test.TestJol$Test object internals:
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0    16        (object header)                           N/A
16     8   long Test.e                                    N/A
24     1   byte Test.a                                    N/A
25     1   byte Test.b                                    N/A
26     1   byte Test.c                                    N/A
27     5        (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 5 bytes external = 5 bytes total

字分裂

前面也看到了,Java 能夠保證單個共享變數讀寫是原子的,類似的陣列元素的讀寫,也會提供這樣的保證

byte[8]
[0][1][2][3]
[0][1][2][3]

如果上述效果不能保證,則稱之為發生了字分裂現象,java 中沒有字分裂,但Java 中某些實現會有類似字分裂現象,例如BitSet、Unsafe 讀寫等

陣列元素讀寫測試

@JCStressTest
@Outcome(id = {"0", "-1"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(expect = Expect.FORBIDDEN, desc = "FORBIDDEN")
@State
public static class Case4 {
   byte[] b = new byte[256];
   int off = ThreadLocalRandom.current().nextInt(256);
   @Actor
   public void actor1() {
       b[off] = (byte) 0xFF;
   }
   @Actor
   public void actor2(I_Result r) {
       r.r1 = b[off];
   }
}

BigSet讀寫測試

@JCStressTest
@Outcome(id = "true, true", expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "INTERESTING")
@State
public static class Case6 {
   BitSet b = new BitSet();
   @Actor
   public void a() {
       b.set(0);
   }
   @Actor
   public void b() {
       b.set(1);
   }
   @Arbiter
   public void c(ZZ_Result r) {
       r.r1 = b.get(0);
       r.r2 = b.get(1);
   }
}

Unsafe 直接操作記憶體

public class TestUnsafe {
   public static final long ARRAY_BASE_OFFSET =
UnsafeHolder.U.arrayBaseOffset(byte[].class);
   static byte[] ss = new byte[8];
   public static void main(String[] args) {
       System.out.println(ARRAY_BASE_OFFSET);
       UnsafeHolder.U.putInt(ss, ARRAY_BASE_OFFSET, 0xFFFFFFFF);
       System.out.println(Arrays.toString(ss));
   }
}

輸出

16
[-1, -1, -1, -1, 0, 0, 0, 0]

來個壓測

@JCStressTest
@Outcome(id = "0", expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(id = "-1", expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "INTERESTING")
@State
public static class Case5 {
   byte[] ss = new byte[256];
   long base = UnsafeHolder.U.arrayBaseOffset(byte[].class);
   long off = base + ThreadLocalRandom.current().nextInt(256 - 4);
   @Actor
   public void writer() {
       UnsafeHolder.U.putInt(ss,  off, 0xFFFF_FFFF);
   }
   @Actor
   public void reader(I_Result r) {
       r.r1 = UnsafeHolder.U.getInt(ss, off);
   }
}

結果:

Observed state   Occurrences              Expectation  Interpretation
-1    25,591,098               ACCEPTABLE  ACCEPTABLE
-16777216           877   ACCEPTABLE_INTERESTING  INTERESTING
-256           923   ACCEPTABLE_INTERESTING  INTERESTING
-65536           925   ACCEPTABLE_INTERESTING  INTERESTING
0     5,093,890               ACCEPTABLE  ACCEPTABLE
16777215         1,673   ACCEPTABLE_INTERESTING  INTERESTING
255         1,758   ACCEPTABLE_INTERESTING  INTERESTING
65535         1,707   ACCEPTABLE_INTERESTING  INTERESTING

安全釋出

構造也不安全

@JCStressTest
@Outcome(id = {"16", "-1"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "INTERESTING")
@State
public static class Case1 {
   Holder f;
   int v = 1;
   @Actor
   public void a1() {
       f = new Holder(v);
   }
   @Actor
   void a2(I_Result r) {
       Holder o = this.f;
       if (o != null) {
           r.r1 = o.x8 + o.x7 + o.x6 + o.x5 + o.x4 + o.x3 + o.x2 + o.x1;
           r.r1 += o.y8 + o.y7 + o.y6 + o.y5 + o.y4 + o.y3 + o.y2 + o.y1;
       } else {
           r.r1 = -1;
       }
   }
   static class Holder {
       int x1, x2, x3, x4;
       int x5, x6, x7, x8;
       int y1, y2, y3, y4;
       int y5, y6, y7, y8;
       
       public Holder(int v) {
           x1 = v;
           x2 = v;
           x3 = v;
           x4 = v;
           x5 = v;
           x6 = v;
           x7 = v;
           x8 = v;
           y1 = v;
           y2 = v;
           y3 = v;
           y4 = v;
           y5 = v;
           y6 = v;
           y7 = v;
           y8 = v;
       }
   }
}

原因分析

比如有個Student類程式碼如下:

public class Student{
    final String name;
    int age;
    
    public Student(name,age){
        this.name =name;
        this.age = age;
    }
}
Student stu為共享變數
stu = new Student("zhangsan",18);

name如果沒有final修飾

t =new Student(name,age)
stu = t
this.name = name
this.age =age

name如果有final修飾,位置任意

t=new Student(name,age)
this.name=name
this.age=age
>----StoreStore----<
stu = t

使用volatile改進

name 有volatile 修飾,注意位置必須在最後

t=new Student(name,age)
this.age=age
this.name=name
>----Store Load----<
stu =t

總結

  1. JMM 是研究的是
  • 多執行緒下Java 程式碼的執行順序,實際程式碼的執行順序與你編寫的程式碼順序不同
  • 共享變數的讀寫操作,在競態條件下,需要考慮共享變數讀寫的原子性、可見性、有序性
  1. 共享變數的問題起因
  • 原子性是由於作業系統的分時機制,執行緒切換所致
  • 有序性和可見性可能來自於編譯器優化、處理器優化、快取優化
  1. JMM 制定了一些規則,理解這些規則,才能寫出正確的執行緒安全程式碼
  • 競態條件會導致程式碼順序被重排
  • 利用synchronized、volatile 一些SA,可以控制執行緒內程式碼的執行順序
  • 執行緒切換時的執行順序與可見性,遵守HB 規則
  • HB 規則還不足夠,需要因果律作為補充
  • 可以通過final 或volatile 實現物件的安全釋出
  1. 從底層理解volatile 與synchronized
  • 記憶體屏障
  • synchronized 是如何解決原子性、可見性、有序性問題的,有哪些優化
  • volatile 是如何解決可見性、有序性問題的,與cas 結合的威力
  • VarHandle 是如何解決可見性、有序性問題的
  1. 更多安全問題
  • 單個變數、陣列元素的讀寫原子性
  • 能夠列舉字分裂的幾個相關例子
  • 構造方法什麼情況下會執行緒不安全,如何改進
  • 徹底掌握DCL 安全單例

相關文章