淺談Java併發中的內存模型

這篇文章主要介紹了Java併發中的內存模型,小編覺得挺不錯的,現在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧

什麼是JavaMemoryModel(JMM)?

JMM通過構建一個統一的內存模型來屏蔽掉不同硬件平臺和不同操作系統之間的差異,讓Java開發者無需關注不同平臺之間的差異,達到一次編譯,隨處運行的目的,這也正是Java的設計目的之一。

CPU和內存

在講JMM之前,我想先和大家聊聊硬件層面的東西。大家應該都知道執行運算操作的CPU本身是不具備存儲能力的,它只負責根據指令對傳遞進來的數據做相應的運算,而數據存儲這一任務則交給內存去完成。雖然內存的運行速度雖然比起硬盤快非常多,但是和3GHZ,4GHZ,甚至5GHZ的CPU比起來還是太慢了,在CPU的眼中,內存運行的速度簡直就是弟弟中的弟弟,等內存進行一次讀寫操作,CPU能思考成百上千次人生了:grin:。但是CPU的運算能力是緊缺資源啊,可不能這麼白白浪費了,所以得想辦法解決這一個問題。

沒有什麼問題是一個緩存不能解決的,如果有,那就再加一個緩存 ——魯迅:反正我沒說這句話

所以人們就想到了給CPU增加一個高速緩存(爲什麼是加高速緩存而不是給內存提高速度就牽扯到硬件成本問題了)來解決這個問題,比如像博主用的Intel的I9 9900k CPU就帶了高達16M的三級緩存,所以硬件上的內存模型大概如下圖所示。

如圖可以很清楚的看到,在CPU內部構建了一到多層的緩存,並且其中的L1 Cache是CPU內核心獨有的,不同的Core之間是不能共享的,而L2 Cache則是所有的核心共享。簡單來說就是CPU在讀取一個數據時會先去最近的Cache層級上讀取,如果找不到則會去下一個層級尋找,都找不到的話就會從內存中去加載,而如果Cache中能拿到所需要的數據就不會去內存讀取。在將數據寫回的時候也會先寫入Cache中,等待合適的時機再寫入到內存中(其中有一個細節就是緩存行的問題,關於這部分內容放在文章結尾)。而由於存在多個cache層級,並且部分cache還不能夠被共享,所以會存在內存可見性的問題。

舉個簡單的例子: 假設現在存在兩個Core,分別是CoreA和CoreB並且他們都擁有屬於自己的L1Chace和共用的L2Cache。同時有一個變量X的值爲1,該變量已經被加載在L2Cahce上。此時CoreA執行運算需要用到變量X,先去L1Cache尋找,未命中,繼續在L2Cache尋找,命中成功,將X=1載入L1Cahce,再經過一系列運算後將X修改爲2並寫入L1Cache。於此同時CoreB剛好也需要X來進行運算,此時他去自己的L1Cahce尋找,未命中,繼續L2Cache尋找,命中成功,將X=1載入自己的L1Cache。此時就出現了問題,CoreA明明已經將X的值修改爲2了,CoreB讀取到的依然是X=1,這就是內存可見性問題。

看到這裏的小夥伴們可能要問了,博主你啥情況啊,你這寫的漸漸忘記標題了啊,說好了Java內存模型,你扯這麼多硬件上的問題幹啥啊?(╯‵□′)╯︵┻━┻

Java中的主內存和工作內存

小夥伴們彆着急,其實JMM和上面的硬件層次上的模型很像,不信看下面的圖片

怎麼樣,是不是看起來很像,可以簡單的理解爲線程的工作內存就是CPU裏Core獨佔的L1Cahce,而主內存就是共享的L2Cache。所以上述的內存一致性問題也會在JMM中存在,而JMM就需要制定一些列的規則來保證內存一致性,這也是Java多線程併發的一個疑難點,那麼JMM制定了哪些規則呢?

##內存間交互操作 首先是主內存的工作內存之間的交互協議,具體來說定義了以下幾個操作(並且保證這幾個操作都是原子性的):

  • lock (鎖定)作用於主內存的變量,將一個變量標識爲一個線程獨佔狀態
  • unlock(解鎖)作用於主內存的變量,將一個處於鎖定狀態的變量釋放出來,釋放之後才能被其他線程鎖定
  • read(讀取)作用於主內存的變量,將一個變量的值從主內存傳輸到線程工作內存中,便於之後的load操作使用
  • load(載入)作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用)作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值)作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲)作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
  • write(寫入)作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

同時還規定了執行上述八個操作時必須遵循以下規則:

  • 如果要把一個變量從主內存中複製到工作內存,就需要按順尋地執行read和load操作, 如果把變量從工作內存中同步回主內存中,就要按順序地執行store和write操作。但Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。
  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中。
  • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。
  • 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。lock和unlock必須成對出現
  • 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值
  • 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。
  • 對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。

上述部分參考並引用《深入理解Java虛擬機》中的內容)

volatile(能夠保證內存可見性和禁止指令重排序)

對於volatile修飾的變量,JMM對其有一些特殊的規定。

