以前一直在實習,博客停寫了一段時間,如今秋招開始了,因此辭職回來專心看書,同時將天天的收穫以博客的形式記錄下來。最近在看jvm相關的書籍,下面對面試中問得最多的部分--java 內存模型進行簡單總結。java
本篇博客大概由一下幾個部分組成:面試
一、程序在真實物理世界的內存模型緩存
二、java的內存模型安全
三、java中的volatile與線程安全網絡
四、happen-before原則與加鎖。多線程
1、程序在物理世界中是怎樣運行的併發
全部的程序,不管什麼語言編寫,最後都會變爲一串機器碼,而cpu的運算過程,就是將這些機器碼轉換爲電信號給相應的運算電路進行邏輯或者算術運算,而後將結果返回。在這個過程,底層的電路如何進行運算並不是討論範疇,咱們主要從更加宏觀的角度理解程序怎麼運行:程序從加載到內存到cpu得出結果返回到內存中,這個過程具體究竟是如何進行的。app
一、程序在物理中的執行過程框架
咱們之前一直知道,cpu從內存中讀取數據遠遠快於從磁盤或者網絡流中讀取數據,因此爲了提升響應速度,咱們不少時候會提早把讀取頻繁的數據預先加載到內存中以提升性能;其實相似的,因爲cpu的運算速度真的太快了,從內存加載數據到cpu的時間和cpu運算速度相比也是太慢了,因此爲了提升程序執行效率,在cpu和內存之間,還有一個緩存區,叫cpu高速緩存區,cpu從高速緩存中讀取數據的速度遠遠快於從主存中讀取數據(固然,高速緩存的物理制形成本也比主內存物理成本高得多)因此,爲了提升速率,真實的程序是按照下面這張示意圖的路徑進行運行的:jvm
總的來講:程序加載到主存以後,當cpu運算須要讀或寫數據的時候,它不會直接對主存進行操做,由於這樣速度實在太慢了,它從位於cpu和主存之間的高速緩存讀取數據/寫入數據,當運算完成時,高速緩存會將結果自動更新到主存中。
二、緩存帶來的問題
和全部緩存機制面臨的問題同樣,cpu高速緩存也面臨這樣的問題:多線程下數據同步問題。現代的cpu,基本都是以多核cpu爲主,因此,再多核(多線程)下,不一樣cpu之間的數據同步問題是高速緩存緩存須要解決的。真實物理世界中,在有必要的狀況下,爲了保證數據同步,能夠經過對內存中的數據進行加鎖操做來保證數據的一致性,這和咱們日常理解的多線程加鎖操做是一致的,不過本人對硬件層面如何實現加鎖不太瞭解,固然也不是此次的重點,因此就不展開了。
2、java的內存模型
咱們知道,java程序是運行在jvm上面的,jvm自己有它本身的一套內存模型,固然,不少時候,jvm的內存模型和真實的物理中程序運行的內存模型是一致的。
一、jvm的高速緩存
和真實的硬件內存模型相似,jvm內存模型中,每一個線程都有本身獨立的工做空間,能夠類比爲前面cpu的高速緩存區域,它也是位於jvm主存和cpu之間,因此,當咱們在多線程的環境下對共享數據進行操做的時候,也會有真實的cpu內存模型面臨的數據一致性的問題,固然,在討論數據一致性問題以前,咱們先討論下再java內存模型中操做原子性的問題。
二、操做指令的原子性
程序指令的原子性就是說這個指令在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太累了。
三、數據同步問題與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內存模型中的另一個問題:數據可見性問題。
四、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僅僅保證了變量的可見性,而沒有保證變量操做是互斥的。在對某個變量進行操做時,對於線程之間的操做結果是相互依賴的過程,只有對變量進行加鎖才能保證數據的一致性。
3、java 的volatile關鍵字和線程安全。
一、volatile和線程安全
由上面的討論以後,讀者可能以爲volatile關鍵字顯得有點雞肋:它並不能保證線程安全,只能保證數據可見性。其實,volatile不少時候是用在初始化變量的時候,保證其餘線程對最新賦值可見,在這點上,它比加鎖的開銷小,看下面的代碼例子:
volatile int i = 0; volatile int j = 1; //下面這條語句時線程1執行的 j=1; //線面這條語句是線程2執行的 i = j;
volatile 能夠保證變量對各個線程都是可見的,例如上面的例子,被volatile修飾以後的變量,能夠保證線程之間用到的i值都是最新的,固然,其實volatile在配合java併發下的其餘工具使用會能夠實現併發下更多的其餘功能,這裏不展開討論
二、指令重排序問題與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原則以及與之相關的多線程知識。
4、happen-before和線程安全
一、什麼是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能保證程序正常邏輯不會變,可是該重排序的仍是會重排序......(這尼瑪不是在將廢話嗎,其實我也以爲)
二、線程安全
水了那麼多,其實討論上面那些的全部東西,都是爲了讓咱們更好地理解線程安全。從java內存模型進行對線程安全的理解,你會發現不少模糊的東西瞬間就開竅了很多,因此,對於jvm的研究,是研究多線程必不可少的關鍵步驟,只有正確理解了java的內存模型,才能更好地理解java中的多線程工做機制。好了,就不說廢話了,碼了這麼多字手好累。
學生黨秋招乾巴爹。