程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> JAVA綜合教程 >> JVM學習(3)——總結Java內存模型,jvmjava

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

編輯:JAVA綜合教程

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


俗話說,自己寫的代碼,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,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倍以上)

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved