從CPU Cache出發完全弄懂volatile/synchronized/cas機制

我的技術博客:www.zhenganwen.tophtml

變量可見嗎

共享變量可見嗎

首先引入一段代碼指出Java內存模型存在的問題:啓動兩個線程t1,t2訪問共享變量sharedVariablet2線程逐漸將sharedVariable自增到MAX,每自增一次就休眠500ms放棄CPU執行權,指望此間另一個線程t1可以在第7-12行輪詢過程當中發現到sharedVariable的改變並將其打印java

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();

}
複製代碼

但上述程序的實際運行結果以下:linux

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以後的輸出達到咱們的預期了:git

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可以保證共享變量在線程之間的可見性。github

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之間彷佛也是可見的:shell

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比L一、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試圖經過某一存儲單元地址訪問數據時,它會自上而下依次從L一、L二、L三、主存中查找,若找到則直接返回對應Cache中的數據而再也不向下查找,若是L一、L二、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。

緩存設計的一個關鍵決定是確保每一個主存塊(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上,從而提高多線程的性能。

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. 依據上述的一、二、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+一、將計算結果賦值給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() {
        
    }

}
複製代碼

(全文完)

參考連接

緩存一致性協議:

相關文章
相關標籤/搜索