java 內存模型的理解


 

 

之前一直在實習,博客停寫了一段時間,現在秋招開始了,所以辭職回來專心看書,同時將每天的收獲以博客的形式記錄下來。最近在看jvm相關的書籍,下面對面試中問得最多的部分--java 內存模型進行簡單總結。

本篇博客大概由一下幾個部分組成:

1、程序在真實物理世界的內存模型

2、java的內存模型

3、java中的volatile與線程安全

4、happen-before原則與加鎖。

 

一、程序在物理世界中是怎樣運行的

  所有的程序,無論什么語言編寫,最后都會變為一串機器碼,而cpu的運算過程,就是將這些機器碼轉換為電信號給相應的運算電路進行邏輯或者算術運算,然后將結果返回。在這個過程,底層的電路如何進行運算並非討論范疇,我們主要從更加宏觀的角度理解程序怎么運行:程序從加載到內存到cpu得出結果返回到內存中,這個過程具體到底是如何進行的。

  1、程序在物理中的執行過程

  我們以前一直知道,cpu從內存中讀取數據遠遠快於從磁盤或者網絡流中讀取數據,所以為了提高響應速度,我們很多時候會提前把讀取頻繁的數據預先加載到內存中以提高性能;其實類似的,由於cpu的運算速度真的太快了,從內存加載數據到cpu的時間和cpu運算速度相比也是太慢了,所以為了提高程序執行效率,在cpu和內存之間,還有一個緩存區,叫cpu高速緩存區,cpu從高速緩存中讀取數據的速度遠遠快於從主存中讀取數據(當然,高速緩存的物理制造成本也比主內存物理成本高得多)所以,為了提高速率,真實的程序是按照下面這張示意圖的路徑進行運行的:

總的來說:程序加載到主存之后,當cpu運算需要讀或寫數據的時候,它不會直接對主存進行操作,因為這樣速度實在太慢了,它從位於cpu和主存之間的高速緩存讀取數據/寫入數據,當運算完成時,高速緩存會將結果自動更新到主存中。

  2、緩存帶來的問題

  和所有緩存機制面臨的問題一樣,cpu高速緩存也面臨這樣的問題:多線程下數據同步問題。現代的cpu,基本都是以多核cpu為主,所以,再多核(多線程)下,不同cpu之間的數據同步問題是高速緩存緩存需要解決的。真實物理世界中,在有必要的情況下,為了保證數據同步,可以通過對內存中的數據進行加鎖操作來保證數據的一致性,這和我們平常理解的多線程加鎖操作是一致的,不過本人對硬件層面如何實現加鎖不太了解,當然也不是這次的重點,所以就不展開了。

 

