Java-指令重排序

Java之指令重排序

背景

問題出現(xiàn)

今天遇到了一個NullPointerException,雖然量不大,但是很怪異,大致長這個樣子

image.png

這是個什么空指針?居然說我LinkedList.iterator().hasNext()方法有問題?可是我就是正常的調用hasNext()啊,怎么就拋出來這種異常了呢?

問題初分析

  • 調用LinkedList.iterator().hasNext()相關的代碼是出現(xiàn)在預加載場景里的,而預加載其實大多數(shù)是在異步線程里進行的,出問題的地方恰好是異步線程和UI線程共同訪問LinkedList的地方(UI線程里add,異步線程里poll),然后我們就會很自然的想到是不是因為在add和poll的過程中發(fā)生了線程切換導致的呢?

LinkedList實現(xiàn)(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為了方便管理,內部實現(xiàn)其實是一個循環(huán)雙向鏈表,voidLink就是連接首尾的那個節(jié)點,使用這么一個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的出隊操作了,驚訝的發(fā)現(xiàn)并沒有任何一個中間環(huán)節(jié)使鏈表上的某一個指針指向了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;
}

這是對應的入隊操作,也沒有發(fā)現(xiàn)任何一個中間步驟讓鏈表上的某個指針指向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,也就是說必然執(zhí)行到

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曾經(jīng)短暫的等于了null,但是這個賦值動作是在構造器里,那問題也只能是出在這里了,出問題的原因也只可能是Java虛擬機把字節(jié)碼指令重排序了,也就是說把構造器里的賦值動作放到了ret指令后面,導致先返回了對象地址之后才執(zhí)行賦值操作
  • 類似問題:單例的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是為了什么呢?答案是禁止指令重排序(雖然這種說法并不嚴謹)

image.png

因為getInstance里面都加上了同步synchronized保護,所以假如執(zhí)行構造器的時候進行了指令重排序,先執(zhí)行了ret指令,把對象地址賦值給了sInstance變量之后,才進行構造器里的賦值,這時候恰好進行了線程切換,切換到了線程B,這個時候如果線程A也恰好進行了從工作內存寫入到堆內存(這是JVM里的概念,從計算機硬件的角度來說就是從高速緩存中寫入到主存中,注:JVM并沒有規(guī)定應該何時進行寫入,所以加上了“如果”兩個字),那么就會檢測到sInstance不是null,然后訪問成員變量,問題就出現(xiàn)了

  • :synchronized在這里就沒有用了嗎?
  • 答:synchronized/鎖在不發(fā)生競態(tài)時確實沒有互斥,另外synchronized有線程可見性也表明了synchronized也是有內存屏障(稍后會講到),看了下面的內容再回來仔細體會一下,發(fā)現(xiàn)synchronized在這里提供的barrier對上述這種多線程問題來說----沒卵用

那么問題就確定了,就是在構造器里因為重排引起的問題!再來貼一下這段代碼

public LinkedList() {
    voidLink = new Link<E>(null, null, null);
    voidLink.previous = voidLink;
    voidLink.next = voidLink;
}

也就是說重排序之后,線程切換恰好發(fā)生在new Link(null, null, null)的時候,導致在另外一個線程調用iterator()時,LinkIterator.link被賦值給了voidLink.previous,然后就出現(xiàn)了空指針,這個問題可以使用volatile解決,那么這個volatile為什么會有作用呢?

指令重排序/線程可見性

  • 有關指令重排序的講解網(wǎng)上有很多,我也就不盜圖了,其實這本來就是很正常的一件事,現(xiàn)代處理器都是亂序執(zhí)行的,這樣可以提高吞吐量(當某些指令不在指令緩存中時,需要從主存中加載指令到高速緩存中,這個時間對于CPU來說是很長的,亂序執(zhí)行允許CPU執(zhí)行后面的指令而不是等待當前IO,這種情況主要出現(xiàn)在跳轉指令),只要保證執(zhí)行結果和順序執(zhí)行的是一致的就OK。
  • 同樣每個CPU都有自己的高速緩存(L1, L2),當某個指令執(zhí)行到寫回時,也許并沒有把緩存中的數(shù)據(jù)寫入到CPU共有的內存中去(L3和內存),這樣也就導致了線程可見性
  • 因為不一樣的處理器采用不一樣的指令集,所以上述行為可能有很多種,Java內存模型的引入其實也就是提供一種更高的抽象,把這些處理丟給JVM去適配(比如hotspot分了各個操作系統(tǒng)的版本),而Java開發(fā)者只需要寫一樣的代碼就可以有意義的執(zhí)行結果。