內存可見性

往簡單來說volatile關鍵字可以理解爲,有一個volatile修飾的變量x,當一個線程需要使用該變量的時候,直接從主內存中讀取,而當一個線程修改該變量的值時,直接寫入到主內存中。根據之前的分析我們能得出具備這些特性的volatile能夠保證一個變量的內存可見性和內存一致性。

指令重排序

指令重排序是一個大部分CPU都有的操作,同時JVM在運行時也會存在指令重排序的操作。 簡單舉個:chestnut:

 private void test(){
  int a,b,c;//1
  a=1;//2
  b=3;//3
  c=a+b;//4
 }

假設有上面這麼一個方法,內部有這4行代碼。那麼JVM可能會對其進行指令重排序,而指令重排序的規定則是as-if-serial 不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。根據這一規定,編譯器和處理器不會對有依賴關係的指令重排序,但是對沒有依賴的指令則可能會進行重排序。放在上面的例子裏面就是,第1行代碼和2,3,4行代碼是有依賴關係的,所以第一行代碼的指令必須排在2,3,4之前,因爲不可能對一個未定義的變量進行賦值操作。而第2,3行代碼之間並沒有相互依賴關係,所以此處可能會發生指令重排序,先執行3,再執行2。而最後的第4行代碼和之前的3行代碼都有依賴關係,所以他一定會放在最後執行。

既然JVM特別指出指令重排序只在單線程下和未排序的效果一致,那是否表示在多線程下會存在一些問題呢? 答案是肯定的,多線程下指令重排序會帶來一些意想不到的結果。

 int a=0;
 //flag作爲一個標識符,標識是否寫入完成
 boolean flag = false;
 public void writer(){
  a=10;//1
  flag=true;//2
 }
 public void reader(){
  if (flag)
   System.out.println("a:"+a);
 }

假設存在一個類,他有上述部分的field和method,該類在設計上以flag作爲寫入是否完成的標誌,在單線程下這並不會存在問題。而此時有兩個線程分別執行writer和reader方法,暫時不考慮內存可見性的問題,假設對a和flag的寫入,是立即被其他線程所知曉的,這個時候大家覺得輸出a的值爲多少?10?

即使不考慮內存可見性,此時a的值還是有可能會輸出0,這就是指令重排序帶來的問題。在上述代碼中註釋1和2處的代碼是沒有依賴關係的,在單線程下先執行1還是2都沒有任何問題,根據as-if-serial 原則此時就可能會發生指令重排序。

而volatile關鍵字可以禁止指令重排序。

long,double的問題

我們都知道JMM定義的8個主內存和工作內存之間的操作都是具備原子性的,但是對long和double這兩個64位的數據類型有一些例外。

允許虛擬機將沒有被volatile修飾的long和double的64數據的讀寫操作劃分爲兩次32位的讀寫操作,即不要求虛擬機保證對他們的load ,store,read,write四個操作的原子性。 但是大部分的虛擬機實現都保證了這四個操作的原子性的,所以大部分時候我們都不需要刻意的對long,double對象使用volatile修飾。

性能問題

volatile是Java提供的保證內存可見性的最輕量級操作,比起重量級的synchronized能快上不少,但是具體能快多少這部分沒辦法量化。而我們可以知道的是volatile修飾的變量讀操作的性能消耗幾乎和普通變量相差無幾,而寫操作則會慢上一些。所以當volatile能解決我們的問題的時候(內存可見性和禁止指令重排序),我們應該優先選擇使用volatile而不是鎖。

synchronized的內存語義

簡單概括就是

當程序進入synchronized塊時,把在synchronized塊中用到的變量從工作內存中清楚,這樣在需要訪問這些變量的時候會重新從主內存中獲取。當程序退出synchronized塊時,把對塊中恭喜變量的修改刷新到主內存。 如此依賴synchronized也能保證了內存的可見性。

final的內存語義

final也能保證內存的可見性

被final修飾的字段在構造器中一旦初始化完成,並且構造器沒有把this引用傳遞出去,那麼其他線程中就能看見final字段的值。

後記之CPU緩存行和僞共享

什麼是僞共享

根據前面的文章,我們知道CPU和Memory之間是有Cache的,而Cache內部是按行存儲的,行擁有固定的大小,這些行被稱爲緩存行。 當CPU訪問的某個變量不在Cache中時,就會去內存裏獲取,並將該變量所在內存的一個緩存行大小的數據讀入Cache中。由於一次讀取並不是單個對象而是一整個緩存行,所以可能會存在多個變量被讀入一個緩存行中。而一個緩存行只能同時被一個線程操作,所以當多個線程同時修改一個緩存行裏的多個變量時會造成其他線程等待從而帶來性能損耗(但是在單線程情況下,僞共享反而會提升性能,因爲一次性可能會緩存多個變量,節省後續變量的讀取時間)。

如何避免僞共享

在Java8之後可以使用JDK提供的@sun.misc.Contended註解來解決僞共享,像Thread中的threadLocalRandom 字段就使用了這個註解。

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持神馬文庫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章