1. 程式人生 > >java記憶體模型和多執行緒

java記憶體模型和多執行緒

單個處理器的頻率越來越難以提升,因此人們轉而面向多處理器,這麼多年來致力於提高程式的執行效率,然而面向多核處理器的併發程式設計卻不是那麼的輕鬆,java在語言級別提供的多執行緒併發能力為我們編寫併發的程式提供了不少便利。但是本文並不打算講述如何編寫多執行緒併發程式,而是嘗試從另一個角度理解一下java併發和多執行緒的基礎,理解其中的內容能夠幫助我們更好的使用java的併發庫。

本文所涉及的有些內容可能和我們之前的認知有些許不同,但這正是JMM存在的意義。上面我們提到一個詞JMM,也即java memory model。這個詞在JSL中出現過,但是對其更詳細的解釋是在JSR133中。

1、什麼是記憶體模型?

在多處理器系統中,每個處理器通常都有一層或多層快取,快取的作用自不必多說,但是快取在提高效能的同時卻帶來了另外一個問題。假如兩個處理器同時操作同一塊記憶體,由於快取的存在,那麼在什麼條件下兩個處理器可以看到一致的值?

在處理器級別,記憶體模型定義了何時一個處理器能夠看到另一個處理器寫入的值或者一個處理器寫入的值何時能夠被其他的處理器看到。一些系統中能夠表現出很強的一致性模型,在任意時刻所有的處理器看到的資料都是一樣的。但是在另外一些系統中卻表現出另外一種相對弱的一致性模型,也就是說有一些被稱為記憶體屏障的特殊指令來控制記憶體的可見性,在高階語言這個層面,這些指令通常伴隨著鎖來生效,通常我們不需要關心。通常大多數處理器架構實現的都是弱一致性模型,這是因為它能夠帶來更好的擴充套件性和效能。由於java

跨平臺的特性,因此java嘗試提供一個在不同平臺上統一的記憶體模型,,至於為什麼提供這種模型,前面已經提到過,是為了定義多執行緒中共享變數的可見性。

JSL中提到過JVM中“存在”一個主記憶體,所有的執行緒共享主記憶體,而每個執行緒有自己獨立的工作記憶體,工作記憶體相互不可見,因此java中執行緒之間的通訊實際上使用的是共享記憶體,另外一種常用的通訊方式是訊息,比如在scala中提供了基於actor的併發系統。下面是一張從網上找來的圖片,描述了記憶體模型的概況:


jvm對變數的操作是在自己的工作記憶體中,之後再重新整理到主記憶體,這樣其他執行緒才有機會看到前面執行緒的操作。基於這種事實,在併發中會出現一些難以預測的行為,尤其是當碰上指令重排序,則情況更加複雜。

2、指令重排序

由於編譯器、runtime、硬體指令重排序的存在,使得多執行緒中記憶體的可見性變得更加難以理解。在不改變程式語義的前提下,編譯器可能會為了提高程式的執行效率而進行指令重排序。具體來說一個對記憶體的寫入指令可能會被“提前”執行,這種指令重排序在編譯器、執行時和硬體上都有可能發生,只要是記憶體模型允許的指令重排序都是合法的,但是有一個“as-if-serial”的原則,也就是不管如何重排序,程式序列的執行結果是不會改變。關於指令重排序可以用下面一個簡單的例子來解釋

/**
 * 指令重排序
 * @author Administrator
 *
 */
public class Reordering {
	private int a,b;
	public void write()
	{
		a = 1;
		b = 2;
	}
	public void read()
	{
		int r1 = b;
		int r2 = a;
	}
}

我們假設上面的程式碼在兩個執行緒之間併發執行,由於這裡涉及到對類的成員變數併發讀寫,因此這不是一個被正確同步過的程式碼,而程式碼的執行順序和結果也不固定,有可能得到以下幾種執行路徑:

                    

以上三種情況都是我們可以預料到的,但是由於指令重排序的存在還可能出現一種看起來有違常理的結果,也就是r1=2r2=0,如果r1=2則說明b=2已經執行,按常理講a=1也已經執行,無論如何r2也不可能為0,但是從單執行緒角度看,由於ba兩個資料不存在依賴關係,因此將b=2操作排在a=1前面執行也是合理的,因此可能會出現下面一種執行路徑:


的確上面的結果有些出乎意料,但是未正確同步的程式碼確實可能出現這種詭異的現象,這對程式設計師來說有點兒不能接受。而解決上面問題的方式就是正確的使用同步,具體來說就是後面要涉及到的內容。

3synchronized

同步大概會涉及到幾個方面,最容易理解的是互斥,在同一時間只能有一個執行緒持有鎖,值得一提的是在jvm層面synchronized關鍵字是利用monitor來實現的,同一個執行緒可以多次進入monitor。然而同步不僅僅包括互斥,同樣重要的還有可見性,同步塊能夠保證被之前執行緒寫過的記憶體對後面進入同步塊的執行緒可見。當退出同步塊的時候伴隨著將快取重新整理到主記憶體的動作,因此此執行緒的寫入可以被後面的執行緒看見。

前面我們討論重排序都是基於多處理器或者多執行緒的場景,但是實際上在單處理器或者單執行緒上也存在重排序,因此java為了保證能夠讓正確的同步不會被重排序所影響,描述了一個被稱為“happens-before”的原則,如果一個操作happens-before另外一個操作,則JMM可以保證第一個操作對第二個操作可見,這些規則大致有以下幾種:

· a.某個執行緒中的每個動作都happens-before 該執行緒中該動作後面的動作。

· b.某個管程上的unlock 動作happens-before 同一個管程上後續的lock 動作。

