1. 程式人生 > >由Java引起的指令重排序思考

由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原則
  1. 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
  2. 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
  3. volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;
  4. 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
  5. 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;
  6. 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;
  7. 執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行;
  8. 物件終結規則:一個物件的初始化完成先行發生於他的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記憶體模型》