Java內存模型的有關原則

(以下內容是抄過來的)

happens-before原則
  1. 程序次序規(guī)則:一個線程內,按照代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作;
  2. 鎖定規(guī)則:一個unLock操作先行發(fā)生于后面對同一個鎖額lock操作;
  3. volatile變量規(guī)則:對一個變量的寫操作先行發(fā)生于后面對這個變量的讀操作;
  4. 傳遞規(guī)則:如果操作A先行發(fā)生于操作B,而操作B又先行發(fā)生于操作C,則可以得出操作A先行發(fā)生于操作C;
  5. 線程啟動規(guī)則:Thread對象的start()方法先行發(fā)生于此線程的每個一個動作;
  6. 線程中斷規(guī)則:對線程interrupt()方法的調用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生;
  7. 線程終結規(guī)則:線程中所有的操作都先行發(fā)生于線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經(jīng)終止執(zhí)行;
  8. 對象終結規(guī)則:一個對象的初始化完成先行發(fā)生于他的finalize()方法的開始;
as-if-serial

as-if-serial 語義的意思指:不管怎么重排序, 單線程下的執(zhí)行結果不能被改變(簡直就是廢話)

數(shù)據(jù)依賴性

如果兩個操作訪問同一個變量,其中一個為寫操作,此時這兩個操作之間存在數(shù)據(jù)依賴性。編譯器和處理器不會改變存在數(shù)據(jù)依賴性關系的兩個操作的執(zhí)行順序,即不會重排序。

volatile語義
是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 N
volatile讀 N N N
volatile寫 N N
提醒

Java的規(guī)范要求只需要保證亂序在單線程里看起來和順序執(zhí)行一樣就OK了

內存屏障(Memory Barrier)

既然前面講了問題所在,也說到了volatile能解決這個問題,那到底為啥能解決呢?

CPU的角度

其實這種問題不只是出現(xiàn)在Java上,畢竟一切的盡頭都是機器指令,所以只要運行在計算機上都會有這種問題,所以其實指令集也針對亂序在多線程時出現(xiàn)的問題做出了拓展,這里我們以x86為例

  • sfence: 內存寫屏障,保證這條指令前的所有的存儲指令必須在這條指令之前執(zhí)行,并且在執(zhí)行此條指令時把寫入到CPU的私有緩存的數(shù)據(jù)刷到公有內存(以下均簡稱主存)
  • lfence: 內存讀屏障,保證這條指令后的所有讀取指令在這條指令后執(zhí)行,并且執(zhí)行此條指令時,清空CPU的讀取緩存,也就是說強制接下來的load從主存中取數(shù)據(jù)
  • mfence: full barrier,代價最大的barrier,有上述兩種barrier的效果,當然也是最穩(wěn)健的的barrier
  • lock: 這個是一種同步指令,也可以禁止lock前的指令和之后的指令重排序(有興趣的同學可以去看一看這個指令,這個指令稍微復雜一些,可以實現(xiàn)的功能也很多,我就不貼了),lock也許是很多JVM底層使用的指令

上述只是x86指令集下的相關指令,不同的指令集可能barrier的效果并不一樣,fence和lock是兩種實現(xiàn)內存屏障的方式(畢竟一個指令集很龐大)

Java的抽象

Java這個時候又來了一波抽象,他把barrier分成了4種

屏障類型 指令示例 解釋
LoadLoadBarriers Load1; LoadLoad;Load2 確保 Load1 數(shù)據(jù)的裝載,之前于Load2 及所有后續(xù)裝載指令的裝載。
StoreStoreBarriers Store1; StoreStore;Store2 確保 Store1 數(shù)據(jù)對其他處理器可見(刷新到內存),之前于Store2 及所有后續(xù)存儲指令的存儲。
LoadStoreBarriers Load1; LoadStore;Store2 Load1 數(shù)據(jù)裝載,之前于Store2 及所有后續(xù)的存儲指令刷新到內存。
StoreLoadBarriers Store1; StoreLoad;Load2 確保 Store1 數(shù)據(jù)對其他處理器變得可見(指刷新到內存),之前于Load2 及所有后續(xù)裝載指令的裝載。StoreLoad Barriers 會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執(zhí)行該屏障之后的內存訪問指令。

