在本系列內容中我們會對JUC做一個系統的學習,本片將會介紹JUC的記憶體部分
我們會分為以下幾部分進行介紹:
我們首先來介紹一下Java記憶體模型:
JMM的主要作用如下:
JMM主要體現在三個方面:
這一小節我們來介紹可見性
首先我們根據一段程式碼來體驗什麼是可視性:
// 我們首先設定一個run執行條件設定為true,線上程t執行1s之後,我們在主執行緒修改run為false希望停下t執行緒
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false;
}
// 執行緒t不會如預想的停下來!
我們進行簡單的分析:
我們提供兩種可見性的解決方法:
// 它可以用來修飾成員變數和靜態成員變數
// 他可以避免執行緒從自己的工作快取中查詢變數的值,必須到主記憶體中獲取它的值,執行緒操作 volatile 變數都是直接操作主記憶體
// 我們首先設定一個run執行條件設定為true,線上程t執行1s之後,我們在主執行緒修改run為false希望停下t執行緒
static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false;
}
// 這時程式會停止!
// 我們對執行緒內容進行加鎖處理,synchronized內部會自動封裝對其主記憶體進行查詢
static Object obj = new Object();
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
synchronized(obj){
while(run){
// ....
}
}
});
t.start();
sleep(1);
run = false;
}
// 這時程式會停止!
我們對volatile和synchronized兩種方法進行簡單對比:
我們在這裡介紹一下為什麼synchronized能進行可見性問題解決:
關於volatile的講解我們會在後面單獨列出
我們在這一小節來修改之前講解的兩階段終止模式
我們重新回顧一下兩階段終止模式:
我們給出具體模式圖:
我們首先介紹錯誤的一些方法:
然後我們再來回想一下我們之前所使用的方法:
/*主函數*/
public class Main(){
public static void main(String[] args){
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();
}
}
/*模式函數(採用interrupt以及isInterrupt判斷來決定是否打斷程序)*/
class TPTInterrupt {
private Thread thread;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
if(current.isInterrupted()) {
log.debug("料理後事");
break;
}
try {
Thread.sleep(1000);
log.debug("將結果儲存");
} catch (InterruptedException e) {
//打斷sleep執行緒會清除打斷標記,所以要新增標記
current.interrupt();
}
// 執行監控操作
}
},"監控執行緒");
thread.start();
}
public void stop() {
thread.interrupt();
}
}
/*結果展示*/
11:49:42.915 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:43.919 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:44.919 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [監控執行緒] - 料理後事
但是在我們學習了Volatile方法之後,我們可以修改上述程式碼:
/*主函數*/
public class Main(){
public static void main(String[] args){
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();
}
}
/*修改後的模式函數*/
class TPTVolatile {
private Thread thread;
// 停止標記用 volatile 是為了保證該變數在多個執行緒之間的可見性
private volatile boolean stop = false;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 我們採用stop變數來判斷是否結束程序
if(stop) {
log.debug("料理後事");
break;
}
try {
Thread.sleep(1000);
log.debug("將結果儲存");
} catch (InterruptedException e) {
}
// 執行監控操作
}
},"監控執行緒");
thread.start();
}
public void stop() {
// 呼叫後,修改stop,讓主執行緒停止操作
stop = true;
//讓執行緒立即停止而不是等待sleep結束
thread.interrupt();
}
}
/*結果展示*/
11:54:52.003 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:53.006 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:54.007 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop
11:54:54.502 c.TPTVolatile [監控執行緒] - 料理後事
我們在這一小節來講解新的模式Balking
我們首先來簡單介紹一下模式:
該模式的用途如下:
我們直接給出該模式的模板程式碼:
public class MonitorService {
// 用來表示是否已經有執行緒已經在執行啟動了
private volatile boolean starting;
// 測試模板的方法
public void start() {
log.info("嘗試啟動監控執行緒...");
// 首先我們需要先鎖住內部資訊,防止多執行緒時導致混亂(因為內部存在資料變動,可能無法導致原子性)
synchronized (this) {
// 我們先來判斷是否該方法已執行,若已執行直接返回即可
if (starting) {
return;
}
// 若未執行,實施方法,並將引數設定為true使後續執行緒無法使用
starting = true;
}
//其實synchronized外面還可以再套一層if,或者改為if(!starting),if框後直接return
// 真正啟動監控執行緒...
}
}
我們再給出一套單例建立物件的案例:
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
我們在這一小節來講解新的原理指令級並行
在正式進入原理講解之前我們需要明白幾個概念:
Clock Cycle Time
主頻的概念大家接觸的比較多,而 CPU 的 Clock Cycle Time(時鐘週期時間),等於主頻的倒數,意思是 CPU 能 夠識別的最小時間單位
CPI
有的指令需要更多的時鐘週期時間,所以引出了 CPI (Cycles Per Instruction)指令平均時鐘週期數
IPC
IPC(Instruction Per Clock Cycle) 即 CPI 的倒數,表示每個時鐘週期能夠執行的指令數
CPU 執行時間
程式的 CPU 執行時間,即我們前面提到的 user + system 時間,可以用下面的公式來表示
程式 CPU 執行時間 = 指令數 * CPI * Clock Cycle Time
我們要講的指令級並行實際上就是概念化的流水線操作:
取指令 - 指令譯碼 - 執行指令 - 記憶體存取 - 資料寫回
的處理 器,就可以稱之為五級指令流水線。我們給出流水線操作圖:
我們首先來介紹一下指令重排:
我們給出一個指令重排的例子:
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
其實指令重排優化就是由流水線操作來演變過來的:
取指令 - 指令譯碼 - 執行指令 - 記憶體存取 - 資料寫回
這 5 個階段我們給出一張指令級並排操作的展示圖:
這一小節我們來介紹可見性
我們同樣採用一個問題來引出有序性概念:
/*程式碼展示*/
int num = 0;
boolean ready = false;
// 執行緒1 執行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 執行緒2 執行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
/*結果展示(多次執行)*/
// 我們會發現1,4都是按照正常邏輯執行,但是0原本來說不應該出現
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
2 matching test results.
[OK] test.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 1,729 ACCEPTABLE_INTERESTING !!!!
1 42,617,915 ACCEPTABLE ok
4 5,146,627 ACCEPTABLE ok
[OK] test.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 1,652 ACCEPTABLE_INTERESTING !!!!
1 46,460,657 ACCEPTABLE ok
4 4,571,072 ACCEPTABLE ok
/*結果分析*/
情況1:執行緒1 先執行,這時 ready = false,所以進入 else 分支結果為 1
情況2:執行緒2 先執行 num = 2,但沒來得及執行 ready = true,執行緒1 執行,還是進入 else 分支,結果為1
情況3:執行緒2 執行到 ready = true,執行緒1 執行,這回進入 if 分支,結果為 4(因為 num 已經執行過了)
// 由於指令重排,num = 2;ready = true; 都不會導致該執行緒出現錯誤,所以可能會將 ready = true操作先進行執行!
特殊情況:執行緒2 執行 ready = true,切換到執行緒1,進入 if 分支,相加為 0,再切回執行緒2 執行 num = 2
我們同樣可以採用兩種方法進行解決:
/*程式碼展示*/
public class ConcurrencyTest {
int num = 0;
// 在加上volatile之後,會導致ready寫操作以及寫之前的操作不會發生指令重排
// 在加上volatile之後,會導致ready讀操作以及讀之後的操作不會發生指令重排
volatile boolean ready = false;
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
/*程式碼展示*/
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
public void actor2(I_Result r) {
// synchronized會控制指令順序不發生改變
synchronized(this){
num = 2;
ready = true;
}
}
}
我們將在這一小節徹底解決volatile原理層面的問題
我們首先需要知道volatile是依靠什麼完成操作的:
volatile 的底層實現原理是記憶體屏障,Memory Barrier(Memory Fence)
對 volatile 變數的寫指令後會加入寫屏障
對 volatile 變數的讀指令前會加入讀屏障
首先我們來檢視寫屏障:
// 寫屏障(sfence)保證在該屏障之前的,對共用變數的改動,都同步到主記憶體當中
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 賦值帶寫屏障
// 寫屏障
}
然後我們來檢視讀屏障:
// 而讀屏障(lfence)保證在該屏障之後,對共用變數的讀取,載入的是主記憶體中最新資料
public void actor1(I_Result r) {
// 讀屏障
// ready 是 volatile 讀取值帶讀屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
我們給出一張讀寫屏障的流程圖:
我們同樣先來展示寫屏障:
// 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 賦值帶寫屏障
// 寫屏障
}
我們再來檢視讀屏障:
// 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前
public void actor1(I_Result r) {
// 讀屏障
// ready 是 volatile 讀取值帶讀屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
我們同樣給出一張流程圖:
但是我們需要注意的是:
volatile不能解決指令交錯:
寫屏障僅僅是保證之後的讀能夠讀到最新的結果,但不能保證讀跑到它前面去
而有序性的保證也只是保證了本執行緒內相關程式碼不被重排序
我們針對注意點給出一張解釋圖:
我們來進行一個簡單的問題解析:
// 以著名的 double-checked locking 單例模式為例
public final class Singleton {
private Singleton() { }
// 這裡建立了唯一一個單例物件
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 我們首先對INSTANCE進行檢測
// (這一步是為了保證我們只有在創造物件的那一步需要涉及到鎖,對於後面的獲取方法不要涉及鎖,加快速率)
if(INSTANCE == null) {
// 這一步是為了保證多執行緒同時進入時,防止由於執行緒指令參雜而導致兩次賦值
synchronized(Singleton.class) {
// 我們需要再次進行判斷,因為當t1執行緒執行到鎖中時,可能有t2程序也通過了第一個if判斷,
// 如果不新增這一步,就會導致t2程序進入後直接再次賦值,導致兩次賦值
if (INSTANCE == null) {
// 在不出現任何問題下,我們對唯一物件進行建立
INSTANCE = new Singleton();
}
}
}
// 如果已有物件,我們直接呼叫即可
return INSTANCE;
}
}
以上的實現特點是:
我們檢視上述程式碼,會感覺所有內容都毫無疏漏,但是如果是多執行緒情況下,出現執行緒的指令重排就會導致錯誤產生:
/*原始碼展示*/
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
/*重要程式碼展示*/
- 17 表示建立物件,將物件參照入棧
- 20 表示複製一份物件參照
- 21 表示利用一個物件參照,呼叫構造方法
- 24 表示利用一個物件參照,賦值給 static INSTANCE
/*指令重排問題*/
在正常情況下,我們會按照17,20,21,24的順序執行
但是如果發生指令重排問題,導致21,24交換位置,就會導致先進行賦值,再去建立物件
這時 t1 還未完全將構造方法執行完畢,如果在構造方法中要執行很多初始化操作,那麼 t2 拿到的是將是一個未初始化完畢的單例
如果同時我們的t2執行緒去執行,就會導致直接呼叫那個未初始化完畢的單例,會導致很多功能失效!
我們針對上述重排問題給出一張流程圖:
其實解決方法很簡單:
我們給出具體解決方法:
/*程式碼展示*/
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 範例沒建立,才會進入內部的 synchronized程式碼塊
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也許有其它執行緒已經建立範例,所以再判斷一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
/*位元組碼展示(帶有屏障解釋)*/
// -------------------------------------> 加入對 INSTANCE 變數的讀屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保證原子性、可見性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入對 INSTANCE 變數的寫屏障
27: aload_0
28: monitorexit ------------------------> 保證原子性、可見性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
/*具體解析*/
如上面的註釋內容所示,讀寫 volatile 變數時會加入記憶體屏障(Memory Barrier(Memory Fence)),保證下面 兩點:
- 可見性
- 寫屏障(sfence)保證在該屏障之前的 t1 對共用變數的改動,都同步到主記憶體當中
- 而讀屏障(lfence)保證在該屏障之後 t2 對共用變數的讀取,載入的是主記憶體中最新資料
- 有序性
- 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後
- 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前
- 更底層是讀寫變數時使用 lock 指令來多核 CPU 之間的可見性與有序性
更簡單來說:
- 由於寫屏障的前面不會發生指令重排,我們的21和24順序不會顛倒,我們的賦值一定是已經完成初始化的賦值!
我們來介紹一下happens-before:
我們來進行總結:
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
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();
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
對變數預設值(0,false,null)的寫,對其它執行緒對該變數的讀可見
具有傳遞性,如果 x hb-> y 並且 y hb-> z 那麼有 x hb-> z
此外我們還需要注意幾點:
happens-before主要遵循以下幾點規則:
程式順序規則:一個執行緒中的每一個操作,happens-before於該執行緒中的任意後續操作。
監視器規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
volatile規則:對一個volatile變數的寫,happens-before於任意後續對一個volatile變數的讀。
傳遞性:若果A happens-before B,B happens-before C,那麼A happens-before C。
執行緒啟動規則:Thread物件的start()方法,happens-before於這個執行緒的任意後續操作。
執行緒終止規則:執行緒中的任意操作,happens-before於該執行緒的終止監測。
我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
執行緒中斷操作:對執行緒interrupt()方法的呼叫,happens-before於被中斷執行緒的程式碼檢測到中斷事件的發生
可以通過Thread.interrupted()方法檢測到執行緒是否有中斷髮生。
物件終結規則:一個物件的初始化完成,happens-before於這個物件的finalize()方法的開始。
我們首先補充兩點概念:
我們最後來介紹幾道經典習題
/* 希望 doInit() 方法僅被呼叫一次,下面的實現是否有問題,為什麼? */
public class TestVolatile {
volatile boolean initialized = false;
void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() {
}
}
/*解析*/
存在問題!
沒有對init設定鎖,可能會導致同時有多個執行緒呼叫,導致多次創造
t1進入,判斷未初始化,進行doInit(),t2進入,判斷未初始化,也進行doInit(),然後兩者才進行initialized=true的更改
/* 程式碼展示 */
// 問題1:為什麼加 final
// 問題2:如果實現了序列化介面, 還要做什麼來防止反序列化破壞單例
public final class Singleton implements Serializable {
// 問題3:為什麼設定為私有? 是否能防止反射建立新的範例?
private Singleton() {}
// 問題4:這樣初始化是否能保證單例物件建立時的執行緒安全?
private static final Singleton INSTANCE = new Singleton();
// 問題5:為什麼提供靜態方法而不是直接將 INSTANCE 設定為 public, 說出你知道的理由
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
/* 問題解析*/
1.(防止被子類繼承從而重寫方法改寫單例)
2.(重寫readResolve方法)
3.(防止外部呼叫構造方法建立多個範例;不能)
4.(能,執行緒安全性由類載入器保障)
5.(可以保證instance的安全性,也能方便實現一些附加邏輯)
/* 程式碼展示 */
// 問題1:列舉單例是如何限制範例個數的
// 問題2:列舉單例在建立時是否有並行問題
// 問題3:列舉單例能否被反射破壞單例
// 問題4:列舉單例能否被反序列化破壞單例
// 問題5:列舉單例屬於懶漢式還是餓漢式
// 問題6:列舉單例如果希望加入一些單例建立時的初始化邏輯該如何做
enum Singleton {
INSTANCE;
}
/* 問題解析 */
1.(列舉類會按照宣告的個數在類載入時範例化物件)
2.(沒有,由類載入器保障安全性)
3.(不能)
4.(不能)
5.(餓漢)
6.(寫構造方法)
/* 程式碼展示 */
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析這裡的執行緒安全, 並說明有什麼缺點
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
/*問題解析*/
(沒有執行緒安全問題,同步程式碼塊粒度太大,效能差)
/* 程式碼展示 */
public final class Singleton {
private Singleton() { }
// 問題1:解釋為什麼要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 問題2:對比實現3, 說出這樣做的意義 (縮小了鎖的粒度,提高了效能)
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 問題3:為什麼還要在這裡加為空判斷, 之前不是判斷過了嗎
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
/*問題解析*/
1.(防止putstatic和invokespecial重排導致的異常)
2.(縮小了鎖的粒度,提高了效能)
3.(為了防止同時有執行緒進入,在第一個執行緒建立後,其他執行緒進入鎖後再次建立)
/*程式碼展示*/
public final class Singleton {
private Singleton() { }
// 問題1:屬於懶漢式還是餓漢式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 問題2:在建立時是否有並行問題
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
/*問題解析*/
1.(懶漢式,由於初始化方法是在該物件第一次呼叫時才初始化,同樣是屬於類載入不會導致該單範例物件被建立,而是首次使用該物件時才會建立)
2.(沒有並行問題,該物件的建立是在初始化建立,初始化只有一次,不會多次建立,不會修改,也沒有並行問題,由系統保護)
下面介紹一下本篇文章的重點內容:
到這裡我們JUC的共用模型之管程就結束了,希望能為你帶來幫助~
該文章屬於學習內容,具體參考B站黑馬程式設計師滿老師的JUC完整教學
這裡附上視訊連結:05.001-本章內容_嗶哩嗶哩_bilibili