天天看點

并發程式設計專題(二)深入了解JMM模型

作者:京城小人物

1、什麼是JMM模型

Java記憶體模型(JavaMemoryModel簡稱JMM) 是一種抽象的概念,并不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變量(包括執行個體字段,靜态字段和構成數組對象的元素)的通路方式。

JVM運作程式的實體是線程,而每個線程建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用于存儲線程私有的資料,而Java記憶體模型中規定所有變量都存儲在主記憶體,主記憶體是共享記憶體區域,所有線程都可以通路,但線程對變量的操作(讀取指派等)必須在工作記憶體中進行,首先要将變量從主記憶體拷貝的自己的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,不能直接操作主記憶體中的變量,工作記憶體中存儲着主記憶體中的變量副本拷貝,前面說過,工作記憶體是每個線程的私有資料區域,是以不同的線程間無法通路對方的工作記憶體,線程間的通信(傳值)必須通過主記憶體來完成。

1.1、JMM不同于JVM記憶體區域模型

JMM與JVM記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變量在共享資料區域和私有資料區域的通路方式,JMM是圍繞原子性,有序性、可見性展開。JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬于共享資料區域,從某個程度上講應該包括了堆和方法區,而工作記憶體資料線程私有資料區域,從某個程度上講則應該包括程式計數器、虛拟機棧以及本地方法棧。

線程,工作記憶體,主記憶體工作互動圖(基于JMM規範):

并發程式設計專題(二)深入了解JMM模型

主記憶體

主要存儲的是Java執行個體對象,所有線程建立的執行個體對象都存放在主記憶體中,不管該執行個體對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類資訊、常量、靜态變量。由于是共享資料區域,多條線程對同一個變量進行通路可能會發生線程安全問題。

工作記憶體

主要存儲目前方法的所有本地變量資訊(工作記憶體中存儲着主記憶體中的變量副本拷貝),每個線程隻能通路自己的工作記憶體,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作記憶體中建立屬于目前線程的本地變量,當然也包括了位元組碼行号訓示器、相關Native方法的資訊。注意由于工作記憶體是每個線程的私有資料,線程間無法互相通路工作記憶體,是以存儲在工作記憶體的資料不存線上程安全問題。

根據JVM虛拟機規範主記憶體與工作記憶體的資料存儲類型以及操作方式,對于一個執行個體對象中的成員方法而言,如果方法中包含本地變量是基本資料類型(boolean,byte,short,char,int,long,float,double),将直接存儲在工作記憶體的幀棧結構中,但倘若本地變量是引用類型,那麼該變量的引用會存儲在功能記憶體的幀棧中,而對象執行個體将存儲在主記憶體(共享資料區域,堆)中。但對于執行個體對象的成員變量,不管它是基本資料類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。至于static變量以及類本身相關資訊将會存儲在主記憶體中。需要注意的是,在主記憶體中的執行個體對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會将要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體。

1.2、JMM存在的必要性

由于JVM運作程式的實體是線程,而每個線程建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用于存儲線程私有的資料,線程與主記憶體中的變量操作必須通過工作記憶體間接完成,主要過程是将變量從主記憶體拷貝的每個線程各自的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,如果存在兩個線程同時對一個主記憶體中的執行個體對象的變量進行操作就有可能誘發線程安全問題。

假設主記憶體中存在一個共享變量x,現在有A和B兩條線程分别對該變量x=1進行操作,A/B線程各自的工作記憶體中存在共享變量副本x。假設現在A線程想要修改x的值為2,而B線程卻想要讀取x的值,那麼B線程讀取到的值是A線程更新後的值2還是更新前的值1呢?

答案是,不确定,即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新後的值2,這是因為工作記憶體是每個線程私有的資料區域,而線程A變量x時,首先是将變量從主記憶體拷貝到A線程的工作記憶體中,然後對變量進行操作,操作完成後再将變量x寫回主内,而對于B線程的也是類似的,這樣就有可能造成主記憶體與工作記憶體間資料存在一緻性問題,假如A線程修改完後正在将資料寫回主記憶體,而B線程此時正在讀取主記憶體,即将x=1拷貝到自己的工作記憶體中,這樣B線程讀取到的值就是x=1,但如果A線程已将x=2寫回主記憶體後,B線程才開始讀取的話,那麼此時B線程讀取到的就是x=2,但到底是哪種情況先發生呢?

這個問題就是主記憶體與工作記憶體之間的具體互動協定,即一個變量如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實作細節,Java記憶體模型定義了以下八種操作來完成

資料同步八大原子操作

  • lock(鎖定):作用于主記憶體的變量,把一個變量标記為一條線程獨占狀态
  • unlock(解鎖):作用于主記憶體的變量,把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定
  • read(讀取):作用于主記憶體的變量,把一個變量值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用
  • load(載入):作用于工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中
  • use(使用):作用于工作記憶體的變量,把工作記憶體中的一個變量值傳遞給執行引擎
  • assign(指派):作用于工作記憶體的變量,它把一個從執行引擎接收到的值賦給工作記憶體的變量
  • store(存儲):作用于工作記憶體的變量,把工作記憶體中的一個變量的值傳送到主記憶體中,以便随後的write的操作
  • write(寫入):作用于工作記憶體的變量,它把store操作從工作記憶體中的一個變量的值傳送到主記憶體的變量中

