之前寫過的JAVA內存模型只涉及了單一數據的可見性,其實這僅僅是java內存模型的一小部分。其java內存模型中更重要的,應該是內存屏障,memory barrier。更粗獷一點的就內存柵欄memory fence。fence比較粗獷,代價也比較大,這里先從memory fence開始說起。
reordering
提到內存屏障,首先應該說到重排序,這里強調一下,重排序只對於那些在當前線程沒有依賴關系的有效,有依賴關系的是不會重排序的。
.java -----> .class ,.class----->匯編, 匯編 ---->CPU指令執行。在這三個過程中,都有可能發生重排序
java重排序的最低保證是,as if serial,即在單個線程內,看起來總認為代碼是在順序運行的,但是從別的線程來看,這些代碼運行的順序就不好說了。
首先,理解重排序,推薦這篇blog,cpu-reordering-what-is-actually-being-reordered
原本打算將其中的內容用java代碼重寫一遍,並進行試驗,代碼如下
public class UnderStandingReordering {
static int[] data = {9, 9, 9, 9, 9};
static boolean is_ready = false;
static void init_data() {
for (int i = 0; i < 5; ++i) {
data[i] = i;
}
is_ready = true;
}
static int sum_data() {
if (!is_ready) {
return -1;
}
int sum = 0;
for (int i = 0; i < 5; ++i) {
sum += data[i];
}
return sum;
}
public static void main(String[] args) throws Exception{
ExecutorService executor1 = Executors.newSingleThreadExecutor();
ExecutorService executor2 = Executors.newSingleThreadExecutor();
executor1.submit(() -> {
try {
int sum = -1;
while (sum < 0) {
TimeUnit.MILLISECONDS.sleep(1);
sum = sum_data();
}
System.out.println(sum);
} catch (Exception ignored) {}
});
TimeUnit.SECONDS.sleep(2);
executor2.submit(UnderStandingReordering::init_data);
}
}
很遺憾的是,在我的電腦中,並沒有模擬出這些情況,可能是因為java的優化已經很牛逼了,嘗試了很多次都沒有出現想要的不確定的結果。
所以只好當做尷尬地搬運工,但是原理是沒問題的。原有的代碼如下:
int data[5] = { 9, 9, 9, 9, 9 };
bool is_ready = false;
void init_data() {
for( int i=0; i < 5; ++i )
data[i] = i;
is_ready = true;
}
void sum_data() {
if( !is_ready )
return;
int sum = 0;
for( int i=0; i <5; ++i )
sum += data[i];
printf( "%d", sum );
}
分別使用線程A和B去執行init_data() 和 sum_data()
其中B線程持續不斷地去調用sun_data()方法,直到輸出sum為止
在B線程運行一段時間后,我們會讓A線程去調用一次init_data(),初始化這個數組。
如果直接從代碼上看,我們認為執行的順序是
store data[0] 0
store data[1] 1
store data[2] 2
store data[3] 3
store data[4] 4
store is_ready 1
理所當然的,is_ready會在所有的數組都初始化后才被設置成true,也就是說,我們輸出的結果是10.
但是,CPU在執行這些指令時(這里的編程語言是C,如果換成java,還有可能在之前JIT編譯時重排序),為了提升效率,可能把指令優化成如下的順序。
這里舉的例子是可能,可能的含義是有可能發生,但是不一定會這樣,至於為什么會這樣,由於對底層不了解,所以這里沒法深入討論,只是說有這個可能。好像涉及到內存總線相關的東西,這里先挖個坑期望日后有能力來填。
store data[3] 3
store data[4] 4
store is_ready 1
store data[0] 0
store data[1] 1
store data[2] 2
所以,就會遇到這種情況,當is_ready變成true之后,data[0]、data[1]、data[2]的值依舊是初始值9,這樣讀到的數組就是9,9,9,3,4。
當然,這里我們都是假設讀的時候是按順序讀的,再接下來討論了第一道柵欄的時候,會發現讀的過程也有可能發生重排序,所以說這雙重可能導致了程序執行結果的不確定性。
memory fence
第一道柵欄
我們將init()的代碼改成如下的形式
lock_type lock;
void init_data() {
synchronized( lock ) {
for( int i=0; i < 5; ++i )
data[i] = i;
}
is_ready = true;
return data;
}
這樣,因為在獲得鎖和釋放鎖的過程中,都會加上一道fence,而在我們修改並存儲is_ready的值之前,synchronized鎖釋放了,這時候會在指令中加入一道內存柵欄,禁止重排序在將指令重排的過程中跨過這條柵欄,於是從字面上看指令就變成了這個樣子
store data[0] 0
store data[1] 1
store data[2] 2
store data[3] 3
store data[4] 4
fence
store is_ready 1
所以像上文中的情況是不允許出現了,但是下面這種形式還是可以的,因為memory fence會阻止指令在重排序的過程中跨過它。
store data[3] 3
store data[4] 4
store data[0] 0
store data[1] 1
store data[2] 2
fence
store is_ready 1
第二道柵欄
這樣,我們就已經可以確保在更新is_ready前所有的data[]都已經被設置成對應的值,不被重排序破壞了。
但是正如上文所提到的,讀操作的指令依舊是有可能被重排序的,所以程序運行的結果依舊是不確定的。
繼續上文說的,正如init_data()的指令可以被重排序,sum_data()的指令也會被重排序,從代碼字面上看,我們認為指令的順序是這樣的
load is_ready
load data[0]
load data[1]
load data[2]
load data[3]
load data[4]
但是實際上,CPU為了優化效率可能會把指令重排序成如下的方式
load data[3]
load data[4]
load is_ready
load data[0]
load data[1]
load data[2]
所以說,即使init_data()已經通過synchronized所提供的fence,保證了is_ready的更新一定在data[]數組被賦值后,但是程序運行的結果依舊是未知。仍有可能讀到這樣的數組:0,1,2,9,9。依舊不是我們所期望的結果。
這時候,需要這load的過程中也添加上一道柵欄
void sum_data() {
synchronized( lock ) {
if( !is_ready )
return;
}
int sum = 0;
for( int i =0; i <5; ++i )
sum += data[i];
printf( "%d", sum );
}
這樣,我們就在is_ready和data[]的讀取中間添加了一道fence,能夠有效地保證is_ready的讀取不會與data[]的讀取進行重排序
load is_ready
fence
load data[0]
load data[1]
load data[2]
load data[3]
load data[4]
當然,data[]中0,1,2,3,4的load順序仍有可能被重排序,但是這已經不會對最終結果產生影響了。
最后,我們通過了這樣兩道柵欄,保證了我們結果的正確性,此時,線程B最后輸出的結果為10。
memory barrier in java
fence vs barrier
幾乎所有的處理器至少支持一種粗粒度的屏障指令,通常被稱為“柵欄(Fence)”,它保證在柵欄前初始化的load和store指令,能夠嚴格有序的在柵欄后的load和store指令之前執行。無論在何種處理器上,這幾乎都是最耗時的操作之一(與原子指令差不多,甚至更消耗資源),所以大部分處理器支持更細粒度的屏障指令。
因為fence和barrier是對於處理器的,而不同的處理器指令間是否能夠重排序也不同,有一些barrier會在真正到處理器的時候被擦除,因為處理器本身就不會進行這類重排序,但是比較粗獷的fence,就會一直存在,因為所有的處理器都是支持寫讀重排序的,因為使用了寫緩沖區。
簡而言之,使用更精確精細的memory barrier,有助於處理器優化指令的執行,提升性能。
volatile、synchronized、CAS
講清楚了重排序和內存柵欄,現在針對java來具體講講。
在java中除了有synchronized進行這種屏障之外,還可以通過volatile達到同樣的內存屏障的效果。
同樣,內存屏障除了有屏障作用外,還確保了synchronized在退出時以及volatile修飾的變量在寫入后立即刷新到主內存中,至於兩種是否有因果關系,待我弄明白后來敘述,我猜測是有的。后來看到了大神之作,就直接貼在這了。
Doug Lea大神在The JSR-133 Cookbook for Compiler Writers中寫到:
內存屏障指令僅僅直接控制CPU與其緩存之間,CPU與其准備將數據寫入主存或者寫入等待讀取、預測指令執行的緩沖中的寫緩沖之間的相互操作。這些操作可能導致緩沖、主內存和其他處理器做進一步的交互。但在JAVA內存模型規范中,沒有強制處理器之間的交互方式,只要數據最終變為全局可用,就是說在所有處理器中可見,並當這些數據可見時可以獲取它們。
Memory barrier instructions directly control only the interaction of a CPU with its cache, with its write-buffer that holds stores waiting to be flushed to memory, and/or its buffer of waiting loads or speculatively executed instructions. These effects may lead to further interaction among caches, main memory and other processors. But there is nothing in the JMM that mandates any particular form of communication across processors so long as stores eventually become globally performed; i.e., visible across all processors, and that loads retrieve them when they are visible.
不過在內存屏障方面,volatile的語義要比synchronized弱一些,synchronized是確保了在獲取鎖和釋放鎖的時候都有內存屏障,且數據一定會從主內存中重新load或者store到主內存。
但是在volatile中,volatile write之前有storestore屏障,之后有storeload屏障。volatile的寫后有loadload屏障和loadstore屏障,確保寫操作后一定會刷新到主內存。
CAS(compare and swap)是處理器提供的原語,在java中是通過UnSafe這個類的方法來調用的,在內存方面,他同時擁有volatile的read和write的語義。即既能保證禁止該指令與之前和之后的指令重排序,有能保證把寫緩沖區的所有數據刷新到內存中。
concurrent package
此節摘抄自深入理解java 內存模型 (程曉明),由於java 的 CAS 同時具有 volatile 讀和 volatile 寫的內存語義,因此 Java 線程 之間的通信現在有了下面四種方式:
1.A 線程寫 volatile 變量,隨后 B 線程讀這個 volatile 變量。
2.A 線程寫 volatile 變量,隨后 B 線程用 CAS 更新這個 volatile 變量。
3.A 線程用 CAS 更新一個 volatile 變量,隨后 B 線程用 CAS 更新這個 volatile變量。
4.A 線程用 CAS 更新一個 volatile 變量,隨后 B 線程讀這個 volatile 變量。
Java 的 CAS 會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以 原子方式對內存執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上 來說,能夠支持原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機 器,因此任何現代的多處理器都會去支持某種能對內存執行原子性讀-改-寫操作的 原子指令)。同時,volatile 變量的讀/寫和 CAS 可以實現線程之間的通信。把這 些特性整合在一起,就形成了整個 concurrent 包得以實現的基石。如果我們仔細 分析 concurrent 包的源代碼實現,會發現一個通用化的實現模式:
1.首先,聲明共享變量為 volatile;
2.然后,使用 CAS 的原子條件更新來實現線程之間的同步;
3.同時,配合以 volatile 的讀/寫和 CAS 所具有的 volatile 讀和寫的內存語義來 實現線程之間的通信。
AQS,非阻塞數據結構和原子變量類(java.util.concurrent.atomic 包中的類), 這些concurrent包中的基礎類都是使用這種模式來實現的,而 concurrent 包中的高層類又是依賴於這些基礎類來實現的。
final
首先final域是不可變的,所以它至少必須在構造方法中初始化,也可以直接在聲明的同時就定義。
為了確保在new這個對象時,不會看到final域的值有變化的情況,所以需要一個內存屏障的保證,確保對final域賦值,和把這個對象的引用賦值給引用對象時,不能進行重排序。這樣才能確保new出來的對象拿到引用之前,final域就已經被賦值了。
當final域是引用對象時,還需要加強到如下
- 在構造函數內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
- 初次讀一個包含 final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。
- 在構造函數內對一個final引用的對象的成員域的寫入,與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
為了修補之前內存模型的缺陷,JSR-133專家組增強了final的語義。通過為final域增加寫和讀重排序規則,可以為java程序員提供初始化安全保證:只要對象是正確構造的 (被構造對象的引用在構造函數中沒有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用),就可以保證任意線程都能看到這個 final 域在構造函數中被 初始化之后的值。
happens-before?
最后,好像漏了什么東西?對,就是這個聽起來很玄乎的happens-before,但是我並不想詳細說這個,覺得happens-before用來講java內存模型實在的太小了,目前我也還在看這篇論文,所以繼續留個坑。
happens-before最先出現在Leslie Lamport的論文Time Clocks and the Ordering of Events in a Distributed System中。該論文於 1978年7月發表在”Communication of ACM”上,並於2000年獲得了首屆PODC最具影響力論文獎,於2007年獲得了ACM SIGOPS Hall of Fame Award 。關於該論文的貢獻是這樣描述的:本文包含了兩個重要的想法,每個都成為了主導分布式計算領域研究十多年甚至更長時間的重要課題。
- 關於分布式系統中事件發生的先后關系(又稱為clock condition)的精確定義和用來對分布式系統中的事件時序進行定義和確定的框架。用於實現clock condition的最簡單方式,就是由Lamport在本文中提出的”logical clocks”,這一概念在該領域產生了深遠的影響,這也是該論文被引用地如此之多的原因。同時它也開啟了人們關於vector 和 matrix clock ,consistent cuts概念(解決了如何定義分布式系統中的狀態這一問題),stable and nonstable predicate detection,認識邏輯(比如用於描述分布式協議的一些知識,常識和定理)的語義基礎等方面的研究。最后,最重要的是它非常早地指出了分布式系統與 其他系統的本質不同,同時它也是第一篇給出了可以用來描述這些不同的數學理論基礎(“happen before”relation)。
- 狀態機方法作為n-模塊冗余的一種通用化實現,無論是對於分布式計算的理論還是實踐來說,其非凡的影響力都已經被證明了。該論文還給出了一個分布式互斥協 議,以保證對於互斥區的訪問權限是按照請求的先后順序獲取的。更重要的是,該論文還解釋了如何將該協議用來作為管理replication的通用方法。從 該方法還引出了如下問題:
a)Byzantine agreement,那些用來保證所有的狀態機即使在出錯情況下也能夠得到相同輸入的協議。很多工作都是源於這個問題,包括fast protocols, impossibility results, failure model hierarchies等等。
b)Byzantine clock synchronization 和ordered multicast protocols。這些協議是用來對並發請求進行排序並保證得到相同的排序結果,通過與agreement協議結合可以保證所有狀態機都具有相同的狀態。
當然,想了解java中的happens-before可以看接下來三個小節的摘抄,程曉明老師的書,以及oracle的文檔,都有。
happens-before原則定義
- 如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
- 兩個操作之間存在happens-before關系,並不意味着一定要按照happens-before原則制定的順序來執行。如果重排序之后的執行結果與按照happens-before關系來執行的結果一致,那么這種重排序並不非法。
happens-before原則規則:
- 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作;
- 鎖定規則:一個unLock操作先行發生於后面對同一個鎖額lock操作;
- volatile變量規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作;
- 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
- 線程啟動規則:Thread對象的start()方法先行發生於此線程的每個一個動作;
- 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
- 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
- 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;
Memory Consistency Properties
Chapter 17 of the Java Language Specification defines the happens-before relation on memory operations such as reads and writes of shared variables. The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. The synchronized and volatile constructs, as well as the Thread.start() and Thread.join() methods, can form happens-before relationships. In particular:
- Each action in a thread happens-before every action in that thread that comes later in the program's order.
- An unlock (synchronized block or method exit) of a monitor happens-before every subsequent lock (synchronized block or method entry) of that same monitor. And because the happens-before relation is transitive, all actions of a thread prior to unlocking happen-before all actions subsequent to any thread locking that monitor.
- A write to a volatile field happens-before every subsequent read of that same field. Writes and reads of volatile fields have similar memory consistency effects as entering and exiting monitors, but do not entail mutual exclusion locking.
- A call to start on a thread happens-before any action in the started thread.
- All actions in a thread happen-before any other thread successfully returns from a join on that thread.
The methods of all classes in java.util.concurrent and its subpackages extend these guarantees to higher-level synchronization. In particular:
- Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.
- Actions in a thread prior to the submission of a Runnable to an Executor happen-before its execution begins. Similarly for Callables submitted to an ExecutorService.
- Actions taken by the asynchronous computation represented by a Future happen-before actions subsequent to the retrieval of the result via Future.get() in another thread.
- Actions prior to "releasing" synchronizer methods such as Lock.unlock, Semaphore.release, and CountDownLatch.countDown happen-before actions subsequent to a successful "acquiring" method such as Lock.lock, Semaphore.acquire, Condition.await, and CountDownLatch.await on the same synchronizer object in another thread.
- For each pair of threads that successfully exchange objects via an Exchanger, actions prior to the exchange() in each thread happen-before those subsequent to the corresponding exchange() in another thread.
- Actions prior to calling CyclicBarrier.await and Phaser.awaitAdvance (as well as its variants) happen-before actions performed by the barrier action, and actions performed by the barrier action happen-before actions subsequent to a successful return from the corresponding await in other threads.
參考文檔
1、cpu-reordering-what-is-actually-being-reordered
2、The JSR-133 Cookbook for Compiler Writers
3、The JSR-133 Cookbook for Compiler Writers ifeve翻譯版
4、深入理解java內存模型 程曉明
5、Time Clocks and the Ordering of Events in a Distributed System(譯)