二、java的內存模型

  我們知道,java程序是運行在jvm上面的,jvm本身有它自己的一套內存模型,當然,很多時候,jvm的內存模型和真實的物理中程序運行的內存模型是一致的。

  1、jvm的高速緩存

  和真實的硬件內存模型類似,jvm內存模型中,每個線程都有自己獨立的工作空間,可以類比為前面cpu的高速緩存區域,它也是位於jvm主存和cpu之間,所以,當我們在多線程的環境下對共享數據進行操作的時候,也會有真實的cpu內存模型面臨的數據一致性的問題,當然,在討論數據一致性問題之前,我們先討論下再java內存模型中操作原子性的問題。

  2、操作指令的原子性

  程序指令的原子性就是說這個指令在cpu運算中是最小的不可分割的了,它要不就執行成功,要不就沒執行。而數據的同步問題,就是在多個原子操作之間出現的,當多個線程下多個原子指令對共享數據進行操作時,便會面臨數據同步的問題,所以解決同步問題之前我們要先知道在java內存模型中,哪些指令或者說操作是原子性的;

  java的jvm中的原子指令主要有一下:lock(鎖),unlock(解鎖),read(讀)和load(加載),use(使用),store(存儲)和write(寫), assign(賦值)。下面詳細解釋下各個指令;

  lock 和unlock操作:對數據進行加鎖/解鎖操作,這組指令從直觀上理解也是一致性的,因為不可能對數據lock/unlock到一半又失敗了,這是不合理的;

  read和load:這兩個指令很容易混淆,我們可以這樣理解:load操作,是從主內存中將數據加載到告訴緩存中,而read操作是數據從高速緩存中加載到cpu中。所以,我們可以看到,數據從主內存到cpu的這個過程,涉及到了兩個原則性的動作。

  use:user指令可以理解為數據被jvm進行某個運算;

  strore/write:分別是read和load的反過程。

  assign:賦值操作,將某個常量或者變量賦值給某個變量。

  舉個例子進行上面指令的理解:i=i+1,在這個程序段中,jvm首先進行load操作,將i的值加載到告訴緩存中,然后進行read操作,將i值加載到cpu的對應寄存器中,當變量read完畢之后,進行use操作,將i變量的值加1,加完1之后,執行assign操作將新的值賦值給i,然后,cpu執行store操作,將數據的運算結果寫進高速緩存中,最后,cpu將i的新值刷新進內存中。注意,在這個過程中,如果是在多線程環境下的,所有的操作,都有可能中途被打斷。

  另外,我們要知道,什么時候會進行相應的指令操作,這對后面我們理解數據一致性和可見性有很大幫助。read/write動作只會發生在use動作或者assign動作之前/后,對於連續的use和assign操作,只有一開始的use和assign進行read操作(這也很合理,對於一個變量的多個運算連,在續進行時,當前的運算依賴上一步的運算結果而不是在高速緩存或者內存中的值,所以,對於同一個變量,不會每次執行use指令和assign指令都進行read操作);而同樣的,進行store操作時,也不是沒執行use或者assign指令一次就直接將當前結果寫進高速緩存的,jvm僅僅將最終運算結果寫到高速緩存。

  上面這段話比較抽象,舉個具體的例子:i=i+1,這個操作步驟是這樣的:load i值--> read i值--> use i值-->assign i值--> store i值-->write i 值;在執行這段代碼的時候,i的read操作只會執行一次,jvm不會再在use和assign之間執行read或者write操作,畢竟這樣做的話jvm太累了。

  3、數據同步問題與volatile關鍵字

  在進行數據同步問題討論之前,先看以下簡單的代碼:

復制代碼
public class Demo1 implements Runnable {
    static volatile int i=0;
    public static void main(String[] args) {
        for(int j=0;j<10000;j++){
            Runnable runnable = new Demo1();
            new Thread(runnable).start();
        }
        Thread.yield();
        System.out.println(Demo1.i);
    }
    public void run() {
        i++;
    }
}
復制代碼

  讀者可能以為上面的例子打印結果一定是9999,但是事實並不是,當然,將j的值設置更大一點效果會更明顯。所以,在多線程環境下對變量進行操作,需要進行加鎖操作,volatile並不保證變量的線程安全,至於為什么,后面會對這里例子出現的情況進行詳細的說明。

  很多人都以為volatile關鍵字的意思就是同步數據,在使用volatile關鍵字修飾變量之后變量會變得線程安全起來,但是,事實上volatile關鍵字並不是保證數據同步的,類似上面這種情況,只有通過加鎖進行同步操作才能保證i值的正常增加,而volatile關鍵字的作用僅僅是保證變量在修改之后立即可見,這就引出了java內存模型中的另外一個問題:數據可見性問題。

 

  4、java內存模型中的數據可見性問題

  可能讀者看到這里會有很強烈的疑問了:數據的一致性和指令原子性、可見性之間有什么關系?它和前面說的高速緩存又有什么關系?在闡述這些問題的時候,我們先要理解java中數據可見性是什么意思:在前面介紹java內存模型的時候,我們有提到java內存模型中的高速緩存區以及java原子操作的八個指令,數據可見性的意思可以理解為,在進行數據操作時,java程序的read和load以及store和write操作變為了一個原子動作,對於讀數據,jvm不再用高速緩存上的緩存副本數據而是直接讀取內存中最新的數據,對於寫數據jvm將運算結果立即寫入內存,而要實現這種操作,只需要將變量修飾為volatile變量即可。

  所以,可見性是保證各個線程讀取到的數據是其他線程最新修改的,那么,為什么可見性不能保證數據的一致性呢?

  以上面的i++例子為例,i++實際上是i=i+1,在jvm運算中,這個代碼段包含了兩個操作:將i的值加1以及將新值賦值給i,這兩個動作對應兩個原子指令,多線程環境下,這兩個指令並不一定是連續,有可能線程1進行了i+1操作之后還沒進行賦值操作,線程2也進行了同樣的操作並且成功將加1后的最新值寫進了內存,但是,線程1中的i值已經處於運算狀態了,不可能再重新讀取內存中的值,所以線程在線程1進行了賦值操作之后再將新i值刷新到內存中時,內存原來的線程2的值就被覆蓋掉了。

  所以,由上面的例子可見,可見性並不能保證數據的一致性,要保證數據一致性,還要有一個互斥條件:一個線程在操作數據的時候,另外一條線程不能進行同樣的操作,這就是為什么上面的計數例子,即使計數的變量被volatile修飾也不能保證是線程安全的原因了:volatile僅僅保證了變量的可見性,而沒有保證變量操作是互斥的。在對某個變量進行操作時,對於線程之間的操作結果是相互依賴的過程,只有對變量進行加鎖才能保證數據的一致性。

 

