天天看點

Java記憶體模型

java記憶體模型規範了java虛拟機與計算機記憶體是如何協同工作的。java虛拟機是一個完整的計算機的一個模型,是以這個模型自然也包含一個記憶體模型——又稱為java記憶體模型。

如果你想設計表現良好的并發程式,了解java記憶體模型是非常重要的。java記憶體模型規定了如何和何時可以看到由其他線程修改過後的共享變量的值,以及在必須時如何同步的通路共享變量。

原始的java記憶體模型存在一些不足,是以java記憶體模型在java1.5時被重新修訂。這個版本的java記憶體模型在java8中人在使用。

java記憶體模型把java虛拟機内部劃分為線程棧和堆。這張圖示範了java記憶體模型的邏輯視圖。

Java記憶體模型

每一個運作在java虛拟機裡的線程都擁有自己的線程棧。這個線程棧包含了這個線程調用的方法目前執行點相關的資訊。一個線程僅能通路自己的線程棧。一個線程建立的本地變量對其它線程不可見,僅自己可見。即使兩個線程執行同樣的代碼,這兩個線程任然在在自己的線程棧中的代碼來建立本地變量。是以,每個線程擁有每個本地變量的獨有版本。

所有原始類型的本地變量都存放線上程棧上,是以對其它線程不可見。一個線程可能向另一個線程傳遞一個原始類型變量的拷貝,但是它不能共享這個原始類型變量自身。

堆上包含在java程式中建立的所有對象,無論是哪一個對象建立的。這包括原始類型的對象版本。如果一個對象被建立然後指派給一個局部變量,或者用來作為另一個對象的成員變量,這個對象任然是存放在堆上。

下面這張圖示範了調用棧和本地變量存放線上程棧上,對象存放在堆上。

Java記憶體模型

一個本地變量可能是原始類型,在這種情況下,它總是“呆在”線程棧上。

一個本地變量也可能是指向一個對象的一個引用。在這種情況下,引用(這個本地變量)存放線上程棧上,但是對象本身存放在堆上。

一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量任然存放線上程棧上,即使這些方法所屬的對象存放在堆上。

一個對象的成員變量可能随着這個對象自身存放在堆上。不管這個成員變量是原始類型還是引用類型。

靜态成員變量跟随着類定義一起也存放在堆上。

存放在堆上的對象可以被所有持有對這個對象引用的線程通路。當一個線程可以通路一個對象時,它也可以通路這個對象的成員變量。如果兩個線程同時調用同一個對象上的同一個方法,它們将會都通路這個對象的成員變量,但是每一個線程都擁有這個本地變量的私有拷貝。

下圖示範了上面提到的點:

Java記憶體模型

兩個線程擁有一些列的本地變量。其中一個本地變量(local variable 2)執行堆上的一個共享對象(object 3)。這兩個線程分别擁有同一個對象的不同引用。這些引用都是本地變量,是以存放在各自線程的線程棧上。這兩個不同的引用指向堆上同一個對象。

注意,這個共享對象(object 3)持有object2和object4一個引用作為其成員變量(如圖中object3指向object2和object4的箭頭)。通過在object3中這些成員變量引用,這兩個線程就可以通路object2和object4。

這張圖也展示了指向堆上兩個不同對象的一個本地變量。在這種情況下,指向兩個不同對象的引用不是同一個對象。理論上,兩個線程都可以通路object1和object5,如果兩個線程都擁有兩個對象的引用。但是在上圖中,每一個線程僅有一個引用指向兩個對象其中之一。

是以,什麼類型的java代碼會導緻上面的記憶體圖呢?如下所示:

如果兩個線程同時執行<code>run()</code>方法,就會出現上圖所示的情景。<code>run()</code>方法調用<code>methodone()</code>方法,<code>methodone()</code>調用<code>methodtwo()</code>方法。

<code>methodone()</code>聲明了一個原始類型的本地變量和一個引用類型的本地變量。

每個線程執行<code>methodone()</code>都會在它們對應的線程棧上建立<code>localvariable1</code>和<code>localvariable2</code>的私有拷貝。<code>localvariable1</code>變量彼此完全獨立,僅“生活”在每個線程的線程棧上。一個線程看不到另一個線程對它的<code>localvariable1</code>私有拷貝做出的修改。

每個線程執行<code>methodone()</code>時也将會建立它們各自的<code>localvariable2</code>拷貝。然而,兩個<code>localvariable2</code>的不同拷貝都指向堆上的同一個對象。代碼中通過一個靜态變量設定<code>localvariable2</code>指向一個對象引用。僅存在一個靜态變量的一份拷貝,這份拷貝存放在堆上。是以,<code>localvariable2</code>的兩份拷貝都指向由<code>mysharedobject</code>指向的靜态變量的同一個執行個體。<code>mysharedobject</code>執行個體也存放在堆上。它對應于上圖中的object3。

注意,<code>mysharedobject</code>類也包含兩個成員變量。這些成員變量随着這個對象存放在堆上。這兩個成員變量指向另外兩個<code>integer</code>對象。這些<code>integer</code>對象對應于上圖中的object2和object4.

