volatile的特性
當我們聲明共享變數為volatile後,對這個變數的讀/寫將會很特別。理解volatile特性的一個好方法是:把對volatile變數的單一讀/寫,看成是使用同一個監視器鎖定對這些單一讀/寫操作做了同步。下面我們透過具體的範例來說明,請看下面的範例程式碼:
class VolatileFeaturesExample { volatile long vl = 0L; //使用volatile声明64位的long型变量 public void set(long l) { vl = l; //单个volatile变量的写 } public void getAndIncrement () { vl++; //复合(多个)volatile变量的读/写 } public long get() { return vl; //单个volatile变量的读 } }
假設有多個執行緒分別呼叫上面程式的三個方法,這個程式在語意上和下面程式等價:
class VolatileFeaturesExample { long vl = 0L; // 64位的long型普通变量 public synchronized void set(long l) { //对单个的普通 变量的写用同一个监视器同步 vl = l; } public void getAndIncrement () { //普通方法调用 long temp = get(); //调用已同步的读方法 temp += 1L; //普通写操作 set(temp); //调用已同步的写方法 } public synchronized long get() { //对单个的普通变量的读用同一个监视器同步 return vl; } }
如上面範例程式所示,對一個volatile變數的單一讀/寫操作,與對一個普通變數的讀/寫操作使用同一個監視器鎖定來同步,它們之間的執行效果相同。
監視器鎖的happens-before規則保證釋放監視器和獲取監視器的兩個線程之間的內存可見性,這意味著對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變數最後的寫入。
監視器鎖的語意決定了臨界區代碼的執行具有原子性。這意味著即使是64位元的long型和double型變量,只要它是volatile變量,對該變量的讀寫就將具有原子性。如果是多個volatile操作或類似於volatile++這種複合操作,這些操作整體上不具有原子性。
簡而言之,volatile變數本身就具有下列特性:
可見性。對一個volatile變數的讀,總是能看到(任意線程)對這個volatile變數最後的寫入。
原子性:對任意單一volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。
volatile寫-讀建立的happens before關係
上面講的是volatile變數本身的特性,對程式設計師來說,volatile對執行緒的記憶體可見度的影響比volatile自身的特性更為重要,也更需要我們去關注。
從JSR-133開始,volatile變數的寫-讀可以實現執行緒之間的通訊。
從內存語義的角度來說,volatile與監視器鎖有相同的效果:volatile寫和監視器的釋放有相同的內存語義;volatile讀與監視器的獲取有相同的內存語義。
請看下面使用volatile變數的範例程式碼:
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 …… } } }
假設執行緒A執行writer()方法之後,執行緒B執行reader()方法。根據happens before規則,這個過程建立的happens before 關係可以分為兩類:
根據程序次序規則,1 happens before 2; 3 happens before 4。
根據volatile規則,2 happens before 3。
根據happens before 的傳遞性規則,1 happens before 4。
上述happens before 關係的圖形化表現形式如下:
在上圖中,每一個箭頭連結的兩個節點,代表了一個happens before 關係。黑色箭頭表示程序順序規則;橘色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens before保證。
這裡A線程寫一個volatile變數後,B線程讀同一個volatile變數。 A線程在寫volatile變量之前所有可見的共享變量,在B線程讀取同一個volatile變量後,將立即變得對B線程可見。
volatile寫-讀的記憶語語義
volatile寫的記憶體語意如下:
當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數刷新到主記憶體。
以上面範例程式VolatileExample為例,假設執行緒A先執行writer()方法,接著執行緒B執行reader()方法,初始時兩個執行緒的本地記憶體中的flag和a都是初始狀態。下圖是線程A執行volatile寫後,共享變數的狀態示意圖:
如上圖所示,線程A在寫flag變數後,本地記憶體A中被線程A更新過的兩個共享變數的值被刷新到主記憶體中。此時,本地記憶體A和主記憶體中的共享變數的值是一致的。
volatile讀的記憶體語意如下:
當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。線程接下來將從主記憶體中讀取共享變數。
下面是線程B讀同一個volatile變數後,共享變數的狀態示意圖:
如上圖所示,在讀取flag變數後,本地記憶體B已經被置為無效。此時,線程B必須從主記憶體讀取共享變數。線程B的讀取操作將導致本地記憶體B與主記憶體中的共享變數的值也變成一致的了。
如果我們把volatile寫和volatile讀這兩個步驟綜合起來看的話,在讀取線程B讀一個volatile變量後,寫線程A在寫這個volatile變量之前所有可見的共享變量的值都將立即變得對讀線程B可見。
下面對volatile寫和volatile讀的內存語義做個總結:
線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所在修改的)訊息。
線程B讀一個volatile變量,實質上是線程B接收了之前某個線程發出的(在寫這個volatile變量之前對共享變量所做修改的)訊息。
線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A透過主記憶體向線程B發送訊息。
volatile記憶體語意的實作
下面,讓我們來看看JMM如何實作volatile寫/讀的記憶體語意。
前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現volatile記憶體語義,JMM會分別限制這兩種類型的重排序類型。以下是JMM針對編譯器所製定的volatile重排序規則表:
是否能重排序
第二個操作
第一個操作
普通讀/寫
volatile讀
volatile寫
普通讀/寫
NO
volatilelecom
NO
NO
NO
volatile寫
NO
NO
舉例來說,第三行最後一個單元格的意思是:在程序順序中,當第一個操作為普通變量的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重新排序這兩個操作。
從上表我們可以看出:
當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重新排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重新排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
當第一個操作是volatile寫,第二個操作是volatile讀時,不能重新排序。
為了實現volatile的記憶體語義,編譯器在產生字節碼時,會在指令序列中插入記憶體屏障來禁止特定類型的處理器重新排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM採取保守策略。以下是基於保守策略的JMM記憶體屏障插入策略:
在每個volatile寫入作業的前面插入一個StoreStore屏障。
在每個volatile寫入操作的後面插入一個StoreLoad屏障。
在每個volatile讀取操作的後面插入一個LoadLoad屏障。
在每個volatile讀取操作的後面插入一個LoadStore屏障。
上述記憶體屏障插入策略非常保守,但它可以保證在任意處理器平台,任意的程式中都能得到正確的volatile記憶體語義。
下面是保守策略下,volatile寫插入內存屏障後產生的指令序列示意圖:
上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫入操作已經對任意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主記憶體。
這裡比較有趣的是volatile寫後面的StoreLoad屏障。這個屏障的功能是避免volatile寫入與後面可能有的volatile讀/寫操作重新排序。因為編譯器常常無法精確判斷在一個volatile寫的後面,是否需要插入一個StoreLoad屏障(例如,一個volatile寫之後方法立即return)。為了確保能正確實現volatile的記憶語語義,JMM在這裡採取了保守策略:在每個volatile寫的後面或在每個volatile讀的前面插入一個StoreLoad屏障。從整體執行效率的角度考慮,JMM選擇了在每個volatile寫的後面插入一個StoreLoad屏障。因為volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀取線程讀同一個volatile變量。當讀取線程的數量大大超過寫線程時,選擇在volatile寫之後插入StoreLoad屏障將帶來可觀的執行效率的提升。從這裡我們可以看到JMM在實現上的一個特點:先確保正確性,然後再去追求執行效率。
下面是在保守策略下,volatile讀取插入記憶體屏障後產生的指令序列示意圖:
上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; //第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; //普通写 v1 = i + 1; // 第一个volatile写 v2 = j * 2; //第二个 volatile写 } … //其他方法 }
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。
上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:
前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。
以上就是Java内存模型深度解析:volatile的内容,更多相关内容请关注PHP中文网(m.sbmmt.com)!