三、java 的volatile關鍵字和線程安全。

  1、volatile和線程安全

  由上面的討論之后,讀者可能覺得volatile關鍵字顯得有點雞肋:它並不能保證線程安全,只能保證數據可見性。其實,volatile很多時候是用在初始化變量的時候,保證其他線程對最新賦值可見,在這點上,它比加鎖的開銷小,看下面的代碼例子:

復制代碼
volatile int i = 0;
    volatile int j = 1;
    //下面這條語句時線程1執行的
    j=1;
    //線面這條語句是線程2執行的
    i = j;
復制代碼

volatile 可以保證變量對各個線程都是可見的,例如上面的例子,被volatile修飾之后的變量,可以保證線程之間用到的i值都是最新的,當然,其實volatile在配合java並發下的其他工具使用會可以實現並發下更多的其他功能,這里不展開討論

 

  2、指令重排序問題與volatile關鍵字

  什么是指令的重排序?在cpu實際執行程序時,為了提高速度,cpu會將“允許被打亂”的指令打亂后再執行,而編譯器在編譯的時候,也會對指令進行重排序以進行優化,這變導致了,程序執行的順序,並不一定和我們編碼順序一樣的,具體可看下面代碼:

//指令重排序
    int n = 0;
    int m = 1;

上面的n和m的初始化順序並不一定是先n在m,因為上面代碼有可能會被編譯器以及cpu進行指令的重排序。那么,代碼什么時候會允許被重排序呢?其實我們可以這樣理解:只要重排序之后的結果和非重排序結果在單線程環境下是一樣的,代碼便可以被重排序。也就是說,代碼的前后再單線程環境下沒有依賴關系時,便可進行重排序操作。例如上面這段代碼,再單線程環境中,先初始化n或者先初始化m對程序的最終結果並沒有影響,那么重排序邊有可能發生,但是,諸如下面的代碼,重排序是不會發生的:

int k = 0;
int l = k;

  因為上面的代碼之間,l的值依賴k的值,所以這時,重排序條件並不滿足,不會進行重排序。

  要注意的是,重排序只是保證在單線程的環境下和非重排序前一致,所以,在多線程環境下,重排序會帶來意向不到的結果,請看下面例子:

