從CPU Cache出發徹底弄懂volatile/synchronized/cas機制

Anwen發表於2019-02-19

個人技術部落格:www.zhenganwen.top

變數可見嗎

共享變數可見嗎

首先引入一段程式碼指出Java記憶體模型存在的問題:啟動兩個執行緒t1,t2訪問共享變數sharedVariablet2執行緒逐漸將sharedVariable自增到MAX,每自增一次就休眠500ms放棄CPU執行權,期望此間另外一個執行緒t1能夠在第7-12行輪詢過程中發現到sharedVariable的改變並將其列印

private static int sharedVariable = 0;
private static final int MAX = 10;

public static void main(String[] args) {
    new Thread(() -> {
        int oldValue = sharedVariable;
        while (sharedVariable < MAX) {
            if (sharedVariable != oldValue) {
                System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                oldValue = sharedVariable;
            }
        }
    }, "t1").start();

    new Thread(() -> {
        int oldValue = sharedVariable;
        while (sharedVariable < MAX) {
            System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
            sharedVariable = oldValue;
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }, "t2").start();

}
複製程式碼

但上述程式的實際執行結果如下:

t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 1->2
t2 do the change : 2->3
t2 do the change : 3->4
t2 do the change : 4->5
t2 do the change : 5->6
t2 do the change : 6->7
t2 do the change : 7->8
t2 do the change : 8->9
t2 do the change : 9->10
複製程式碼

volatile能夠保證可見性

可以發現t1執行緒幾乎察覺不到t2每次對共享變數sharedVariable所做的修改,這是為什麼呢?也許會有人告訴你給sharedVariable加個volatile修飾就好了,確實,加了volatile之後的輸出達到我們的預期了:

t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 1->2
t1 watched the change : 1->2
t2 do the change : 2->3
t1 watched the change : 2->3
t2 do the change : 3->4
t1 watched the change : 3->4
t2 do the change : 4->5
t1 watched the change : 4->5
t2 do the change : 5->6
t1 watched the change : 5->6
t2 do the change : 6->7
t1 watched the change : 6->7
t2 do the change : 7->8
t1 watched the change : 7->8
t2 do the change : 8->9
t1 watched the change : 8->9
t2 do the change : 9->10
複製程式碼

這也比較好理解,官方說volatile能夠保證共享變數線上程之間的可見性。

synchronized能保證可見性嗎?

但是,也可能會有人跟你說,你使用synchronized + wait/notify模型就好了:將所有對共享變數操作都放入同步程式碼塊,然後使用wait/notify協調共享變數的修改和讀取

private static int sharedVariable = 0;
private static final int MAX = 10;
private static Object lock = new Object();
private static boolean changed = false;

public static void main(String[] args) {
    new Thread(() -> {
        synchronized (lock) {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                while (!changed) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() +
                                   " watched the change : " + oldValue + "->" + sharedVariable);
                oldValue = sharedVariable;
                changed = false;
                lock.notifyAll();
            }
        }
    }, "t1").start();

    new Thread(() -> {
        synchronized (lock) {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                while (changed) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() +
                                   " do the change : " + sharedVariable + "->" + (++oldValue));
                sharedVariable = oldValue;
                changed = true;
                lock.notifyAll();
            }
        }
    }, "t2").start();

}
複製程式碼

你會發現這種方式即使沒有給sharedVariablechangedvolatile,但他們在t1t2之間似乎也是可見的:

t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 0->2
t1 watched the change : 0->2
t2 do the change : 0->3
t1 watched the change : 0->3
t2 do the change : 0->4
t1 watched the change : 0->4
t2 do the change : 0->5
t1 watched the change : 0->5
t2 do the change : 0->6
t1 watched the change : 0->6
t2 do the change : 0->7
t1 watched the change : 0->7
t2 do the change : 0->8
t1 watched the change : 0->8
t2 do the change : 0->9
t1 watched the change : 0->9
t2 do the change : 0->10
t1 watched the change : 0->10
複製程式碼

CAS能保證可見性嗎?

sharedVariable的型別改為AtomicIntegert2執行緒使用AtomicInteger提供的getAndSetCAS更新該變數,你會發現這樣這能做到可見性。

private static AtomicInteger sharedVariable = new AtomicInteger(0);
private static final int MAX = 10;

