1、執行緒安全
字數1w多了,老鐵們點個贊吧o( ̄︶ ̄)o
多個執行緒對同一個共享變數進行讀寫操作時可能產生不可預見的結果,這就是執行緒安全問題。
執行緒安全的核心點就是共享變數,只有在共享變數的情況下才會有執行緒安全問題。這裡說的共享變數,是指多個執行緒都能訪問的變數,一般包括成員變數和靜態變數,方法內定義的區域性變數不屬於共享變數的範圍。
執行緒安全問題示例:
import lombok.extern.slf4j.Slf4j;
/**
* @Author FengJian
* @Date 2021/1/27 10:59
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count++;
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count--;
}
}
};
t1.start();
t2.start();
/**
* join方法:使main執行緒與t1、t2執行緒同步執行,即t1、t2執行緒都執行完,main執行緒才會繼續執行(但t1、t2之間依然是並行執行的)
* 主要是為了等待兩個執行緒執行完後,在main執行緒列印count的值
*/
t1.join();
t2.join();
log.debug("count的值為:{}",count);
}
}
執行上述程式碼三次的結果如下:
[main] DEBUG c.ThreadSafeTest - count的值為:-904
[main] DEBUG c.ThreadSafeTest - count的值為:-2206
[main] DEBUG c.ThreadSafeTest - count的值為:73
在上述程式碼中,執行緒t1中count進行5000次自增操作,而執行緒t2中count則進行5000次自減操作。在兩個執行緒都執行結束後,按照預期結果,count的值應為0。但由列印結果可知,count的值並不為0,且每次執行的結果都不一樣。這就是多執行緒對共享變數進行操作出現的不可預見的結果,即常說的執行緒安全問題。
而執行緒安全,則指的是在多執行緒環境下,程式可以始終執行正確的行為,符合預期的邏輯。具體到上述程式碼,就是不論執行多少次,在t1、t2執行緒執行完畢後,count的值都應該始終符合預期的結果0。上述程式碼明顯是執行緒不安全的。
2、出現執行緒安全的原因
執行緒安全是使用多執行緒必定會面臨的問題,導致執行緒不安全的主要原因有以下三點:
①原子性:一個或者多個操作在 CPU 執行的過程中被中斷
②可見性:一個執行緒對共享變數的修改,另外一個執行緒不能立刻看到
③有序性:序執行的順序沒有按照程式碼的先後順序執行
2.1、原子性
2.1.1 什麼是原子性問題
原子性問題,其實說的是原子性操作。即一個或多個操作,應該是一個不可分的整體,這些操作要麼全部執行並且不被打斷,要麼就都不執行。
以上述程式碼中的count的自增(count++
)和自減(count--
)為例。
count++
和count--
看似只有一行程式碼,但實際上這一行程式碼在編譯後的位元組碼指令以及在JVM執行的對應操作如下:
count++:
getstatic count //獲取靜態變數count的值
iconst_1 //準備常量1
iadd //自增
putstatic count //將修改後的值存入靜態變數count
count--:
getstatic count //獲取靜態變數count的值
iconst_1 //準備常量1
isub //自減
putstatic count //將修改後的值存入靜態變數count
由此可知,count自增或自減的操作,並不是一個原子操作,即中間過程是有可能被打斷的。
count自增自減操作需要四個步驟(指令)才能完成,這意味著如果這執行這四個步驟的某一步時,執行緒發生了上下文切換,那麼自增自減操作將被打斷暫停。
如果使用單執行緒來執行自增自減操作,這實際上並無問題:
上圖為單執行緒執行count自增自減的一次過程,可以看出在沒有執行緒上下文切換的情況下,即使自增自減不是原子操作,count的最後結果都會是0。
但在多執行緒環境下,就會出現問題了:
可以看到由於自增自減不是原子操作,因此線上程t1執行自增過程中,如果進行上下文切換,則將導致執行緒t1還沒來得及把count = 1 寫入主存,count的值就被t2執行緒讀取,所以在最後,執行緒t2自減得出的值-1寫入主存後,會被執行緒t1覆蓋,變為1。
這結果明顯是不符合我們的預期的,實際上,上述圖片展示的只是一種可能的結果。還有可能是t2寫入count的步驟是最後執行的,那麼最後count的值將為-1。
這就是由於非原子操作帶來的多執行緒訪問共享變數出現不符合預期的結果,即由於原子性帶來的執行緒安全問題。
上面示例中兩個執行緒t1、t2分別執行count++和count--出現的問題,就是由於原子性帶來的執行緒安全問題。
2.1.2、原子性問題解決辦法
解決辦法就是將count++和count--的操作變為原子操作,Java中的實現方法是:
①上鎖:使用synchronized
只需要建立一個物件作為鎖,並在訪問count時用synchronized進行加鎖即可。
static int count = 0;
static Object lock = new Object(); //鎖物件
synchronized(lock){
count++;
}
synchronized(lock){
count--;
}
上鎖後,執行自增自減的示意圖如下:
由於鎖的存在,則保證了不持有鎖的t2執行緒會被阻塞,直到t1執行緒執行自增完畢,並釋放鎖。在這一過程中,雖然依舊存線上程的上下文切換,但是t2執行緒是無法對共享變數count進行操作的,因此保證了t1執行緒中count++操作的原子性。
因此使用synchronized鎖可以解決原子性帶來的執行緒安全問題。
②、迴圈CAS操作
其基本思路就是迴圈進行CAS操作(compare and swap,比較並交換)。即對共享變數進行計算前,執行緒會先將該共享變數儲存一份舊值a,計算完畢後得出結果值b。在將b從執行緒的本地記憶體重新整理回主記憶體前,會先比較主記憶體中的值是否和a一致。如果一致,則將b重新整理回主記憶體。若不一致,則一直迴圈比較,直到主記憶體中的值與a一致,才把共享變數的值設為b,操作才結束。
在Java中,使用CAS操作保證原子性的具體實現就是Lock和原子類(AtomicInteger)。它們都是通過使用unsafe的compareAndSwap方法實現CAS操作保證原子性的。
Lock的使用:
static int count = 0;
static Lock lock = new Lock (); //鎖物件
lock.lock(); //加鎖
count++;
lock.unlock(); //解鎖
lock.lock(); //加鎖
count--;
lock.unlock(); //解鎖
原子類的使用:
static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); //自增
count.decrementAndGet(); //自減
以上都是Java中可以保證原子操作的具體方法,它們各有優缺點,要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的執行緒安全問題。
2.2、可見性
2.2.1、什麼是可見性問題
可見性實際上指的是記憶體可見性問題。總的來說就是一個執行緒對共享變數的修改,另外一個執行緒不能立刻看到,從而產生的執行緒安全問題。
在上一篇筆記【JAVA併發第三篇】執行緒間通訊 中的通過共享記憶體進行通訊實際上講的就是記憶體可見性問題。這裡再從執行緒安全的角度講述一遍。
我們知道,CPU要從記憶體中讀取出資料來進行計算,但實際上CPU並不總是直接從記憶體中讀取資料。由於CPU和記憶體間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機制,如圖
上圖所示是一個雙核心的CPU系統架構,每個核心都有自己的控制器和運算器,也都有自己的一級快取,還有可能有所有CPU核心共享的二級快取,每個核心都可以獨立執行執行緒。
因此,CPU讀取資料的順序是:暫存器-快取記憶體-主存。主存中的部分資料,會先拷貝一份放到cache中,當CPU計算時,會直接從cache中讀取資料,計算完畢後再將計算結果放置到cache中,最後在主存中重新整理計算結果。所以每個CPU都會擁有一份拷貝。
以上只是CPU訪問記憶體,進行計算的基本方式。實際上,不同的硬體,訪問過程會存在不同程度的差異。比如,不同的計算機,CPU和主存間可能會存在三級快取、四級快取、五級快取等等的情況。
為了遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果,定義了Java的記憶體模型(Java Memory Model,JMM)。
JMM 的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到主存和從主存中取出變數這樣的底層細節。這裡的變數指的是能夠被多個執行緒共享的變數,它包括了例項欄位、靜態欄位和構成陣列物件的元素,方法內的區域性變數和方法的引數為執行緒私有,不受JMM的影響。
Java的記憶體模型如下:
Java記憶體模型中的本地記憶體,對應的就是CPU結構圖中的cache1或者cache2。它實際上並不真實存在,其包含了快取、寫緩衝區、暫存器以及其他的硬體和編譯器的優化。
JMM規定:將所有共享變數放到主記憶體中,當執行緒使用變數時,會把其中的變數複製到自己的本地記憶體,執行緒讀寫時操作的是本地記憶體中的變數副本。一個執行緒不能訪問其他執行緒的本地記憶體。
這樣的情況下,如果有一個變數i線上程A、B的本地記憶體中都有一份副本。此時,若執行緒A想修改i的值,線上程A將修改後的值放入到本地記憶體,但又未重新整理回主記憶體時,如果執行緒B讀取變數i的值,則讀到的是未修改時的值,這就造成了讀寫共享變數出現不可預期的結果,產生執行緒安全問題。
有程式碼如下:
/**
* @Author FengJian
* @Date 2021/2/21 23:47
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafe02 {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread My_Thread = new Thread(new Runnable() {
@Override
public void run() {
while (run) {
}
}
}, "My_Thread");
My_Thread.start(); //啟動My_Thread執行緒
log.debug(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
Thread.sleep(1000); //主執行緒休眠1s
run = false; //改變My_Thread執行緒執行條件
log.debug(Thread.currentThread().getName()+"正在執行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
}
}
從執行結果發現,即使在主執行緒中修改了共享變數run的值,My_Thread執行緒依然在迴圈並不會停止:
其原因就是main執行緒對共享變數run的修改,另外一個執行緒My_Thread並不能立刻看到:
這就是由於記憶體可見性帶來的多執行緒訪問共享變數出現不符合預期的結果,即由於可見性帶來的執行緒安全問題。
2.2.2、可見性問題解決辦法
解決辦法就是保證共享變數的可見性,具體實現就是任何對共享變數的訪問都要從共享記憶體(主記憶體)中獲取。在Java中的實現方法是:
①加鎖,synchronized和Lock都可以保證
執行緒在加鎖時,會清空本地記憶體中共享變數的值,共享變數的使用需要從主記憶體中重新獲取。而在釋放鎖資源時,則必須先把此共享變數同步回主記憶體中。
由於鎖的存在,未持有鎖的執行緒並不能操作共享變數,而當阻塞的執行緒獲得鎖時,主記憶體中共享變數的值已經重新整理過了,因此執行緒修改共享變數對其他執行緒是可見的。這保證了共享變數的可見性,可以解決記憶體可見性產生的執行緒安全問題。
②使用volatile修飾共享變數
當一個變數被宣告為volitale時,執行緒在寫入變數時,不會把值快取本地記憶體,而是會立即把值重新整理回主存,而當要讀取該共享變數時,執行緒則會先清空本地記憶體中的副本值,從主存中重新獲取。這些也都保證了記憶體的可見性。
優先使用volatile關鍵字來解決可見性問題,加鎖消耗的資源更多。
2.3、有序性
2.3.1、什麼是有序性問題
有序性,實際上是指令的重排序問題。
我們知道,CPU的執行速度是比記憶體要快出很多個數量級的。CPU為了執行效率,會把CPU指令進行重新排序。即我們編寫的Java程式碼並不一定按照順序一行一行的往下執行,處理器會根據需要重新排序這些指令,稱為指令並行重排序。
同時,JIT編譯器也會在程式碼編譯的時候對程式碼進行重新整理,最大限度的去優化程式碼的執行效率,稱為編譯器的重排序。
而又由於處理器與主存之間會使用快取和讀/寫緩衝機制,因此從主存載入和儲存操作也有可能是經過指令重排序的,稱為記憶體系統重排序。
綜上所述,在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排序,再加上主記憶體和處理器的快取,Java原始碼經過層層的重排序,最後才得出最終結果。
由圖可知,從Java原始碼到最後的執行指令,會經歷3種重排序的優化。若有ava程式碼如下:
int a = 2; //A
int b = 3; //B
int c = a*b; //C
經過上述3種重排序後,語句A和語句B的執行順序是可能互換的,並且這種互換並不影響程式碼的正確性。但是我們發現語句C則不能和A、B互換,否則得出的結果將不正確,因為他們之間存在著資料依賴關係,即語句C的資料依賴A和B得出。
由此,我們可以發現,以上3種指令的重排序並不能隨意排序,他們需要遵守一定的規則,以保證程式的正確性。
①as-if-serial語義
as-if-serial語義是指:不管怎麼樣重排序,單執行緒程式的執行結果都不能被改變。即不會對存在資料依賴關係的操作進行重排序。
編譯器、處理器進行指令重排序優化時都必須遵守as-if-serial語義。即在單執行緒的情況下,指令重排序只能對不影響處理結果的部分進行重排序。
以上述語句A、B、C為例,存在資料依賴關係的語句C和A或B不能被重排序:
as-if-serial語義把單執行緒程式保護起來了,遵守該語義的編譯器、處理器等使我們編寫單執行緒有一個錯覺:單執行緒程式是按照原始碼的順序來執行的。實際上在由於as-if-serial語義的存在,我們編寫單執行緒時,完全可以認為原始碼是按照順序執行的,因為即使程式碼被進行了重排序,其結果也不會改變,同時單執行緒中也無需擔心記憶體可見性問題。
as-if-serial語義的核心思想是:不會對存在資料依賴關係的操作進行重排序。
那麼資料依賴型別有哪些呢?如下表所示:
型別 | 示例 | 說明 |
---|---|---|
寫後讀 | a = 1; b = a | 寫一個變數後再讀該變數 |
寫後寫 | a = 1; a = 2 | 寫一個變數後再寫該變數 |
讀後寫 | a = b; b = 2 | 讀一個變數後再寫該變數 |
以上三種依賴關係,一旦重排序兩個操作的執行順序,其結果就會改變,所以依照as-if-serial語義,Java在單執行緒的情況下不會對這三種依賴關係進行重排序(多執行緒情況不符合此情況)。
as-if-serial語義是基於資料依賴關係的,但它無法保證多執行緒環境下,重排序之後程式執行結果的正確性。
有程式碼如下:
/**
* @Author FengJian
* @Date 2021/2/24 16:44
* @Version 1.0
*/
@Slf4j(topic = "c.HappensBeforeTest")
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
關於上述程式碼,我們先忽略記憶體可見性的問題(即執行緒t2修改了a和finish,但t1可能看不到的快取問題)。在此前提下如果成功列印a*a的值,那麼結果應該為4。
但實際上a*a列印的結果還可能為0,這是由於指令重排序的存在導致的。
線上程t2中,由於a = 2;
和finish = true;
沒有資料依賴關係,依照as-if-serial語義,可以對這兩條語句進行重排序,因此會出現finish = true;
的指令比a = 2;
先執行的情況。
如果在先執行finish = true;
,而a = 2;
沒有執行時發生執行緒上下文切換,輪到執行緒t1執行,那麼t1執行緒中的if語句條件為真,而a的值依然為初始值0,則a*a的結果為0。
可以看出,即使在假設沒有記憶體可見性問題的前提下,上述程式碼的結果也是不可預期的,因此上述程式碼也是執行緒不安全的,其原因就是重排序破壞了多執行緒程式的語義。
②happens-before規則
既然是重排序出現問題,那麼解決思路就是禁止重排序。但是也要注意不能全部禁用重排序,重排序的目的是為了提升執行效率,如果全部禁用那麼Java程式的效能將會很差。所以,應該做到的是部分禁用,Java的記憶體模型提供了一個可用於多執行緒環境,也適用於單執行緒環境的規則:happens-before規則。
happens-before規則的定義如下:A happens-before B,那麼操作A的執行結果對操作B是可見的,且操作A的執行順序排在操作B之前。這裡的操作A和操作B可以在同一個執行緒中,也可以在不同執行緒中。
注意:執行順序只是happens-before向開發人員做的保證,實際上在處理器和編譯器上執行時並不一定按照操作A排在操作B之前執行。
如果重排序之後,依然可以保證與先A後B的執行結果一樣,那麼進行重排序也是可以的。也就是說,符合happens-before的操作,只要不改變執行結果,處理器和編譯器怎麼優化(重排序)都行。
只是我們開發人員可以直接認為操作A的執行順序排在操作B之前。
happens-before保證操作A的執行結果對B可見,依靠這個原則,可以解決多執行緒環境下記憶體可見性和有序性問題。
回到程式碼:
/**執行緒t1**/
if(finish){
a*a;
}
/**執行緒t2**/
a = 2;
finish = true;
一共有四個操作a = 2;
、finish = true;
、if(finish)
、a*a;
,想要上述程式碼達到執行緒安全(即列印都正確輸出4),只需要:
即在t2執行緒計算a*a;
和if(finish);
之前,需要知道t1執行緒中a = 2;
和finish = true;
(t2執行緒對t1執行緒的結果可見)。
要達到這一目的,就需要上圖中,①和②所示的happens-before關係。
那要如何達到呢?這就需要了解happens-before的六大具體規則了(兩個操作,只需要符合其中任何一條就可以認為是happens-before關係):
- ①程式順序規則:一個執行緒中的每個操作,按照程式順序,前面的操作 happens-before 於該執行緒中的任意後續操作。
以上述程式碼為例:
/**執行緒t2**/
a = 2; //操作1
finish = true; //操作2
/**執行緒t1**/
if(finish ); //操作3
a*a; //操作4
操作1 happens-before 操作2
操作3 happens-before 操作4
- ②監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
synchronized (lock) { //加鎖
// x是共享變數,初始值=10
if (x < 12) {
x = 12;
}
} //解鎖
若有兩個執行緒A、B,先後執行這段程式碼。則執行緒A執行完畢後X = 12並釋放鎖。而執行緒B獲得鎖後,進入程式碼塊,在if中取X值判斷是否小於12。
此時 執行緒A中X=12的操作 happens-before 執行緒B中取X值判斷的操作(即執行緒B能看到執行緒A中執行的X=12的結果)
- ③volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
volatile int x = 10;
/**執行緒t1**/
x = 11; //操作1
/**執行緒t2**/
int y = x; //操作2
操作1 happens-before 操作2
-
④傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
-
⑤start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。
-
⑥join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
以上就是happens-before的六大常用規則(全部有八種,但後面兩種應該很少用到)
2.3.2、有序性問題解決辦法
解決有序性問題,實際上就是要運用以上提到的兩種規則,as-if-serial語義解決了單執行緒程式的有序性問題,而happens-before關係則能解決多執行緒程式的有序性問題。
再回顧一下原始程式碼,這是一段存在有序性問題執行緒不安全的程式碼,我們要利用happens-before關係解決有序性問題:
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
提取一下關鍵的操作,如下嗷:
/**執行緒t1**/
if(finish){
a*a;
}
/**執行緒t2**/
a = 2;
finish = true;
我們的目標是運用happens-before的六大常用規則達到如下圖的happens-before關係,以實現上訴程式碼的執行緒安全
解決辦法如下:
①、方法一:運用volatile修飾變數
使用到happens-before規則中的程式順序規則、volatile變數規則和傳遞性。
首先,按照程式順序規則,可以知道如下的happens-before關係:
執行緒t1 | 執行緒t2 |
---|---|
if(finish) happens-before a*a; | a = 2; happens-before finish = true; |
這由執行緒中的程式碼很容易就能得出。接下來運用volatile變數規則,需要用volatile修飾一個變數,我們選變數finish
。即初始化時程式碼改為為volatile static boolean finish = false;
。
那麼根據volatile變數規則,可知對finish
的寫要happens-before於對finish
的讀。
因此給finish
加上volatile關鍵字後,就可以達到如下效果:
volatile關鍵字不僅可以保證記憶體可見性問題,同時依照happens-before的volatile變數規則,對於volatile修飾的變數,要保證對該變數寫的結果要對讀的操作可見,因此volatile禁止對有讀寫操作的volatile修飾的變數進行重排序。
也就是說,volatile關鍵字不僅可以解決可見性問題,還可以解決有序性問題。
最後,通過傳遞性。可知:
可知,圖示的三和五,就是我們的目標。到此,我們利用happens-before關係保證了程式碼的可見性和有序性問題。
雖然分析的過程比較長,但是在原始碼中,我們實際上只改動了一行程式碼。即將static boolean finish = false;
改為volatile static boolean finish = false;
而已,就可以使我們的程式碼改變執行緒安全的。
這就是運用volatile修飾變數來解決執行緒安全的辦法。volatile直接通過禁止相關的重排序來達到有序性的目的。
②、方法二:加鎖,synchronized
這個應該比較容易理解,對相關程式碼加鎖後,同一時刻就只有一個執行緒在執行,也就相當於對相關變數的操作,是保證有序的。
不過synchronized並不像volatile一樣禁止指令重排序,實際上synchronized塊內部的程式碼指令依然是可以進行重排序優化的。
3、小結
- 多個執行緒對同一個共享變數進行讀寫操作時就可能產生不可預見的結果,就是執行緒安全問題。其重點是多執行緒對共享變數進行讀和寫,如果只有讀,並不會有執行緒安全問題。
- 執行緒安全的原因有:①執行緒切換帶來的原子性問題②快取帶來的可見性問題③指令重排序帶來的原子性問題。
- 執行緒安全的解決辦法:①對於原子性問題,使用鎖synchronized和Lock、或者使用原子類(AtomicInteger等)②對於可見性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字③對於有序性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字
點個贊吧彥祖,(◕ᴗ◕✿)
由於能力有限,可能存在錯誤,感謝並懇請老鐵們指出。以上內容為本人在學習過程中所做的筆記。參考的書籍、文章或部落格如下:
[1]方騰飛,魏鵬,程曉明. Java併發程式設計的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java併發程式設計之美[M].電子工業出版社.
[3]mg驛站. 多執行緒篇-執行緒安全-原子性、可見性、有序性解析.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java併發的原子性、可見性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程式設計師七哥.happens-before是什麼?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344