深刻淺出計算機組成原理學習筆記:第三十八講

在我工做的十幾年裏,寫了不少Java的程序。同時,我也面試過大量的Java工程師。對於一些表示本身深刻了解和擅長多線程的同窗,
我常常會問這樣一個面試題:「 volatile這個關鍵字有什麼做用?」若是你或者你的朋友寫過Java程序,不妨來一塊兒試着回答一下這個問題。java

就我面試過的工程師而言,即便是工做了多年的Java工程師,也不多有人能準確說出volatile這個關鍵字的含義。這裏面最多見的理解錯誤有兩個,
一個是把volatile當成一種鎖機制,認爲給變量加上了volatile,就好像是給函數加了sychronized關鍵字同樣,不一樣的線程對於特定變量的訪問會去加鎖;
另外一個是把volatile當成一種原子化的操做機制,認爲加了volatile以後,對於一個變量的自增的操做就會變成原子性的了。面試

// 一種錯誤的理解,是把 volatile 關鍵詞,當成是一個鎖,能夠把 long/double 這樣的數的操做自動加鎖
private volatile long synchronizedValue = 0;

// 另外一種錯誤的理解,是把 volatile 關鍵詞,當成可讓整數自增的操做也變成原子性的
private volatile int atomicInt = 0;
amoticInt++;

事實上,這兩種理解都是徹底錯誤的。不少工程師容易把volatile關鍵字,當成和鎖或者數據數據原子性相關的知識點。而實際上,
volatile關鍵字的最核心知識點,要關係到Java內存模型(JMM,Java MemoryModel)上。

雖然JMM只是Java虛擬機這個進程級虛擬機裏的一個內存模型,可是這個內存模型,和計算機組成裏的CPU、高速緩存和主內存組合在一塊兒的硬件體系很是類似。
理解了JMM,可讓你很容易理解計算機組成裏CPU、高速緩存和主內存之間的關係。

緩存

1、「隱身」的變量  

一、程序做了什麼?

咱們先來一塊兒看一段Java程序。這是一段經典的volatile代碼,來自知名的Java開發者網站dzone.com,後續咱們會修改這段代碼來進行各類小實驗。多線程

public class VolatileTest {
    private static volatile int COUNTER = 0;

    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }

    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }

    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}

首先

 

而後

最後

Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5

由於全部數據的讀和寫都來自主內存。那麼天然地,咱們的ChangeMaker和ChangeListener之間,看到的COUNTER值就是同樣的。ide

二、volatile關鍵字給去掉,會發生什麼事情呢?

private static int COUNTER = 0;

沒錯,你會發現,咱們的ChangeMaker仍是能正常工做的,每隔500ms仍然可以對COUNTER自增1。可是,奇怪的事情在ChangeListener上發生了,函數

咱們的ChangeListener再也不工做了。在ChangeListener眼裏,它彷佛一直以爲COUNTER的值仍是一開始的0。彷佛COUNTER的變化,對於咱們的ChangeListener完全「隱身」了。性能

Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5

咱們去掉了volatile關鍵字。這個時候,ChangeListener又是一個忙等待的循環,它嘗試不停地獲取COUNTER的值,這樣就會從當前線程的「Cache」裏面獲取。
因而,這個線程就沒有時間從主內存裏面同步更新後的COUNTER值。這樣,它就一直卡死在COUNTER=0的死循環上了。網站

三、再也不讓ChangeListener進行徹底的忙等待,而是在while循環裏面,小小地等待上5毫秒,看看會發生什麼狀況?
那volatile關鍵字究竟表明什麼含義呢?

咱們能夠再對程序作一些小小的修改。咱們再也不讓ChangeListener進行徹底的忙等待,而是在while循環裏面,小小地等待上5毫秒,看看會發生什麼狀況。atom

static class ChangeListener extends Thread {
    @Override
    public void run() {
        int threadValue = COUNTER;
        while ( threadValue < 5){
            if( threadValue!= COUNTER){
                System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                threadValue= COUNTER;
            }
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) { e.printStackTrace(); }
        }
    }
}

好了,不知道你有沒有本身動手試一試呢?又一個使人驚奇的現象要發生了。雖然咱們的COUNTER變量,仍然沒有設置volatile這個關鍵字,可是咱們的ChangeListener彷佛「睡醒了」。
在經過Thread.sleep(5)在每一個循環裏「睡上「5毫秒以後,ChangeListener又可以正常取到COUNTER的值了。spa

Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5

雖然仍是沒有使用volatile關鍵字,可是短短5ms的Thead.Sleep給了這個線程喘息之機。既然這個線程沒有這麼忙了,
它也就有機會把最新的數據從主內存同步到本身的高速緩存裏面了。因而,ChangeListener在下一次查看COUNTER值的時候,就能看到ChangeMaker形成的變化了。

四、那volatile關鍵字究竟表明什麼含義?

這些有意思的現象,其實來自於咱們的Java內存模型以及關鍵字volatile的含義。 那volatile關鍵字究竟表明什麼含義呢?它會確保咱們對於這個變量的讀取和寫入,

都必定會同步到主內存裏,而不是從Cache裏面讀取。該怎麼理解這個解釋呢?咱們經過剛纔的例子來進行分析。

雖然Java內存模型是一個隔離了硬件實現的虛擬機內的抽象模型,可是它給了咱們一個很好的「緩存同步」問題的示例。也就是說,若是咱們的數據,在不一樣的線程或者CPU核裏面去更新,