public static void main(String[] args) {
    new Thread(() -> {
        int oldValue = sharedVariable.get();
        while (sharedVariable.get() < MAX) {
            if (sharedVariable.get() != oldValue) {
                System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                oldValue = sharedVariable.get();
            }
        }
    }, "t1").start();

    new Thread(() -> {
        int oldValue = sharedVariable.get();
        while (sharedVariable.get() < MAX) {
            System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
            sharedVariable.set(oldValue);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }, "t2").start();

}
複製程式碼

為什麼synchronizedCAS也能做到可見性呢?其實這是因為synchronized鎖釋放-獲取CAS修改-讀取都有著和volatile域的寫-讀有相同的語義。既然這麼神奇,那就讓我們一起去Java記憶體模型、synchronized/volatile/CAS的底層實現一探究竟吧!

CPU Cache

要理解變數線上程間的可見性,首先我們要了解CPU的讀寫模型,雖然可能有些無聊,但這對併發程式設計的理解有很大的幫助!

主存RAM & 快取記憶體Cache

在計算機技術發展過程中,主儲存器存取速度一直比CPU操作速度慢得多,這使得CPU的高速處理能力不能充分發揮,整個計算機系統的工作效率受到影響,因此現代處理器一般都引入了高速緩衝儲存器(簡稱快取記憶體)。

快取記憶體的存取速度能與CPU相匹配,但因造價高昂因此容量較主存小很多。據程式區域性性原理,當CPU試圖訪問主存中的某一單元(一個儲存單元對應一個位元組)時,其鄰近的那些單元在隨後將被用到的可能性很大。因而,當CPU存取主存單元時,計算機硬體就自動地將包括該單元在內的那一組單元(稱之為記憶體塊block,通常是連續的64個位元組)內容調入快取記憶體,CPU即將存取的主存單元很可能就在剛剛調入到快取記憶體的那一組單元內。於是,CPU就可以直接對快取記憶體進行存取。在整個處理過程中,如果CPU絕大多數存取主存的操作能被存取快取記憶體所代替,計算機系統處理速度就能顯著提高。

Cache相關術語

以下術語在初次接觸時可能會一知半解,but take it easy,後文的講解將逐步揭開你心中的謎團。

Cache Line & Slot & Hot Data

前文說道,CPU請求訪問主存中的某一儲存單元時,會將包括該儲存單元在內的那一組單元都調入快取記憶體。這一組單元(我們通常稱之為記憶體塊block)將會被存放在快取記憶體的快取行中(cache line,也叫slot)。快取記憶體會將其儲存單元均分成若干等份,每一等份就是一個快取行,如今主流CPU的快取行一般都是64個位元組(也就是說如果快取記憶體大小為512位元組,那麼就對應有8個快取行)。

另外,被快取行快取的資料稱之為熱點資料(hot data)。

Cache Hit

當CPU通過暫存器中儲存的資料地址請求訪問資料時(包括讀操作和寫操作),首先會在Cache中查詢,如果找到了則直接返回Cache中儲存的資料,這稱為快取命中(cache hit),根據操作型別又可分為讀快取命中和寫快取命中。

Cache Miss & Hit Latency

與cache hit相對應,如果沒有找到那麼將會通過系統匯流排(System Bus)到主存中找,這稱為快取缺失(cache miss)。如果發生了快取缺失,那麼原本應該直接存取主存的操作因為Cache的存在,浪費了一些時間,這稱為命中延遲(hit latency)。確切地說,命中延遲是指判斷Cache中是否快取了目標資料所花的時間。

Cache分級

如果開啟你的工作管理員檢視CPU效能,你可能會發現筆者的快取記憶體有三塊區域:L1(一級快取,128KB)、L2(二級快取,512KB)、L3(共享快取3.0MB):

image

起初Cache的實現只有一級快取L1,後來隨著科技的發展,一方面主存的增大導致需要快取的熱點資料變多,單純的增大L1的容量所獲取的價效比會很低;另一方面,L1的存取速度和主存的存取速度進一步拉大,需要一個基於兩者存取速度之間的快取做緩衝。基於以上兩點考慮,引入了二級快取L2,它的存取速度介於L1和主存之間且存取容量在L1的基礎上進行了擴容。

上述的L1和L2一般都是處理器私有的,也就是說每個CPU核心都有它自己的L1和L2並且是不與其他核心共享的。這時,為了能有一塊所有核心都共享的快取區域,也為了防止L1和L2都發生快取缺失而進一步提高快取命中率,加入了L3。可以猜到L3比L1、L2的存取速度都慢,但容量較大。

Cache替換演算法 & Cache Line Conflict

為了保證CPU訪問時有較高的命中率,Cache中的內容應該按一定的演算法替換。一種較常用的演算法是“最近最少使用演算法”(LRU演算法),它是將最近一段時間內最少被訪問過的行淘汰出局。因此需要為每行設定一個計數器,LRU演算法是把命中行的計數器清零,其他各行計數器加1。當需要替換時淘汰行計數器計數值最大的資料行出局。這是一種高效、科學的演算法,其計數器清零過程可以把一些頻繁呼叫後再不需要的資料(對應計數值最大的資料)淘汰出Cache,提高Cache的利用率。

Cache相對於主存來說容量是極其有限的,因此無論如何實現Cache的儲存機制(後文快取關聯絡將會詳細說明),如果不採取合適的替換演算法,那麼隨著Cache的使用不可避免會出現Cache中所有Cache Line都被佔用導致需要快取新的記憶體塊時無法分配Cache Line的情況;或者是根據Cache的儲存機制,為該記憶體塊分配的Cache Line正在使用中。以上兩點均會導致新的記憶體塊無Cache Line存放,這叫做Cache Line Conflict。

CPU快取架構

至此,我們大致能夠得到一個CPU快取架構了:

k8Wd6P.png

如圖當CPU試圖通過某一儲存單元地址訪問資料時,它會自上而下依次從L1、L2、L3、主存中查詢,若找到則直接返回對應Cache中的資料而不再向下查詢,如果L1、L2、L3都cache miss了,那麼CPU將不得不通過匯流排訪問主存或者硬碟上的資料。且通過下圖所示的各硬體存取操作所需的時鐘週期(cycle,CPU主頻的倒數就是一個時鐘週期)可以知道,自上而下,存取開銷越來越大,因此Cache的設計需儘可能地提高快取命中率,否則如果到最後還是要到記憶體中存取將得不償失。

k8hcMq.png

為了方便大家理解,筆者摘取了酷殼中的一篇段子:

我們知道計算機的計算資料需要從磁碟排程到記憶體,然後再排程到L2 Cache,再到L1 Cache,最後進CPU暫存器進行計算。

給老婆在電腦城買本本的時候向電腦推銷人員問到這些引數,老婆聽不懂,讓我給她解釋,解釋完後,老婆說,“原來電腦內部這麼麻煩,怪不得電腦總是那麼慢,直接操作記憶體不就快啦”。我是那個汗啊。

我只得向她解釋,這樣做是為了更快速的處理,她不解,於是我打了下面這個比喻——這就像我們喂寶寶吃奶一樣:

  • CPU就像是已經在寶寶嘴裡的奶一樣,直接可以嚥下去了。需要1秒鐘

  • L1快取就像是已衝好的放在奶瓶裡的奶一樣,只要把孩子抱起來才能喂到嘴裡。需要5秒鐘。

  • L2快取就像是家裡的奶粉一樣,還需要先熱水衝奶,然後把孩子抱起來喂進去。需要2分鐘。

  • 記憶體RAM就像是各個超市裡的奶粉一樣,這些超市在城市的各個角落,有的遠,有的近,你先要定址,然後還要去商店上門才能得到。需要1-2小時。

  • 硬碟DISK就像是倉庫,可能在很遠的郊區甚至工廠倉庫。需要大卡車走高速公路才能運到城市裡。需要2-10天。

所以,在這樣的情況下——

  • 我們不可能在家裡不存放奶粉。試想如果得到孩子餓了,再去超市買,這不更慢嗎?

  • 我們不可以把所有的奶粉都衝好放在奶瓶裡,因為奶瓶不夠。也不可能把超市裡的奶粉都放到家裡,因為房價太貴,這麼大的房子不可能買得起。

  • 我們不可能把所有的倉庫裡的東西都放在超市裡,因為這樣幹成本太大。而如果超市的貨架上正好賣完了,就需要從庫房甚至廠商工廠裡調,這在計算裡叫換頁,相當的慢。

Cache結構和快取關聯性

如果讓你來設計這樣一個Cache,你會如何設計?

如果你跟筆者一樣非科班出身,也許會覺得使用雜湊表是一個不錯的選擇,一個記憶體塊對應一條記錄,使用記憶體塊的地址的雜湊值作為鍵,使用記憶體塊儲存的資料作為值,時間複雜度O(1)內完成查詢,簡單又高效。

但是如果你每一次快取記憶體塊前都對地址做雜湊運算,那麼所需時間可能會遠遠大於Cache存取所需的幾十個時鐘週期時間,並且這可不是我們應用程式常用的memcache,這裡的Cache是實實在在的硬體,在硬體層面上去實現一個對記憶體地址雜湊的邏輯未免有些趕鴨子上架的味道。

以我們常見的X86晶片為例,Cache的結構下圖所示:整個Cache被分為S個組,每個組又有E行個最小的儲存單元——Cache Line所組成,而一個Cache Line中有B(B=64)個位元組用來儲存資料,即每個Cache Line能儲存64個位元組的資料,每個Cache Line又額外包含1個有效位(valid bit)、t個標記位(tag bit),其中valid bit用來表示該快取行是否有效tag bit用來協助定址唯一標識儲存在Cache Line中的塊;而Cache Line裡的64個位元組其實是對應記憶體地址中的資料拷貝。根據Cache的結構,我們可以推算出每一級Cache的大小為B×E×S。

從CPU Cache出發徹底弄懂volatile/synchronized/cas機制

快取設計的一個關鍵決定是確保每個主存塊(block)能夠儲存在任何一個快取槽裡,或者只是其中一些(此處一個槽位就是一個快取行)。

有三種方式將快取槽對映到主存塊中:

  1. 直接對映(Direct mapped cache) 每個記憶體塊只能對映到一個特定的快取槽。一個簡單的方案是通過塊索引block_index對映到對應的槽位(block_index % cache_slots)。被對映到同一記憶體槽上的兩個記憶體塊是不能同時換入快取的。(注:block_index可以通過實體地址/快取行位元組計算得到)
  2. N路組關聯(N-way set associative cache) 每個記憶體塊能夠被對映到N路特定快取槽中的任意一路。比如一個16路快取,每個記憶體塊能夠被對映到16路不同的快取槽。一般地,具有一定相同低bit位地址的記憶體塊將共享16路快取槽。(譯者注:相同低位地址表明相距一定單元大小的連續記憶體)
  3. 完全關聯(Fully associative cache) 每個記憶體塊能夠被對映到任意一個快取槽。操作效果上相當於一個雜湊表。

其中N路組關聯是根據另外兩種方式改進而來,是現在的主流實現方案。下面將對這三種方式舉例說明。

Fully associative cache

Fully associative,顧名思義全關聯。就是說對於要快取的一個記憶體塊,可以被快取在Cache的任意一個Slot(即快取行)中。以32位作業系統(意味著到記憶體定址時是通過32位地址)為例,比如有一個0101...10 000000 - 0101...10 111111(為了節省版面省略了高26位中的部分bit位,這個區間代表高26位相同但低6位不同的64個地址,即64位元組的記憶體塊)記憶體塊需要快取,那麼它將會被隨機存放到一個可用的Slot中,並將高26位作為該Slot的tag bit(前文說到每行除了儲存記憶體塊的64位元組Cache Line,還額外有1個bit標識該行是否有效和t個bit作為該行的唯一ID,本例中t就是26)。這樣當記憶體需要存取這個地址範圍內的資料地址時,首先會去Cache中找是否快取了高26位(tag bit)為0101...10的Slot,如果找到了再根據資料地址的低6位定位到Cache Line的某個儲存單元上,這個低6位稱為位元組偏移(word offset)

可能你會覺得這不就是雜湊表嗎?的確,它在決定將記憶體塊放入哪個可用的Slot時是隨機的,但是它並沒有將資料地址做雜湊運算並以雜湊值作為tag bit,因此和雜湊表還是有本質的區別的。

此種方式沒有得到廣泛應用的原因是,記憶體塊會被放入哪個Slot是未知的,因此CPU在根據資料地址查詢Slot時需要將資料地址的高位(本例中是高26位)和Cache中的所有Slot的tag bit做線性查詢,以我的L1 128KB為例,有128 * 1024 / 64 = 2048個Slot,雖然可以在硬體層面做並行處理,但是效率並不可觀。

Direct Mapped Cache

這種方式就是首先將主存中的記憶體塊和Cache中的Slot分別編碼得到block_indexslot_index,然後將block_indexslot_index取模從而決定某記憶體塊應該放入哪個Slot中,如下圖所示:

image

下面將以我的L1 Cache 128KB,記憶體4GB為例進行分析:

4GB記憶體的定址範圍是000...000(32個0)到111...111(32個1),給定一個32位的資料地址,如何判斷L1 Cache中是否快取了該資料地址的資料?

首先將32位地址分成如下三個部分:

image

如此的話對於給定的32位資料地址,首先不管低6位,取出中間的slot offset個bit位,定位出是哪一個Slot,然後比較該Slot的tag bit是否和資料地址的剩餘高位匹配,如果匹配那麼表示Cache Hit,最後在根據低6位從該Slot的Cache Line中找到具體的儲存單元進行存取資料。

Direct Mapped Cache的缺陷是,低位相同但高位不同的記憶體塊會被對映到同一個Slot上(因為對SlotCount取模之後結果相同),如果碰巧CPU請求存取這些記憶體塊,那麼將只有一個記憶體塊能夠被快取到Cache中對應的Slot上,也就是說容易發生Cache Line Conflict。

N-Way Set Associative Cache

N路組關聯,是對Direct Mapped Cache和Full Associative Cache的一個結合,思路是不要對於給定的資料地址就定死了放在哪個Slot上。

如同上文給出的x86的Cache結構圖那樣,先將Cache均分成S個組,每個組都有E個Slot。假設將我的L1 Cache 128KB按16個Slot劃分為一個組,那麼組數為:128 * 1024 / 64(Slot數)/ 16 = 128 個組(我們將每個組稱為一個Set,表示一組Slot的集合)。如此的話,對於給定的一個資料地址,仍將其分為以下三部分:

image

與Direct Mapped Cache不同的地方就是將原本表示對映到哪個Slot的11箇中間bit位改成了用7個bit位表示對映到哪個Set上,在確定Set之後,記憶體塊將被放入該Set的哪個Slot是隨機的(可能當時哪個可以用就放到哪個了),然後以剩餘的高位19個bit位作為最終存放該記憶體塊的tag bit

這樣做的好處就是,對於一個給定的資料地址只會將其對映到特定的Set上,這樣就大大減小了Cache Line Conflict的機率,並且CPU在查詢Slot時只需在具體的某個Set中線性查詢,而Set中的Slot個數較少(分組分得越多,每個組的Slot就越少),這樣線性查詢的時間複雜度也近似O(1)了。

如何編寫對Cache Hit友好的程式

通過前面對CPU讀寫模型的理解,我們知道一旦CPU要從記憶體中訪問資料就會產生一個較大的時延,程式效能顯著降低,所謂遠水救不了近火。為此我們不得不提高Cache命中率,也就是充分發揮區域性性原理

區域性性包括時間區域性性、空間區域性性。

  • 時間區域性性:對於同一資料可能被多次使用,自第一次載入到Cache Line後,後面的訪問就可以多次從Cache Line中命中,從而提高讀取速度(而不是從下層快取讀取)。
  • 空間區域性性:一個Cache Line有64位元組塊,我們可以充分利用一次載入64位元組的空間,把程式後續會訪問的資料,一次性全部載入進來,從而提高Cache Line命中率(而不是重新去定址讀取)。

讀取時儘量讀取相鄰的資料地址

首先來看一下遍歷二維陣列的兩種方式所帶來的不同開銷:

static int[][] arr = new int[10000][10000];
public static void main(String[] args) {
    m1();		//輸出 16
    m2();		//輸出 1202	每次測試的結果略有出入
}
public static void m1() {
    long begin = System.currentTimeMillis();
    int a;
    for (int i = 0; i < arr.length; i++) {
        for (int j = 0; j < arr[i].length; j++) {
            a = arr[i][j];
        }
    }
    long end = System.currentTimeMillis();
    System.out.println(end - begin + "================");
}
public static void m2() {
    long begin = System.currentTimeMillis();
    int a;
    for (int j = 0; j < arr[0].length; j++) {
        for (int i = 0; i < arr.length; i++) {
            a = arr[i][j];
        }
    }
    long end = System.currentTimeMillis();
    System.out.println(end - begin + "================");
}
複製程式碼

經過多次測試發現逐列遍歷的效率明顯低於逐行遍歷,這是因為按行遍歷時資料地址是相鄰的,因此可能會對連續16個int變數(16x4=64位元組)的訪問都是訪問同一個Cache Line中的內容,在訪問第一個int變數並將包括其在內連續64位元組加入到Cache Line之後,對後續int變數的訪問直接從該Cache Line中取就行了,不需要其他多餘的操作。而逐列遍歷時,如果列數超多16,意味著一行有超過16個int變數,每行的起始地址之間的間隔超過64位元組,那麼每行的int變數都不會在同一個Cache Line中,這會導致Cache Miss重新到記憶體中載入記憶體塊,並且每次跨快取行讀取,都會比逐行讀取多一個Hit Latency的開銷。

上例中的ij體現了時間區域性性,ij作為迴圈計數器被頻繁操作,將被存放在暫存器中,CPU每次都能以最快的方式訪問到他們,而不會從Cache、主存等其他地方訪問。

而優先遍歷一行中相鄰的元素則利用了空間區域性性,一次性載入地址連續的64個位元組到Cache Line中有利於後續相鄰地址元素的快速訪問。

Cache Consistency & Cache Lock & False Sharing

那麼是不是任何時候,操作同一快取行比跨快取行操作的效能都要好呢?沒有萬能的機制,只有針對某一場景最合適的機制,連續緊湊的記憶體分配(Cache的最小儲存單位是Cache Line)也有它的弊端。

這個弊端就是快取一致性引起的,由於每個CPU核心都有自己的Cache(通常是L1和L2),並且大多數情況下都是各自訪問各自的Cache,這很有可能導致各Cache中的資料副本以及主存中的共享資料之間各不相同,有時我們需要呼叫各CPU相互協作,這時就不得不以主存中的共享資料為準並讓各Cache保持與主存的同步,這時該怎麼辦呢?

這個時候快取一致性協議就粉墨登場了:如果(各CPU)你們想讓快取行和主存保持同步,你們都要按我的規則來修改共享變數

這是一個跟蹤每個快取行的狀態的快取子系統。該系統使用一個稱為 “匯流排動態監視” 或者稱為*“匯流排嗅探”* 的技術來監視在系統匯流排上發生的所有事務,以檢測快取中的某個地址上何時發生了讀取或寫入操作。

當這個快取子系統在系統匯流排上檢測到對快取中載入的記憶體區域進行的讀取操作時,它會將該快取行的狀態更改為 “shared”。如果它檢測到對該地址的寫入操作時,會將快取行的狀態更改為 “invalid”

該快取子系統想知道,當該系統在監視系統匯流排時,系統是否在其快取中包含資料的惟一副本。如果資料由它自己的 CPU 進行了更新,那麼這個快取子系統會將快取行的狀態從 “exclusive” 更改為 “modified”。如果該快取子系統檢測到另一個處理器對該地址的讀取,它會阻止訪問,更新系統記憶體中的資料,然後允許該處理的訪問繼續進行。它還允許將該快取行的狀態標記為 shared

簡而言之就是各CPU都會通過匯流排嗅探來監視其他CPU,一旦某個CPU對自己Cache中快取的共享變數做了修改(能做修改的前提是共享變數所在的快取行的狀態不是無效的),那麼就會導致其他快取了該共享變數的CPU將該變數所在的Cache Line置為無效狀態,在下次CPU訪問無效狀態的快取行時會首先要求對共享變數做了修改的CPU將修改從Cache寫回主存,然後自己再從主存中將最新的共享變數讀到自己的快取行中。

並且,快取一致性協議通過快取鎖定來保證CPU修改快取行中的共享變數並通知其他CPU將對應快取行置為無效這一操作的原子性,即當某個CPU修改位於自己快取中的共享變數時會禁止其他也快取了該共享變數的CPU訪問自己快取中的對應快取行,並在快取鎖定結束前通知這些CPU將對應快取行置為無效狀態。

在快取鎖定出現之前,是通過匯流排鎖定來實現CPU之間的同步的,即CPU在回寫主存時會鎖定匯流排不讓其他CPU訪問主存,但是這種機制開銷較大,一個CPU對共享變數的操作會導致其他CPU對其他共享變數的訪問。

快取一致性協議雖然保證了Cache和主存的同步,但是又引入了一個新的的問題:偽共享(False Sharing)。

如下圖所示,資料X、Y、Z被載入到同一Cache Line中,執行緒A在Core1修改X,執行緒B在Core2上修改Y。根據MESI(可見文尾百科連結)大法,假設是Core1是第一個發起操作的CPU核,Core1上的L1 Cache Line由S(共享)狀態變成M(修改,髒資料)狀態,然後告知其他的CPU核,圖例則是Core2,引用同一地址的Cache Line已經無效了;當Core2發起寫操作時,首先導致Core1將X寫回主存,Cache Line狀態由M變為I(無效),而後才是Core2從主存重新讀取該地址內容,Cache Line狀態由I變成E(獨佔),最後進行修改Y操作, Cache Line從E變成M。可見多個執行緒操作在同一Cache Line上的不同資料,相互競爭同一Cache Line,導致執行緒彼此牽制影響(這一行為稱為乒乓效應),變成了序列程式,降低了併發性。此時我們則需要將共享在多執行緒間的資料進行隔離,使他們不在同一個Cache Line上,從而提升多執行緒的效能。

從CPU Cache出發徹底弄懂volatile/synchronized/cas機制

Cache Line偽共享的兩種解決方案:

  • 快取行填充(Cache Line Padding),通過增加兩個變數的地址距離使之位於兩個不同的快取行上,如此對共享變數X和Y的操作不會相互影響。
  • 執行緒不直接操作全域性共享變數,而是將全域性共享變數讀取一份副本到自己的區域性變數,區域性變數線上程之間是不可見的因此隨你執行緒怎麼玩,最後執行緒再將玩出來的結果寫回全域性變數。

Cache Line Padding

著名的併發大師Doug Lea就曾在JDK7的LinkedTransferQueue中通過追加位元組的方式提高佇列的操作效率:

public class LinkedTransferQueue<E>{
    private PaddedAtomicReference<QNode> head;
    private PaddedAtomicReference<QNode> tail;
    static final class PaddedAtomicReference<E> extends AtomicReference<T{
        //給物件追加了 15 * 4 = 60 個位元組
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
        PaddedAtomicReference(T r){
            super(r);
        }
    }
}
public class AtomicReference<V> implements Serializable{
    private volatile V value;
}
複製程式碼

你能否看懂第6行的用意?這還要從物件的記憶體佈局說起,讀過《深入理解Java虛擬機器(第二版)》的人應該知道非陣列物件的記憶體佈局是這樣的

  • 物件頭

    物件頭又分為一下三個部分:

    • Mark Word,根據JVM的位數不同表現為32位或64位,存放物件的hashcode、分代年齡、鎖標誌位等。該部分的資料可被複用,指向偏向執行緒的ID或指向棧中的Displaced Mark Word又或者指向重量級鎖。
    • Class Mete Data,型別指標(也是32位或64位),指向該物件所屬的類位元組碼在被載入到JVM之後存放在方法區中的型別資訊。
    • Array Length,如果是陣列物件會有這部分資料。
  • 例項資料

    執行時物件所包含的資料,是可以動態變化的,而且也是為各執行緒所共享的,這部分的資料又由以下型別的資料組成:

    • byte, char, short, int, float,佔四個位元組(注意這是JVM中的資料型別,而不是Java語言層面的資料型別,兩者還是有本質上的不同的,由於JVM指令有限,因此不足4個自己的資料都會使用int系列的指令操作)。
    • long,double,佔8個位元組。
    • reference,根據虛擬機器的實現不同佔4個或8個位元組,但32位JVM中引用型別變數佔4個位元組。
  • 對齊填充

    這部分資料沒有實質性的作用,僅做佔位目的。對於Hotspot JVM來說,它的記憶體管理是以8個位元組為單位的,而非陣列物件的物件頭剛好是8個位元組(32位JVM)或16個位元組(64位JVM),因此當例項資料不是8個位元組的倍數時用來做對齊填充。

搞清楚物件記憶體佈局之後我們再來看一下上述中的程式碼,在效能較高的32位JVM中,引用變數佔4個位元組,如此的話PaddedAtomicReference型別的物件光例項資料部分就包含了p0-pe15個引用變數,再加上從父類AtomicReference中繼承的一個引用變數一共是16個,也就是說光例項資料部分就佔了64個位元組,因此物件headtail一定不會被載入到同一個快取行,這樣的話對佇列頭結點和為尾結點的操作不會因為快取鎖定而序列化,也不會發生互相牽制的乒乓效應,提高了佇列的併發效能。

併發程式設計三要素

經過上述CPU Cache的洗禮,我們總算能夠進入Java併發程式設計了,如果你真正理解了Cache,那麼理解Java併發模型就很容易了。

併發程式設計的三要素是:原子性、可見性、有序性。

可見性

不可見問題是CPU Cache機制引起的,CPU不會直接訪問主存而時大多數時候都在操作Cache,由於每個執行緒可能會在不同CPU核心上進行上下文切換,因此可以理解為每個執行緒都有自己的一份“本地記憶體”,當然這個本地記憶體不是真實存在的,它是對CPU Cache的一個抽象:

image

如果執行緒Thread-1在自己的本地記憶體中修改共享變數的副本時如果不及時重新整理到主存並通知Thread-2從主存中重新讀取的話,那麼Thread-2將看不到Thread-1所做的改變並仍然我行我素的操作自己記憶體中的共享變數副本。這也就是我們常說的Java記憶體模型(JMM)。

那麼執行緒該如何和主存互動呢?JMM定義了以下8種操作以滿足執行緒和主存之間的互動,JVM實現必須滿足對所有變數進行下列操作時都是原子的、不可再分的(對於double和long型別的變數來說,load、store、read、write操作在某些平臺上允許例外)

  • lock,作用於主記憶體的變數,將一個物件標識為一條執行緒獨佔的狀態
  • unlock,作用於主記憶體的變數,將一個物件從被鎖定的狀態中釋放出來
  • read,從主存中讀取變數
  • load,將read讀取到的變數載入本地記憶體中
  • use,將本地記憶體中的變數傳送給執行引擎,每當JVM執行到一個需要讀取變數的值的位元組碼指令時會執行此操作
  • assign,把從執行引擎接收到的值賦給本地記憶體中的變數,每當JVM執行到一個需要為變數賦值的位元組碼指令時會執行此操作。
  • store,執行緒將本地記憶體中的變數寫回主存
  • write,主存接受執行緒的寫回請求更新主存中的變數

如果需要和主存進行互動,那麼就要順序執行readload指令,或者storewrite指令,注意,這裡的順序並不意味著連續,也就是說對於共享變數ab可能會發生如下操作read a -> read b -> load b -> load

如此也就能理解本文開頭的第一個示例程式碼的執行結果了,因為t2執行緒的執行sharedVariable = oldValue需要分三步操作:assign -> store -> write,也就是說t2執行緒在自己的本地記憶體對共享變數副本做修改之後(assign)、執行storewrite將修改寫回主存之前,t2可以插進來讀取共享變數。而且就算t2將修改寫回到主存了,如果不通過某種機制通知t1重新從主存中讀,t1還是會守著自己本地記憶體中的變數發呆。

為什麼volatile能夠保證變數線上程中的可見性?因為JVM就是通過volatile調動了快取一致性機制,如果對使用了volatile的程式,檢視JVM解釋執行或者JIT編譯後生成的彙編程式碼,你會發現對volatile域(被volatile修飾的共享變數)的寫操作生成的彙編指令會有一個lock字首,該lock字首表示JVM會向CPU傳送一個訊號,這個訊號有兩個作用:

  • 對該變數的改寫立即重新整理到主存(也就是說對volatile域的寫會導致assgin -> store -> write的原子性執行)
  • 通過匯流排通知其他CPU該共享變數已被更新,對於也快取了該共享變數的CPU,如果接收到該通知,那麼會在自己的Cache中將共享變數所在的快取行置為無效狀態。CPU在下次讀取讀取該共享變數時發現快取行已被置為無效狀態,他將重新到主存中讀取。

你會發現這就是在底層啟用了快取一致性協議。也就是說對共享變數加上了volatile之後,每次對volatile域的寫將會導致此次改寫被立即重新整理到主存並且後續任何對該volatile域的讀操作都將重新從主存中讀。

原子性

原子性是指一個或多個操作必須連續執行不可分解。上述已經提到,JMM提供了8個原子性操作,下面通過幾個簡單的示例來看一下在程式碼層面,哪些操作是原子的。

對於int型別的變數ab

  1. a = 1

    這個操作是原子的,位元組碼指令為putField,屬於assign操作

  2. a = b

    這個操作不是原子的,需要先執行getField讀變數b,再執行putField對變數a進行賦值

  3. a++

    實質上是a = a + 1,首先getField讀取變數a,然後執行add計算a + 1的值,最後通過putField將計算後的值賦值給a

  4. Object obj = new Object()

    首先會執行allocMemory為物件分配記憶體,然後呼叫<init>初始化物件,最後返回物件記憶體地址,更加複雜,自然也不是原子性的。

有序性

由於CPU具有多個不同型別的指令執行單元,因此一個時鐘週期可以執行多條指令,為了儘可能地提高程式的並行度,CPU會將不同型別的指令分發到各個執行單元同時執行,編譯器在編譯過程中也可能會對指令進行重排序。

比如:

a = 1;
b = a;
flag = true;
複製程式碼

flag = true可以重排序到b = a甚至a = 1前面,但是編譯器不會對存在依賴關係的指令進行重排序,比如不會將b = a重排序到a = 1的前面,並且編譯器將通過插入指令屏障的方式也禁止CPU對其重排序。

對於存在依賴關係兩條指令,編譯器能夠確保他們執行的先後順序。但是對於不存在依賴關係的指令,編譯器只能確保書寫在前面的先行發生於書寫在後面的,比如a = 1先行發生於flag = true,但是a = 1flag = true之前執行,先行發生僅表示a = 1這一行為對flag = true可見。

happens-before

在Java中,有一些天生的先行發生原則供我們參考,通過這些規則我們能夠判斷兩條程式的有序性(即是否存在一個先行發生於另一個的關係),從而決定是否有必要對其採取同步。

  • 程式順序規則:在單執行緒環境下,按照程式書寫順序,書寫在前面的程式 happens-before 書寫在後面的。
  • volatile變數規則:對一個volatile域的寫 happens-before 隨後對同一個volatile域的讀。
  • 監視器規則:一個執行緒釋放其持有的鎖物件 happens-before 隨後其他執行緒(包括這個剛釋放鎖的執行緒)對該物件的加鎖。
  • 執行緒啟動規則:對一個執行緒呼叫start方法 happens-before 執行這個執行緒的run方法
  • 執行緒終止規則:t1執行緒呼叫t2.join,檢測到t2執行緒的執行終止 happens-before t1執行緒從join方法返回
  • 執行緒中斷規則:對一個執行緒呼叫interrupt方法 happens-before 這個執行緒響應中斷
  • 物件終結規則:對一個物件的建立new happens-before 這個物件的finalize方法被呼叫
  • 傳遞性:如果A happens-before B且B happens-before C,則有A happens-before C

通過以上規則我們解決本文開頭提出的疑惑,為何synchronized鎖釋放、CAS更新和volatile寫有著相同的語義(即都能夠讓對共享變數的改寫立即對所有執行緒可見)。

鎖釋放有著volatile域寫語義

new Thread(() -> {
    synchronized (lock) {
        int oldValue = sharedVariable;
        while (sharedVariable < MAX) {
            while (!changed) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() +
                               " watched the change : " + oldValue + "->" + sharedVariable);
            oldValue = sharedVariable;
            changed = false;
            lock.notifyAll();
        }
    }
}, "t1").start();