如果要把一個變量從主記憶體中複制到工作記憶體中,就需要按順序地執行read和load操作,如果把變量從工作記憶體中同步到主記憶體中,就需要按順序地執行store和write操作。但Java記憶體模型隻要求上述操作必須按順序執行,而沒有保證必須是連續執行。

同步規則分析

  • 1 不允許一個線程無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中
  • 2 一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或者assign)的變量。即就是對一個變量實施use和store操作之前,必須先自行assign和load操作。
  • 3 一個變量在同一時刻隻允許一條線程對其進行lock操作,但lock操作可以被同一線程重複執行多次,多次執行lock後,隻有執行相同次數的unlock操作,變量才會被解鎖。lock和unlock必須成對出現。
  • 4 如果對一個變量執行lock操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量之前需要重新執行load或assign操作初始化變量的值。
  • 5 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。
  • 6 對一個變量執行unlock操作之前,必須先把此變量同步到主記憶體中(執行store和write操作)

2、并發程式設計的原子性、可見性、有序性

原子性

原子性指的是一個操作是不可中斷的,即使是在多線程環境下,一個操作一旦開始就不會被其他線程影響。

在java中,對基本資料類型的變量的讀取和指派操作是原子性操作有點要注意的是,對于32位系統的來說,long類型資料和double類型資料(對于基本資料類型,byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫并非原子性的,也就是說如果存在兩條線程同時對long類型或者double類型的資料進行讀寫是存在互相幹擾的,因為對于32位虛拟機來說,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會導緻一個線程在寫時,操作完前32位的原子操作後,輪到B線程讀取時,恰好隻讀取到了後32位的資料,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它可能是“半個變量”的數值,即64位資料被兩個線程分成了兩次讀取。但也不必太擔心,因為讀取到“半個變量”的情況比較少見,至少在目前的商用的虛拟機中,幾乎都把64位的資料的讀寫操作作為原子操作來執行,是以對于這個問題不必太在意,知道這麼回事即可。

例:

x=10 變量的指派是原子操作
因為x是int類型,32位CPU上int占32位,在X86上由硬體直接提供了原子性支援。
不管有多少個線程同時執行類似x=1這樣的指派語句,x的值最終還是被賦的值


x=y 變量之間的互相指派不是原子操作
這個指令包含兩步,讀取y的值到寄存器,在把該值寫入x,在這兩步中間可能會有
其他線程來改變y的值,導緻x的值和預期不一樣


x++,++x 這也不是原子操作
這個指令會分為三步,從記憶體中讀x的值到寄存器中,對寄存器加1,再把新值寫回x
所處的記憶體位址,在多線程環境中,如果兩個線程同時都讀取到x的值到寄存器中,
兩個線程都加一,最終會導緻x的值和預期不一樣


x=x+1,這也不是原子操作
這個指令會分為兩步,一個是+1,一個是把x+1的結果指派給x,兩步操作,
多線程環境下會有線程安全問題。           

有序性

有序性是指對于單線程的執行代碼,我們總是認為代碼的執行是按順序依次執行的,這樣的了解并沒有毛病,畢竟對于單線程而言确實如此,但對于多線程環境,則可能出現亂序現象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一緻,要明白的是,在Java程式中,倘若在本線程内,所有操作都視為有序行為,如果是多線程環境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程内保證串行語義執行的一緻性,後半句則指指令重排現象和工作記憶體與主記憶體同步延遲現象。

可見性

可見性指的是當一個線程修改了某個共享變量的值,其他線程是否能夠馬上得知這個修改的值。對于串行程式來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值,後續的操作中都能讀取這個變量值,并且是修改過的新值。

但在多線程環境中可就不一定了,由于線程對共享變量的操作都是線程拷貝到各自的工作記憶體進行操作後才寫回到主記憶體中的,這就可能存在一個線程A修改了共享變量x的值,還未寫回主記憶體時,另外一個線程B又對主記憶體中同一個共享變量x進行操作,但此時A線程工作記憶體中共享變量x對線程B來說并不可見,這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題。

另外指令重排以及編譯器優化也可能導緻可見性問題,我們知道無論是編譯器優化還是處理器優化的重排現象,在多線程環境下,确實會導緻程式輪序執行的問題,進而也就導緻可見性問題。

2.1、JMM如何解決原子性&可見性&有序性問題

原子性問題

除了JVM自身提供的對基本資料類型讀寫操作的原子性外,可以通過 synchronized和Lock實作原子性。因為synchronized和Lock能夠保證任一時刻隻有一個線程通路該代碼塊。

可見性問題

volatile關鍵字保證可見性。當一個共享變量被volatile修飾時,它會保證修改的值立即被其他的線程看到,即修改的值立即更新到主存中,當其他線程需要讀取時,它會去記憶體中讀取新值。synchronized和Lock也可以保證可見性,因為它們可以保證任一時刻隻有一個線程能通路共享資源,并在其釋放鎖之前将修改的變量重新整理到記憶體中。

有序性問題

在Java裡面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證了有序性。

繼續閱讀