問題
(1)volatile是如何保證可見性的?
(2)volatile是如何禁止重排序的?
(3)volatile的實現原理?
(4)volatile的缺陷?
簡介
volatile可以說是Java虛擬機提供的最輕量級的同步機制了,但是它並不容易被正確地理解,以至於很多人不習慣使用它,遇到多線程問題一律使用synchronized或其它鎖來解決。
瞭解volatile的語義對理解多線程的特性具有很重要的意義,所以彤哥專門寫了一篇文章來解釋volatile的語義到底是什麼。
語義一:可見性
前面介紹Java內存模型的時候,我們說過可見性是指當一個線程修改了共享變量的值,其它線程能立即感知到這種變化。
而普通變量無法做到立即感知這一點,變量的值在線程之間的傳遞均需要通過主內存來完成,比如,線程A修改了一個普通變量的值,然後向主內存回寫,另外一條線程B只有在線程A的回寫完成之後再從主內存中讀取變量的值,才能夠讀取到新變量的值,也就是新變量才能對線程B可見。
在這期間可能會出現不一致的情況,比如:
(1)線程A並不是修改完成後立即回寫;
線路A修改了變量x的值爲5,但是還沒有回寫,線程B從主內存讀取到的還舊值0)
(2)線程B還在用着自己工作內存中的值,而並不是立即從主內存讀取值;
線程A回寫了變量x的值爲5到主內存中,但是線程B還沒有讀取主內存的值,依舊在使用舊值0在進行運算)
基於以上兩種情況,所以,普通變量都無法做到立即感知這一點。
但是,volatile變量可以做到立即感知這一點,也就是volatile可以保證可見性。
java內存模型規定,volatile變量的每次修改都必須立即回寫到主內存中,volatile變量的每次使用都必須從主內存刷新最新的值
volatile的可見性可以通過下面的示例體現:
public class VolatileTest {
// public static int finished = 0;
public static volatile int finished = 0;
private static void checkFinished() {
while (finished == 0) {
// do nothing
}
System.out.println("finished");
}
private static void finish() {
finished = 1;
}
public static void main(String[] args) throws InterruptedException {
// 起一個線程檢測是否結束
new Thread(() -> checkFinished()).start();
Thread.sleep(100);
// 主線程將finished標誌置爲1
finish();
System.out.println("main finished");
}
}
在上面的代碼中,針對finished變量,使用volatile修飾時這個程序可以正常結束,不使用volatile修飾時這個程序永遠不會結束。
因爲不使用volatile修飾時,checkFinished()所在的線程每次都是讀取的它自己工作內存中的變量的值,這個值一直爲0,所以一直都不會跳出while循環。
使用volatile修飾時,checkFinished()所在的線程每次都是從主內存中加載最新的值,當finished被主線程修改爲1的時候,它會立即感知到,進而會跳出while循環。
語義二:禁止重排序
前面介紹Java內存模型的時候,我們說過Java中的有序性可以概括爲一句話:如果在本線程中觀察,所有的操作都是有序的;如果在另一個線程中觀察,所有的操作都是無序的。
前半句是指線程內表現爲串行的語義,後半句是指“指令重排序”現象和“工作內存和主內存同步延遲”現象。
普通變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲得正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致,因爲一個線程的方法執行過程中無法感知到這點,這就是“線程內表現爲串行的語義”。
比如,下面的代碼:
// 兩個操作在一個線程
int i = 0;
int j = 1;
上面兩句話沒有依賴關係,JVM在執行的時候爲了充分利用CPU的處理能力,可能會先執行int j = 1;
這句,也就是重排序了,但是在線程內是無法感知的。
看似沒有什麼影響,但是如果是在多線程環境下呢?
我們再看一個例子:
public class VolatileTest3 {
private static Config config = null;
private static volatile boolean initialized = false;
public static void main(String[] args) {
// 線程1負責初始化配置信息
new Thread(() -> {
config = new Config();
config.name = "config";
initialized = true;
}).start();
// 線程2檢測到配置初始化完成後使用配置信息
new Thread(() -> {
while (!initialized) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
}
// do sth with config
String name = config.name;
}).start();
}
}
class Config {
String name;
}
這個例子很簡單,線程1負責初始化配置,線程2檢測到配置初始化完畢,使用配置來幹一些事。
在這個例子中,如果initialized不使用volatile來修飾,可能就會出現重排序,比如在初始化配置之前把initialized的值設置爲了true,這樣線程2讀取到這個值爲true了,就去使用配置了,這時候可能就會出現錯誤。
(此處這個例子只是用於說明重排序,實際運行時很難出現。)
通過這個例子,彤哥相信大家對“如果在本線程內觀察,所有操作都是有序的;在另一個線程觀察,所有操作都是無序的”有了更深刻的理解。
所以,重排序是站在另一個線程的視角的,因爲在本線程中,是無法感知到重排序的影響的。
而volatile變量是禁止重排序的,它能保證程序實際運行是按代碼順序執行的。
實現:內存屏障
上面講了volatile可以保證可見性和禁止重排序,那麼它是怎麼實現的呢?
答案就是,內存屏障。
內存屏障有兩個作用:
(1)阻止屏障兩側的指令重排序;
(2)強制把寫緩衝區/高速緩存中的數據回寫到主內存,讓緩存中相應的數據失效;
我們還是來看一個例子來理解內存屏障的影響:
public class VolatileTest4 {
// a不使用volatile修飾
public static long a = 0;
// 消除緩存行的影響
public static long p1, p2, p3, p4, p5, p6, p7;
// b使用volatile修飾
public static volatile long b = 0;
// 消除緩存行的影響
public static long q1, q2, q3, q4, q5, q6, q7;
// c不使用volatile修飾
public static long c = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (a == 0) {
long x = b;
}
System.out.println("a=" + a);
}).start();
new Thread(()->{
while (c == 0) {
long x = b;
}
System.out.println("c=" + c);
}).start();
Thread.sleep(100);
a = 1;
b = 1;
c = 1;
}
}
這段代碼中,a和c不使用volatile修飾,b使用volatile修飾,而且我們在a/b、b/c之間各加入7個long字段消除僞共享的影響。
在a和c的兩個線程的while循環中我們獲取一下b,你猜怎樣?如果把long x = b;
這行去掉呢?運行試試吧。
缺陷
上面我們介紹了volatile關鍵字的兩大語義,那麼,volatile關鍵字是不是就是萬能的了呢?
當然不是,忘了我們內存模型那章說的一致性包括的三大特性了麼?
一致性主要包含三大特性:原子性、可見性、有序性。
volatile關鍵字可以保證可見性和有序性,那麼volatile能保證原子性麼?
請看下面的例子:
public class VolatileTest5 {
public static volatile int counter = 0;
public static void increment() {
counter++;
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(100);
IntStream.range(0, 100).forEach(i->
new Thread(()-> {
IntStream.range(0, 1000).forEach(j->increment());
countDownLatch.countDown();
}).start());
countDownLatch.await();
System.out.println(counter);
}
}
這段代碼中,我們起了100個線程分別對counter自增1000次,一共應該是增加了100000,但是實際運行結果卻永遠不會達到100000。
可以看到counter++被分解成了四條指令:
(1)getstatic,獲取counter當前的值併入棧
(2)iconst_1,入棧int類型的值1
(3)iadd,將棧頂的兩個值相加
(4)putstatic,將相加的結果寫回到counter中
由於counter是volatile修飾的,所以getstatic會從主內存刷新最新的值,putstatic也會把修改的值立即同步到主內存。
但是中間的兩步iconst_1和iadd在執行的過程中,可能counter的值已經被修改了,這時並沒有重新讀取主內存中的最新值,所以volatile在counter++這個場景中並不能保證其原子性。
volatile關鍵字只能保證可見性和有序性,不能保證原子性,要解決原子性的問題,還是隻能通過加鎖或使用原子類的方式解決。
進而,我們得出volatile關鍵字使用的場景:
(1)運算的結果並不依賴於變量的當前值,或者能夠確保只有單一的線程修改變量的值;
(2)變量不需要與其他狀態變量共同參與不變約束。
說白了,就是volatile本身不保證原子性,那就要增加其它的約束條件來使其所在的場景本身就是原子的。
比如:
private volatile int a = 0;
// 線程A
a = 1;
// 線程B
if (a == 1) {
// do sth
}
a = 1;
這個賦值操作本身就是原子的,所以可以使用volatile來修飾。
結合synchronized保證原子性
private static volatile SnowflakeIdWorker snowWorker = null;
private static final long DEFAULT_WORKER_ID = 1L;
/**
* id生成
*/
public static String generator(){
if (snowWorker == null) {
synchronized (SnowflakeIdWorker.class) {
snowWorker = new SnowflakeIdWorker(DEFAULT_WORKER_ID,DEFAULT_WORKER_ID);
}
}
return String.valueOf(snowWorker.nextId());
}
總結
(1)volatile關鍵字可以保證可見性;
(2)volatile關鍵字可以保證有序性;
(3)volatile關鍵字不可以保證原子性;
(4)volatile關鍵字的底層主要是通過內存屏障來實現的;
(5)volatile關鍵字的使用場景必須是場景本身就是原子的;