注意,這是Java內存模型里的內存屏障,只是Java的規(guī)范,對于不同的處理器/指令集,JVM有不同的實現(xiàn)方式,比如有可能在x86上一個StoreLoad會使用mfence去實現(xiàn)(當然這只是我的意淫)

再次說明一下,這四個barrier是JVM內存模型的規(guī)范,而不是具體的字節(jié)碼指令,因為你可以看到volatile變量在字節(jié)碼中只是一個標志位,javap搞出來的字節(jié)碼中并沒有任何的barriers,只是說JVM執(zhí)行引擎會在執(zhí)行時會插一個對應的屏障,或者說在JIT/AOT生成機器指令的時候插一條對應邏輯的barriers,說句人話,這個barrier不是javac插的!所以你通過javap看不到,如果想要看到volatile的作用,可以把字節(jié)碼轉成匯編(很多很多),具體指令如下

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly [ClassName]

提醒

到這里我們可以看到,其實不存在任何一種指令能夠禁止亂序執(zhí)行,我們能做到的只是把這一堆指令根據(jù)"分段",比如在指令中插入一條full barrier指令,然后所有指令被分成了兩段,barrier前面的我們稱之為程序段A,后面的稱之為程序段B,通過barrier我們能夠保證A所有指令的執(zhí)行都在B之前,也就是說,程序段A和B分別都是亂序執(zhí)行的

再舉個例子,假如我們在一個變量的賦值前后各加一個barrier

full barrier;
instance = new Singleton(); //C
full barrier;

那么在外界看起來就好像是禁止了C處指令重排一樣,其實C處又可以拆成一堆指令,這一堆指令在兩個barrier之間其實又是亂序的

對于內存屏障的使用

volatile

上面我們說了volatile的兩大語義:

  • 保證線程可見性
  • "禁止"指令重排序(Java5之后引入/修復的)

現(xiàn)在我們來看看JVM到底會對volatile進行怎么樣的處理

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障
  • 在每個 volatile 寫操作的后面插入一個 StoreLoad 屏障
  • 在每個 volatile 讀操作的后面插入一個 LoadLoad 屏障
  • 在每個 volatile 讀操作的后面插入一個 LoadStore 屏障

此處盜一波圖(來自《深入理解Java內存模型》,網(wǎng)上可以找到)

image.png
image.png

結合四種屏障的效果我們來看一下volatile是怎么實現(xiàn)我們最熟知的可見性和解決重排序問題的(上面說到volatile的具體語義已經(jīng)一目了然了,但是表中的語義貌似和我們平時對volatile的認知關系不大)

(volatile的具體語義是指上述表中普通讀寫和volatile讀寫是否可以重排的關系)

  • 可見性:這個主要體現(xiàn)在對volatile的寫上面,當volatile寫之后執(zhí)行到了萬能的StoreLoad屏障,然后這個屏障的語義可以把所有的寫操作刷新到公共內存中去,并且使得其他緩存中的這個變量的緩存失效,所以下次在此讀取時,就會重新從主存中l(wèi)oad
  • "禁止"重排序:這個之前也已經(jīng)解釋清楚了,兩個屏障之間仍是可以亂序的,只是保證了barrier兩側整體之間時順序的

synchronized

synchronized我們都知道就是鎖,但是在java中,synchronized也是可以保證線程可見性的,我們知道信號量只能實現(xiàn)鎖的功能,它是沒有我們之前說過的內存屏障的功能的,那其實synchronized在代碼塊最后也是會加入一個barrier的(應該是store barrier)

final

final除了我們平時所理解的語義之外,其實還蘊含著禁止把構造器final變量的賦值重排序到構造器外面,實現(xiàn)方式就是在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函數(shù)內部是不是也被"框"在(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(這也提醒我們,盡量減少多線程的公有變量)

一開始的問題

  • 我自己也模擬著去復現(xiàn)這個bug,最終僥幸碰上了一次,自己模擬最重要的還是要看怎么樣去誘導JVM/CPU去重排構造函數(shù)
  • 這個問題或許在現(xiàn)在的項目里存在在各個地方,但是因為這個問題恰好是如果重排了,就會報NullPointer,那對于其他場景,也許只是一個基本變量,所以即使出現(xiàn)了重排導致了問題,可能也只是運行出現(xiàn)異常,而不是直接crash了

參考

  • Wikipedia(wiki是個好東西)
  • 《深入理解Java內存模型》
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容