Java併發中的記憶體模型

zYinux發表於2019-05-12

什麼是JavaMemoryModel(JMM)?

JMM通過構建一個統一的記憶體模型來遮蔽掉不同硬體平臺和不同作業系統之間的差異,讓Java開發者無需關注不同平臺之間的差異,達到一次編譯,隨處執行的目的,這也正是Java的設計目的之一。

CPU和記憶體

在講JMM之前,我想先和大家聊聊硬體層面的東西。大家應該都知道執行運算操作的CPU本身是不具備儲存能力的,它只負責根據指令對傳遞進來的資料做相應的運算,而資料儲存這一任務則交給記憶體去完成。雖然記憶體的執行速度雖然比起硬碟快非常多,但是和3GHZ,4GHZ,甚至5GHZ的CPU比起來還是太慢了,在CPU的眼中,記憶體執行的速度簡直就是弟弟中的弟弟,等記憶體進行一次讀寫操作,CPU能思考成百上千次人生了?。但是CPU的運算能力是緊缺資源啊,可不能這麼白白浪費了,所以得想辦法解決這一個問題。

沒有什麼問題是一個快取不能解決的,如果有,那就再加一個快取 ——魯迅:反正我沒說這句話

所以人們就想到了給CPU增加一個快取記憶體(為什麼是加快取記憶體而不是給記憶體提高速度就牽扯到硬體成本問題了)來解決這個問題,比如像博主用的Intel的I9 9900k CPU就帶了高達16M的三級快取,所以硬體上的記憶體模型大概如下圖所示。

Java併發中的記憶體模型

如圖可以很清楚的看到,在CPU內部構建了一到多層的快取,並且其中的L1 Cache是CPU核心心獨有的,不同的Core之間是不能共享的,而L2 Cache則是所有的核心共享。簡單來說就是CPU在讀取一個資料時會先去最近的Cache層級上讀取,如果找不到則會去下一個層級尋找,都找不到的話就會從記憶體中去載入,而如果Cache中能拿到所需要的資料就不會去記憶體讀取。在將資料寫回的時候也會先寫入Cache中,等待合適的時機再寫入到記憶體中(其中有一個細節就是快取行的問題,關於這部分內容放在文章結尾)。而由於存在多個cache層級,並且部分cache還不能夠被共享,所以會存在記憶體可見性的問題。

舉個簡單的例子: 假設現在存在兩個Core,分別是CoreA和CoreB並且他們都擁有屬於自己的L1Chace和共用的L2Cache。同時有一個變數X的值為1,該變數已經被載入在L2Cahce上。此時CoreA執行運算需要用到變數X,先去L1Cache尋找,未命中,繼續在L2Cache尋找,命中成功,將X=1載入L1Cahce,再經過一系列運算後將X修改為2並寫入L1Cache。於此同時CoreB剛好也需要X來進行運算,此時他去自己的L1Cahce尋找,未命中,繼續L2Cache尋找,命中成功,將X=1載入自己的L1Cache。此時就出現了問題,CoreA明明已經將X的值修改為2了,CoreB讀取到的依然是X=1,這就是記憶體可見性問題。

看到這裡的小夥伴們可能要問了,博主你啥情況啊,你這寫的漸漸忘記標題了啊,說好了Java記憶體模型,你扯這麼多硬體上的問題幹啥啊?(╯‵□′)╯︵┻━┻

Java中的主記憶體和工作記憶體

小夥伴們彆著急,其實JMM和上面的硬體層次上的模型很像,不信看下面的圖片

Java併發中的記憶體模型

怎麼樣,是不是看起來很像,可以簡單的理解為執行緒的工作記憶體就是CPU裡Core獨佔的L1Cahce,而主記憶體就是共享的L2Cache。所以上述的記憶體一致性問題也會在JMM中存在,而JMM就需要制定一些列的規則來保證記憶體一致性,這也是Java多執行緒併發的一個疑難點,那麼JMM制定了哪些規則呢?

##記憶體間互動操作 首先是主記憶體的工作記憶體之間的互動協議,具體來說定義了以下幾個操作(並且保證這幾個操作都是原子性的):

  • lock (鎖定)作用於主記憶體的變數,將一個變數標識為一個執行緒獨佔狀態
  • unlock(解鎖)作用於主記憶體的變數,將一個處於鎖定狀態的變數釋放出來,釋放之後才能被其他執行緒鎖定
  • read(讀取)作用於主記憶體的變數,將一個變數的值從主記憶體傳輸到執行緒工作記憶體中,便於之後的load操作使用
  • load(載入)作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用)作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值)作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存)作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
  • write(寫入)作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

同時還規定了執行上述八個操作時必須遵循以下規則:

  • 如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作, 如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。但Java記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。
  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個執行緒丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現
  • 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值
  • 如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。

(上述部分參考並引用《深入理解Java虛擬機器》中的內容)

volatile(能夠保證記憶體可見性和禁止指令重排序)

對於volatile修飾的變數,JMM對其有一些特殊的規定。

記憶體可見性

