JVM學習(3)——總結Java內存模型

俗話說,本身寫的代碼,6個月後也是別人的代碼……複習!複習!複習!涉及到的知識點總結以下:java

  • 爲何學習Java的內存模式
  • 緩存一致性問題
  • 什麼是內存模型
  • JMM(Java Memory Model)簡介
  • volatitle關鍵字
  • 原子性
  • 可見性
  • 有序性
  • 指令重排
  • 先行發生——happen-before原則
  • 解釋執行和編譯執行
  • 其餘語言(c和c++)也有內存模型麼?

 


  爲何須要關注Java內存模型?c++

 
  以前有一個我實習的同事(已經工做的)反諷我:學(關注)這個有什麼用?
  我沒有回答,我牢記一句話: 大天蒼蒼兮大地茫茫,人各有志兮何可思量。 我只知道併發程序的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線程的工做內存存儲時,必須出現兩個動做:併發

  1. 由JVM主內存執行的讀(read)操做
  2. 由Java線程的工做內存執行相應的load操做

  反過來,當數據從線程的工做內存拷貝到JVM的主內存時,也出現兩個操做:

  1. 由Java線程的工做內存執行的存儲(store)操做;
  2. 由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;         //語句4
View 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倍以上)
相關文章
相關標籤/搜索