可見性、原子性和有序性問題
並發編程背景
核心矛盾
這些年,我們的 CPU、內存、I/O 設備都在不斷迭代,不斷朝着更快的方向努力。但是,在這個快速發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異。
我形象的描述了一下這三者的速度上的差異:所謂天上一天地上一年(愛因斯坦的相對論是有合理解釋的),CPU和內存之間的速度差異就是CPU天上一天,內存地上一年(假設 CPU 執行一條普通指令需要一天,那么 CPU 讀寫內存得等待一年的時間)。內存和I/O設備的速度差異就更大了,內存是天上一天,I/O設備是地上十年。
木桶原理
一只水桶能裝多少水取決於它最短的那塊木塊
程序的大部分語句都要訪問內存,有些還要訪問I/O,根據木桶原理,程序整體的性能取決於最慢的操作,所以只是單方面的提高CPU的性能是無效的,才出現了一些提高CPU性能使用率的優化
如何提高CPU的性能?
- CPU增加了緩存,以均衡與內存的速度差異(計算機體系結構方面優化)
- 操作系統增加了進程、線程,以分時復用CPU,進而均衡CPU與I/O設備的速度差異(操作系統方面優化)
- 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用(編譯程序帶來的優化)
很多時候人們總是會很開心享受眼前的好處,卻總是忽略了一句話:采用一項技術的同時,一定會產生一個新的問題,並發程序很多的詭異問題的根源也從這里開始
可見性
單核時期,所有的線程都是操作同一個CPU的,所以CPU的緩存也是線程之間可見的,如下圖所示:
線程1和線程2都是編輯同一個CPU里面的緩存,所以線程1更新了變量A的值,線程2之后再訪問變量A,得到的一定是A的最新值
一個線程對共享資源的修改,另一個線程能夠立刻看到,稱之為可見性
多核時代,每個CPU都有自己單獨的緩存,當多個線程在不同的CPU上執行時,這些線程操作的就是不同的CPU緩存了,如下圖所示:線程1在CPU-1的緩存上編輯變量A,線程2在CPU-2的緩存上編輯變量A,這個時候線程1對變量A的操作對於線程2來說就不具備可見性
二話不說,上代碼解釋是最直接的方式
下面這段代碼創建了兩個線程,每個線程都會調用一次updateVar
的方法,都會循環10000次的sharedVariable += 1
操作,然后打印出共享變量的結果。
/**
* 可見性測試Demo
*
* @author BO
* @date 2019-03-25
*/
public class VisibilityTest {
private long sharedVariable = 0;
private void updateVar() {
int times = 0;
while (times++ < 10000) {
sharedVariable += 1;
}
}
public static void main(String[] args) throws InterruptedException {
final VisibilityTest test = new VisibilityTest();
// 創建兩個線程,執行修改方法
Thread thread1 = new Thread(() -> {
test.updateVar();
});
Thread thread2 = new Thread(() -> {
test.updateVar();
});
// 啟動線程
thread1.start();
thread2.start();
// 等待兩個線程執行結束
thread1.join();
thread2.join();
System.out.println("執行后共享變量的值為:" + test.sharedVariable);
}
}
講道理,這里應該是要輸出執行后共享變量的值為:20000
的,因為單線程里調用兩次updateVar
方法,sharedVariable
的值就是20000,但實際上,我執行了n次(手都要點的酸死),執行的結果是10000個到20000之間的隨機數。
我們假設線程1和線程2同時開始執行,那么第一次都會將sharedVariable = 0
讀取到各自的CPU緩存里,執行了updateVar
方法后,各自緩存中的sharedVariable
的值都是1,同時寫入內存后,內存中的sharedVariable
是1而不是我們講道理的2,這就是緩存的可見性問題
這里因為兩個線程的啟動時有時差的,假設兩個線程是同時執行的,這里返回的結果應該是10000
原子性
操作系統帶來的多進程大家都體驗過,我們可以一邊聽歌一邊聊天就是多進程帶來的好處
操作系統允許進程執行一段時間,假設100ms,過來100ms操作系統就會重新選擇進程來執行,這個就是上面提到的分時服用CPU的原理,操作系統以100ms作為時間片進行任務切換
在一個時間片內,如果一個進程進行一個IO操作,加入讀一個文件,這個時候進程可以把自己標記為“休眠狀態”並讓出CPU的使用權,等待文件讀進內存,操作系統會把這個休眠的進程喚醒,喚醒后的進程就有機會重新獲得CPU的使用權
這個就是分時復用CPU的過程,這樣很好的提高了CPU的使用率
但是由於進程之間是不能進行內存空間的共享的,所以進程要做任務切換就要切換內存映射地址,而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了,我們接下來講的就是對於線程的任務切換,也就是線程切換
我們現在使用的都是高級編程語言,高級編程語言里一條語句需要多條CPU指令完成。舉個簡單的列子,上面的代碼中,sharedVariable += 1
,在操作系統中至少需要三條CPU指令才能完成
- 把
sharedVariable
從內存加載到CPU的寄存器; - 在寄存器中執行 +1 操作
- 把結果寫入內存(緩存機制可能導致寫入到了CPU的緩存)
在操作系統中,進行任務切換會發生在任何一條CPU指令執行完,假設任務切換發生在第一步,如下圖就是兩個線程的執行過程
這個時候就導致本來是兩次 +1 操作,結果到內存的實際值仍然是1
在化學上我們稱化學反應不可再分的基本微粒成為原子,如果我們不熟悉操作系統的執行規則,我們會默認為sharedVariable += 1
就是一個原子,可能知道有線程切換會發生在這個操作之前,也或者這個操作之后,就是不會發生在這個操作中間,這就是我們經常忽略的原子性問題
所謂原子性,我們把一個或者多個操作在CPU執行的過程中不被中斷的特性成為原子性,就像化學反應上的原子一樣,是最小的單位了,不可分割
有序性
編譯器為了性能的優化,有時候會改變程序中語句的先后順序,有序性指的是程序按照代碼的先后順序執行,可能大家很疑惑這個怎么也會導致並發問題呢?請看下面的一個實例
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
上面這段代碼是一個典型的雙重檢查創建實例對象,在獲取實例getInstance
方法中,先判斷了實例instance == null
,如果為空就鎖定Singleton.class
類,然后再檢查實例是否為空,如果還為空就創建一個Singleton
實例。
此時兩個線程1和2同時調用getInstance
方法,它們會同時都發現了實例為空,於是同時對Singleton.class
類進行了加鎖,這里JVM保證只有一個線程加鎖成功(不要問我為什么,就是這樣的),這里假設線程1成功獲取到了鎖,線程1就會去創建Singleton
實例,然后釋放鎖,線程2檢查instance == null
時發現不為空,就不會再實例化了,這是不是很完美的代碼。
但實際上,這里會出現一個很隱蔽的問題,問題就在這個new Singleton()
中,按照實例化的順序,new操作是這樣的:
- 分配一塊內存區域M;
- 在內存M上初始化
Singleton
對象; - 然后M的地址賦值給
instance
變量。
但是實際上編譯器認為第2步和第3步對結果並沒有影響,所以根據實際情況進行了優化,變成了:
- 分配一塊內存區域M;
- 然后M的地址賦值給
instance
變量。 - 在內存M上初始化
Singleton
對象;
這個時候如果線程1獲取到鎖后,開始實例化Singleton
對象,進行第2步操作結束后,操作系統進行了任務切換,此時線程2在進行第一個instance == null
檢查時,發現instance
變量已經不為空,所以就直接返回了這個實例,但實際上這個實例還沒有進行初始化,當程序中使用這個實例進行獲取這個實例的成員變量時,就會出現NPE異常了,這個就是有序性導致的並發問題
總結
只要我們能夠深刻理解可見性、原子性、有序性在並發場景下的原理,很多並發Bug都是可以解決、可以診斷的
實戰
在 32 位的機器上對 long 型變量進行加減操作存在並發隱患,到底是不是這樣的呢?
因為long型變量是64位,在32位CPU上執行寫操作,會被分成兩次寫操作,所以這個操作並不是原子性的,如果線程1在完成第一次寫操作后,出現線程切換,線程2將這個變量進行其他操作,就會導致線程1的這次操作存在問題