復制代碼
public class Demo2 extends Thread {
    static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws Exception {

        for (int i = 0; i < 1000; i++) {
            x = y = a = b = 0;
            Thread one = new Thread() {
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread two = new Thread() {
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            one.start();
            two.start();
            one.join();
            two.join();
            System.out.println(x + " " + y);
        }
    }
}
復制代碼

  上面的代碼,分析之后,有可能出現10,01,但是其實在現代jvm內存模型中更是有可能出現00的,因為賦值操作之間在不同線程之間交換順序是對在該線程下最終結果沒有影響的,所以重排序有可能出現,不過由於程序復雜度問題,可能讀者測試的該例子時候jvm不一定進行了指令重排序。但是,當程序復雜度提高之后,重排序效果會很明顯的。

  經過上面的舉例子,相信讀者對重排序的問題理解也更深刻了,那么重排序和volatile關鍵字又有什么關系呢?

  其實,volatile關鍵字還有一個作用:被它修飾之后的變量,不會進行指令重排序,更具體地說,被volatile修飾之后變量,該變量之后的代碼塊,不會因為重排序而出現在該變量之前,該變量之前的代碼塊,也不會因為重排序而出現在該變量之后。所以,我們看到很多開源框架包括jdk源碼,在設置某些閾值的時候,都會用volatile進行修飾,其目的就是防止指令重排序在多線程環境下導致意想不到的結果,比如下面的例子:

int k = 1;
volatile boolean flag = false;
int j = 0;
int h = 2;

  比如上面的例子,k=1的代碼不可能在重排序之后位於flag=false之后,j=0和h=2也不可能在重排序之后出現在flag=false之前,當然,j=0和h=2之間是可以發生重排序的。總的來說,volatile關鍵字就像一堵牆,牆內和牆外之間的代碼不能在重排序之后互相交換位置,但是牆的同一端還是可以進行指令重排序的。

 

  那么,除了volatile關鍵字之外,jvm中,還有沒有其他辦法可以防止或者說有必要避免指令重排序呢?在jvm的內存模型中,有著名的happen-before原則,滿足這些原則的指令都不可能進行指令重排序,下面詳細討論happen-before原則以及與之相關的多線程知識。

 

四、happen-before和線程安全

  1、什么是happen-before?

  前面說了,編譯器以及cpu為了提高執行速率,會對代碼編譯后的指令進行重新優化排序。但是,當代碼符合happen-before時,jvm規定不能進行重排序的,因為這會影響程序得出正確的結果;

  原則一:對用一個lock ,unlock  happen-before於lock操作。

  原則二:線程啟動動作必須happen-before於線程中所有動作。

  原則三:一個線程對另一個線程的interrupt動作必須happen-before 與該線程感知到它被interrupt。

  原則四:對象的構造函數運行完成happen-before 於finalizer的開始

  原則五:單線程環境中,動作a出現在動作b之前,則a happen-before於b之前。

  原則六:傳遞性,a happen-before b,b happen-before c,則a happen-before c。

  下面對這些原則進行簡單的說明:

  原則1,可能讀者會感到疑惑:解鎖為什么會發生於加鎖之前呢?不應該是先有加鎖,然后才會有解鎖嗎?其實一開始我也有這樣的疑惑,后來才知道,happen-before的意思不完全是描述程序動作的發生先后發生動作,它表示的意思是:如果a happen-before於b,則線程1進行a操作后,線程1的整個a操作過程以及造成的影響都是立即能被線程2接下來進行的b動作看到的(更多時候,我們僅僅理解了a和b是同一個線程的情況,沒有深入理解a動作和b動作有可能發生在不同線程之間)。

  然后,原則1就好理解了:它實際是這個意思,某個線程(線程1)對變量加鎖動作必須要在另外一個線程(線程2)對該變量解鎖后才能發生,並且線程1的解鎖前的所有操作對線程2都是立即可見的,這期間不會有指令重排序造成的影響--指令重排序不可能將線程1解鎖前的動作重排序到線程2加鎖的動作之后。

  按照常理以及上面的思路,原則2,3,4,6很好理解,這里不累贅了,下面再簡單提提原則5,原則5什么意思?a書寫在b前面,則a happen-before於b前面?不是說有可能發生指令重排序嗎?其實,原則5的意思是說單線程環境下,程序可以在重排序之后保證和重排序結果一樣,其實就是說jvm能保證程序正常邏輯不會變,但是該重排序的還是會重排序......(這尼瑪不是在將廢話嗎,其實我也覺得)

 

  2、線程安全

  水了那么多,其實討論上面那些的所有東西,都是為了讓我們更好地理解線程安全。從java內存模型進行對線程安全的理解,你會發現很多模糊的東西瞬間就開竅了不少,所以,對於jvm的研究,是研究多線程必不可少的關鍵步驟,只有正確理解了java的內存模型,才能更好地理解java中的多線程工作機制。好了,就不說廢話了,碼了這么多字手好累。

  學生黨秋招干巴爹。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM