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

前言

​ 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 安全單例
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章