浅析Java内存模型
概述
Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量是線程共享的,存在競爭問題的。
?
Java內存模型規定了所有的變量都存儲在主內存,每條線程還有自己的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取、賦值等),都必須在工作內存中進行,而不能直接讀寫主內存中的變量。(注:這里說的工作內存或本地內存都是虛擬出來的,實質上包括了寄存器、緩存或中間的存儲器)
不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,三者關系的交互如圖所示:
如何交互
那么內存之間是如何交互的呢?JMM定義了8中操作來完成,虛擬機保證每一種操作都是原子性的。
Lock:作用于主內存的變量,它把一個變量標識為線程獨占狀態
Unlock:作用于主內存的變量,將一個處于鎖定狀態下的變量釋放出來,釋放后的變量才可以被其他線程加鎖。
Read:作用于主內存的變量,將一個變量的值從主內存傳輸到線程的工作內存。
Load:作用于工作內存的變量,將read操作得到的變量的值放入工作內存中的變量副本中
Use:作用于工作內存的變量,將工作內存中變量的值傳遞給執行引擎,當虛擬機需要使用時會執行這個操作。
Assign:作用于工作內存的變量,將一個執行引擎接收到的值賦給工作內存中的變量,當虛擬機遇到給變量賦值的字節碼時會執行此操作。
Store:將工作內存中的一個變量值傳送到主內存中
Write:作用于主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
?
重排序
從上面可以大致看出來線程之間通信的流程,但是,在執行程序時,編譯器和處理器往往會對指令進行重排序,也就是不一定按照程序寫的執行。這往往是為了提高性能,提高并發度。但是對于多線程程序來說,這往往會造成程序執行的結果不一致,所以我們就得需要通過synchronized,volatile等方式進行同步。
內存屏障
由于有重排序,為了保證內存的可見性,java編譯器在生成的指令序列中會插入內存屏障指令來禁止特定類型的處理器重排序,JMM把內存屏障指令分為下列四類:
| 屏障類型 | 指令示例 | 說明 |
| LoadLoad Barriers | Load1; LoadLoad; Load2 | 確保Load1數據的裝載,之前于Load2及所有后續裝載指令的裝載。 |
| StoreStore Barriers | Store1; StoreStore; Store2 | 確保Store1數據對其他處理器可見(刷新到內存),之前于Store2及所有后續存儲指令的存儲。 |
| LoadStore Barriers | Load1; LoadStore; Store2 | 確保Load1數據裝載,之前于Store2及所有后續的存儲指令刷新到內存。 |
| StoreLoad Barriers | Store1; StoreLoad; Load2 | 確保Store1數據對其他處理器變得可見(指刷新到內存),之前于Load2及所有后續裝載指令的裝載。 StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執行該屏障之后的內存訪問指令。 |
?
Happens-Before
在JMM中,如果一個操作執行的結果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關系,這兩個操作可以是一個線程之間,也可以是兩個線程之間。
Happens-before規則如下:
1、程序順序規則:一個線程中的每個操作,happens-before于該線程中的任意后續操作。
2、監視器鎖規則:一個監視器的解鎖,happens-before于隨后這個監視器的加鎖。
3、volatile規則:對于volatile域的寫,happens-before于任意后續對這個volatile的讀。
4、傳遞性:如果A happens-before B,B happens-before C,那么A happens-before C。
?
Happens-before并不是說,兩個操作代碼上執行時間的先后順序,而是保證一個操作的結果對另一個操作可見,保證執行結果是順序的。
?
?
數據依賴性
?
如果兩個操作訪問同一個變量,且其中兩個操作有一個是寫操作,此時這兩個操作之間就存在數據依賴性。
數據依賴分為三種類型:
?
寫后讀:
a=1; b=a;
寫后寫:
a=1; a=1;
讀后寫:
b=a; a=1;
所謂的數據依賴性就是指當發生重排序的時候,其結果會發生改變,所以編譯器和處理器在重排序的時候,不會改變存在數據依賴性的兩個操作的執行順序
?
?
As-if-serial語義:
?
不管怎么重排序,(單線程)程序的執行結果不能被改變。所以為了遵循這種語義,編譯器和處理器不會對存在數據依賴性的操作進行重排序。
?
?
控制依賴性:
?
if(flag) //---1
int i= a*a; //-----2
操作1和操作2之間存在控制依賴,所以在多線程的程序中,編譯器和處理器會啟動猜測執行
處理器可以提前計算a*a的結果,然后放到一個重排序的緩沖的硬件緩存中,當操作1的條件為真時,將計算結果寫入到變量i 中。所以我們會發現在這里對兩個操作做了重排序,所以破壞了多線程程序的語義。
但是對于單線程而言,重排序存在控制依賴的操作,不會改變執行結果,但是在多線程中,重排序存在控制依賴的操作,可能會改變執行結果。
?
順序一致性:
?
當程序未使用同步時,就會出現數據競爭,所以會造成結果的改變。
當使用了同步以后,這便是一個沒有數據競爭的程序。如果程序是正確使用同步的,那么執行的程序將會具有順序一致性:即程序的執行結果與在順序一致性模型中執行的完全相同。
?
所謂順序一致性模型是一個理論的參考模型,具有兩大特性:
一個線程中的所有操作都必須按照程序的順序來執行;
無論程序是否同步,所有線程都只能看到一個單一執行順序,在順序一致性模型中,每個操作都是原子性的執行而且必須立即對其他線程可見。
?
注:
JMM不保證對64位的long和double型的變量(沒有volatile修飾)讀寫具有原子性,而內存一致性模型保證對所有的讀寫操作都具有原子性。
?
因為在一些32位的處理器上,如果對64位的long和double讀寫具有原子性,那么需要很大的開銷,所以java不強求必須對這兩種具有原子性。JVM在這些處理器上運行時,會將一個64位的long/double寫操作分成兩個32位的寫操作來執行,此時對其就無法保證原子性,所以有可能造成讀取的時候讀了一半數的錯誤。
volatile
volatile可是看成是弱一級的Synchronized,換句話說就是給volatile變量單個的讀寫操作,使用同一個鎖對這些單個的操作進行了同步。
我們來看一下volatile的效果:
使用volatile:
[java] view plaincopy
class VolatileFeaturesExample {
//使用volatile聲明64位的long型變量
volatile long vl = 0L;
?
public void set(long l) {
vl = l; //單個volatile變量的寫
}
?
public void getAndIncrement () {
vl++; //復合(多個)volatile變量的讀/寫
}
?
public long get() {
return vl; //單個volatile變量的讀
}
}
使用synchronized替換volatile
[java] view plaincopy
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通變量
?
//對單個的普通 變量的寫用同一個鎖同步
public synchronized void set(long l) {
vl = l;
}
?
public void getAndIncrement () { //普通方法調用
long temp = get(); //調用已同步的讀方法
temp += 1L; //普通寫操作
set(temp); //調用已同步的寫方法
}
public synchronized long get() {
//對單個的普通變量的讀用同一個鎖同步
return vl;
}
}
?
我們可以看出來,對Volatile的單個讀寫操作,與對一個普通變量的讀寫操作使用同一個鎖來同步,之間的效果是一樣的。
我們發現及時64位的long和double,只要是volatile,那么該變量的讀寫就是原子性的,而volatile++這時復合操作,所以不具有原子性。
volatile的性質(可見性和原子性)
可見性:讀取一個volatile變量的時候,總是可以看到其他線程對這個變量的最后寫入,也就是說每次讀取的都是最新值。
原子性:對任意單個volatile變量操作具有原子性
?
我們可以從可見性得到,volatile可以建立一定意義上的happens-before關系,因為其寫優先于讀。
執行流程
當線程A寫一個volatile變量的時候,JMM會把該變量本地內存中的值刷新到主內存,因此本地內存中的值和主內存中的是一致的。
?
當線程B讀取一個volatile,JMM會將該線程的本地內存置為無效,直接從主內存中讀取,這樣讀取到的值就是剛剛寫入的
?
也就是說線程B在讀取volatile變量的時候,線程A之前所有對此變量的操作都對線程B可見
?
換個角度,我們可以這么說:
線程A在寫入一個volatile變量,實際上是對下一個將要讀取這個volatile變量的線程B發出了消息,而線程B讀取這個volatile變量就是接收了線程A發出的消息。
所以線程A寫volatile,線程B讀volatile,可以看成線程A通過主內存向線程B發送了消息。
如何實現
我們知道重排序,那么是否會對volatile變量重排序呢,JMM限制了這種變量的重排序規則:
?
| 是否能重排序 | 第二個操作 | ||
| 第一個操作 | 普通讀/寫 | volatile讀 | volatile寫 |
| 普通讀/寫 | ? | ? | NO |
| volatile讀 | NO | NO | NO |
| volatile寫 | ? | NO | NO |
?
從上表中我們可以看出:
如果第二個操作是volatile的寫,無論第一個操作是什么,都不會被編譯器或處理器重排序。確保volatile之前的寫操作不會被排到其后面。
如果第一個操作是volatile的讀,無論第二個操作是什么,都不會被重排序。
如果第一個操作是volatile寫,而第二個是volatile讀,不能重排序。
?
volatile為了實現這個規則,編譯器在生成字節碼的時候,給字節碼指令前后都添加了內存屏障,禁止特定類型的處理器重排序。
規則如下:
給每個volatile的寫操作前面添加了StoreStore屏障
===》禁止之前的普通寫操作與volatile寫操作進行重排序
?
給每個volatile的寫操作后面添加了StoreLoad屏障
===》禁止后面的普通讀/寫操作與volatile寫操作重排序
?
給每個volatile的讀操作后面添加了LoadLoad屏障
===》防止后面的普通讀操作和volatile讀操作的進行重排序
給每個volatile的讀操作后面添加了LoadStore屏障
===》防止后面的寫操作與volatile讀操作進行重排序
?
?
在實際情況下可以省略一些屏障,而且屏障的設立和處理器也有很大關系,像X86僅僅會有StoreLoad屏障,因為它只允許寫-讀操作的重排序。
?
鎖
?
上文說到了鎖可以保證強大的互斥性和同步性
?
鎖同樣和volatile一樣,也可以建立happens-before關系
?
比如說線程A只有釋放鎖了以后,線程B可以獲得鎖,因此線程釋放鎖之前對共享變量的修改,在線程B獲得鎖之后都是可見的。
?
鎖與volatile
Volatile僅僅只對單個volatile變量的讀寫操作具有原子性,而鎖很強大,可以保證整個臨界區代碼執行都具有原子性,所以相對而言volatile的可伸縮性和性能比較好。
?
內存語義
線程A釋放鎖的時候,JMM會把線程對應的本地內存中的共享變量刷新到主內存中。
當線程B獲取鎖時,JMM會把線程對應的本地內存置為無效,直接從主內存中讀取共享變量。
?
類似于volatile,鎖也有自己的語義,線程A釋放鎖的時候,其實就是給將要獲取這個鎖的線程發出了消息
線程B獲得鎖也就是接收了線程A所發送的消息
?
這個過程其實就是兩個線程通過主內存進行通信
鎖的實現
通常是通過ReentrantLock 中的lock方法實現,這個鎖一般分為公平鎖和非公平鎖,默認為非公平鎖。
?
公平鎖在釋放鎖的最后寫volatile變量state,在獲取鎖的時候首先讀取volatile變量,所以根據volatile的happens-before規則,釋放鎖的線程寫入的volatile對獲取鎖的線程可見。
?
非公平鎖會調用compareAndSetState(CAS),這個方法最終調到本地方法上,一些內在的規定使得CAS同時具有volatile讀和volatile寫的內存語義。
?
公平鎖和非公平鎖:
公平鎖和非公平鎖在釋放時,最后都要寫一個volatile變量state
公平鎖在獲取時首先讀取這個volatile
而非公平鎖在獲取時,首先會用CAS更新這個volatile變量,這個操作同時具有volatile讀和volatile寫的內存語義。
?
final
對于final域的讀寫,編譯器和處理器需要遵循兩個重排序規則:
1、在構造函數中對final域的寫入,與隨后將此被構對象的引用賦給其他引用,兩者之間不能重排序。
2、初次讀取一個包含final域的引用和隨后讀取這個final域,兩者之間不能重排序
?
?
寫final域的重排序規則禁止把final域的寫重排序到構造函數之外
編譯器會在final域的寫之后,構造函數return之前,插入一個StoreStore屏障,這個屏障禁止final域的寫重排序到構造函數外面,
這樣可以保證其他任何線程,在引用對象時,對象的final域已經正確初始化(但是普通域有可能沒有被初始化)。
?
?
編譯器會在讀final域操作的前面插入一個loadLoad屏障。
這樣可以確保,在讀取一個對象的final域之前,一定會先讀取包含這個final域的對象引用,如果該引用不為null,說明引用對象的final域已經被正確初始化過了
?
?
如果final域是一個引用類型,那么會增加一個約束:
在構造函數內對final引用對象的成員域的寫入,和在構造函數外把這個被構造對象的引用賦值給另一個引用變量,兩者之間不能重排序。
?
寫final域的重排序規則可以確保,在引用變量對其他線程可見之前,該引用變量指向的對象已經在構造函數中被正確初始化過了但是還要有一個保證就是:在構造函數返回之前,不能讓這個被構造對象的引用為其他線程所見,也就是對象引用不能再構造函數中溢出。
?
我們看個例子:
[java] view plaincopy
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
?
public FinalReferenceEscapeExample () {
i = 1; //1寫final域
obj = this; //2 this引用在此“逸出”
}
?
public static void writer() {
new FinalReferenceEscapeExample ();
}
?
public static void reader {
if (obj != null) { //3
int temp = obj.i; //4
}
}
}
線程A執行write方法,線程B執行read方法。
?
操作2使得對象在未完成構造前就對線程B可見,所以這有可能使得操作2和操作1重排序,然后線程B就無法正確讀取到final域的值
final的實現
上面已經說過,再來總結一下:
寫final域的重排序規則要求編譯器在 final域的寫之后,構造函數return之前插入StroeStore屏障,
讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個loadLoad屏障
總結
JMM把happens-before要求禁止的重排序分成了兩種:
對于會改變程序執行結果的重排序,JMM要求編譯器和處理器會禁止這種排序
對于不會改變程序執行結果的重排序,JMM對編譯器和處理器不作要求(可以重排序)
?
也可以說,JMM遵循一個原則:
只要不改變程序(單線程程序和多線程正確同步的程序)的執行結果,編譯器和處理器怎么優化都可以。
?
轉載于:https://www.cnblogs.com/salansun/p/4746819.html
總結
以上是生活随笔為你收集整理的浅析Java内存模型的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一文掌握vscode远程调试python
- 下一篇: 机载 LiDAR 点云数据分类