new Thread(() -> {
    synchronized (lock) {
        int oldValue = sharedVariable;
        while (sharedVariable < MAX) {
            while (changed) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() +
                               " do the change : " + sharedVariable + "->" + (++oldValue));
            sharedVariable = oldValue;
            changed = true;
            lock.notifyAll();
        }
    }
}, "t2").start();
複製程式碼
  1. 對於t2單個執行緒使用程式順序規則,第34行對共享變數sharedVariable的寫 happens-before 第 38行退出臨界區釋放鎖。
  2. 對於t1t2的併發執行,第38t2對鎖的釋放 happens-before 第2t1對鎖的獲取。
  3. 同樣根據程式順序規則,第2行鎖獲取 happens-before 第 13行對共享變數sharedVariable的讀。
  4. 依據上述的1、2、3和傳遞性,可得第34行對共享變數sharedVariable的寫 happens-before 第13行對共享變數sharedVariable的讀。

總結:通過對共享變數寫-讀的前後加鎖,是的普通域的寫-讀有了和volatile域寫-讀相同的語義。

原子類CAS更新有著volatile域寫語義

前文已說過,對於基本型別或引用型別的讀取(use)和賦值(assign),JMM要求JVM實現來確保原子性。因此這類操作的原子性不用我們擔心,但是複雜操作的原子性該怎麼保證呢?

