Java內存模型:JMM(Java Memory Model),定義了一套在多線程環境下,讀寫共享數據(成員變量、數組)時,對數據的可見性,有序性和原子性的規則和保障。
原子性
問題分析
【問題
】:兩個線程對初始值為0的靜態變量操作,一個線程做自增,一個線程做自減,各做50000次,結果是0嗎?
public class Demo01 {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和t2兩個子線程執行完畢后,再執行后續代碼t1.join();t2.join();System.out.println(i);}
}
【
結果
】:上邊代碼輸出,每次運行的結果不一樣。
【原因
】:Java中對靜態變量的自增自減并不是原子操作,對于i++而言:
getstatic i // 獲取靜態變量i的值
iconst_1 // 準備常量1
iadd // 加法
putstatic i // 將修改后的值存入靜態變量i中
Java的內存模型如下,如果需要完成靜態變量的自增、自減,需要在主內存和工作線程的內存中進行交換數據。
由于當線程是按順序執行,所以并不會出現問題。
但是在多線程下,可能出現交錯運行。線程是一個搶占式的,大家都是輪流使用CPU
解決方法
synchronized(對象) {要作為原子操作的代碼
}
修正后:
public class Demo02 {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);}
}
這樣++i和–i的四條指令都可以作為一個整體來運行。
并且:t1和t2必須鎖的是同一個obj對象(相當于這兩個人進入了兩個不同的房間)
Monitor(監視器)
Monitor 是一種線程同步機制,可以理解為 對象鎖 的內部實現。每個 Java 對象(Object
)在 JVM 內部都有一個關聯的 Monitor,用于實現 synchronized
同步機制。
Monitor 的組成部分
(1) Owner(持有者)
- 作用:表示當前持有 Monitor 的線程。
- 特點:
- 當線程進入
synchronized
代碼塊時,會嘗試獲取 Monitor 的owner
權限。 - 如果
owner
為null
(即沒有線程持有鎖),當前線程會成為owner
。 - 如果
owner
已經被其他線程持有,當前線程會進入entryList
等待。
- 當線程進入
(2) EntryList(入口隊列)
- 作用:存儲 競爭鎖的線程(即等待獲取鎖的線程)。
- 特點:
- 當線程 A 持有鎖時,線程 B 嘗試進入
synchronized
代碼塊,會進入entryList
并進入 BLOCKED 狀態。 - 當線程 A 釋放鎖(退出
synchronized
代碼塊),JVM 會從entryList
中喚醒一個線程,使其競爭鎖。
- 當線程 A 持有鎖時,線程 B 嘗試進入
(3) WaitSet(等待隊列)
- 作用:存儲 調用了
wait()
的線程(即主動放棄鎖的線程)。 - 特點:
- 當線程 A 調用
wait()
時,它會釋放鎖,并進入waitSet
,狀態變為 WAITING。 - 當其他線程調用
notify()
或notifyAll()
時,JVM 會從waitSet
中隨機喚醒一個(或全部)線程,使其重新競爭鎖。
- 當線程 A 調用
3. Monitor 的工作流程
synchronized (obj) { // 1. 嘗試獲取 Monitor 的 ownerwhile (!condition) {obj.wait(); // 2. 釋放鎖,進入 waitSet}// 3. 執行同步代碼
}
obj.notify(); // 4. 喚醒 waitSet 中的線程
- 線程 A 進入
synchronized
代碼塊:- 檢查
owner
,如果為空,線程 A 成為owner
。 - 如果
owner
已被線程 B 持有,線程 A 進入entryList
(BLOCKED 狀態)。
- 檢查
- 線程 A 調用
wait()
:- 釋放
owner
,線程 A 進入waitSet
(WAITING 狀態)。 - JVM 從
entryList
中喚醒一個線程(如線程 B),使其成為新的owner
。
- 釋放
- 線程 B 調用
notify()
:- 從
waitSet
中隨機喚醒一個線程(如線程 A),使其重新進入entryList
(BLOCKED 狀態)。 - 線程 A 需要重新競爭鎖(不會立即獲得鎖)。
- 從
- 線程 B 退出
synchronized
代碼塊:- 釋放
owner
,JVM 從entryList
中選擇一個線程(如線程 A),使其成為新的owner
。
- 釋放
可見性
問題分析
【問題
】:main線程對于run變量的修改對t線程是不可見的,這就導致了t線程無法停止:
public class Demo03 {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線程剛開始從main線程的內存中讀取了run的值到工作線程
- 因為t線程要頻繁的從主內存中讀取run的值,JIT編譯器會將run的值緩存到自己的工作內存中的高速緩沖區中,這樣就可以減少對主內存的讀取。
- 主線程睡眠1s后,main線程修改了run的值,并同步到貯存,而t線程仍然是從自己工作內存中的高速緩存中讀取這個變量的值,結果永遠是舊值。
解決辦法
volatile(易變關鍵字):用來修飾成員變量和靜態成員變量,可以避免線程從自己的工作緩存中查找變量的值
,必須到主存中獲取它的值,線程操作volatile變量都是直接操作主內存。
public class Demo03 {static volatile boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {// ...}});t.start();Thread.sleep(1000);run = false; // 不會停下來}
}
- volatile:保證的是在多個線程之間,一個線程對volatile變量的修改對另一個線程可見,但是不能保證原子性,只能用在
一個寫線程,多個讀線程
的情況。 - synchronized:既可以保證原子性,也能保證代碼塊變量的可見性。但是缺點是:synchronized屬于重量級操作,性能相對更低。
【
補充
】:在上邊的代碼中,如果不加volatile,但是在for循環里加System.out.println(),t線程也能正常看到對run變量的修改。
【原因
】:System.out.println()底層使用syncronized關鍵字,強制要求當前的線程不要從高速緩存中獲取,從主線程中獲取。
有序性
問題分析——指令重排序
public class Demo04 {static int num = 0;static boolean ready = false;// 線程1:執行此方法public static void actor1(I_Result r) {if(ready) {r.r1 = num + num;}else {r.r1 = 1;}}// 線程2:執行此方法public static void actor2(I_Result r) {num = 2;ready = true;}public static void main(String[] args) throws InterruptedException {I_Result r = new I_Result();Thread t1 = new Thread(() -> {actor1(r);});Thread t2 = new Thread(() -> {actor2(r);});t1.start();t2.start();t1.join();t2.join();System.out.println(r.r1);}
}class I_Result {int r1;
}
上邊的代碼執行一共可能會有三種不同的輸出:
- 正常執行:t2先執行完,t1后執行 ==> 輸出4
- t1先執行完,t2后執行 ==> 輸出1
- 指令重排序(導致ready先變成true,num還未賦值)
- t2先執行ready = true
- t1執行(此時num還未被t2修改):r.r1 = num + num = 0
- t2再執行num = 2(但是t1已經計算完畢,不會影響結果)
解決方法
如果要保證線程安全,可以:
- 使用
volatile
修飾ready
和num
,禁止指令重排序,并保證可見性:
static volatile int num = 0;static volatile boolean ready = false;
- 這樣
t2
的num = 2
和ready = true
不會重排序,且t1
能立即看到修改。 - 可能的輸出:
1
或4
(不會出現0
)。
- 使用
synchronized
加鎖,確保原子性:
public static synchronized void actor1(I_Result r) { ... }public static synchronized void actor2(I_Result r) { ... }
- 這樣
t1
和t2
不會同時執行,輸出一定是1
或4
。
有序性理解
static int i, j;
// 在某個線程內執行:
i = ...; // 較為耗時的操作
j = ...;
由于這段代碼先執行i還是先執行j對結果并不會有影響,所以上面代碼的執行可以是先對i賦值,再對j賦值
;也可以是先對j賦值,再對i賦值
。
案例:雙重檢查鎖
public 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中并不是原子操作,它分為三個步驟:
- 分配內存空間(malloc)
- 初始化對象(調用構造方法Sington())
- 將INSTANCE指向分配的內存地址(賦值)
但是JVM可能會對指令重排序(優化執行順序),變成:
- 分配內存空間
- 將INSTANCE指向分配的內存地址(此時INSTANCE != null,但是對象未初始化)
- 初始化對象(調用構造方法)
如果發生這種重排序,可能導致:
- 線程 A 執行 INSTANCE = new Singleton();,但只完成了 步驟 1 和 2(INSTANCE 已不為 null,但對象未初始化)。
- 線程 B 調用 getInstance(),發現 INSTANCE != null,直接返回 未初始化完成的對象,導致錯誤!
解決辦法
使用volatile禁止指令重排序
private static volatile Singleton INSTANCE = null;
happens-before
是JMM的核心規則,定義了 多線程環境下操作的可見性和順序性,確保一個線程對共享變量的修改能被其他線程正確觀察到。
Java 內存模型定義了 6 種 Happens-Before 規則:程序順序、鎖、volatile、線程啟動、線程終止、傳遞性
(1) 程序順序規則(Program Order Rule)
在同一個線程中,前面的操作 Happens-Before 后面的操作。
int x = 1; // (1)
int y = x + 1; // (2) —— (1) Happens-Before (2)
- 單線程下,代碼順序執行,
(1)
的結果對(2)
可見。
(2) 鎖規則(Monitor Lock Rule)
解鎖操作 Happens-Before 后續的加鎖操作。
synchronized (lock) {x = 10; // (1)
} // 解鎖 (1) Happens-Before 后續的加鎖
synchronized (lock) {int y = x; // (2) —— 能讀到 x = 10
}
- 線程 A 解鎖后,線程 B 加鎖時能看到 A 的修改。
(3) volatile 變量規則(Volatile Variable Rule)
volatile 變量的寫操作 Happens-Before 后續的讀操作。
volatile boolean flag = false;// 線程 A
flag = true; // (1) —— 寫操作// 線程 B
if (flag) { // (2) —— (1) Happens-Before (2),能讀到 flag = true// do something
}
volatile
保證可見性,寫操作后,讀操作一定能看到最新值。
(4) 線程啟動規則(Thread Start Rule)
線程的
start()
方法 Happens-Before 該線程的所有操作。
int x = 0;Thread t = new Thread(() -> {System.out.println(x); // (2) —— 能讀到 x = 1
});
x = 1; // (1)
t.start(); // (1) Happens-Before (2)
- 主線程修改
x = 1
后,子線程能讀到這個值。
(5) 線程終止規則(Thread Termination Rule)
線程的所有操作 Happens-Before 它的終止檢測(如
join()
)。
int x = 0;Thread t = new Thread(() -> {x = 1; // (1)
});
t.start();
t.join(); // (2) —— (1) Happens-Before (2)
System.out.println(x); // 輸出 1
- 子線程修改
x = 1
后,主線程join()
后能讀到最新值。
(6) 傳遞性規則(Transitivity Rule)
**如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
int x = 0;
volatile boolean flag = false;// 線程 A
x = 1; // (1)
flag = true; // (2) —— (1) Happens-Before (2)// 線程 B
if (flag) { // (3) —— (2) Happens-Before (3)System.out.println(x); // 輸出 1 —— (1) Happens-Before (3)
}
- 由于
(1) → (2) → (3)
,所以(1)
對(3)
可見。
CAS與原子類
CAS
CAS:Compare and Swap,是一種樂觀鎖的思想。
【案例
】多個線程要對一個共享變量的整型變量執行 + 1操作:
// 需要不斷嘗試
while(true) {int 舊值 = 共享變量; // 舊值 = 0int 結果 = 舊值 + 1; // 結果 = 0 + 1 = 1/*這時候如果別的線程把共享變量改成了5,本線程的正確結果1就作廢了,此時:compareAdnSwap:返回false,重新嘗試,直到:compareAndSwap:返回true,表示本線程做修改的同時,其他線程沒有干擾*/if(compareAndSwap(舊值, 結果)) {// 成功,退出循環}
}
【
注意
】:
共享變量一定要用volatile修飾,保證共享變量的可見性,當前線程拿到的共享變量必須一定要是新值。(結合CAS和volatile就可以實現無鎖并發了,適用于競爭不激烈、多核CPU的場景)
- 如果競爭激烈,線程重試會頻繁發生,效率會受到影響
- 因為沒有使用synchronized,線程并不會陷入阻塞,這也是效率提升的因素
CAS底層依賴于Unsafe類來直接調用操作系統底層的CAS指令
樂觀鎖與悲觀鎖
CAS:最樂觀的估計,不怕別的線程來修改共享變量,如果改了就重試即可。
synchronized:最悲觀的估計,得防著其他線程來修改共享變量,直接給代碼上鎖,等執行完解開鎖了,其他線程才有機會執行。
原子操作類
juc(java.util.concurrent)包下提供了原子操作類,可以提供線程安全的操作,例如:AtomicInteger、AtomicBoolean…,他們的底層就是使用CAS + volatile
來實現的。
public class Demo05 {static AtomicInteger i = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndIncrement();}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndDecrement();}});t1.start();t2.start();// 讓主線程等待t1和t2兩個子線程執行完畢后,再執行后續代碼t1.join();t2.join();System.out.println(i);}
}
synchronized優化
JVM中,每個對象都有對象頭(包括class指針、Mark Word)。Mark Word平時存儲這個對象的哈希碼、分代年齡…,當加鎖時,這些信息就會被替換成標記位、線程鎖記錄指針、重量級指針、線程id…
輕量級鎖
如果一個對象雖然有多個線程訪問,但多線程訪問的時間是錯開的(沒有競爭),那么可以用輕量級鎖來優化。
這就類似于:學生A(線程A)用課本占座,短暫的離開教室了一下(時間片到)
- 回來發現課本沒變(沒有競爭),就會繼續上課(仍然保持輕量級鎖)
- 如果期間又來了一個學生B(線程B),就會告知學生A(線程A)此時有并發訪問,線程A就會升級成重量級鎖,進入重量級鎖的流程。
每個線程的棧幀都會包含一個鎖記錄的結構,內部可以存儲鎖定對象的Mark Word。
static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步塊Amethod2();}
}
public static void method2() {synchronized(obj) {// 同步塊B}
}
線程1 | 對象Mark Word | 線程2 |
---|---|---|
訪問同步塊A,把MarkWord賦值到線程1的鎖記錄 | 01(無鎖) | - |
CAS修改MarkWord為線程1鎖記錄 | 01(無鎖) | - |
成功(加鎖) | 00(輕量級鎖)線程1鎖記錄地址 | - |
執行同步塊A | 00(輕量級鎖)線程1鎖記錄地址 | - |
訪問同步塊B,把MarkWord賦值到線程1的鎖記錄 | 00(輕量級鎖)線程1鎖記錄地址 | - |
CAS修改MarkWord為線程1鎖記錄 | 00(輕量級鎖)線程1鎖記錄地址 | - |
失敗(發現是自己的鎖) | 00(輕量級鎖)線程1鎖記錄地址 | - |
鎖重入 | 00(輕量級鎖)線程1鎖記錄地址 | - |
執行同步塊B | 00(輕量級鎖)線程1鎖記錄地址 | - |
同步塊B執行完畢 | 00(輕量級鎖)線程1鎖記錄地址 | - |
同步塊A執行完畢 | 00(輕量級鎖)線程1鎖記錄地址 | - |
成功(解鎖) | 01(無鎖) | - |
- | 01(無鎖) | 訪問同步塊A,把MarkWord賦值到線程2的鎖記錄 |
- | 01(無鎖) | CAS修改MarkWord為線程2鎖記錄 |
- | 00(輕量級鎖)線程1鎖記錄地址 | 成功(加鎖) |
… | … | … |
鎖膨脹
在嘗試加輕量級鎖的過程中,CAS操作無法成功,這時如果其他線程為這個對象加上輕量級鎖(有競爭),這時就需要進行鎖膨脹,將輕量級鎖變為重量級鎖
static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步塊}
}
線程1 | 對象Mark Word | 線程2 |
---|---|---|
訪問同步塊,把MarkWord賦值到線程1的鎖記錄 | 01(無鎖) | - |
CAS修改MarkWord為線程1鎖記錄 | 01(無鎖) | - |
成功(加鎖) | 00(輕量級鎖)線程1鎖記錄地址 | - |
執行同步塊 | 00(輕量級鎖)線程1鎖記錄地址 | - |
執行同步塊 | 00(輕量級鎖)線程1鎖記錄地址 | 訪問同步塊,把MarkWord賦值到線程2 |
執行同步塊 | 00(輕量級鎖)線程1鎖記錄地址 | CAS修改MarkWord為線程2鎖記錄 |
執行同步塊 | 00(輕量級鎖)線程1鎖記錄地址 | 失敗(發現別人已經占了鎖) |
執行同步塊 | 00(輕量級鎖)線程1鎖記錄地址 | CAS修改Mark為重量級鎖 |
執行同步塊 | 10(重量級鎖)重量鎖指針 | 阻塞中 |
執行完畢 | 10(重量級鎖)重量鎖指針 | 阻塞中 |
失敗(解鎖) | 10(重量級鎖)重量鎖指針 | 阻塞中 |
釋放重量鎖,喚起阻塞線程競爭 | 10(重量級鎖)重量鎖指針 | 阻塞中 |
- | 10(重量級鎖)重量鎖指針 | 競爭重量鎖 |
- | 10(重量級鎖)重量鎖指針 | 成功(加鎖) |
… | … | … |
加重量級鎖是為了后邊喚醒的時候,根據重量級鎖的指針喚醒阻塞中的線程。
重量級鎖
重量級鎖競爭時,可以使用自旋來進行優化,如果當時線程自旋成功(說明此時持有鎖的線程已經退出同步代碼塊,釋放鎖),此時當前線程就可以避免阻塞,直接進入運行狀態。
自旋鎖是自適應的
- 對象剛剛的一次自選操作成功了,那么認為這次自旋成功的可能性會高,就會多自旋幾次;
- 反之,就少自旋 或 不自旋
注意,自旋會占用CPU時間,只有多核的CPU才能發揮自旋的優勢。
偏向鎖
只有第一次使用CAS將線程ID設置到對象的Mark Word投,之后發現這個線程ID是自己的,就表示沒有競爭,不用重新CAS。
其他優化
- 較少上鎖時間:同步代碼塊中盡量短
- 減少鎖的粒度:將一個鎖拆分成多個鎖提高并發度
- ConcurrentHashMap:每次只鎖住了一個部分,其他讀取操作不會受到影響。
- LongAdder:累加工具類,分為base和cells兩部分
- 沒有并發爭用或cells數組正在初始化時,就會使用CAS來累加到base
- 有并發爭用,就會初始化cells數組,數組有多少個cell,就允許多少線程并行修改,最后將數組中每個cell累加,再加上base就是最終的值
- LinkedBlockingQueue:出隊和入隊使用的就是不同的鎖,相對于LinkedBlockingArray只有一個鎖效率要高
- 鎖粗化:StringBuffer的append方法都會調用synchronized來進行同步保護,如果不加以限制,那么下邊這段代碼會重復調用三次synchronized。JVM會將多次的append的加鎖操作粗化為一次(因為都是一個對象加鎖,沒必要重入多次)
new StringBuffer().append("a").append("b").append("c");
- 鎖消除:JVM會進行代碼的逃逸分析,例如某個加鎖對象是方法內局部變量,不會被其他線程訪問到,這時就會被即時編譯器忽略掉所有同步操作。
- 讀寫分離:CopyOnWriteArrayList、CopyOnWriteSet(讀原始數組的內容;寫操作會復制一份,在新數組上進行寫操作)