· c.對某個 volatile 欄位的寫操作happens-before 每個後續對該volatile 欄位的讀

操作。

· d.在某個執行緒物件上呼叫start()方法happens-before 該啟動了的執行緒中的任意

動作。

· e.某個執行緒中的所有動作happens-before 任意其它執行緒成功從該執行緒物件上的

join()中返回。

· f.如果某個動作a happens-before 動作b,且 b happens-before 動作c,則有a happens-before c

這些都是JMM為我們提供的一種保證,因此針對以上場景的程式碼編寫不需要顯示的進行同步,比如對一個執行緒的start,然後在run方法中執行一些操作,JMM保證執行run方法的時候執行緒肯定已經啟動了,happens-before不會受到任何級別的指令重排序影響,也就是說針對以上或者能夠用以上原則推匯出來的happens-before關係,JVM通過插入正確的記憶體屏障指令可以保證程式的正確語義。由於synchronized關鍵字是通過對monitorlockunlock實現的因此上面的原則也包含了synchronized,值得一提的是final關鍵字的語義則稍有不同。

4final

語法層面上我們都知道被final修飾的變數都是不可變的,這也意味著一旦final欄位被第一次初始化,後面都不會再出現對它的寫操作,因此被final修飾的欄位天然是執行緒安全的。在JSR133之前final語義是不完備的,甚至和普通的欄位並沒有區別,這導致某些場景下final所表現出的行為違背了原本所規定的不可變性質。比如下面一段簡單的程式碼:

public class FinalFieldExample {
	final int a;
	static FinalFieldExample obj;
	public FinalFieldExample()
	{
		a= 4;
	}
	
	public static void writer()//執行緒A執行
	{
		obj = new FinalFieldExample();
	}
	
	public static void reader()//執行緒B執行
	{
		if(null != obj)
		{
			int r1 = obj.a;
		}
	}
}

在多執行緒的場景下final欄位a可能為0也可能為4,為什麼會出現如此奇怪的現象?這需要將上面的程式碼拆開來看,writer方法中的obj = new FinalFieldExample();這段程式碼實際上是由很多指令組成的,從邏輯上講如果按照粗粒度來劃分至少也有物件初始化和引用賦值兩步,假設物件引用的賦值操作先行發生,那麼對於執行緒B來說看到的是一個不完整的物件,這裡的不完整也就是說物件的屬性還未完全初始化好,因為物件的初始化並不是一個原子的操作,因此a可能為0,這種不確定性並不是final關鍵字想要的結果。在JSR-133出現之前如果想保證前面的程式碼執行正確,需要對讀寫方法加鎖。但是從另外一個角度去想,final表示的是隻讀,那就不會產生併發的問題,也就不應該用鎖,於是JSR-133final的語義做了增強,因此上面的程式碼在JSR-133之後不會有任何歧義產生,final的值在任何時刻都是4。一句題外話final關鍵字對於JVM的優化是很友好的,這有助於編譯器(前端、後端)對程式碼進行內聯,內聯的好處不必多講,因此能夠使用final修飾的儘可能使用final上面的程式碼還可以引出一個非常有名的問題“Double-Checked Locking,記得之前我們寫懶載入通常會這麼寫:

class Foo { 
	  private Helper helper = null;
	  public Helper getHelper() {
	    if (helper == null) 
	      synchronized(this) {
	        if (helper == null) 
	          helper = new Helper();
	      }    
	    return helper;
	   }
	  // other functions and members...
}

實際上上面的程式碼類似前面講到的內容,會有Helper未完全初始化的問題,因此有人建議將helper欄位設定為volatile,但是在JSR-133之前加volatile也是沒有用的,後面JSR-133增強了volatile的語義,使得加volatile的寫法是可行的,至於為什麼之前不行,之後又可行了,需要去了解volatile語義前後的差別,其實主要是重排序規則的變化,後面的volatile語義更接近同步塊。實際上《java併發程式設計實踐》一書中給出了一個更優雅的實現方式:

public class ResourceFactory
{
	private static class ResourceHolder
	{
		public static Resource resource = new Resource();
	}
	
	public static Resource getResource()
	{
		return ResourceHolder.resource;
	}
}

利用jvm自身的初始化加鎖機制很好的解決了懶載入的問題。

5、volatile

volatile保證了記憶體的可見性,但是正如上一節所講的雙檢查鎖的問題,在JSR-133之前volatile的語義允許volatile變數和非volatile變數之間的重排序,這就導致了一個問題,假設執行緒A進入同步塊,並且構造Helper,此時對Helper的賦值操作很肯能會和Helper例項變數的初始化(此處假設Helper有成員變數,這很合理)操作重排序,這會導致執行緒B看到一個未被完全初始化的Helper物件。JSR-133增強了volatile的語義,使得volatile變數和普通的變數的操作也不允許重排序出現,這就使得執行緒B只會看到一個完全或者壓根沒有初始化的物件,不會產生歧義。

程式每次讀取到的volatile變數都是其他執行緒寫入的最新值,每次對volatile變數的寫入也都會觸發快取重新整理的動作。不依賴當前狀態的變數通常可以使用volatile來避免鎖的競爭,比如標記某次初始化是否進行過的變數。

private volatile boolean isInitialized;

至於volatile的實現原理,鑑於篇幅的原因,不再詳細介紹,有興趣的可以自行google

本文主要介紹了JMM模型以及happens-before原則,並對java中幾個常見的併發api做了簡單的介紹,如果對併發方面的內容感興趣可以看下官方對JMM的介紹,並且強烈推薦《java併發程式設計實踐》以及Doug Lea的《Concurrent Programming in Java》兩本書。