一個很典型的例子,我們啟動十個執行緒對共享變數i執行10000次i++操作,結果能達到我們預期的100000嗎?

private static volatile int i = 0;

public static void main(String[] args) throws InterruptedException {
    ArrayList<Thread> threads = new ArrayList<>();
    Stream.of("t0","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
        threadName -> {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    i++;
                }
            }, threadName);
            threads.add(t);
            t.start();
        }
    );
    for (Thread thread : threads) {
        thread.join();
    }
    System.out.println(i);	
}
複製程式碼

筆者測試了幾次都沒有達到預期。

也許你會說給i加上volatile就行了,真的嗎?你不妨試一下。

如果你理性的分析一下即使是加上volatile也不行。因為volatile只能確保變數i的可見性,而不能保證對其複雜操作的原子性。i++就是一個複雜操作,它可被分解為三步:讀取i、計算i+1、將計算結果賦值給i。

要想達到預期,必須使這一次的i++ happens-before 下一次的i++,既然這個程式無法滿足這一條件,那麼我們可以手動新增一些讓程式滿足這個條件的程式碼。比如將i++放入臨界區,這是利用了監視器規則,我們不妨驗證一下:

private static int i = 0;
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    ArrayList<Thread> threads = new ArrayList<>();
    Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
        threadName -> {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    synchronized (lock) {
                        i++;
                    }
                }
            }, threadName);
            threads.add(t);
            t.start();
        }
    );
    for (Thread thread : threads) {
        thread.join();
    }
    System.out.println(i);	//10000
}
複製程式碼

