理解Java內存模型

概述

一直以來總是把Java內存模型當成JVM運行時數據區域,但是現在發現並不是這樣的。JVM運行時數據區域是在Java程序運行時的內存區域的劃分,例如堆、虛擬機棧、方法區之類,而Java內存模型是用來屏蔽Java程序在不同操作系統上對內存進行訪問的差異性,也就是使得Java在不同的平臺上訪問內存具有一致性,這也是Java具備跨平臺能力的基礎。Java內存模型其實就是一種規範,它會對程序中變量的訪問規則進行定義,目的是解決由於多線程通過共享內存進行通信時,存在的緩存一致性問題、處理器優化問題和指令重排問題。這些問題和原子性、可見性、有序性相對應,因此Java內存模型是圍繞着併發過程中如何處理原子性、可見性和有序性這三個特徵來設計的。

原子性

原子性從字面意思理解,就是不可分割的。對於涉及共享變量訪問的操作,若該操作從其執行線程以外的任意線程來看是不可分割的,那麼該操作就是原子操作。當前執行的代碼塊要麼全做,要麼全部不做,以防止執行過程中的某個變量出現一致性的問題。i++這種操作並不是原子性的,但是即使i = 1這種賦值操作也未必是原子性的。我們知道int是32位的,而double和long都是64位的,在JVM中是允許將沒有被volatile修飾的64位數據劃分爲兩次32位數據來進行讀寫操作的,這也就是long和double的非原子協定。

可見性

可見性和有序性都可以由volatile關鍵字保證。每個線程會在本地內存保存引用變量在堆內存中的副本,線程對變量的所有操作都是在本地區域中進行的,執行結束後再同步到堆內存中。在這個過程中會產生一個時間差,在這個時間差內,該線程對副本的操作對於其他線程都是不可見的。這使得線程之間無法及時感知到該變量的更改,從而導致一致性的問題。當使用volatile關鍵字對該變量進行修飾後,意味着任何對此變量的操作都會直接在內存中進行,不會產生副本,以保證共享變量的可見性。

有序性

從代碼層面的角度來看,整個程序的邏輯順序一定不會改變,而此處的有序性指的是禁止JVM或OS對其底層指令進行優化,使得一段非原子性代碼的執行在指令層面是有序的,不會進行指令重排。指令重排不會影響單個線程的執行,但是會影響到多個線程併發執行的正確性。例如對於代碼instance = new Object(),這條語句實際上包含了三步操作:

1、分配對象的內存空間;
2、初始化對象;
3、設置instance指向剛分配的內存地址。

出於對流水線指令優化的考慮,OS可能會將以上三條指令的運行順序變爲:

1、分配對象的內存空間;
2、設置instance指向剛分配的內存地址;
3、初始化對象。

如果線程A在重排序後,執行完第2步——設置instance指向剛分配的內存地址,而線程B在instance不爲null時就會返回,此時由於已經分配內存地址,所以線程B判斷instance確實不爲null,因此會返回一個只初始化到一半的“半個”實例。該過程在懶漢式單例模式在多線程下的雙重檢查鎖中有很深的體現,此處不再展開詳談,可參考單例模式探討。然而,單單使用volatile並不一定能夠保證線程安全,要使volatile變量提供理想的線程安全,必須同時滿足下面兩個條件:1、對變量的寫操作不依賴於當前值;2、該變量沒有包含在具有其他變量的不變式中。比如對於i–而言,該代碼片段的實現實際上需要三步:

1、取得原有的i值;
2、計算i-1;
3、對i進行賦值。

volatile關鍵字僅能保證某個線程對變量i的更改是立即可見的,但是在該三個步驟中,如果有多個線程同時訪問,就會出現線程安全問題。在線程A和線程B取得原有的i值後,同時做減1操作,再對i進行賦值時,i會減少2。因爲此時多個線程的執行是無序的,沒有任何機制來保證多個線程執行的有序性和原子性。所以我們需要創建一扇門,使用synchronized關鍵字對該過程進行同步,使得每次只有一個線程能夠進行這扇門並獲得共享變量i,在使用完後釋放掉。可以看出volatile僅能在賦值等不可分割的操作中保證線程安全。關鍵字synchronized可以保證線程間的可見性和有序性,從可見性的角度上講,synchronized可以完全替代關鍵字volatile;從有序性的角度上看,由於synchronized限制每次只能有一個線程可以訪問同步塊,而串行語義又是一致的,因此一定保證了有序性。但是我們需要避免錯誤地加鎖,將i–放入synchronized同步代碼塊時,鎖不能像以下代碼片段一樣加在i上:synchronized(i)。因爲Integer屬於不變對象,一旦被創建,就不會被修改,i–在真正執行時會變成:i = Integer.valueOf(i.intValue()-1)i--的本質是創建一個新的Integer對象,並將它的引用賦給i。由於i一直在變化,多個線程間看到的不一定是同一個i對象,因此兩個線程每次加鎖都可能加在了不同的對象實例上,synchronized本質上只是鎖住了一段內存地址,例如線程A鎖住2,線程B等待2,2變爲1後,線程C鎖住1,而線程B依然會對2進行i–操作,從而導致最終結果出現問題。對於基本數據類型而言,synchronized(i)都是錯誤的加鎖方式,正確的加鎖方式應該是synchronized(instance),對實例進行加鎖。
synchronized關鍵字能夠一次性滿足原子性、可見性、有序性三個要求,但是它的實質是悲觀鎖,頻繁地使用會嚴重地影響性能,所以建議謹慎使用。

happen-before原則

如果內存模型的有序性都要靠volatile和synchronized關鍵字進行實現,那是非常繁瑣的,所以Java內存模型中定義了線程中兩個操作之間的順序關係,用來判斷數據之間是否線程安全:

  • 單線程happen-before原則:在同一個線程中,代碼流程在前面的操作happen-before代碼流程在後面的操作,代碼流程是代碼的邏輯順序而不完全是代碼順序,因爲可能存在分支等情況。
  • 鎖的happen-before原則:對一個鎖的unlock操作happen-before該鎖的lock操作。
  • volatile的happen-before原則:對一個被volatile修飾變量的寫操作happen-before對此變量的任意操作。
  • 線程啓動的happen-before原則:對一個線程的start方法happen-before該線程的其它方法。
  • 線程中斷的happen-before原則:對線程interrupt方法的調用happen-before被中斷線程的代碼檢測到中斷事件的發生。
  • 線程終結的happen-before原則:線程中的所有操作都happen-before線程的終止檢測。
  • 對象創建的happen-before原則:一個對象的初始化方法調用happen-before該對象的finalize方法調用。
  • happen-before的傳遞性原則:如果A操作 happen-before B操作,B操作 happen-before C操作,那麼A操作 happen-before C操作。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章