本博客系列是學習並發編程過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。
前言
之前的文章中講到,JMM是內存模型規范在Java語言中的體現。JMM保證了在多核CPU多線程編程環境下,對共享變量讀寫的原子性、可見性和有序性。
本文就具體來講講JMM是如何保證共享變量訪問的可見性的。
什么是可見性問題
我們從一段簡單的代碼來看看到底什么是可見性問題。
public class VolatileDemo {
boolean started = false;
public void startSystem(){
System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
started = true;
System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
}
public void checkStartes(){
if (started){
System.out.println("system is running, time:"+System.currentTimeMillis());
}else {
System.out.println("system is not running, time:"+System.currentTimeMillis());
}
}
public static void main(String[] args) {
VolatileDemo demo = new VolatileDemo();
Thread startThread = new Thread(new Runnable() {
@Override
public void run() {
demo.startSystem();
}
});
startThread.setName("start-Thread");
Thread checkThread = new Thread(new Runnable() {
@Override
public void run() {
while (true){
demo.checkStartes();
}
}
});
checkThread.setName("check-Thread");
startThread.start();
checkThread.start();
}
}
上面的列子中,一個線程來改變started
的狀態,另外一個線程不停地來檢測started
的狀態,如果是true就輸出系統啟動,如果是false就輸出系統未啟動。那么當start-Thread
線程將狀態改成true后,check-Thread
線程在執行時是否能立即“看到”這個變化呢?答案是不一定能立即看到。這邊我做了很多測試,大多數情況下是能“感知”到started這個變量的變化的。但是偶爾會存在感知不到的情況。請看下下面日志記錄:
start-Thread begin to start system, time:1577079553515
start-Thread success to start system, time:1577079553516
system is not running, time:1577079553516 ==>此處start-Thread線程已經將狀態設置成true,但是check-Thread線程還是沒檢測到
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
上面的現象可能會讓人比較困惑,為什么有時候check-Thread
線程能感知到狀態的變化,有時候又感知不到變化呢?這個現象就是在多核CPU多線程編程環境下會出現的可見性問題。
Java內存模型規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存,線程在工作內存中保存的值是主內存中值的副本,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存。等到線程對變量操作完畢之后會將變量的最新值刷新回到主內存。
但是何時刷新這個最新值又是隨機的。所以就有可能一個線程已經將一個共享變量更新了,但是還沒刷新回主內存,那么這時其他對這個變量進行讀寫的線程就看不到這個最新值。(還有一種可能就是雖然修改線程已經將最新值刷新到主內存中去了,但是讀線程的工作內存中副本的緩存值還沒過期,那么讀線程還是會使用這個副本值,而不是主內存中的最新值)這個就是多CPU多線程編程環境下的可見性問題。也是上面代碼會出現問題的原因。
JMM對可見性問題的保證
在多CPU多線程編程環境下,對共享變量的讀寫會出現可見性問題。但是幸好JMM提供了相應的技術手段來幫我們規避這些問題,可以讓程序正確運行。JMM針對可見性問題,主要提供了如下手段:
- volatile關鍵字
- synchronized關鍵字
- Lock鎖
- CAS操作(原子操作類)
volatile關鍵字
使用volatile關鍵字修飾一個變量可以保證變量的可見性。所以對於上面的代碼,我們只需要簡單的修改下代碼就可以讓程序正確運行了。
private volatile boolean started = false;
使用volatile修飾一個共享變量可以達到如下的效果:
- 一旦線程對這個共享變量的副本做了修改,會立馬刷新最新值到主內存中去;
- 一旦線程對這個共享變量的副本做了修改,其他線程中對這個共享變量拷貝的副本值會失效,其他線程如果需要對這個共享變量進行讀寫,必須重新從主內存中加載。
那么volatile具體是怎么達到上面兩個效果的呢?其實volatile底層使用的是內存屏障來保證可見性的。
內存屏障(英語:Memory barrier),也稱內存柵欄,內存柵障,屏障指令等,是一類同步屏障指令,是CPU或編譯器在對內存隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行后才可以開始執行此點之后的操作。大多數現代計算機為了提高性能而采取亂序執行,這使得內存屏障成為必須。
語義上,內存屏障之前的所有寫操作都要寫入內存;內存屏障之后的讀操作都可以獲得同步屏障之前的寫操作的結果。因此,對於敏感的程序塊,寫操作之后、讀操作之前可以插入內存屏障。
對內存屏障做下簡單的總結:
- 內存屏障是一個指令級別的同步點;
- 內存屏障之前的寫操作都必須立馬刷新回主內存;
- 內存屏障之后的讀操作都必須從主內存中讀取最新值;
- 在有內存屏障的地方,會禁止指令重排序,即屏障下面的代碼不能跟屏障上面的代碼交換執行順序,即在執行到內存屏障這句指令時,在它前面的操作已經全部完成。
synchronized關鍵字
使用synchronized代碼塊或者synchronized方法也可以保證共享變量的可見性。只要如下修改上面的代碼,我們就能得到正確的執行結果。
public synchronized void startSystem(){
System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
value = 2;
started = true;
System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
}
public synchronized void checkStartes(){
if (started){
System.out.println("system is running, time:"+System.currentTimeMillis());
}else {
System.out.println("system is not running, time:"+System.currentTimeMillis());
}
}
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。我們發現鎖具有和volatile一致的內存語義,所以使用synchronized也可以實現共享變量的可見性。
Lock接口
使用Lock相關的實現類也可以保證共享變量的可見性。其實現原理和synchronized的實現原理類似,這邊也就不再贅述了。
CAS機制(Atomic類)
使用原子操作類也可以保證共享變量操作的可見性。所以我們只要如下修稿上面的代碼就行了。
private AtomicBoolean started = new AtomicBoolean(false);
原子操作類底層使用的是CAS機制。Java中CAS機制每次都會從主內存中獲取最新值進行compare,比較一致之后才會將新值set到主內存中去。而且這個整個操作是一個原子操作。所以CAS操作每次拿到的都是主內存中的最新值,每次set的值也會立即寫到主內存中。