執行結果證明我們的邏輯沒錯,這就是有理論支撐的好處,讓我們有方法可尋!併發不是玄學,只要我們有足夠的理論支撐,也能輕易地寫出高並準確的程式碼。正確性是併發的第一要素!在實現這一點的情況下,我們再談併發效率。

於是我們重審下這段程式碼的併發效率有沒有可以提升的地方?由於synchronized會導致同一時刻十個執行緒只有1個執行緒能獲取到鎖,其餘九個都將被阻塞,而執行緒阻塞-被喚醒會導致使用者態到核心態的轉換(可參考筆者的 Java執行緒是如何實現的一文),開銷較大,而這僅僅是為了執行以下i++?這會導致CPU資源的浪費,吞吐量整體下降。

為了解決這一問題,CAS誕生了。

CAS(Compare And Set)就是一種原子性的複雜操作,它有三個引數:資料地址、更新值、預期值。當需要更新某個共享變數時,CAS將先比較資料地址中的資料是否是預期的舊值,如果是就更新它,否則更新失敗不會影響資料地址處的資料。

CAS自旋(迴圈CAS操作直至更新成功才退出迴圈)也被稱為樂觀鎖,它總認為併發程度沒有那麼高,因此即使我這次沒有更新成功多試幾次也就成功了,這個多試幾次的開銷並沒有執行緒阻塞的開銷大,因此在實際併發程度並不高時比synchronized的效能高許多。但是如果併發程度真的很高,那麼多個執行緒長時間的CAS自旋帶來的CPU開銷也不容樂觀。由於80%的情況下併發都程度都較小,因此常用CAS替代synchronized以獲取效能上的提升。

