淺析java記憶體模型--JMM
在併發程式設計中,多個執行緒之間採取什麼機制進行通訊(資訊交換),什麼機制進行資料的同步?
在Java語言中,採用的是共享記憶體模型來實現多執行緒之間的資訊交換和資料同步的。
執行緒之間通過共享程式公共的狀態,通過讀-寫記憶體中公共狀態的方式來進行隱式的通訊。同步指的是程式在控制多個執行緒之間執行程式的相對順序的機制,在共享記憶體模型中,同步是顯式的,程式設計師必須顯式指定某個方法/程式碼塊需要在多執行緒之間互斥執行。
在說Java記憶體模型之前,我們先說一下Java的記憶體結構,也就是執行時的資料區域:
Java虛擬機器在執行Java程式的過程中,會把它管理的記憶體劃分為幾個不同的資料區域,這些區域都有各自的用途、建立時間、銷燬時間。
Java執行時資料區分為下面幾個記憶體區域:
1.PC暫存器/程式計數器:
嚴格來說是一個數據結構,用於儲存當前正在執行的程式的記憶體地址,由於Java是支援多執行緒執行的,所以程式執行的軌跡不可能一直都是線性執行。當有多個執行緒交叉執行時,被中斷的執行緒的程式當前執行到哪條記憶體地址必然要儲存下來,以便用於被中斷的執行緒恢復執行時再按照被中斷時的指令地址繼續執行下去。為了執行緒切換後能恢復到正確的執行位置,每個執行緒都需要有一個獨立的程式計數器,各個執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體,這在某種程度上有點類似於“ThreadLocal”,是執行緒安全的。
2.Java棧 Java Stack:
Java棧總是與執行緒關聯在一起的,每當建立一個執行緒,JVM就會為該執行緒建立對應的Java棧,在這個Java棧中又會包含多個棧幀(Stack Frame),這些棧幀是與每個方法關聯起來的,每執行一個方法就建立一個棧幀,每個棧幀會含有一些區域性變數、操作棧和方法返回值等資訊。每當一個方法執行完成時,該棧幀就會彈出棧幀的元素作為這個方法的返回值,並且清除這個棧幀,Java棧的棧頂的棧幀就是當前正在執行的活動棧,也就是當前正在執行的方法,PC暫存器也會指向該地址。只有這個活動的棧幀的本地變數可以被操作棧使用,當在這個棧幀中呼叫另外一個方法時,與之對應的一個新的棧幀被建立,這個新建立的棧幀被放到Java棧的棧頂,變為當前的活動棧。同樣現在只有這個棧的本地變數才能被使用,當這個棧幀中所有指令都完成時,這個棧幀被移除Java棧,剛才的那個棧幀變為活動棧幀,前面棧幀的返回值變為這個棧幀的操作棧的一個運算元。
由於Java棧是與執行緒對應起來的,Java棧資料不是執行緒共有的,所以不需要關心其資料一致性,也不會存在同步鎖的問題。
在Java虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器可以動態擴充套件,如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。在Hot Spot虛擬機器中,可以使用-Xss引數來設定棧的大小。棧的大小直接決定了函式呼叫的可達深度。
3.堆 Heap:
堆是JVM所管理的記憶體中國最大的一塊,是被所有Java執行緒鎖共享的,不是執行緒安全的,在JVM啟動時建立。堆是儲存Java物件的地方,這一點Java虛擬機器規範中描述是:所有的物件例項以及陣列都要在堆上分配。Java堆是GC管理的主要區域,從記憶體回收的角度來看,由於現在GC基本都採用分代收集演算法,所以Java堆還可以細分為:新生代和老年代;新生代再細緻一點有Eden空間、From Survivor空間、To Survivor空間等。
4.方法區Method Area:
方法區存放了要載入的類的資訊(名稱、修飾符等)、類中的靜態常量、類中定義為final型別的常量、類中的Field資訊、類中的方法資訊,當在程式中通過Class物件的getName.isInterface等方法來獲取資訊時,這些資料都來源於方法區。方法區是被Java執行緒鎖共享的,不像Java堆中其他部分一樣會頻繁被GC回收,它儲存的資訊相對比較穩定,在一定條件下會被GC,當方法區要使用的記憶體超過其允許的大小時,會丟擲OutOfMemory的錯誤資訊。方法區也是堆中的一部分,就是我們通常所說的Java堆中的永久區 Permanet Generation,大小可以通過引數來設定,可以通過-XX:PermSize指定初始值,-XX:MaxPermSize指定最大值。
5.常量池Constant Pool:
常量池本身是方法區中的一個數據結構。常量池中儲存瞭如字串、final變數值、類名和方法名常量。常量池在編譯期間就被確定,並儲存在已編譯的.class檔案中。一般分為兩類:字面量和應用量。字面量就是字串、final變數等。類名和方法名屬於引用量。引用量最常見的是在呼叫方法的時候,根據方法名找到方法的引用,並以此定為到函式體進行函式程式碼的執行。引用量包含:類和介面的許可權定名、欄位的名稱和描述符,方法的名稱和描述符。
6.本地方法棧Native Method Stack:
本地方法棧和Java棧所發揮的作用非常相似,區別不過是Java棧為JVM執行Java方法服務,而本地方法棧為JVM執行Native方法服務。本地方法棧也會丟擲StackOverflowError和OutOfMemoryError異常。
主記憶體和工作記憶體:
Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在JVM中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數與Java程式設計裡面的變數有所不同步,它包含了例項欄位、靜態欄位和構成陣列物件的元素,但不包含區域性變數和方法引數,因為後者是執行緒私有的,不會共享,當然不存在資料競爭問題(如果區域性變數是一個reference引用型別,它引用的物件在Java堆中可被各個執行緒共享,但是reference引用本身在Java棧的區域性變量表中,是執行緒私有的)。為了獲得較高的執行效能,Java記憶體模型並沒有限制執行引起使用處理器的特定暫存器或者快取來和主記憶體進行互動,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。
JMM規定了所有的變數都儲存在主記憶體(Main Memory)中。每個執行緒還有自己的工作記憶體(Working Memory),執行緒的工作記憶體中儲存了該執行緒使用到的變數的主記憶體的副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數(volatile變數仍然有工作記憶體的拷貝,但是由於它特殊的操作順序性規定,所以看起來如同直接在主記憶體中讀寫訪問一般)。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒之間值的傳遞都需要通過主記憶體來完成。
執行緒1和執行緒2要想進行資料的交換一般要經歷下面的步驟:
1.執行緒1把工作記憶體1中的更新過的共享變數重新整理到主記憶體中去。
2.執行緒2到主記憶體中去讀取執行緒1重新整理過的共享變數,然後copy一份到工作記憶體2中去。
Java記憶體模型是圍繞著併發程式設計中原子性、可見性、有序性這三個特徵來建立的,那我們依次看一下這三個特徵:
原子性(Atomicity):一個操作不能被打斷,要麼全部執行完畢,要麼不執行。在這點上有點類似於事務操作,要麼全部執行成功,要麼回退到執行該操作之前的狀態。
基本型別資料的訪問大都是原子操作,long 和double型別的變數是64位,但是在32位JVM中,32位的JVM會將64位資料的讀寫操作分為2次32位的讀寫操作來進行,這就導致了long、double型別的變數在32位虛擬機器中是非原子操作,資料有可能會被破壞,也就意味著多個執行緒在併發訪問的時候是執行緒非安全的。
下面我們來演示這個32位JVM下,對64位long型別的資料的訪問的問題:
public class NotAtomicity {
//靜態變數t
public static long t = 0;
//靜態變數t的get方法
public static long getT() {
return t;
}
//靜態變數t的set方法
public static void setT(long t) {
NotAtomicity.t = t;
}
//改變變數t的執行緒
public static class ChangeT implements Runnable{
private long to;
public ChangeT(long to) {
this.to = to;
}
public void run() {
//不斷的將long變數設值到 t中
while (true) {
NotAtomicity.setT(to);
//將當前執行緒的執行時間片段讓出去,以便由執行緒排程機制重新決定哪個執行緒可以執行
Thread.yield();
}
}
}
//讀取變數t的執行緒,若讀取的值和設定的值不一致,說明變數t的資料被破壞了,即執行緒不安全
public static class ReadT implements Runnable{
public void run() {
//不斷的讀取NotAtomicity的t的值
while (true) {
long tmp = NotAtomicity.getT();
//比較是否是自己設值的其中一個
if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {
//程式若執行到這裡,說明long型別變數t,其資料已經被破壞了
System.out.println(tmp);
}
////將當前執行緒的執行時間片段讓出去,以便由執行緒排程機制重新決定哪個執行緒可以執行
Thread.yield();
}
}
}
public static void main(String[] args) {
new Thread(new ChangeT(100L)).start();
new Thread(new ChangeT(200L)).start();
new Thread(new ChangeT(-300L)).start();
new Thread(new ChangeT(-400L)).start();
new Thread(new ReadT()).start();
}
}
我們建立了4個執行緒來對long型別的變數t進行賦值,賦值分別為100,200,-300,-400,有一個執行緒負責讀取變數t,如果正常的話,讀取到的t的值應該是我們賦值中的一個,但是在32的JVM中,事情會出乎預料。如果程式正常的話,我們控制檯不會有任何的輸出,可實際上,程式一執行,控制檯就輸出了下面的資訊:
-4294967096
4294966896
-4294967096
-4294967096
4294966896
之所以會出現上面的情況,是因為在32位JVM中,64位的long資料的讀和寫都不是原子操作,即不具有原子性,併發的時候相互干擾了。
32位的JVM中,要想保證對long、double型別資料的操作的原子性,可以對訪問該資料的方法進行同步,就像下面的:
public class Atomicity {
//靜態變數t
public static long t = 0;
//靜態變數t的get方法,同步方法
public synchronized static long getT() {
return t;
}
//靜態變數t的set方法,同步方法
public synchronized static void setT(long t) {
Atomicity.t = t;
}
//改變變數t的執行緒
public static class ChangeT implements Runnable{
private long to;
public ChangeT(long to) {
this.to = to;
}
public void run() {
//不斷的將long變數設值到 t中
while (true) {
Atomicity.setT(to);
//將當前執行緒的執行時間片段讓出去,以便由執行緒排程機制重新決定哪個執行緒可以執行
Thread.yield();
}
}
}
//讀取變數t的執行緒,若讀取的值和設定的值不一致,說明變數t的資料被破壞了,即執行緒不安全
public static class ReadT implements Runnable{
public void run() {
//不斷的讀取NotAtomicity的t的值
while (true) {
long tmp = Atomicity.getT();
//比較是否是自己設值的其中一個
if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {
//程式若執行到這裡,說明long型別變數t,其資料已經被破壞了
System.out.println(tmp);
}
////將當前執行緒的執行時間片段讓出去,以便由執行緒排程機制重新決定哪個執行緒可以執行
Thread.yield();
}
}
}
public static void main(String[] args) {
new Thread(new ChangeT(100L)).start();
new Thread(new ChangeT(200L)).start();
new Thread(new ChangeT(-300L)).start();
new Thread(new ChangeT(-400L)).start();
new Thread(new ReadT()).start();
}
}
這樣做的話,可以保證對64位資料操作的原子性。
可見性:一個執行緒對共享變數做了修改之後,其他的執行緒立即能夠看到(感知到)該變數這種修改(變化)。
Java記憶體模型是通過將在工作記憶體中的變數修改後的值同步到主記憶體,在讀取變數前從主記憶體重新整理最新值到工作記憶體中,這種依賴主記憶體的方式來實現可見性的。
無論是普通變數還是volatile變數都是如此,區別在於:volatile的特殊規則保證了volatile變數值修改後的新值立刻同步到主記憶體,每次使用volatile變數前立即從主記憶體中重新整理,因此volatile保證了多執行緒之間的操作變數的可見性,而普通變數則不能保證這一點。
除了volatile關鍵字能實現可見性之外,還有synchronized,Lock,final也是可以的。
使用synchronized關鍵字,在同步方法/同步塊開始時(Monitor Enter),使用共享變數時會從主記憶體中重新整理變數值到工作記憶體中(即從主記憶體中讀取最新值到執行緒私有的工作記憶體中),在同步方法/同步塊結束時(Monitor Exit),會將工作記憶體中的變數值同步到主記憶體中去(即將執行緒私有的工作記憶體中的值寫入到主記憶體進行同步)。
使用Lock介面的最常用的實現ReentrantLock(重入鎖)來實現可見性:當我們在方法的開始位置執行lock.lock()方法,這和synchronized開始位置(Monitor Enter)有相同的語義,即使用共享變數時會從主記憶體中重新整理變數值到工作記憶體中(即從主記憶體中讀取最新值到執行緒私有的工作記憶體中),在方法的最後finally塊裡執行lock.unlock()方法,和synchronized結束位置(Monitor Exit)有相同的語義,即會將工作記憶體中的變數值同步到主記憶體中去(即將執行緒私有的工作記憶體中的值寫入到主記憶體進行同步)。
final關鍵字的可見性是指:被final修飾的變數,在建構函式數一旦初始化完成,並且在建構函式中並沒有把“this”的引用傳遞出去(“this”引用逃逸是很危險的,其他的執行緒很可能通過該引用訪問到只“初始化一半”的物件),那麼其他執行緒就可以看到final變數的值。
有序性:對於一個執行緒的程式碼而言,我們總是以為程式碼的執行是從前往後的,依次執行的。這麼說不能說完全不對,在單執行緒程式裡,確實會這樣執行;但是在多執行緒併發時,程式的執行就有可能出現亂序。用一句話可以總結為:在本執行緒內觀察,操作都是有序的;如果在一個執行緒中觀察另外一個執行緒,所有的操作都是無序的。前半句是指“執行緒內表現為序列語義(WithIn Thread As-if-Serial Semantics)”,後半句是指“指令重排”現象和“工作記憶體和主記憶體同步延遲”現象。
Java提供了兩個關鍵字volatile和synchronized來保證多執行緒之間操作的有序性,volatile關鍵字本身通過加入記憶體屏障來禁止指令的重排序,而synchronized關鍵字通過一個變數在同一時間只允許有一個執行緒對其進行加鎖的規則來實現,
在單執行緒程式中,不會發生“指令重排”和“工作記憶體和主記憶體同步延遲”現象,只在多執行緒程式中出現。
happens-before原則:
Java記憶體模型中定義的兩項操作之間的次序關係,如果說操作A先行發生於操作B,操作A產生的影響能被操作B觀察到,“影響”包含了修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。
下面是Java記憶體模型下一些”天然的“happens-before關係,這些happens-before關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們進行隨意地重排序。
a.程式次序規則(Pragram Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說應該是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈結構。
b.管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而”後面“是指時間上的先後順序。
c.volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀取操作,這裡的”後面“同樣指時間上的先後順序。
d.執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
e.執行緒終於規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()的返回值等作段檢測到執行緒已經終止執行。
f.執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否有中斷髮生。
g.物件終結規則(Finalizer Rule):一個物件初始化完成(構造方法執行完成)先行發生於它的finalize()方法的開始。
g.傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
一個操作”時間上的先發生“不代表這個操作會是”先行發生“,那如果一個操作”先行發生“是否就能推匯出這個操作必定是”時間上的先發生 “呢?也是不成立的,一個典型的例子就是指令重排序。所以時間上的先後順序與happens-before原則之間基本沒有什麼關係,所以衡量併發安全問題一切必須以happens-before 原則為準。
1.面對目前流行的技術不知如何下手?
2.一家公司待久了,過得很安逸,但跳槽時面試碰壁?
3.覺得現在的技術基礎感覺到很紮實,但就是自己的技術提升不上?
4.覺得自己很牛B,一般需求都能搞定,但是所學的知識點沒有系統化,很難在技術領域繼續突破?
5.現在覺得自己技術還可以,但就是薪資漲不上去?
如果以上五點你都有或者有裡面其中的一點,那麼你都可以加群討論學習,架構交流群號:【 478030634 】 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多