注意,<code>methodtwo()</code>建立一個名為<code>localvariable</code>的本地變量。這個成員變量是一個指向一個<code>integer</code>對象的對象引用。這個方法設定<code>localvariable1</code>引用指向一個新的<code>integer</code>執行個體。在執行<code>methodtwo</code>方法時,<code>localvariable1</code>引用将會在每個線程中存放一份拷貝。這兩個<code>integer</code>對象執行個體化将會被存儲堆上,但是每次執行這個方法時,這個方法都會建立一個新的<code>integer</code>對象,兩個線程執行這個方法将會建立兩個不同的<code>integer</code>執行個體。<code>methodtwo</code>方法建立的<code>integer</code>對象對應于上圖中的object1和object5。

還有一點,<code>mysharedobject</code>類中的兩個<code>long</code>類型的成員變量是原始類型的。因為,這些變量是成員變量,是以它們任然随着該對象存放在堆上,僅有本地變量存放線上程棧上。

現代硬體記憶體模型與java記憶體模型有一些不同。了解記憶體模型架構以及java記憶體模型如何與它協同工作也是非常重要的。這部分描述了通用的硬體記憶體架構,下面的部分将會描述java記憶體是如何與它“聯手”工作的。

下面是現代計算機硬體架構的簡單圖示:

Java記憶體模型

一個現代計算機通常由兩個或者多個cpu。其中一些cpu還有多核。從這一點可以看出,在一個有兩個或者多個cpu的現代計算機上同時運作多個線程是可能的。每個cpu在某一時刻運作一個線程是沒有問題的。這意味着,如果你的java程式是多線程的,在你的java程式中每個cpu上一個線程可能同時(并發)執行。

每個cpu都包含一系列的寄存器,它們是cpu内記憶體的基礎。cpu在寄存器上執行操作的速度遠大于在主存上執行的速度。這是因為cpu通路寄存器的速度遠大于主存。

每個cpu可能還有一個cpu緩存層。實際上,絕大多數的現代cpu都有一定大小的緩存層。cpu通路緩存層的速度快于通路主存的速度,但通常比通路内部寄存器的速度還要慢一點。一些cpu還有多層緩存,但這些對了解java記憶體模型如何和記憶體互動不是那麼重要。隻要知道cpu中可以有一個緩存層就可以了。

一個計算機還包含一個主存。所有的cpu都可以通路主存。主存通常比cpu中的緩存大得多。

通常情況下,當一個cpu需要讀取主存時,它會将主存的部分讀到cpu緩存中。它甚至可能将緩存中的部分内容讀到它的内部寄存器中,然後在寄存器中執行操作。當cpu需要将結果寫回到主存中去時,它會将内部寄存器的值重新整理到緩存中,然後在某個時間點将值重新整理回主存。

當cpu需要在緩存層存放一些東西的時候,存放在緩存中的内容通常會被重新整理回主存。cpu緩存可以在某一時刻将資料局部寫到它的記憶體中,和在某一時刻局部重新整理它的記憶體。它不會再某一時刻讀/寫整個緩存。通常,在一個被稱作“cache lines”的更小的記憶體塊中緩存被更新。一個或者多個緩存行可能被讀到緩存,一個或者多個緩存行可能再被重新整理回主存。

上面已經提到,java記憶體模型與硬體記憶體架構之間存在差異。硬體記憶體架構沒有區分線程棧和堆。對于硬體,所有的線程棧和堆都分布在主内中。部分線程棧和堆可能有時候會出現在cpu緩存中和cpu内部的寄存器中。如下圖所示:

Java記憶體模型

當對象和變量被存放在計算機中各種不同的記憶體區域中時,就可能會出現一些具體的問題。主要包括如下兩個方面:

-線程對共享變量修改的可見性

-當讀,寫和檢查共享變量時出現race conditions

下面我們專門來解釋以下這兩個問題。

如果兩個或者更多的線程在沒有正确的使用<code>volatile</code>聲明或者同步的情況下共享一個對象,一個線程更新這個共享對象可能對其它線程來說是不接見的。

想象一下,共享對象被初始化在主存中。跑在cpu上的一個線程将這個共享對象讀到cpu緩存中。然後修改了這個對象。隻要cpu緩存沒有被重新整理會主存,對象修改後的版本對跑在其它cpu上的線程都是不可見的。這種方式可能導緻每個線程擁有這個共享對象的私有拷貝,每個拷貝停留在不同的cpu緩存中。

下圖示意了這種情形。跑在左邊cpu的線程拷貝這個共享對象到它的cpu緩存中,然後将count變量的值修改為2。這個修改對跑在右邊cpu上的其它線程是不可見的,因為修改後的count的值還沒有被重新整理回主存中去。

Java記憶體模型

解決這個問題你可以使用java中的<code>volatile</code>關鍵字。<code>volatile</code>關鍵字可以保證直接從主存中讀取一個變量,如果這個變量被修改後,總是會被寫回到主存中去。

想象一下,如果線程a讀一個共享對象的變量count到它的cpu緩存中。再想象一下,線程b也做了同樣的事情,但是往一個不同的cpu緩存中。現線上程a将<code>count</code>加1,線程b也做了同樣的事情。現在<code>count</code>已經被增在了兩個,每個cpu緩存中一次。

如果這些增加操作被順序的執行,變量<code>count</code>應該被增加兩次,然後原值+2被寫回到主存中去。

然而,兩次增加都是在沒有适當的同步下并發執行的。無論是線程a還是線程b将<code>count</code>修改後的版本寫回到主存中取,修改後的值僅會被原值大1,盡管增加了兩次。

下圖示範了上面描述的情況:

Java記憶體模型