如下是Unsafe類中的CAS自旋:

public final int getAndSetInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var4));

    return var5;
}
複製程式碼

CAS操作在x86上是由cmpxchg(Compare Exchange)實現的(不同指令集有所不同)。而Java中並未公開CAS介面,CAS以``compareAndSetXxx的形式定義在Unsafe類(僅供Java核心類庫呼叫)中。我們可以通過反射呼叫,但是JDK提供的AtomicXxx`系列原子操作類已能滿足我們的大多數需求。

於是我們來看一下啟動十個執行緒執行1000 000次i++在使用CAS和使用synchronized兩種情況下的效能之差:

CAS大約在200左右:

private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
    ArrayList<Thread> threads = new ArrayList<>();
    long begin = System.currentTimeMillis();
    Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
        threadName -> {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    i.getAndIncrement();
                }
            }, threadName);
            threads.add(t);
            t.start();
        }
    );
    for (Thread thread : threads) {
        thread.join();
    }
    long end = System.currentTimeMillis();
    System.out.println(end - begin);	//70-90之間
}
複製程式碼

使用synchronized大約在480左右:

private static int i = 0;
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    ArrayList<Thread> threads = new ArrayList<>();
    long begin = System.currentTimeMillis();
    Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
        threadName -> {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 1000000; j++) {
                    synchronized (lock) {
                        i++;
                    }
                }
            }, threadName);
            threads.add(t);
            t.start();
        }
    );
    for (Thread thread : threads) {
        thread.join();
    }
    long end = System.currentTimeMillis();
    System.out.println(end - begin);
}
複製程式碼

但是我們的疑問還沒解開,為什麼原子類的CAS更新具有volatile寫的語義?單單CAS只能確保use -> assgin是原子的啊。

看一下原子類的原始碼就知道了,以AtomicInteger,其他的都類同:

public class AtomicInteger extends Number implements java.io.Serializable {
    private volatile int value;
    public final int getAndSet(int newValue) {
        return unsafe.getAndSetInt(this, valueOffset, newValue);
    }
}
複製程式碼

你會發現原子類封裝了一個volatile域,豁然開朗吧。CAS更新的volatile域,我們知道volatile域的更新將會導致兩件事發生:

  • 將改寫立即重新整理到主存
  • 通知其他CPU將快取行置為無效

volatile禁止重排序

volatile的另一個語義就是禁止指令重排序,即volatile產生的彙編指令lock具有個指令屏障使得該屏障之前的指令不能重排序到屏障之後。這個作用使用單例模式的併發優化案例來說再好不過了。

懶載入模式

利用類載入過程的初始化(當類被主動引用時應當立即對其初始化)階段會執行類構造器<clinit>按照顯式宣告為靜態變數初始化的特點。(類的主動引用、被動引用、類構造器、類載入過程詳見《深入理解Java虛擬機器(第二版)》)

public class SingletonObject1 {

    private static final SingletonObject1 instance = new SingletonObject1();

    public static SingletonObject1 getInstance() {
        return instance;
    }

    private SingletonObject1() {

    }
}
複製程式碼

什麼是對類的主動引用:

  • newgetStaticputStaticinvokeStatic四個位元組碼指令涉及到的類,對應語言層面就是建立該類例項、讀取該類靜態欄位、修改該類靜態欄位、呼叫該類的靜態方法
  • 通過java.lang.reflect包的方法對該類進行反射呼叫時
  • 當初始化一個類時,如果他的父類沒被初始化,那麼先初始化其父類
  • 當JVM啟動時,首先會初始化main函式所在的類

什麼是對類的被動引用:

  • 通過子類訪問父類靜態變數,子類不會被立即初始化
  • 通過陣列定義引用的類不會被立即初始化
  • 訪問某個類的常量,該類不會被立即初始化(因為經過編譯階段的常量傳播優化,該常量已被複制一份到當前類的常量池中了)

餓漢模式1

需要的時候才去建立例項(這樣就能避免暫時不用的大記憶體物件被提前載入):

public class SingletonObject2 {

    private static SingletonObject2 instance = null;

    public static SingletonObject2 getInstance() {
        if (SingletonObject2.instance == null) {
            SingletonObject2.instance = new SingletonObject2();
        }
        return SingletonObject2.instance;
    }

    private SingletonObject2() {

    }
}
複製程式碼

餓漢模式2

上例中的餓漢模式在單執行緒下是沒問題的,但是一旦併發呼叫getInstance,可能會出現t1執行緒剛執行完第6行還沒來得及建立物件,t2執行緒就執行到第6行的判斷了,這會導致多個執行緒來到第7行並執行,導致SingletonObject2被例項化多次,於是我們將第6-7行通過synchronized序列化:

public class SingletonObject3 {
    private static SingletonObject3 instance = null;

    public static SingletonObject3 getInstance() {
        synchronized (SingletonObject3.class) {
            if (SingletonObject3.instance == null) {
                SingletonObject3.instance = new SingletonObject3();
            }
        }
        return SingletonObject3.instance;
    }

    private SingletonObject3() {

    }

}
複製程式碼

DoubleCheckedLocking

我們已經知道synchronized是重量級鎖,如果單例被例項化後,每次獲取例項還需要獲取鎖,長期以往,開銷不菲,因此我們在獲取例項時加上一個判斷,如果單例已被例項化則跳過獲取鎖的操作(僅在初始化單例時才可能發生衝突):

public class SingletonObject4 {

    private static SingletonObject4 instance = null;

    public static SingletonObject4 getInstance() {
        if (SingletonObject4.instance == null) {
            synchronized (SingletonObject4.class){
                if (SingletonObject4.instance == null) {
                    SingletonObject4.instance = new SingletonObject4();
                }
            }
        }
        return SingletonObject4.instance;
    }

    private SingletonObject4() {
        
    }
}
複製程式碼

DCL2

這樣真的就OK了嗎,確實同一時刻只有一個執行緒能夠進入到第9行建立物件,但是你別忘了new Object()是可以被分解的!其對應的偽指令如下:

allocMemory 	//為物件分配記憶體
<init>		    //執行物件構造器
return reference //返回物件在堆中的地址
複製程式碼

而且上述三步是沒有依賴關係的,這意味著他們可能被重排序成下面的樣子:

allocMemory 	//為物件分配記憶體
return reference //返回物件在堆中的地址
<init>		    //執行物件構造器
複製程式碼

這時可能會導致t1執行緒執行到第2行時,t1執行緒判斷instance引用地址不為null於是去使用這個instance,而這時物件還沒構造完!!這意味著如果物件可能包含的引用變數為null而沒被正確初始化,如果t1執行緒剛好訪問了該變數那麼將丟擲空指標異常

於是我們利用volatile禁止<init>重排序到為instance賦值之後:

public class SingletonObject5 {
    
    private volatile static SingletonObject5 instance = null;

    public static SingletonObject5 getInstance() {
        if (SingletonObject5.instance == null) {
            synchronized (SingletonObject5.class) {
                if (SingletonObject5.instance == null) {
                    SingletonObject5.instance = new SingletonObject5();
                }
            }
        }
        return SingletonObject5.instance;
    }

    private SingletonObject5() {
        
    }
}
複製程式碼

InstanceHolder

我們還可以利用類只被初始化一次的特點將單例定義在內部類中,從而寫出更加優雅的方式:

public class SingletonObject6 {
    
    private static class InstanceHolder{
        public static SingletonObject6 instance = new SingletonObject6();    
    }

    public static SingletonObject6 getInstance() {
        return InstanceHolder.instance;
    }

    private SingletonObject6() {
        
    }

}
複製程式碼

列舉例項的構造器只會被呼叫一次

這是由JVM規範要求的,JVM實現必須保證的。

public class SingletonObject7 {
    
    private static enum Singleton{
        INSTANCE;

        SingletonObject7 instance;
        private Singleton() {
            instance = new SingletonObject7();
        }
    }

    public static SingletonObject7 getInstance() {
        return Singleton.INSTANCE.instance;
    }

    private SingletonObject7() {
        
    }

}
複製程式碼

(全文完)

參考連結

快取一致性協議:

相關文章