Java之指令重排序
背景
問題出現
今天遇到了一個NullPointerException,雖然量不大,但是很怪異,大致長這個樣子
這是個什么空指針?居然說我LinkedList.iterator().hasNext()方法有問題?可是我就是正常的調用hasNext()啊,怎么就拋出來這種異常了呢?
問題初分析
- 調用LinkedList.iterator().hasNext()相關的代碼是出現在預加載場景里的,而預加載其實大多數是在異步線程里進行的,出問題的地方恰好是異步線程和UI線程共同訪問LinkedList的地方(UI線程里add,異步線程里poll),然后我們就會很自然的想到是不是因為在add和poll的過程中發生了線程切換導致的呢?
LinkedList實現(Java8之前)
源碼
- Link
private static final class Link<ET> {
ET data;
Link<ET> previous, next;
Link(ET o, Link<ET> p, Link<ET> n) {
data = o;
previous = p;
next = n;
}
}
Link其實就是我們所說的Node, 從這里可以看出來這是一個雙向鏈表
- LinkedList()
public LinkedList() {
voidLink = new Link<E>(null, null, null);
voidLink.previous = voidLink;
voidLink.next = voidLink;
}
voidLink也是一個Link類型,LinkedList為了方便管理,內部實現其實是一個循環雙向鏈表,voidLink就是連接首尾的那個節點,使用這么一個voidLink也可以減少大量空指針判斷和保護,若鏈表為空,voidLink的previous和next都指向自己
- LinkedList#poll() -> LinkedList#removeFirst() ->LinkedList#removeFirstImpl()
private E removeFirstImpl() {
Link<E> first = voidLink.next;
if (first != voidLink) {
Link<E> next = first.next;
voidLink.next = next;
next.previous = voidLink;
size--;
modCount++;
return first.data;
}
throw new NoSuchElementException();
}
這也就是LinkedList的出隊操作了,驚訝的發現并沒有任何一個中間環節使鏈表上的某一個指針指向了null,那再來看一下add方法
- LinkedList#add() -> LinkedList#addLastImpl()
private boolean addLastImpl(E object) {
Link<E> oldLast = voidLink.previous;
Link<E> newLink = new Link<E>(object, oldLast, voidLink);
voidLink.previous = newLink;
oldLast.next = newLink;
size++;
modCount++;
return true;
}
這是對應的入隊操作,也沒有發現任何一個中間步驟讓鏈表上的某個指針指向null,那再來看下報錯的地方
- LinkedList#itertator() -> LinkedList#listIterator(0) -> new LinkedIterator(LinkedList, int)
LinkIterator(LinkedList<ET> object, int location) {
list = object;
expectedModCount = list.modCount;
if (location >= 0 && location <= list.size) {
// pos ends up as -1 if list is empty, it ranges from -1 to
// list.size - 1
// if link == voidLink then pos must == -1
link = list.voidLink;
if (location < list.size / 2) {
for (pos = -1; pos + 1 < location; pos++) {
link = link.next;
}
} else {
for (pos = list.size; pos >= location; pos--) {
link = link.previous;
}
}
} else {
throw new IndexOutOfBoundsException();
}
}
最終一系列的調用,調用到這個構造方法里,location恒等于0,也就是說必然執行到
link = link.previous;
- 報錯的地方 LinkedListIterator#hasNext()
public boolean hasNext() {
return link.previous != list.voidLink;
}
報錯信息顯示這個link是空,這個link是LinkIterator的一個成員變量
private static final class LinkIterator<ET> implements ListIterator<ET> {
int pos, expectedModCount;
final LinkedList<ET> list;
Link<ET> link, lastLink;
//...
}
如剛才所分析,這個link明明在LinkedListIterator的構造方法里賦值成了voidLink.previous,而這個voidLink.previous在LinkedList構造方法里就賦值成了它自己啊,在之后的poll()和add()之后都不會再有任何一個中間步驟變成null,那問題出在哪里了?
問題可能出在哪里
- 審視整個流程,只有在LinkedList的構造器里面voidLink.previous曾經短暫的等于了null,但是這個賦值動作是在構造器里,那問題也只能是出在這里了,出問題的原因也只可能是Java虛擬機把字節碼指令重排序了,也就是說把構造器里的賦值動作放到了ret指令后面,導致先返回了對象地址之后才執行賦值操作
- 類似問題:單例的double check
public class PreloadManager {
private volatile static PreloadManager sInstance;
public static PreloadManager getInstance() {
if (sInstance == null) {
synchronized(PreloadManager.class) {
if (sInstance == null) {
sInstance = new PreloadManager();
}
}
}
return sInstance;
}
//...
}
sInstance必須要用volatile修飾,有的同學可能說是為了保證線程可見性,但是其實synchronized也可以保證線程可見性(有興趣的同學可以自己去驗證一下),那volatile是為了什么呢?答案是禁止指令重排序(雖然這種說法并不嚴謹)
因為getInstance里面都加上了同步synchronized保護,所以假如執行構造器的時候進行了指令重排序,先執行了ret指令,把對象地址賦值給了sInstance變量之后,才進行構造器里的賦值,這時候恰好進行了線程切換,切換到了線程B,這個時候如果線程A也恰好進行了從工作內存寫入到堆內存(這是JVM里的概念,從計算機硬件的角度來說就是從高速緩存中寫入到主存中,注:JVM并沒有規定應該何時進行寫入,所以加上了“如果”兩個字),那么就會檢測到sInstance不是null,然后訪問成員變量,問題就出現了
- 問:synchronized在這里就沒有用了嗎?
- 答:synchronized/鎖在不發生競態時確實沒有互斥,另外synchronized有線程可見性也表明了synchronized也是有內存屏障(稍后會講到),看了下面的內容再回來仔細體會一下,發現synchronized在這里提供的barrier對上述這種多線程問題來說----沒卵用
那么問題就確定了,就是在構造器里因為重排引起的問題!再來貼一下這段代碼
public LinkedList() {
voidLink = new Link<E>(null, null, null);
voidLink.previous = voidLink;
voidLink.next = voidLink;
}
也就是說重排序之后,線程切換恰好發生在new Link(null, null, null)的時候,導致在另外一個線程調用iterator()時,LinkIterator.link被賦值給了voidLink.previous,然后就出現了空指針,這個問題可以使用volatile解決,那么這個volatile為什么會有作用呢?
指令重排序/線程可見性
- 有關指令重排序的講解網上有很多,我也就不盜圖了,其實這本來就是很正常的一件事,現代處理器都是亂序執行的,這樣可以提高吞吐量(當某些指令不在指令緩存中時,需要從主存中加載指令到高速緩存中,這個時間對于CPU來說是很長的,亂序執行允許CPU執行后面的指令而不是等待當前IO,這種情況主要出現在跳轉指令),只要保證執行結果和順序執行的是一致的就OK。
- 同樣每個CPU都有自己的高速緩存(L1, L2),當某個指令執行到寫回時,也許并沒有把緩存中的數據寫入到CPU共有的內存中去(L3和內存),這樣也就導致了線程可見性
- 因為不一樣的處理器采用不一樣的指令集,所以上述行為可能有很多種,Java內存模型的引入其實也就是提供一種更高的抽象,把這些處理丟給JVM去適配(比如hotspot分了各個操作系統的版本),而Java開發者只需要寫一樣的代碼就可以有意義的執行結果。
Java內存模型的有關原則
(以下內容是抄過來的)
happens-before原則
- 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生于書寫在后面的操作;
- 鎖定規則:一個unLock操作先行發生于后面對同一個鎖額lock操作;
- volatile變量規則:對一個變量的寫操作先行發生于后面對這個變量的讀操作;
- 傳遞規則:如果操作A先行發生于操作B,而操作B又先行發生于操作C,則可以得出操作A先行發生于操作C;
- 線程啟動規則:Thread對象的start()方法先行發生于此線程的每個一個動作;
- 線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生;
- 線程終結規則:線程中所有的操作都先行發生于線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
- 對象終結規則:一個對象的初始化完成先行發生于他的finalize()方法的開始;
as-if-serial
as-if-serial 語義的意思指:不管怎么重排序, 單線程下的執行結果不能被改變(簡直就是廢話)
數據依賴性
如果兩個操作訪問同一個變量,其中一個為寫操作,此時這兩個操作之間存在數據依賴性。編譯器和處理器不會改變存在數據依賴性關系的兩個操作的執行順序,即不會重排序。
volatile語義
是否能重排序 | 第二個操作 | ||
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile讀 | volatile寫 |
普通讀/寫 | N | ||
volatile讀 | N | N | N |
volatile寫 | N | N |
提醒
Java的規范要求只需要保證亂序在單線程里看起來和順序執行一樣就OK了
內存屏障(Memory Barrier)
既然前面講了問題所在,也說到了volatile能解決這個問題,那到底為啥能解決呢?
CPU的角度
其實這種問題不只是出現在Java上,畢竟一切的盡頭都是機器指令,所以只要運行在計算機上都會有這種問題,所以其實指令集也針對亂序在多線程時出現的問題做出了拓展,這里我們以x86為例
- sfence: 內存寫屏障,保證這條指令前的所有的存儲指令必須在這條指令之前執行,并且在執行此條指令時把寫入到CPU的私有緩存的數據刷到公有內存(以下均簡稱主存)
- lfence: 內存讀屏障,保證這條指令后的所有讀取指令在這條指令后執行,并且執行此條指令時,清空CPU的讀取緩存,也就是說強制接下來的load從主存中取數據
- mfence: full barrier,代價最大的barrier,有上述兩種barrier的效果,當然也是最穩健的的barrier
- lock: 這個是一種同步指令,也可以禁止lock前的指令和之后的指令重排序(有興趣的同學可以去看一看這個指令,這個指令稍微復雜一些,可以實現的功能也很多,我就不貼了),lock也許是很多JVM底層使用的指令
上述只是x86指令集下的相關指令,不同的指令集可能barrier的效果并不一樣,fence和lock是兩種實現內存屏障的方式(畢竟一個指令集很龐大)
Java的抽象
Java這個時候又來了一波抽象,他把barrier分成了4種
屏障類型 | 指令示例 | 解釋 |
---|---|---|
LoadLoadBarriers | Load1; LoadLoad;Load2 | 確保 Load1 數據的裝載,之前于Load2 及所有后續裝載指令的裝載。 |
StoreStoreBarriers | Store1; StoreStore;Store2 | 確保 Store1 數據對其他處理器可見(刷新到內存),之前于Store2 及所有后續存儲指令的存儲。 |
LoadStoreBarriers | Load1; LoadStore;Store2 | Load1 數據裝載,之前于Store2 及所有后續的存儲指令刷新到內存。 |
StoreLoadBarriers | Store1; StoreLoad;Load2 | 確保 Store1 數據對其他處理器變得可見(指刷新到內存),之前于Load2 及所有后續裝載指令的裝載。StoreLoad Barriers 會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執行該屏障之后的內存訪問指令。 |
注意,這是Java內存模型里的內存屏障,只是Java的規范,對于不同的處理器/指令集,JVM有不同的實現方式,比如有可能在x86上一個StoreLoad會使用mfence去實現(當然這只是我的意淫)
再次說明一下,這四個barrier是JVM內存模型的規范,而不是具體的字節碼指令,因為你可以看到volatile變量在字節碼中只是一個標志位,javap搞出來的字節碼中并沒有任何的barriers,只是說JVM執行引擎會在執行時會插一個對應的屏障,或者說在JIT/AOT生成機器指令的時候插一條對應邏輯的barriers,說句人話,這個barrier不是javac插的!所以你通過javap看不到,如果想要看到volatile的作用,可以把字節碼轉成匯編(很多很多),具體指令如下
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly [ClassName]
提醒
到這里我們可以看到,其實不存在任何一種指令能夠禁止亂序執行,我們能做到的只是把這一堆指令根據"分段",比如在指令中插入一條full barrier指令,然后所有指令被分成了兩段,barrier前面的我們稱之為程序段A,后面的稱之為程序段B,通過barrier我們能夠保證A所有指令的執行都在B之前,也就是說,程序段A和B分別都是亂序執行的。
再舉個例子,假如我們在一個變量的賦值前后各加一個barrier
full barrier;
instance = new Singleton(); //C
full barrier;
那么在外界看起來就好像是禁止了C處指令重排一樣,其實C處又可以拆成一堆指令,這一堆指令在兩個barrier之間其實又是亂序的
對于內存屏障的使用
volatile
上面我們說了volatile的兩大語義:
- 保證線程可見性
- "禁止"指令重排序(Java5之后引入/修復的)
現在我們來看看JVM到底會對volatile進行怎么樣的處理
- 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障
- 在每個 volatile 寫操作的后面插入一個 StoreLoad 屏障
- 在每個 volatile 讀操作的后面插入一個 LoadLoad 屏障
- 在每個 volatile 讀操作的后面插入一個 LoadStore 屏障
此處盜一波圖(來自《深入理解Java內存模型》,網上可以找到)
結合四種屏障的效果我們來看一下volatile是怎么實現我們最熟知的可見性和解決重排序問題的(上面說到volatile的具體語義已經一目了然了,但是表中的語義貌似和我們平時對volatile的認知關系不大)
(volatile的具體語義是指上述表中普通讀寫和volatile讀寫是否可以重排的關系)
- 可見性:這個主要體現在對volatile的寫上面,當volatile寫之后執行到了萬能的StoreLoad屏障,然后這個屏障的語義可以把所有的寫操作刷新到公共內存中去,并且使得其他緩存中的這個變量的緩存失效,所以下次在此讀取時,就會重新從主存中load
- "禁止"重排序:這個之前也已經解釋清楚了,兩個屏障之間仍是可以亂序的,只是保證了barrier兩側整體之間時順序的
synchronized
synchronized我們都知道就是鎖,但是在java中,synchronized也是可以保證線程可見性的,我們知道信號量只能實現鎖的功能,它是沒有我們之前說過的內存屏障的功能的,那其實synchronized在代碼塊最后也是會加入一個barrier的(應該是store barrier)
final
final除了我們平時所理解的語義之外,其實還蘊含著禁止把構造器final變量的賦值重排序到構造器外面,實現方式就是在final變量的寫之后插入一個store-store barrier
思考
public class Singleton {
public volatile static Singleton sInstance = new Singleton();
public LinkedList<String> mList = new LinkedList<>();
public static void main(String[] args) {
sInstance.mList.add("A");//A
}
}
在A處,add函數內部是不是也被"框"在(sIntance的)屏障中間了呢?
我認為不會,因為sInstance.mList在是一個load操作,add()又是另外一個操作,所以我覺得add應該會在barrier的外面
我的想法是
//store-store barrier
LinkedList<String> list = sInstance.mList;
//store-load barrier
mList.add("A");
(有可能是我理解錯了)
性能
內存屏障禁止了CPU恣意妄為的重排序,所以肯定是會降低一定的效率,不過比synchronized應該還是要好一些的
建議
也不要過度使用volatile,如果是多個線程共有的變量,而且不能確保是沒問題的,那么最好加上volatile(這也提醒我們,盡量減少多線程的公有變量)
一開始的問題
- 我自己也模擬著去復現這個bug,最終僥幸碰上了一次,自己模擬最重要的還是要看怎么樣去誘導JVM/CPU去重排構造函數
- 這個問題或許在現在的項目里存在在各個地方,但是因為這個問題恰好是如果重排了,就會報NullPointer,那對于其他場景,也許只是一個基本變量,所以即使出現了重排導致了問題,可能也只是運行出現異常,而不是直接crash了
參考
- Wikipedia(wiki是個好東西)
- 《深入理解Java內存模型》