由淺入深Java記憶體模型
JMM
Java記憶體模型描述了Java程式中各種變數(共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取這些變數的底層細節。
主存:所有共享變數都儲存在主存中。
工作記憶體:每個執行緒都有自己獨立的工作記憶體,裡面儲存該執行緒使用到的變數的副本。
兩條規定:
執行緒對共享變數的所有操作都必須在自己的工作記憶體中進行,不能直接從主存中讀寫。
不同執行緒的工作記憶體之間無法直接相互訪問,執行緒之間的變數傳遞,必須通過主存來完成。
在開始併發程式設計時,我們需要思考兩個關鍵的問題:1.執行緒之間如何通訊?2.執行緒之間如何同步?
執行緒通訊
在指令式程式設計中,執行緒之間有兩種通訊方式:
共享記憶體:執行緒之間存在公共狀態,執行緒間通過獨寫記憶體中的公共狀態來隱式進行通訊。
訊息傳遞:執行緒之間沒有公共狀態,需要傳送訊息來進行顯式通訊。
執行緒同步
同步是指程式用於控制執行緒發生相對順序執行的機制。在共享記憶體模型裡,程式設計師需要給程式碼加上制定的互斥操作來顯式進行;在訊息傳遞模型中,通訊是對程式設計師透明的,是隱式進行的。
可見性
所有的例項域、靜態域、陣列元素是儲存在堆中的,執行緒之間可以共享,可以將它們稱為“共享變數”,他們可能會在併發程式設計時出現“可見性”問題;而區域性變數、方法引數、異常處理引數不會線上程之間共享,不受記憶體模型的影響。
假如一個變數被多個執行緒使用到,那麼這個共享變數會在多個執行緒的工作記憶體中都存在副本。
當一個共享變數被一個執行緒修改,能夠及時被其他執行緒看到,這叫做可見性。
要實現可見性,需要保證兩點:
共享變數被修改後,能及時重新整理到主存中去。
其他執行緒能及時將主存中更新的資訊重新整理到自己的工作記憶體中。
重排序
as-if-serial語義:
無論怎麼重排序,程式執行的結果必須是與未排序情況下一致的。(Java保證在單執行緒情況下遵循詞語義)
多執行緒中程式交錯執行時,重排序可能會導致記憶體可見性問題。
資料依賴性
如果兩個操作訪問同一個變數,而且這兩個操作中有一個為寫操作,那麼這兩個操作之間就存在了資料依賴性。資料依賴性存在以下三種情況:
操作 | 示例 |
---|---|
先寫,後讀 | a=1;b=a; |
先寫,後寫 | a=1;a=2; |
先讀,後寫 | b=a;a=1; |
不難發現,上面的三種情況,只要重排序其指令,結果都會產生變化。
所以編譯器和處理器在進行重排序時,必須遵守資料依賴性。不能對存在資料依賴性的兩個操作進行重排序。
控制依賴性
看下面一段程式碼
1if(flag){ //操作1
2 int num=a+b; //操作2
3}
可以看到,操作1和操作2並不存在資料依賴,但是存在控制依賴。當代碼中出現控制依賴時,會影響程式的並行度。因此,編譯器和處理器會採用一種“猜測執行”來克服控制依賴性對並行度的影響(並行是為了效率和效能)處理器可能提前執行操作2,將a+b計算出來,並放置到一個叫“重排序快取”的快取中,假如得知操作1中的flag為真,再將結果寫入num中。
在單執行緒程式中,對存在控制依賴關係的重排序,不會影響結果;不過在多執行緒中,可能會影響到結果。
指令重排序:
實際執行的程式碼順序和程式設計師書寫的順序是不一樣的,編譯器或處理器為了提高效能,在不影響程式結果的前提下,會進行執行順序的優化。
編譯器優化重排序(編譯器)
指令級並行重排序(處理器)
記憶體系統重排序(處理器)
導致不可見的原因:
執行緒的交叉執行(原子性問題)
重排序結合線程交叉執行(原子性問題)
共享變數更新後的值,沒有在工作記憶體和主存之間得到及時的更新。(可見性問題)
synchronized實現可見性
原子性:
通過互斥鎖來實現。
可見性:
執行緒解鎖前,必須把共享變數的最新值重新整理到主存中去。
執行緒加鎖時,會清空當前工作記憶體中共享變數的值,從主存中重新獨取最新的值。
流程:
獲得互斥鎖
清空工作記憶體
從主存拷貝共享變數的最新副本到工作記憶體
執行程式碼
將更改後的共享變數的值重新整理到主存
釋放互斥鎖
volatile實現可見性
能夠保證volatile變數的可見性,但是不能保證volatile變數複合操作的原子性。volatile
通過加入記憶體屏障和禁止指令重排序來實現可見性的。對volatile
變數執行寫操作時,會在寫入後加一條store
的屏障指令;對volatile
變數執行讀操作時,會在讀操作前加入一條load
屏障指令。
處理器級別的重排序與記憶體屏障指令
由於處理器的速度很快,為了避免處理器停頓下來等待記憶體(記憶體肯定跟不上處理器的速度)而產生的延遲,現代的處理器使用快取區來臨時儲存處理器向記憶體寫入的資料,然後再提供給記憶體,以保證連續不斷地高效執行。
雖然快取區存在諸多好處,但是它是僅對處理器可見的。這將會產生一個重要的問題:處理器堆記憶體的獨寫操作的順序,可能與記憶體中實際發生讀寫操作順序不一致。(因為現代的處理器大都允許使用重排序)
所以,為了保證可見性,Java編譯器會在生成指令序列時,插入記憶體屏障來禁止特定的處理器進行重排序。
執行緒寫入volatile
變數的過程:
改變執行緒工作記憶體中
volatile
變數副本的值將改變後的副本的值從工作記憶體重新整理到主存
執行緒讀volatile
變數的過程:
從主存中獨取
volatile
變數的最新的值到工作記憶體中。從工作記憶體中獨取變數的副本
volatile
不能保證volatile
變數符合操作的原子性:
舉一個例子:
1public class VolatileDemo{
2 private volatile int num=0;
3 public int getNumber(){
4 return this.num;
5 }
6 public void increase(){
7 this.num++;
8 }
9 public static void main(String[] args){
10 final VolatileDemo v=new VolatileDemo();
11 for(int i;i<500;i++){
12 new Thread(new Runnable(){
13 public void run(){
14 v.increase();
15 }
16 }).start();
17 }
18 //主執行緒主動讓出資源讓500個子執行緒執行。這個‘1’指的是主執行緒
19 while(Thread.activeCount()>1){
20 Thread.yield();
21 }
22 System.out.println(v.getNumber());
23 }
24}
這個程式是開啟500個執行緒,每個執行緒執行一次increase()
操作,給變數num
加一。執行這個程式多次,發現並不是每次輸出結果都是500。
發生了什麼問題?
因為this.num++
這條語句,其實是三步操作,不具備原子性。假設一個執行場景:
此時num=1
執行緒A讀取num為1,A工作記憶體中num=1
執行緒B獨取num為1,B工作記憶體中num=1
執行緒B進行加1操作,寫入B工作記憶體,B工作記憶體中num=2,更新到主存,主存中num=2.
執行緒A進行加1操作,寫入A工作記憶體,A工作記憶體中num=2, 更新到主存,主存中num=2.
可見,進行了兩次加1操作,但是主存中的num只增加了1。怎麼解決呢?我們要保證num自增操作的原子性。
volatile注意事項
對變數的寫入操作不能依賴當前值。
該變數沒有包含在具有其他變數的不變式中。
兩者比較
volatile 不需要加鎖,比synchronized更輕量級,不會阻塞執行緒。
從可見性角度講,volatile讀相當於加鎖,寫相當於解鎖。(前文提到的屏障指令)
synchronized可以保證原子性和可見性,volatile只保證了可見性。