往簡單來說volatile關鍵字可以理解為,有一個volatile修飾的變數x,當一個執行緒需要使用該變數的時候,直接從主記憶體中讀取,而當一個執行緒修改該變數的值時,直接寫入到主記憶體中。根據之前的分析我們能得出具備這些特性的volatile能夠保證一個變數的記憶體可見性和記憶體一致性。

指令重排序

指令重排序是一個大部分CPU都有的操作,同時JVM在執行時也會存在指令重排序的操作。 簡單舉個?

    private void test(){
        int a,b,c;//1
        a=1;//2
        b=3;//3
        c=a+b;//4
    }
複製程式碼

假設有上面這麼一個方法,內部有這4行程式碼。那麼JVM可能會對其進行指令重排序,而指令重排序的規定則是as-if-serial 不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。根據這一規定,編譯器和處理器不會對有依賴關係的指令重排序,但是對沒有依賴的指令則可能會進行重排序。放在上面的例子裡面就是,第1行程式碼和2,3,4行程式碼是有依賴關係的,所以第一行程式碼的指令必須排在2,3,4之前,因為不可能對一個未定義的變數進行賦值操作。而第2,3行程式碼之間並沒有相互依賴關係,所以此處可能會發生指令重排序,先執行3,再執行2。而最後的第4行程式碼和之前的3行程式碼都有依賴關係,所以他一定會放在最後執行。

既然JVM特別指出指令重排序只在單執行緒下和未排序的效果一致,那是否表示在多執行緒下會存在一些問題呢? 答案是肯定的,多執行緒下指令重排序會帶來一些意想不到的結果。

    int a=0;
    //flag作為一個識別符號,標識是否寫入完成
    boolean flag = false;
    public void writer(){
        a=10;//1
        flag=true;//2
    }
    public void reader(){
        if (flag)
            System.out.println("a:"+a);
    }
複製程式碼

假設存在一個類,他有上述部分的field和method,該類在設計上以flag作為寫入是否完成的標誌,在單執行緒下這並不會存在問題。而此時有兩個執行緒分別執行writer和reader方法,暫時不考慮記憶體可見性的問題,假設對a和flag的寫入,是立即被其他執行緒所知曉的,這個時候大家覺得輸出a的值為多少?10?

即使不考慮記憶體可見性,此時a的值還是有可能會輸出0,這就是指令重排序帶來的問題。在上述程式碼中註釋1和2處的程式碼是沒有依賴關係的,在單執行緒下先執行1還是2都沒有任何問題,根據as-if-serial 原則此時就可能會發生指令重排序。

而volatile關鍵字可以禁止指令重排序。

long,double的問題

我們都知道JMM定義的8個主記憶體和工作記憶體之間的操作都是具備原子性的,但是對long和double這兩個64位的資料型別有一些例外。

允許虛擬機器將沒有被volatile修飾的long和double的64資料的讀寫操作劃分為兩次32位的讀寫操作,即不要求虛擬機器保證對他們的load ,store,read,write四個操作的原子性。 但是大部分的虛擬機器實現都保證了這四個操作的原子性的,所以大部分時候我們都不需要刻意的對long,double物件使用volatile修飾。

效能問題

volatile是Java提供的保證記憶體可見性的最輕量級操作,比起重量級的synchronized能快上不少,但是具體能快多少這部分沒辦法量化。而我們可以知道的是volatile修飾的變數讀操作的效能消耗幾乎和普通變數相差無幾,而寫操作則會慢上一些。所以當volatile能解決我們的問題的時候(記憶體可見性和禁止指令重排序),我們應該優先選擇使用volatile而不是鎖。

synchronized的記憶體語義

簡單概括就是

當程式進入synchronized塊時,把在synchronized塊中用到的變數從工作記憶體中清楚,這樣在需要訪問這些變數的時候會重新從主記憶體中獲取。當程式退出synchronized塊時,把對塊中恭喜變數的修改重新整理到主記憶體。 如此依賴synchronized也能保證了記憶體的可見性。

final的記憶體語義

final也能保證記憶體的可見性

被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this引用傳遞出去,那麼其他執行緒中就能看見final欄位的值。

後記之CPU快取行和偽共享

什麼是偽共享

根據前面的文章,我們知道CPU和Memory之間是有Cache的,而Cache內部是按行儲存的,行擁有固定的大小,這些行被稱為快取行。 當CPU訪問的某個變數不在Cache中時,就會去記憶體裡獲取,並將該變數所在記憶體的一個快取行大小的資料讀入Cache中。由於一次讀取並不是單個物件而是一整個快取行,所以可能會存在多個變數被讀入一個快取行中。而一個快取行只能同時被一個執行緒操作,所以當多個執行緒同時修改一個快取行裡的多個變數時會造成其他執行緒等待從而帶來效能損耗(但是在單執行緒情況下,偽共享反而會提升效能,因為一次性可能會快取多個變數,節省後續變數的讀取時間)。

如何避免偽共享

在Java8之後可以使用JDK提供的@sun.misc.Contended註解來解決偽共享,像Thread中的threadLocalRandom 欄位就使用了這個註解。

相關文章