天天看點

Java筆記:Java記憶體模型

本文是在翻閱多篇有關java記憶體模型相關的文章之後,基于自己的了解,為了加深記憶,寫的一篇java記憶體模型的總結。好記性不如爛筆頭,共勉。

為什麼要提出Java記憶體模型

由于在java多線程程式設計中,方法區和堆空間(執行個體對象,數組對象,靜态對象)都是線程共享的,而程式計數器、java方法棧和本地方法棧才是線程私有的。因為這些共享變量的存在,在并發程式設計領域需要解決的核心問題就是它們的可見性、有序性和原子性問題。如果不能解決這些問題,在并發程式設計中會造成詭異的bug。那麼可見性、有序性和原子性都是怎麼造成的呢?

  • 可見性:主要是為了平衡CPU處理速度和記憶體讀寫速度之間差異性,為了能夠減少CPU等待IO讀寫的時間,是以需要增加CPU緩存,這樣就引入了可見性問題。一個線程對共享變量進行了改變,另外一個線程也能看到,稱之為可見性。
  • 原子性:為了更好的利用CPU,引入多線程切換,這樣會造成原子性問題。一個或者多個操作不被中斷執行的特性,稱為原子性。
  • 有序性:為了更好的利用緩存,編譯器或者處理器對指令會有重排序的優化,這樣就引入了有序性問題。

基于以上的問題,如果能禁用緩存或者禁止編譯器重排序,一切就迎刃而解了。但是,這樣的話,程式的運作效率就堪憂了。是以對于開發程式員而言,需要有一個按需禁用緩存或者禁止編譯器重排序的規範,這樣就能平衡運作效率和線程安全了。是以,java在1.5版本引入了java記憶體模型。

什麼是java記憶體模型

就我的了解而言,java記憶體模型是一種規範,在必要的情況下,讓一個線程對共享變量的修改對其他線程可見。根據免費電子書-深入了解java記憶體模型對java記憶體模型的抽象,可以這麼了解:

Java筆記:Java記憶體模型

每個線程對共享變量都有一個抽象的本地記憶體副本(其實本地記憶體并不真實存在),java記憶體模型需要做的是就是通過重新整理緩存、編譯器優化等操作讓線程之間共享變量可見。

既然java記憶體模型是一種規範,那麼我們需要了解的就是鎖、final、volatile三種方法的使用,以及8大happens-before原則。

happens-before原則

happens-before原則不是指一條指令必須先于另一條指令發生,而是指一條指令的結果需要對後面一條指令可見。as-if-serial原則。舉個例子就是:指令1happens-before指令2,如果指令1和指令2不存在資料依賴關系,也就是指令1的結果對指令2沒有影響,這種情況編譯器仍可以進行重排序;否則,不允許。以下是規範的八大原則:

  • 控制流順序執行原則:一條語句A在書寫上先于另一條語句B,那麼A happens-before B;
  • 鎖的原則:對同一個鎖的解鎖happens-before後續對同一個鎖的加鎖操作;
  • volatile原則:對volatile變量的寫操作happens-before後續對這個變量的讀操作;
  • 傳遞性:A happens-before B,B happens-before C,那麼A happens-before C;
  • 線程的start原則:線上程A中調用線程B的start()方法,那麼線程B能看到線程A中start之前的的所有操作。換句話說就是,線程A調用B.start之前的所有操作 happens-before B中的任何語句;
  • 線程的join原則:如果線上程A中調用線程B的join()方法,那麼線程B中的任意操作 happens-before join()的傳回;
  • 線程的interrupt原則:線程的interrupt()方法的調用 happens-before 被中斷線程的代碼檢測到中斷事件的發生;
  • 對象銷毀原則:一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。

happens-before的底層實作

通過在指令的前後插入記憶體屏障來保證可見性。總共有四種記憶體屏障:

記憶體屏障 指令示例 含義
讀讀屏障 Load1;LoadLoad;Load2 確定對指令Load1的讀要先于對指令2以及後續所有讀指令的讀
讀寫屏障 Load1;LoadStore;Store2 確定對Load1的讀要先于對指令2以及後續所有寫指令的寫
寫寫屏障 Store1;StoreStore;Store2 確定對Store1的寫(重新整理到記憶體)要先于指令2以及後續所有寫指令的寫
寫讀屏障 Store1;StoreLoad;Load2 確定對Store1的寫在所有處理器中都可見(重新整理到記憶體),要先于後續的讀操作;并且會使StoreLoad之前所有的記憶體通路指令都執行完成後,才會執行後續的 記憶體通路指令

根據上面的鎖的原則,對同一個鎖的解鎖操作會先行發生于對同一個鎖的加鎖操作,在JVM進行逃逸分析之後,如果确定鎖是被不同的線程所持有,那麼在解鎖的時候會強制重新整理緩存,如果隻是由同一個線程持有,那麼會移除加鎖和解鎖操作。

加鎖和解鎖的記憶體語義

解鎖的時候,線程會将本地記憶體強制重新整理到主記憶體當中,而加鎖的時候,緩存中的共享變量會置為無效,線程需要從主記憶體中重新擷取共享變量的值。如圖:

Java筆記:Java記憶體模型

volatile

volatile是用來保證變量的可見性和有序性的,但不保證原子性。對于volatile變量的讀操作,會在之後插入讀寫屏障和讀讀屏障,就是確定在volatile讀之後,後續所有記憶體操作才允許執行;對于volatile變量的寫操作,會在其之前插入讀寫屏障,在其後插入寫讀屏障,確定該變量對後續記憶體操作可見,以及也不會重排序到讀取之前。

volatile提供了一種不保證原子性的變量可見性和有序性的機制,有時候可以替代鎖。适合于讀多寫少的場景。如果頻繁寫的話,會頻繁強制重新整理記憶體,這個對性能也是很有影響的。

final

被final标記的變量,生而不可變,天生就是線程安全的,但是1.5之後java記憶體模型對其也做了重排的限制。

由于1.5之前編譯器和處理器對final字段的優化太過努力了,以緻于都優化出錯了。有這麼一個場景就是,編譯器重排序指令的時候,将對象中的final字段重排序到了構造函數的外面,以緻于可能其他線程對其的讀取有種final字段可變的錯覺。

是以,java記憶體模型對final字段的讀寫有以下重排的限制:

  • 對于final字段的寫,在其後面插入寫寫屏障,就保證了final字段不會重排到構造函數外面,其他線程讀到的都會是初始化完成了的final字段值;
  • 對于final字段的讀,初次讀對象引用要在初次讀對象中的final字段之前,編譯器會在final字段讀的前面插入讀讀屏障。這個是處理器層面上的重排序限制,而編譯器層面上,由于這兩個存在資料依賴關系,不會進行重排序。

還有值得注意的是,在對象還沒有初始化完全的時候,不要逸出,例如:

public class Escape{
	private final int i;
	static Escape obj;
	public Escape(int i){
		this.i = i;
		obj = this;//這樣就逸出了
	}
}
           

而且,這種情況,在多線程環境下,讀取到的i的值也可能不對。

參考資料

極客時間-深入拆解java虛拟機第13篇

極客時間-java并發程式設計實戰第2篇

免費電子書-深入了解java記憶體模型