前言
JMM即java記憶體模型,JMM研究的就是多執行緒下Java程式碼的執行順序,共享變數的讀寫。它定義了Java虛擬機器在計算機記憶體中的工作方式。從抽象角度看,JMM定義了執行緒和主存之間的抽象關係:執行緒之前的共享變數儲存在主記憶體中,每個執行緒有個私有的本地記憶體,本地記憶體中儲存了該執行緒讀寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他硬體和編譯器優化。
先丟擲兩個問題:
- 你寫的程式碼一定是實際執行的程式碼嗎?
- 程式碼的編寫順序,一定是實際執行的順序嗎?
參考文獻:
Java Language Specification Chapter 17. Threads and Locks
JSR-133: JavaTM Memory Model and Thread Specification
書籍:《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
思考為什麼
-
如果讓一個執行緒總是佔用CPU 是不合理的,所有任務排程器會讓執行緒分時使用CPU
-
編譯器以及硬體層面都會做層層優化,提升效能
-
Compiler/JIT 優化
-
Processor 流水線優化
-
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執行synchronized時,jvm呼叫monitorenter時,就會先作業系統申請一個作業系統的Moniter鎖(底層由c++實現的),並把其地址存放在LOCK物件頭中。
- 當執行緒1根據LOCK物件頭找到Moniter鎖,判斷owner是否被佔用,沒有被佔用,就會修改其值,等於持有了鎖。
- 大概執行緒2同樣會執行monitorenter指令,根據LOCK物件頭找到Moniter鎖,判斷owner是否被佔用,發現已經被佔用,首先會自旋嘗試獲取,一定次數沒獲取到,就會進入EntryList佇列等待,並從執行狀態變成阻塞狀態,執行緒3也是如此。
- 當執行緒1執行完畢或出現異常時就會執行monitorexit,釋放owner並喚醒EntryList中的被阻塞執行緒,具體都佇列頭還是佇列尾部去喚醒,這個根據具體演算法實現,這裡不做贅述。
- 假如執行緒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
總結
- JMM 是研究的是
- 多執行緒下Java 程式碼的執行順序,實際程式碼的執行順序與你編寫的程式碼順序不同
- 共享變數的讀寫操作,在競態條件下,需要考慮共享變數讀寫的原子性、可見性、有序性
- 共享變數的問題起因
- 原子性是由於作業系統的分時機制,執行緒切換所致
- 有序性和可見性可能來自於編譯器優化、處理器優化、快取優化
- JMM 制定了一些規則,理解這些規則,才能寫出正確的執行緒安全程式碼
- 競態條件會導致程式碼順序被重排
- 利用synchronized、volatile 一些SA,可以控制執行緒內程式碼的執行順序
- 執行緒切換時的執行順序與可見性,遵守HB 規則
- HB 規則還不足夠,需要因果律作為補充
- 可以通過final 或volatile 實現物件的安全釋出
- 從底層理解volatile 與synchronized
- 記憶體屏障
- synchronized 是如何解決原子性、可見性、有序性問題的,有哪些優化
- volatile 是如何解決可見性、有序性問題的,與cas 結合的威力
- VarHandle 是如何解決可見性、有序性問題的
- 更多安全問題
- 單個變數、陣列元素的讀寫原子性
- 能夠列舉字分裂的幾個相關例子
- 構造方法什麼情況下會執行緒不安全,如何改進
- 徹底掌握DCL 安全單例