前言
??網上講volatile并配上示例代碼的文章、教程很多。本文相對于他們菩佑,最大的進步是講了為啥不能使用Thread.sleep()、Thread.yield()凝化、System.out.println()這些方法稍坯,為這一點糾結了半個月。
1. volatile關鍵字的作用
volatile在java多線程編程中用來保證可見性和有序性。先簡略講一下瞧哟。
- 可見性:
- 對volatile修飾的變量的修改會立即刷新回內存混巧,并通知其他緩存失效,因此在讀取volatile類型的變量時總是拿到最新的值勤揩;此處要注意不是加了volatile修飾的變量就不被緩存了咧党,而是這個緩存有獨特的機制保證同步(下文細講)。
- 有序性:
- 對volatile修飾的變量的寫操作與該操作前后的其他內存操作不會一起
重排序
陨亡,前面的歸前面的傍衡,后面的歸后面的。
2. 用一個例子來說明有序性
??先講一下什么是指令重排序负蠕。因為指令執(zhí)行一般比IO快蛙埂,所以在不影響的情況下,等待IO過程中可以先去執(zhí)行其他指令遮糖。
- java代碼編譯成jvm字節(jié)碼的時候绣的,編譯器會根據自己的優(yōu)化規(guī)則,將代碼語句執(zhí)順序打亂止吁。
- 在虛擬機層面被辑,虛擬機會按照自己的規(guī)則將程序編寫順序打亂,即在時間順序上敬惦,寫在后面的代碼可能會先執(zhí)行,而寫在前面的代碼可能會后執(zhí)行谈山,以盡可能充分地利用CPU俄删;
- 操作系統(tǒng)會根據自己的規(guī)則重排序指令;
- 在硬件層面奏路,CPU會將接收到的一批指令按照規(guī)則重排序畴椰。
??volatile就是告訴編譯、執(zhí)行等等一系列過程鸽粉,對volatile變量的操作不準進行重排序(根據規(guī)則斜脂,如果該操作不影響最終結果的正確性還是可以進行重排序的,比如vloatile修飾的變量在一段代碼整個執(zhí)行過程中只有讀操作触机,這個變量用不用volatile修飾都無所謂)帚戳。
??下面是一個重排序的示例。
??在你真正理解之前儡首,對示例一個字都不要動片任,尤其是不要加上Thread.sleep()、Thread.yield()蔬胯、System.out.println()這些方法对供,看完本文你會明白為什么。
package com.example.demo;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class TestMain {
static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> set = new HashSet<>();
Map<String, Integer> res = new HashMap<>();
while (true) {
x = 0;
y = 0;
res.clear();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
int a = y; // 語句1
x = 1; // 語句2
res.put("a", a);
}
});
Thread b = new Thread(new Runnable() {
@Override
public void run() {
int b = x; // 語句3
y = 1; // 語句4
res.put("b", b);
}
});
a.start();
b.start();
a.join();
b.join();
set.add("a=" + res.get("a") + ",b=" + res.get("b"));
System.out.println(set);
}
}
}
??啟動這個程序氛濒,多運行一段時間产场,就會出現(xiàn)以下輸出結果:
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]
??按照我們通常理解的程序執(zhí)行順序來看:
- 如果按照語句1/語句3 -> 語句2/語句4的順序執(zhí)行鹅髓,就會得到a=0,b=0;
- 如果按照語句1 -> 語句2 -> 語句3/語句4的順序執(zhí)行京景,就會得到a=0,b=1窿冯;
- 如果按照語句3 -> 語句4 -> 語句1/語句2的順序執(zhí)行,就會得到a=1,b=0醋粟;
其中->代表前面的語句必須在后面的語句直線執(zhí)行靡菇,/代表兩個語句執(zhí)行順序可以對調或者同時執(zhí)行。
??還有第四種結果a=1,b=1米愿。要想得到a=1,b=1厦凤,就必須是語句2/語句4 -> 語句1/語句3這種順序,明顯違背了我們看到的代碼語句順序育苟。這里就是指令經過了重排序较鼓。
??我們在x、y變量上加上volatile關鍵字违柏,重試一次博烂,
static volatile int x = 0, y = 0;
??無論程序運行多久,都只能得到3種結果:
[a=0,b=0, a=1,b=0, a=0,b=1]
??就像章節(jié)1中所說漱竖,volatile阻止指令重排序有四層含義:
- 阻止在語言層面的Java編譯器重排序禽篱;
- 阻止JVM為了做性能優(yōu)化做的指令重排序;
- 阻止操作系統(tǒng)重排序馍惹;
- 阻止CPU指令重排躺率。
??這四層重排序是互不相關的。所以即使是在單核心CPU的機器上万矾,volatile仍然是必要的悼吱。當然操作系統(tǒng)、CPU用的并不是volatile指令良狈,而是jvm根據操作系統(tǒng)的不同將volatile轉換為操作系統(tǒng)自定義的阻止重排序指令發(fā)送給操作系統(tǒng)后添,同樣,操作系統(tǒng)根據不同的CPU轉換為對應的阻止重排序指令發(fā)送給CPU薪丁。
4. 關于禁止重排序
??禁止重排序主要是使用了內存屏障遇西,內存屏障有四種:
- LoadLoad屏障
- StoreStore屏障
- LoadStore屏障
- StoreLoad屏障
??比如loadload屏障,加在Load1和Load2兩個原子操作之間 窥突,保證在Load2及后續(xù)的讀操作讀取之前努溃,Load1已經讀取。其他同理阻问。
??內存屏障在volatile變量上的使用:
- 在每個volatile寫入之前梧税,插入一個StoreStore,寫入之后,插入一個StoreLoad第队;
- 在每個volatile讀取之前哮塞,插入LoadLoad,之后插入LoadStore凳谦。
??更詳細的關于內存屏障的講解可以自己去看博客忆畅。
4. 用一個例子來說明可見性
??還是那句話,在你真正理解之前尸执,對示例一個字都不要動家凯,尤其是不要加上Thread.sleep()、Thread.yield()如失、System.out.println()這些方法绊诲,看完本文你會明白為什么。
package com.example.demo;
public class TestExample2 {
private static boolean ready;
private static class ReaderThread extends Thread {
@Override
public void run() {
System.out.println("start");
while(!ready) {
}
System.out.println("end");
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(5000);
ready = true;
}
}
??我們在子線程啟動后褪贵,將ready變量設置為true掂之,目的是結束子線程的循環(huán),從而結束子線程脆丁。但是實際啟動后程序一直無法結束世舰。
??同樣的,在ready變量上加上volatile關鍵字槽卫,重試一次跟压,
static volatile boolean ready;
??程序運行大概五秒后結束。即主線程對ready的修改歼培,子線程看到了裆馒。
??這就是volatile關鍵字的可見性作用。
6. 緩存一致性協(xié)議
??以Intel多核CPU經典的MESI協(xié)議為例丐怯。在MESI協(xié)議中,每個Cache line(緩存塊翔横,同時讀取同時失效同時更新读跷,為什么這樣一塊一塊,參考局部性原理)有4個狀態(tài)禾唁,它們分別是:
狀態(tài) | 描述 |
---|---|
M(Modified) | 數據有效效览,但是被修改了,和內存中的數據不一致荡短,只存在于本Cache中 |
E(Exclusive) | 數據有效丐枉,和內存中的數據一致,只存在于本Cache中 |
S(Shared) | 數據有效掘托,和內存中的數據一致瘦锹,存在于很多Cache中 |
I(Invalid) | 數據無效 |
??M(Modified)和E(Exclusive)狀態(tài)的Cache line,數據是獨有的,不同點在于M狀態(tài)的數據是dirty的(和內存的不一致)弯院,E狀態(tài)的數據是clean的(和內存的一致)辱士。
S(Shared)狀態(tài)的Cache line,數據和其他Core的Cache共享听绳。只有clean的數據才能被多個Cache共享颂碘。
當前狀態(tài) | 事件 | 行為 | 下一個狀態(tài) |
---|---|---|---|
I(Invalid) | Local Read | 1. 如果其它Cache沒有這份數據,本Cache從內存中取數據椅挣,Cache line狀態(tài)變成E头岔; 2. 如果其它Cache有這份數據,且狀態(tài)為M鼠证,則將數據更新到內存峡竣,本Cache再從內存中取數據,2個Cache 的Cache line狀態(tài)都變成S名惩; 3. 如果其它Cache有這份數據澎胡,且狀態(tài)為E,本Cache從內存中取數據娩鹉,這些Cache的Cache line狀態(tài)都變成S; 4. 如果其它Cache有這份數據攻谁,且狀態(tài)為S,本Cache從內存中取數據弯予,并把自己的Cache line狀態(tài)設置為S戚宦。 |
E/S |
I(Invalid) | Local Write | 1. 從內存中取數據,在Cache中修改锈嫩,狀態(tài)變成M受楼; 2. 如果其它Cache有這份數據,且狀態(tài)為M呼寸,則要先將數據更新到內存艳汽; 3. 如果其它Cache有這份數據,則其它Cache的Cache line狀態(tài)變成I |
M |
I(Invalid) | Remote Read | Invalid对雪,別的核的操作與它無關 | I |
I(Invalid) | Remote Write | Invalid河狐,別的核的操作與它無關 | I |
E(Exclusive) | Local Read | 從Cache中取數據,Cache line狀態(tài)不變 | E |
E(Exclusive) | Local Write | 修改Cache中的數據瑟捣,Cache line狀態(tài)變成M | M |
E(Exclusive) | Remote Read | 數據和其它核共用馋艺,Cache line都變成S | S |
E(Exclusive) | Remote Write | 數據被修改,本Cache line狀態(tài)變成I | I |
S(Shared) | Local Read | 從Cache中取數據迈套,Cache line狀態(tài)不變 | S |
S(Shared) | Local Write | 修改Cache中的數據捐祠,Cache line狀態(tài)變成M,其它核共享的Cache line狀態(tài)變成I | M |
S(Shared) | Remote Read | 狀態(tài)不變 | S |
S(Shared) | Remote Write | 數據被修改桑李,本Cache line不能再使用踱蛀,狀態(tài)變成I | I |
M(Modified) | Local Read | 從Cache中取數據窿给,Cache line狀態(tài)不變 | M |
M(Modified) | Local Write | 修改Cache中的數據,Cache line狀態(tài)不變 | M |
M(Modified) | Remote Read | 數據被寫到內存中星岗,使其它核能使用到最新的數據填大,Cache line狀態(tài)變成S | S |
M(Modified) | Remote Write | 數據被寫到內存中,使其它核能使用到最新的數據俏橘,由于其它核會修改數據允华,Cache line狀態(tài)變成I | I |
有兩點說明:
- MESI協(xié)議并不是對所有變量都默認開啟的。比如java代碼在編譯成CPU可執(zhí)行的指令時寥掐,volatile修飾的變量就會啟用MESI協(xié)議靴寂;
- MESI協(xié)議只是Intel的,AMD的就不是MESI協(xié)議召耘,甚至有的CPU就沒有緩存一致性協(xié)議百炬,直接用的總線鎖,甚至總線鎖也沒有污它,對你的volatile視而不見剖踊。
7. 從一開始就說的一個問題。為什么不要加上Thread.sleep()衫贬、Thread.yield()德澈、System.out.println()這些方法
7.1 從表面上看,這幾個方法造成了緩存的刷新
??我們修改一下3中的例子固惯,去掉volatile關鍵字梆造,加上Thread.yield()。
package com.example.demo;
public class TestExample2 {
private static boolean ready;
private static class ReaderThread extends Thread {
@Override
public void run() {
System.out.println("start");
while(!ready) {
//System.out.println(ready);
Thread.yield();
}
System.out.println("end");
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(5000);
ready = true;
}
}
??程序執(zhí)行大概5秒就結束了葬毫。說明變量ready在CPU的緩存與內存做了數據同步镇辉,即使沒有使用volatile修飾該變量。
??我們看不到Thread.yield()的源碼贴捡,但是可以先看看System.out.println()的源碼
public void println(boolean x) {
synchronized (this) {
print(x);
newLine();
}
}
可以看到其中使用了Synchronized(this)鎖忽肛。鎖釋放、獲取的時候會把當前線程的共享變量刷新到主內存烂斋。所以鎖具有volatile具有的所有能立麻裁。并且Synchronized()在可見性和一致性保證之外,還多了原子性的保證源祈。
??我們看不到Thread.yield()的源碼,但是官方文檔在Thread.yield()和Thread.sleep()是這么說的:
the compiler does not have to flush writes cached in registers out to shared memory before a call to Thread.sleep or Thread.yield, nor does the compiler have to reload values cached in registers after a call to Thread.sleep or Thread.yield.
??并且還舉了一個例子:
while (!this.done)
Thread.sleep(1000);
??The compiler is free to read the field this.done just once, and reuse the cached value in each execution of the loop. This would mean that the loop would never terminate, even if another thread changed the value of this.done.
??顯然色迂,Thread.yield()和Thread.sleep()并沒有刷新緩存的效果香缺。但是我們發(fā)現(xiàn)實驗結果和這個解釋不一樣。
7.2 最終原因的解釋
??不但上面幾個方法會讓程序結束歇僧,如果我們new一個數組图张,例如:
Object[] a=new Object[10000];
或者發(fā)送一個http請求:
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet("http://www.baidu.com");
CloseableHttpResponse response = null;
try {
response = httpClient.execute(httpGet);
HttpEntity responseEntity = response.getEntity();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (httpClient != null) {
httpClient.close();
}
if (response != null) {
response.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
這個程序都會結束锋拖。
??這些方法的一個共同點就是,這些方法耗時祸轮,但是CPU計算占用很少兽埃。不是在做內存分配,就是在做IO适袜,雖然占用了CPU時間片柄错,但是CPU比較閑。
??原來苦酱,通過JVM大神們每天喪(干)心(得)彩勖病(漂)狂(亮)的努力,JVM針對現(xiàn)在的硬件水平已經做了很大程度的優(yōu)化疫萤,基本上很大程度的保障了工作內存和主內存的及時同步颂跨,相當于默認使用了volitale。但只是最大程度扯饶。在CPU資源一直被占用的時候恒削,工作內存與主內存中間的同步,也就是變量的可見性就不會那么及時尾序!