## 11 眼見不實—可見性
> 人生的價值,并不是用時間,而是用深度去衡量的。
> ——列夫·托爾斯泰
本節介紹并發三大特性的可見性。并發編程路上可謂困難重重。不過沒有關系,道高一尺,魔高一丈。我們現在講解的所有問題,都有能降伏住他的武器。但要想做常勝將軍,那就要做到知己知彼。我們只要搞清楚有哪些問題,問題的根本原因是什么,困難才會迎刃而解。
由于我們的程序在絕大多數情況下是單線程運行的,另外即使是多線程,如果對象是無狀態的,也不會有線程安全的問題。所以 JVM 更多會考慮單線程的需求。這也就造就了多線程程序在共享資源訪的訪問上存在問題。比如本節所討論的可見性。
## 1\. 什么是可見性
可見性指的是,某個線程對共享變量進行了修改,其它線程能夠立刻看到修改后的最新值。乍一聽這個定義,你可能會覺得這不是廢話嗎?變量被修改了,線程當然能夠立刻讀取到!否則即使單線程的程序也會出問題啊!沒錯,變量被修改后,在本線程中確實能夠立刻被看到,但并不保證別的線程會立刻看到。原因就是編程領域經典的兩大難題之一----緩存一致性。
我們看一個例子,代碼如下:
~~~java
public class visibility {
private static class ShowVisibility implements Runnable{
public static Object o = new Object();
private Boolean flag = false;
@Override
public void run() {
while (true) {
if (flag) {
System.out.println(Thread.currentThread().getName()+":"+flag);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
ShowVisibility showVisibility = new ShowVisibility();
Thread blindThread = new Thread(showVisibility);
blindThread.start();
//給線程啟動的時間
Thread.sleep(500);
//更新flag
showVisibility.flag=true;
System.out.println("flag is true, thread should print");
Thread.sleep(1000);
System.out.println("I have slept 1 seconds. I guess there was nothing printed ");
}
}
~~~
這段代碼很簡單,ShowVisibility 實現 Runnable 接口,在 run 方法中判斷成員變量 flag 值為 true 時進行打印。main 方法中通過 showVisibility 對象啟動一個線程。主線程等待 0.5 秒后,改變 showVisibility 中 flag 的值為 true。按正常思路,此時 blindThread 應該開始打印。但是,實際情況并非如此。運行此程序,輸出如下:
~~~
flag is true, thread should print
I have slept 1 seconds. I guess there was nothing printed
~~~
沒錯,flag 改為 true 后,blindThread 沒有任何打印。也就是說 blindThread 并沒有觀察到到 flag 的值變化。為了測試 blindThread 到底多久能看到 flag 的變化,我決定先看會電視,可是等我刷完一集《樂隊的夏天》回來,還是沒有任何輸出。

是不是很神奇?是不是很玄學?作為程序員,你一定碰到過怎么都找不出原因的 bug,最后歸于玄學。其實作為代碼來說,不會有什么玄學。遇到的所有問題一定有其原因。只不過有些隱藏得很深,我們很難發現。或者也可能限于自己的認知,苦苦思考也找不到答案。
回到例子的問題本身來,執行結果完全違背我們的直覺。如果是單線程程序,做了一個變量的修改,那么程序是立即就能看到的。然而在多線程程序中并非如此。原因是 CPU 為提高計算的速度,使用了緩存。
## 2\. CPU 緩存模型
大家一定都知道摩爾定律。根據定律,CPU 每 18 個月速度將會翻一番。CPU 的計算速度提升了,但是內存的訪問速度卻沒有什么大幅度的提升。這就好比一個腦瓜很聰明程序員,接到需求后很快就想好程序怎么寫了。但是他的電腦性能很差,每敲一行代碼都要反應好久,導致完成編碼的時間依舊很長。所以人再聰明沒有用,瓶頸在計算機的速度上。CPU 計算也是同樣的道理,瓶頸出現在對內存的訪問上。沒關系,我們可以使用緩存啊,這已經是路人皆知的手段了。CPU 更狠一點,用了 L1、L2、L3,一共三級緩存。其中 L1 緩存根據用途不同,還分為 L1i 和 L1d 兩種緩存。如下圖:

緩存的訪問速度是主存的幾分之一,甚至幾十分之一。通過緩存,極大的提高了 CPU 計算速度。CPU 會先從主存中復制數據到緩存,CPU 在計算的時候就可以從緩存讀取數據了,在計算完成后再把數據從緩存更新回主存。這樣在計算期間,就無須訪問主存了,速度大大提升。加上緩存后,CPU 的數據訪問如下:

我們再回頭看上文的例子。blindThread 線程啟動后,就進入 while 循環中,一直進行運算,運算時把 flag 從主存拿到了自己線程中的緩存,此后就會一直從緩存中讀取 flag 的值。即便是main線程修改了 flag 的值。但是 blindThread 線程的緩存并未更新,所以取到的還一直是之前的值。導致 blindThread 線程一致也不會有輸出。
## 3\. 最低安全性
在前面的例子中,blindThread 線程讀取到flag的值是之前有效的 false。但其現在已經失效了。也就是說 blindThread 讀取到了失效數據。雖然線程在未做同步的時候會讀取到失效值,但是起碼這個值是曾經存在過的。這稱之為最低安全性。我猜你一定會問,難道線程還能讀取到從來沒有設置過的值嗎?是的,對于 64 位類型的變量 long 和 double,JVM 會把讀寫操作分解為兩個 32 位的操作。如果兩個線程分別去讀和寫,那么在讀的時候,可能寫線程只修改了一個 32 位的數據。此時讀線程會讀取到原來數值一個 32 位的數值和新的數值一個 32 位的數值。兩個不同數值各自的一個 32 位數值合在一起會產生一個新的數值,沒有任何線程設置過的數值。這就好比馬和驢各一半的基因,會生出騾子一樣。此時,就違背了最低安全性。
## 4\. 初識 volatile 關鍵字
要想解決可見性問題其實很簡單。第一種方法就是解決一切并發問題的方法–同步。不過讀和寫都需要同步。
此外還有一個方法會簡單很多,使用 volatile 關鍵字。
我們把例子中下面這行代碼做一下修改。
~~~java
private Boolean flag = false;
~~~
改為:
~~~java
private volatile Boolean flag = false;
~~~
我們再次運行。現在程序居然可以正常輸出了!是不是很簡單的修改?
volatile 修飾的變量,在發生變化的時候,其它線程會立刻覺察到,然后從主存中取得更新后的值。volatile 除了簡潔外,還有個好處就是它不會加鎖,所以不會阻塞代碼。關于 volatile 更多的知識我們后面還會做詳細講解。現在我們只要知道他能夠以輕量級的方式實現同步就可以了。
## 5\. 總結
本節我們學習了可見性。如果不了解可見性,我們寫出的并發代碼,可能會出現各種違背邏輯的現象。現在我們已經弄清了問題產生的原因以及如何去解決,所以可見性的問題也沒什么可怕的。開發遇到問題時不要慌,所有的問題都有其產生的原因,找到原因再對癥下藥,保準藥到病除。
開發工作中,我會遇到一些同事,遇到問題后不去分析問題產生的原因,先是自己猜測,試著亂改。發現自己不能解決后,網上搜索。找到相關帖子或文章,也不看原因是什么,直接復制粘貼代碼,又是一頓試。即使這樣最后解決了問題,我想對于他來說也是毫無收獲的。我們不管遇到什么難題,一定不能亂了陣腳,還是從分析問題入手。最終解決問題一定是基于你分析出的原因。而不是靠猜測和盲目亂試。
- 前言
- 第1章 Java并發簡介
- 01 開篇詞:多線程為什么是你必需要掌握的知識
- 02 絕對不僅僅是為了面試—我們為什么需要學習多線程
- 03 多線程開發如此簡單—Java中如何編寫多線程程序
- 04 人多力量未必大—并發可能會遇到的問題
- 第2章 Java中如何編寫多線程
- 05 看若兄弟,實如父子—Thread和Runnable詳解
- 06 線程什么時候開始真正執行?—線程的狀態詳解
- 07 深入Thread類—線程API精講
- 08 集體協作,什么最重要?溝通!—線程的等待和通知
- 09 使用多線程實現分工、解耦、緩沖—生產者、消費者實戰
- 第3章 并發的問題和原因詳解
- 10 有福同享,有難同當—原子性
- 11 眼見不實—可見性
- 12 什么?還有這種操作!—有序性
- 13 問題的根源—Java內存模型簡介
- 14 僵持不下—死鎖詳解
- 第4章 如何解決并發問題
- 15 原子性輕量級實現—深入理解Atomic與CAS
- 16 讓你眼見為實—volatile詳解
- 17 資源有限,請排隊等候—Synchronized使用、原理及缺陷
- 18 線程作用域內共享變量—深入解析ThreadLocal
- 第5章 線程池
- 19 自己動手豐衣足食—簡單線程池實現
- 20 其實不用造輪子—Executor框架詳解
- 第6章 主要并發工具類
- 21 更高級的鎖—深入解析Lock
- 22 到底哪把鎖更適合你?—synchronized與ReentrantLock對比
- 23 按需上鎖—ReadWriteLock詳解
- 24 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap上
- 25 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap下
- 26不讓我進門,我就在門口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒數計時開始,三、二、一—CountDownLatch詳解
- 28 人齊了,一起行動—CyclicBarrier詳解
- 29 一手交錢,一手交貨—Exchanger詳解
- 30 限量供應,不好意思您來晚了—Semaphore詳解
- 第7章 高級并發工具類及并發設計模式
- 31 憑票取餐—Future模式詳解
- 32 請按到場順序發言—Completion Service詳解
- 33 分階段執行你的任務-學習使用Phaser運行多階段任務
- 34 誰都不能偷懶-通過 CompletableFuture 組裝你的異步計算單元
- 35拆分你的任務—學習使用Fork/Join框架
- 36 為多線程們安排一位經理—Master/Slave模式詳解
- 第8章 總結
- 37 結束語