由於不一樣的線程或CPU核有着本身各自的緩存,頗有可能在A線程的更新,到B線程裏面是看不見的。

2、CPU高速緩存的寫入

一、Java內存模型和計算機組成裏的CPU結構對照起來看

一、咱們如今用的Intel CPU,一般都是多核的的。每個CPU核裏面,都有獨立屬於本身的L一、L2的Cache,而後再有多個CPU核共用的L3的Cache、主內存。

二、由於CPU Cache的訪問速度要比主內存快不少,而在CPU Cache裏面,L1/L2的Cache也要比L3的Cache快。

三、因此,上一講咱們能夠看到,CPU始終都是儘量地從CPU Cache中去獲取數據,而不是每一次都要從主內存裏面去讀取數據。

這個層級結構,就好像咱們在Java內存模型裏面,每個線程都有屬於本身的線程棧。線程在讀取COUNTER的數據的時候,

實際上是從本地的線程棧的Cache副本里面讀取數據,而不是從主內存裏面讀取數據。

若是咱們對於數據僅僅只是讀,問題還不大。咱們在上一講裏,已經看到Cache Line的組成,以及如何從內存裏面把對應的數據加載到Cache裏。

二、寫入策略:寫直達

一、邏輯圖

二、流程說明

最簡單的一種寫入策略,叫做寫直達(Write-Through)。在這個策略裏,每一次數據都要寫入到主內存裏面。在寫直達的策略裏面,

一、寫入前,咱們會先去判斷數據是否已經在Cache裏面了。若是數據已經在Cache裏面了,咱們先把數據寫入更新到Cache裏面,再寫入到主內存裏面;

二、若是數據不在Cache裏,咱們就只更新主內存。

三、存在的問題

寫直達的這個策略很直觀,可是問題也很明顯,那就是這個策略很慢。不管數據是否是在Cache裏面,咱們都須要把數據寫到主內存裏面。

這個方式就有點兒像咱們上面用volatile關鍵字,始終都要把數據同步到主內存裏面。

三、寫回

一、邏輯圖

二、工做流程說明

這個時候,咱們就想了,既然咱們去讀數據也是默認從Cache裏面加載,可否不用把全部的寫入都同步到主內存裏呢?只寫入CPU Cache裏面是否是能夠?

固然是能夠的。在CPU Cache的寫入策略裏,還有一種策略就叫做寫回(Write-Back)。這個策略裏,咱們再也不是每次都把數據寫入到主內存,而是隻寫到CPU Cache裏。
只有當CPU Cache裏面的數據要被「替換」的時候,咱們才把數據寫入到主內存裏面去。

寫回策略的過程是這樣的:

一、若是發現咱們要寫入的數據,就在CPU Cache裏面,那麼咱們就只是更新CPU Cache裏面的數據。同時,咱們會標記CPU Cache裏的這個Block是髒(Dirty)的。

所謂髒的,就是指這個時候,咱們的CPU Cache裏面的這個Block的數據,和主內存是不一致的。

二、若是咱們發現,咱們要寫入的數據所對應的Cache Block裏,放的是別的內存地址的數據,那麼咱們就要看一看,那個Cache Block裏面的數據有沒有被標記成髒的。

三、若是是髒的話,咱們要先把這個Cache Block裏面的數據,寫入到主內存裏面。

四、而後,再把當前要寫入的數據,寫入到Cache裏,同時把Cache Block標記成髒的。

五、若是Block裏面的數據沒有被標記成髒的,那麼咱們直接把數據寫入到Cache裏面,而後再把CacheBlock標記成髒的就行了。

在用了寫回這個策略以後,咱們在加載內存數據到Cache裏面的時候,也要多出一步同步髒Cache的動做。

六、若是加載內存裏面的數據到Cache的時候,發現Cache Block裏面有髒標記,咱們也要先把Cache Block裏的數據寫回到主內存,才能加載數據覆蓋掉Cache。

三、存在問題

若是咱們大量的操做,都可以命中緩存。那麼大部分時間裏,咱們都不須要讀寫主內存,天然性能會比寫直達的效果好不少

要解決這個問題,咱們須要引入一個新的方法,叫做MESI協議。這是一個維護緩存一致性協議。這個協議不只能夠用在CPU Cache之間,也能夠普遍用於各類須要使用緩存,

同時緩存之間須要同步的場景下。今天的內容差很少了,咱們放在下一講,仔細講解緩存一致性問題。

3、總結延伸

 

最後,咱們一塊兒來回顧一下這一講的知識點。經過一個使用Java程序中使用volatile關鍵字程序,咱們能夠看到,在有緩存的狀況下會遇到一致性問題。
volatile這個關鍵字能夠保障咱們對於數據的讀寫都會到達主內存。

進一步地,咱們能夠看到,Java內存模型和CPU、CPU Cache以及主內存的組織結構很是類似。在CPUCache裏,對於數據的寫入,

咱們也有寫直達和寫回這兩種解決方案。寫直達把全部的數據都直接寫入到主內存裏面,簡單直觀,可是性能就會受限於內存的訪問速度。

而寫回則一般只更新緩存,只有在須要把緩存裏面的髒數據交換出去的時候,才把數據同步到主內存裏。在緩存常常會命中的狀況下,性能更好。

可是,除了採用讀寫都直接訪問主內存的辦法以外,如何解決緩存一致性的問題,咱們仍是沒有解答。這個問題的解決方案,咱們放到下一講來詳細解說。

相關文章
相關標籤/搜索