標籤:管理 靜態 計數 賦值 兩種 不同 作業系統 輕量級進程 導致
1.硬體的效率與一致性
由於電腦的存放裝置與處理器的運算速度有幾個數量級的差別,而絕大多數的運算任務都要與記憶體互動,所以現代電腦系統不得不加入一層讀寫速度儘可能接近處理器運算速度的快取來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到緩衝之中,讓運算能快速進行,當運算結束後再從緩衝同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。
基於快取的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為電腦系統帶來更多的複雜度,因為他引入了一個新的問題:緩衝一致性。在多處理器系統中,每個處理器都有自己的快取,而他們又共用同一主記憶體。為瞭解決一致性的問題,需要各個處理器訪問緩衝時都遵循一些協議,在讀寫時要根據協議來操作,這類協議有MSI MESI MOSI Synapse Firefly及DragonProtocol等。不同架構的物理機器可以擁有不一樣的記憶體模型,java也有自己的記憶體模型(記憶體模型可以理解為在特定的操作協議下,對特定的記憶體或告訴緩衝進行讀寫訪問的過程抽象)
除了增加快取之外,為了使得處理器內部的運算單元能盡量充分利用,處理器可能會對輸入代碼進行亂序執行最佳化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的順序一致,因此,若果存在一個計算任務依賴於另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的先後順序來保證。
2.java記憶體模型
java定義記憶體模型是為了消除掉各種硬體和作業系統的記憶體訪問差異,以實現讓java程式在各種平台上都能達到一致的訪問效果。定義Java記憶體模型並不是一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的並行作業不會產生歧義;但是,也必須得足夠寬鬆,使得虛擬機器的實現能有足夠的自由空間去利用硬體的各種特性(寄存器、快取等)來擷取更好的執行速度。經過長時間的驗證和修補,在JDK1.5發布後,Java記憶體模型就已經成熟和完善起來了。
①主記憶體和工作記憶體
Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣底層細節。此處的變數與Java編程時所說的變數不一樣,指包括了執行個體欄位、靜態欄位和構成數組對象的元素,但是不包括局部變數與方法參數,後者是線程私人的,不會被共用。
Java記憶體模型中規定了所有的變數都儲存在主記憶體(和物理硬體中的主記憶體名字一樣,兩者也可類比,但這隻是虛擬機器的一部分)中,每條線程還有自己的工作記憶體(可以與前面將的處理器的快取類比),線程的工作記憶體中儲存了該線程使用到的變數到主記憶體副本拷貝,線程對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同線程之間無法直接存取對方工作記憶體中的變數,線程間變數值的傳遞均需要在主記憶體來完成,線程、主記憶體和工作記憶體的互動關係如所示,和很類似。
這裡的主記憶體、工作記憶體與Java記憶體地區的Java堆、棧、方法區不是同一層次記憶體劃分。
② 記憶體間互動操作
關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完成,虛擬機器實現時必須保證這八種操作都是原子的。:
- lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條線程獨佔狀態。
- unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他線程鎖定。
- read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到線程的工作記憶體中,以便隨後的load動作使用
- load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
- use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
- assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
- store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
- write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。
如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。Java記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間,store和write之間是可以插入其他指令的,如對主記憶體中的變數a、b進行訪問時,可能的順序是read a,read b,load b, load a。Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則:
- 不允許read和load、store和write操作之一單獨出現
- 不允許一個線程丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
- 不允許一個線程無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。
- 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。
- 一個變數在同一時刻只允許一條線程對其進行lock操作,lock和unlock必須成對出現
- 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始設定變數的值
- 如果一個變數事先沒有被lock伺服器用戶端檔案鎖,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變數。
- 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。
③對於volatile型變數的規則
volidate是最輕量級的同步機制。先來理解一下它:當一個變數定義為volidate以後,它將具備兩種特性,第一是保證此變數對所有線程的可見度,這裡的“可見度”是指當一條線程修改了這個變數的值,新值對於其他線程來說是可以立即得知的。而普通變數不能做到這一點,普通便利的值在縣城建傳遞需要通過主記憶體來完成,例如,線程A修改一個普通變數的值,然後向主記憶體進行回寫,另外一條線程B線上程A會寫完成了之後再從主記憶體中進行讀寫操作,新變數值才會對線程B可見。但是如果操作不是原子的,依然沒法保證volatile同步的正確性。只有在下述情況,才可以使用這個關鍵字:
- 對變數的寫入操作不依賴於該變數的當前值(比如a=0;a=a+1的操作,整個流程為a初始化為0,將a的值在0的基礎之上加1,然後賦值給a本身,很明顯依賴了當前值),或者確保只有單一線程修改變數。
- 該變數不會與其他狀態變數納入不變性條件中。(當變數本身是不可變時,volatile能保證安全訪問,比如雙重判斷的單例模式。但一旦其他狀態變數參雜進來的時候,並發情況就無法預知,正確性也無法保障)。
/** * 基於雙重判斷的單例模式 */public class Singleton { private volatile static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } public static void main(String[] args) { Singleton.getInstance(); }}
volatile還有個特性就是,可以禁止指令進行重排序最佳化。普通變數僅僅會保證在該方法的執行過程中所有以來複製結果的地方都能擷取到正確的結果,而不能保證變數賦值操作的順序與程式碼中的執行順序一致。如下例子:
Map configOptions;char [] configText;//此變數必須定義為volatilevolatile boolean initialized = false;//假設一下代碼線上程A中執行,類比讀取配置資訊,當讀取完成後,將initialized設定為true來通知其他線程配置可使用configOptions = new HashMap();configText = readConfigFile(fileName);processConfigOptions(configText, configOptions);initialized = true;//假設以下代碼線上程B中執行,等待initialized 為true,代表線程A已經把配置資訊初始化完成while(!initialized ){ sleep();}//使用線程A中初始化好的配置資訊doSomethingWithConfig();
如果initialized 沒有使用volatile修飾,就可能由於指令重排的最佳化,導致位於線程A中的最後一句代碼“initialized = true”被提前執行,這樣線上程B中使用配置資訊的代碼就可能出現錯誤。
所以,volatile本身強大的地方就是他還能預防這種情況發生,雖然犧牲了一點效能,但是大大增強了程式的可靠性。但是記住,不要依賴於volatile,在合適的時候才使用他(上文已經說明),如果情況不合適,就使用傳統的synchronized關鍵字同步共用變數的訪問,用來保證程式正確性(這個關鍵字的效能會隨著jvm不斷完善而不斷提升,將來效能會慢慢逼近volatile)。
Java記憶體模型中對volatile變數定義的特殊規則:
- 在工作記憶體中,每次使用volatile變數前都必須從主記憶體中重新整理最新的值,用於保證能看到其他線程對變數V所做的修改後的值。
- 在工作記憶體中,每次修改後的值都必須立刻同步回主記憶體中,用於保證其他線程可以看到自己對變數的修改。
- volatile修飾的變數不會被指令重排最佳化,保證代碼的執行順序與程式的順序相同。
4. 對於long和double行變數的特殊規則
對於64位的資料類型(long和double),在模型中特別定義;了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位元據的讀寫劃分為兩次32位的操作來進行,即允許虛擬機器不保證64位元據類型的load、store、read和write這四個操作的原子性。
5. 原子性、可見度與有序性
原子性:由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store和write這六個,我們可以大致的認為基礎資料型別 (Elementary Data Type)的訪問讀寫是具備原子性的(long和double除外)。Java代碼中的同步塊即synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。內部是通過位元組碼指令monitorenter和monitorexit來實現。
可見度:就是當一個線程修改了共用變數的值,其他線程能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值。關鍵字synchronized和final也能保證可見度。首先同步塊是因為對變數執行unlock操作之前,必須先把次變數同步回主記憶體中。而final關鍵字的可見度是指:被final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把this指標傳遞出去,那麼在其他線程中就能看見final欄位的值。
有序性:用synchronized和volatile關鍵字來保證線程操作之間的有序性。volatile本省就包含禁止指令重排序的語義,而synchronized則是因為:一個變數在同一時刻只允許一條線程對齊進行lock操作。這個規則決定了持有同一個鎖的兩個同步塊只能串列的進入。
6. 先行發生原則
如果Java記憶體模型中所有的有序性都只靠volatile和synchronized來完成,那麼有一些操作將會變得很囉嗦。java記憶體模型中的一個重點原則——先行發生原則(Happens-Before),使用這個原則作為依據,來指導你判斷是否存線上程安全和競爭問題。
- 程式順序規則:在程式中,如果A操作在B操作之前(比如A代碼在B代碼上面,或者由A程式調用B程式),那麼在這個線程中,A操作將在B操作之前執行。
- 管理鎖定規則:一個unlock操作先於後面對同一個鎖的lock操作之前執行。
- volatile變數規則:對一個volatile變數的寫操作必須在對該變數的讀操作之前發生。
- 線程啟動規則:線程的Thread.start()必須在該線程所有其他動作之前發生。
- 線程終止規則:線程中所有操作都先行發生於該線程的終止檢測。可以通過Thread.join()方法結束、Thread.isAlive()的傳回值判斷線程是否終止。
- 線程中斷規則:對線程interrupt()方法的調用必須在被中斷線程的代碼檢測到interrupt調用之前執行。
- 對象終結規則:對象的初始化(建構函式的調用)必須在該對象的finalize()方法完成。
- 傳遞性:如果A先行發生於B,B先行發生於C,那麼A先行發生於C。
接下來從下面這個例子感受一下“時間上的先後順序”“與”“先行發生”之間有什麼不同。
private int value = 0;public void setValue(int value){ this.value = value;}public int getValue(){ return value;}
假設存線上程A和B,線程A先調用setValue(1),然後線程B調用了同一個對象的getValue(),那麼線程B返回的值是什嗎?
分析:沒有同步塊—管程鎖定規則不適用;value沒有被volatile修飾,所以volatile變數規則不適用;後面的線程啟動、終止、中斷規則和對象終結規則也扯不上關係,所以我們無法確定這兩個線程誰先執行,因此我們說這裡的操作時線程不安全的。
如何修複呢?可以為set、get方法定義為synchronized方法,這樣可以使用管程鎖定規則;或者把value設定為volatile變數,由於set方法對value的修改不依賴value的原值,滿足volatile關鍵字使用情境。
2. Java與線程
線程也叫作輕量級進程,是大多現代作業系統的基本調度單位。在同一個進程中,多個線程共用記憶體空間,因此需要足夠的同步機制才能保證正常訪問。每個線程本身都有各自的程式計數器、棧和局部變數等。在java中使用線程調度的方式是搶佔式的,需要由作業系統分配執行時間,線程本身無法決定(例如java中,只有Thread.yield()可以讓出自己的執行時間,但是並沒有提供可以主動擷取執行時間的操作)。雖然java中線程調度由系統執行,但是還是可以通過設定線程優先順序來“建議”作業系統多給某些線程分配執行時間(然後,這並不一定就能保證高優先順序的先執行)。
Java定義了如下幾種線程狀態,一個線程僅處於一個狀態:
java記憶體模型與線程