俗話說,自己寫的代碼,6個月後也是別人的代碼……複習!複習!複習!涉及到的知識點總結如下: 為什麼學習Java的記憶體模式 緩存一致性問題 什麼是記憶體模型 JMM(Java Memory Model)簡介 volatitle關鍵字 原子性 可見性 有序性 指令重排 先行發生——happen-befor
俗話說,自己寫的代碼,6個月後也是別人的代碼……複習!複習!複習!涉及到的知識點總結如下:
- 為什麼學習Java的記憶體模式
- 緩存一致性問題
- 什麼是記憶體模型
- JMM(Java Memory Model)簡介
- volatitle關鍵字
- 原子性
- 可見性
- 有序性
- 指令重排
- 先行發生——happen-before原則
- 解釋執行和編譯執行
- 其他語言(c和c++)也有記憶體模型麽?
為什麼需要關註Java記憶體模型?
之前有一個我實習的同事(已經工作的)反諷我:學(關註)這個有什麼用? 我沒有回答,我牢記一句話:大天蒼蒼兮大地茫茫,人各有志兮何可思量。我只知道併發程式的bug非常難找。它們常常不會在測試中發現,而是直到程式運行在高負荷的情況下或者長期運行之後才發生,但是那時候再修複的代價是很大的,且也非常難於重現和跟蹤。故開發,維護人員需要花費比之前更多的努力,去提前保證程式是正確同步的。而這不容易,但是它比前者——調試一個沒有正確同步的程式要容易的多。 本文肯定不會,也不可能全面深入的總結完每個Java記憶體模型的知識點,只是作為熟悉JVM的記憶體模型,而內部的一些具體的原理和細節,之後開專題總結之。 緩存一致性問題眾所周知,電腦某個運算的完成不僅僅依靠cpu及其寄存器,還要和記憶體交互!cpu需要讀取記憶體中的運行數據,存儲運算結果到記憶體中……其中很自然的也是無法避免的就涉及到了I/O操作,而常識告訴我們,I/O操作和cpu的運算速度比起來,簡直沒得比!前者遠遠慢於後者(書上說相差幾個數量級!),前面JVM學習2也總結了這個情景,人們解決的方案是加緩存——cache(高速緩存),cache的讀寫速度儘可能的接近cpu運算速度,來作為記憶體和cpu之間的緩衝!舊的問題解決了,但是引發了新的問題!如果有多個cpu怎麼辦?
現代操作系統都是多核心了,如果多個cpu和一塊記憶體進行交互,那麼每個cpu都有自己的高速緩存塊……咋辦?也就是說,多個cpu的運算都訪問了同一塊記憶體塊的話,可能導致各個cpu的緩存數據不一致!if發生了上述情景,then以哪個cpu的緩存為主呢?為瞭解決這個問題,人們想到,讓各個cpu在訪問緩存時都遵循某事先些規定的協議!因為無規矩不成方圓!如圖(現在可以回答什麼是記憶體模型了):
什麼是記憶體模型?
通俗的說,就是在某些事先規定的訪問協議約束下,電腦處理器對記憶體或者高速緩存的訪問過程的一種抽象!這是物理機下的東西,其實對虛擬機來說(JVM),道理是一樣的!
什麼是Java的記憶體模型(JMM)?
教科書這樣寫的:JVM規範說,Java程式在各個os平臺下必須實現一次編譯,到處運行的效果!故JVM規範定義了一個模型來屏蔽掉各類硬體和os之間記憶體訪問的差異(比如Java的併發程式必須在不同的os下運行效果是一致的)!這個模型就是Java的記憶體模型!簡稱JMM。
讓我通俗的說:Java記憶體模型定義了把JVM中的變數存儲到記憶體和從記憶體中讀取出變數的訪問規則,這裡的變數不算Java棧內的局部變數,因為Java棧是線程私有的,不存在共用問題。細節上講,JVM中有一塊主記憶體(不是完全對應物理機主記憶體的那個概念,這裡說的JVM的主記憶體是JVM的一部分,它主要對應Java堆中的對象實例及其相關信息的存儲部分)存儲了Java的所有變數。且Java的每一個線程都有一個工作記憶體(對應Java棧),裡面存放了JVM主記憶體中變數的值的拷貝!且Java線程的工作記憶體和JVM的主記憶體獨立!如圖:
當數據從JVM的主記憶體複製一份拷貝到Java線程的工作記憶體存儲時,必須出現兩個動作:
- 由JVM主記憶體執行的讀(read)操作
- 由Java線程的工作記憶體執行相應的load操作
反過來,當數據從線程的工作記憶體拷貝到JVM的主記憶體時,也出現兩個操作:
- 由Java線程的工作記憶體執行的存儲(store)操作;
- 由JVM主記憶體執行的相應的寫(write)操作
read,load,store,write的操作都是原子的,即執行期間不會被中斷!但是各個原子操作之間可能會發生中斷!對於普通變數,如果一個線程中那份JVM主記憶體變數值的拷貝更新了,並不能馬上反應在其他變數中,因為Java的每個線程都私有一個工作記憶體,裡面存儲了該條線程需要用到的JVM主記憶體中的變數拷貝!(比如實例的欄位信息,類型的靜態變數,數組,對象……)如圖:
A,B兩條線程直接讀or寫的都是線程的工作記憶體!而A、B使用的數據從各自的工作記憶體傳遞到同一塊JVM主記憶體的這個過程是有時差的,或者說是有隔離的!通俗的說他們之間看不見!也就是之前說的一個線程中的變數被修改了,是無法立即讓其他線程看見的!如果需要在其他線程中立即可見,需要使用 volatile 關鍵字。現在引出volatile關鍵字:
volatile 關鍵字是幹嘛的?舉例說明。
前面說了,各個線程之間的變數更新,如果想讓其他線程立即可見,那麼需要使用它,故volatile欄位是用於線程間通訊的特殊欄位。每次讀volatile欄位都會看到其它線程寫入該欄位的最新值!也就是說,一旦一個共用變數(成員、靜態)被volatile修飾,那麼就意味著:a線程修改了該變數的值,則這個新的值對其他線程來說,是立即可見的!先看一個例子:
這段代碼會完全運行正確麽?即一定會中斷麽?
//線程A boolean stop = false; while(!stop){ doSomething(); } //========= //線程B stop = true;View Code
有些人在寫程式時,如果需要中斷線程,可能都會採用這種辦法。但是這樣做是有bug的!雖然這個可能性很小,但是只要一旦bug發生,後果很嚴重!前面已經說了,Java的每個線程在運行過程中都有自己的工作記憶體,且Java的併發模型採用的是共用記憶體模型,Java線程之間的通信總是隱式進行,整個通信過程對程式員完全透明,這也是為什麼如果編寫多線程程式的Java程式員不理解隱式進行的線程之間通信的工作機制,則很可能會遇到各種奇怪的併發問題的原因。針對本題的A、B線程,如果他們之間通信,畫成圖是這樣的:
那麼線程A和B需要通信的時候,第一步A線程會將本地工作記憶體中的stop變數的值刷新到JVM主記憶體中,主記憶體的stop變數=false,第二步,線程B再去主記憶體中讀取stop的拷貝,臨時存儲在B,此時B中工作記憶體的stop也為false了。當線程B更改了stop變數的值為true之後,同樣也需要做類似線程A那樣的工作……但是此時此刻,恰恰B還沒來得及把更新之後的stop寫入主存當中(前面說了各個原子操作之間可以中斷),就轉去做其他事情了,那麼線程A由於不知道線程B對stop變數的更改,因此還會一直迴圈下去。這就是死迴圈的潛在bug!
從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主記憶體。JMM通過控制主記憶體與每個線程的工作記憶體之間的交互,來為java程式員提供記憶體可見性保證。但是它們之間不是立即可見的!如果stop使用了volatile修飾,會使得:
- B線程更新stop值為true,會強制將修改後的值立即寫入JVM主記憶體,不許原子操作之間中斷。
- 線程B修改stop時,也會讓線程A的工作記憶體中的stop緩存行失效!因為A線程的工作記憶體中JVM主記憶體的stop的拷貝值緩存行無效了,所以A線程再次讀取stop的值會去JVM主記憶體讀取
這樣A得到的就是最新的正確的stop值——true。程式完美的實現了中斷。很多人還認為,volatile這麼好,它比鎖的性能好多了!其實這不是絕對的,很片面,只能說volatile比重量級的鎖(Java中線程是映射到操作系統的原生線程上的,如果要喚醒或者是阻塞一條線程需要操作系統的幫忙,這就需要從用戶態轉換到核心態,而狀態轉換需要相當長的時間……所以說syncronized關鍵字是java中比較重量級的操作)性能好,而且valatile萬萬不能代替鎖,因為它不是線程安全的,既volatile修飾符無法保證對變數的任何操作都是原子的!(鑒於主要涉及了Java的併發編程,之後再開專題總結)。
什麼是原子性?
在Java中,對基本數據類型的變數的操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。看例子:
1 int x = 10; //語句1 2 y = x; //語句2 3 x++; //語句3 4 x = x + 1; //語句4View Code
這幾個語句哪個是原子操作?
其實只有語句1是原子性操作,其他三個語句都不是原子性操作。語句1是直接將數值10賦值給x,也就是說線程執行這個語句會直接將數值10寫入到工作記憶體中。線程執行語句2實際上包含2個操作,它先要去主記憶體讀取x的值,再將x的值寫入工作記憶體,雖然讀取x的值以及將x的值寫入工作記憶體這2個操作都是原子性操作,但是合起來就不是原子性操作了。同樣的,x++和 x = x+1包括3個操作:讀取x的值,進行加1操作,寫入新的值。所以上面4個語句只有語句1的操作具備原子性。也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。
不過這裡有一點需要註意:在32位平臺下,對64位數據的讀取和賦值是需要通過兩個操作來完成的,不能保證其原子性。但是好像在最新的JDK中,JVM已經保證對64位數據的讀取和賦值也是原子性操作了。從上面可以看出,Java記憶體模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。
何時使用volatile關鍵字?
通常來說,使用volatile必須具備以下2個條件:
- 對變數的寫操作不依賴於當前值
- 該變數沒有包含在具有其他變數的不變式中
這些條件表明,可以被寫入 volatile 變數的這些有效值獨立於任何程式的狀態,包括變數的當前狀態。我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程式在併發時能夠正確執行。比如boolean類型的標記變數。
前面只是大概總結了下Java的記憶體模式和volatile關鍵字,不是很深入,留待後續併發專題補充。下麵接著看幾個之前和之後會遇到的概念:
到底什麼是可見性?如何保證?
大白話就是一個線程修改了變數,其他線程可以立即能夠知道。保證可見性可以使用之前提到的volatile關鍵字(強制立即寫入主記憶體,使得其他線程共用變數緩存行失效),還有重量級鎖synchronized (也就是線程間的同步,unlock之前,寫變數值回主存,看作順序執行的),最後就是常量——final修飾的(一旦初始化完成,其他線程就可見)。其實這裡忍不住還是補充下,關鍵字volatile 的語義除了保證不同線程對共用變數操作的可見性,還能禁止進行指令重排序!也就是保證有序性。這樣又引出一個問題: 什麼是有序性和重排序? 還是大白話,在本線程內,所有的操作看起來都是有序的,但是在本線程之外(其他線程)觀察,這些操作都是無序的。涉及到了:- 指令重排(破壞線程間的有序性)
- 之前說的工作記憶體和主記憶體同步延時(也就是線程A先後更新兩個變數m和n,但是由於線程工作記憶體和JVM主記憶體之間的同步延時,線程B可能還沒完全同步線程A更新的兩個變數,可能先看到了n……對於B來說,它看A的操作就是無序的,順序無法保證)。
談談對指令重排的理解
要知道,編譯器和處理器會儘可能的讓程式的執行性能更優越!為此,他們會對一些指令做一些優化性的順序調整!比如有這樣一個可重排語句:a=1;
b=2;
View Code
先給a賦值,和先給b賦值,其實沒什麼區別,效果是一樣的,這樣的代碼就是可重排代碼,編譯器會針對上下文對指令做順序調整,哪個順序好,就用哪個,所以實際上兩句話怎麼個執行順序,是不一定的。
有可重排就自然會有不可重排,首先要知道Java記憶體模型具備一些先天的“有序性”,即不需要通過任何手段就能夠保證有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。反之遵循了happen-before原則,JVM就無法對指令進行重排序(看起來的)。這樣又引出了一個新問題:
什麼是先行發生原則happens-before?
下麵就來具體介紹下happens-before(先行發生原則,這裡的先行和時間上先行是兩碼事;):
- 程式次序規則:在一個線程內,書寫在前面的操作先行發生於書寫在後面的操作,就像剛剛說的,一段代碼的執行在單個線程中看起來是有序的,程式看起來執行的順序是按照代碼順序執行的,因為虛擬機可能會對程式代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程式順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程式執行看起來是有序執行的,這一點要註意理解。事實上,這個規則是用來保證程式在單線程中執行結果的正確性,但無法保證程式在多線程中執行的正確性。
- 鎖定規則:一個unLock操作先行發生於後面對同一個鎖的lock操作,也就是說無論在單線程中還是多線程中,同一個鎖如果出於被鎖定的狀態,那麼必須先對鎖進行了釋放操作,後面才能繼續進行lock操作。
- volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作,這是一條比較重要的規則。就是說如果一個線程先去寫一個volatile變數,然後另一個線程去讀取,那麼寫入操作肯定會先行發生於讀操作。
- 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C,實際上就是體現happens-before原則具備傳遞性。
- 線程啟動規則:Thread對象的start()方法先行發生於此線程的每個一個動作
- 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
- 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,Thread.join()。
- 對象終結規則:一個對象的初始化完成(構造器執行結束)先行發生於他的finalize()方法的開始
前4條規則是比較重要的,後4條規則都是常識。
比如像如下這樣的線程內的串列語義()是不可重排語句:
- 寫後讀
a = 1; b = a;// 寫一個變數之後,再讀這個變數。
- 寫後寫
a = 1; a = 2; // 寫一個變數之後,再寫這個變數。
- 讀後寫
a = b; b = 1; // 讀一個變數之後,再寫這個變數。
以上語句不可重排,單線程的程式看起來執行的順序是按照代碼順序執行的,這句話要正確理解:JVM實際上還是可能會對程式代碼不存在數據依賴性的指令進行指令重排序,雖然進行重排序,但是最終執行的結果是與單線程的程式順序執行的結果一致的。因此,在單個線程中,程式執行看起來是有序執行的,這一點要註意理解。事實上,這個規則是用來保證程式在單線程中執行結果的正確性,但無法保證程式在多線程中執行的正確性。對於多線程環境,編譯器不考慮多線程間的語義。看一個例子:
1 class OrderExample { 2 private int a = 0; 3 4 private boolean flag = false; 5 6 public void writer() { 7 a = 1; 8 flag = true; 9 } 10 11 public void reader() { 12 if (flag) { 13 int i = a + 1; 14 } 15 } 16 }View Code
讓線程A首先執行writer()方法,接著讓線程B線程執行reader()方法,線程B如果看到了flag,那麼就可能會立即進入if語句,但是在int i=a+1處不一定能看到a已經被賦值為1,因為在writer中,兩句話順序可能打亂!有可能對於B線程,它看A是無序的!編譯器無法保證有序性。因為A完全可以先執行flag=true,再執行a=1,不影響結果!如圖:
也就是說多線程之間無法保證指令的有序性!先行發生原則的程式次序有序性原則是針對單線程的。也就是說,如果是一個線程去先後執行這兩個方法,完全是ok的!符合happens-before原則的第一條——程式次序有序性,故不存在指令重排問題。
如何解決呢?還是套用先行發生原則,看第二條鎖定原則,我們可以使用同步鎖:
class OrderExample { private int a = 0; private boolean flag = false; public synchronized void writer() { a = 1; flag = true; } public synchronized void reader() { if (flag) { int i = a + 1; } } }View Code
因為寫、讀都加鎖了,他們之間本質是串列的,即使線程A占有寫鎖期間,JVM對寫做了指令重排也沒關係,因為此時鎖被A拿了,B線程無法執行讀操作,直到A線程把寫操作執行完畢,釋放了該鎖,B線程才能拿到這同一個對象鎖,而此時,a肯定是1,flag也必然是true了。此時必然是有序的。通俗的說,同步後,即使做了重排,因為互斥的緣故,reader 線程看writer線程也是順序執行的。
其他語言(c和c++)也有記憶體模型麽?
大部分其他的語言,像C和C++,都沒有被設計成直接支持多線程。這些語言對於發生在編譯器和處理器平臺架構的重排序行為的保護機制會嚴重的依賴於程式中所使用的線程庫(例如pthreads),編譯器,以及代碼所運行的平臺所提供的保障。
最後補充下一個問題:Java的位元組碼兩種運行方式——解釋執行和編譯執行
- 解釋運行:解釋執行以解釋方式運行位元組碼,解釋執行的意思是:讀一句執行一句。
- 編譯運行(JIT):將位元組碼編譯成機器碼,直接執行機器碼,是在運行時編譯(不是代碼寫完了編譯的),編譯後性能有數量級的提升(能差10倍以上)