1. 程式人生 > >JVM記憶體模型、指令重排、記憶體屏障概念解析

JVM記憶體模型、指令重排、記憶體屏障概念解析

 在高併發模型中,無是面對物理機SMP系統模型,還是面對像JVM的虛擬機器多執行緒併發記憶體模型,指令重排(編譯器、執行時)和記憶體屏障都是非常重要的概念,因此,搞清楚這些概念和原理很重要。否則,你很難搞清楚哪些操作是在併發執行緒中絕對安全的?哪些是相對安全的?哪些併發同步手段效能最低?valotile的二層語義分別是什麼,等?

一、什麼是重排序

請先看這樣一段程式碼

package com.cby.jvmOrder;

/**
 * Created by cby on 2018/10/16.
 */
public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

        public static void main(String[] args) throws InterruptedException {
             for (int i=0;i<100;i++){
                 testThread();
             }
        }
        public static void testThread() throws InterruptedException{
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();
            other.start();
            one.join();
            other.join();
            System.out.println("(" + x + "," + y + ")");
        }
}

結果:(0,1) 或者 (1,1) 或者 (1,0)

      很容易想到這段程式碼的執行結果可能為(1,0)、(0,1)或(1,1),因為執行緒one可以線上程two開始之前就執行完了,也有可能反之,甚至有可能二者的指令是同時或交替執行的。

      然而,這段程式碼的執行結果也可能是(0,0). 因為,在實際執行時,程式碼指令可能並不是嚴格按照程式碼語句順序執行的。得到(0,0)結果的語句執行過程,如下圖所示。值得注意的是,a=1和x=b這兩個語句的賦值操作的順序被顛倒了,或者說,發生了指令“重排序”(reordering)。(事實上,輸出了這一結果,並不代表一定發生了指令重排序,記憶體可見性問題也會導致這樣的輸出,詳見後文)

   

     對重排序現象不太瞭解的開發者可能會對這種現象感到吃驚,但是,筆者開發環境下做的一個小實驗證實了這一結果。

     實驗程式碼是構造一個迴圈,反覆執行上面的例項程式碼,直到出現a=0且b=0的輸出為止。實驗結果說明,迴圈執行到第13830次時輸出了(0,0)。

     大多數現代微處理器都會採用將指令亂序執行(out-of-order execution,簡稱OoOE或OOE)的方法,在條件允許的情況下,直接運行當前有能力立即執行的後續指令,避開獲取下一條指令所需資料時造成的等待3。通過亂序執行的技術,處理器可以大大提高執行效率。
     除了處理器,常見的Java執行時環境的JIT編譯器也會做指令重排序操作,即生成的機器指令與位元組碼指令順序不一致。

二、as-if-serial語義

     As-if-serial語義的意思是,所有的動作(Action)都可以為了優化而被重排序,但是必須保證它們重排序後的結果和程式程式碼本身的應有結果是一致的。Java編譯器、執行時和處理器都會保證單執行緒下的as-if-serial語義。
     比如,為了保證這一語義,重排序不會發生在有資料依賴的操作之中。

int a = 1;
int b = 2;
int c = a + b;

      將上面的程式碼編譯成Java位元組碼或生成機器指令,可視為展開成了以下幾步動作(實際可能會省略或新增某些步驟)。

  1. 對a賦值1
  2. 對b賦值2
  3. 取a的值
  4. 取b的值
  5. 將取到兩個值相加後存入c

      在上面5個動作中,動作1可能會和動作2、4重排序,動作2可能會和動作1、3重排序,動作3可能會和動作2、4重排序,動作4可能會和1、3重排序。但動作1和動作3、5不能重排序。動作2和動作4、5不能重排序。因為它們之間存在資料依賴關係,一旦重排,as-if-serial語義便無法保證。

      為保證as-if-serial語義,Java異常處理機制也會為重排序做一些特殊處理。例如在下面的程式碼中,y = 0 / 0可能會被重排序在x = 2之前執行,為了保證最終不致於輸出x = 1的錯誤結果,JIT在重排序時會在catch語句中插入錯誤代償程式碼,將x賦值為2,將程式恢復到發生異常時應有的狀態。這種做法的確將異常捕捉的邏輯變得複雜了,但是JIT的優化的原則是,盡力優化正常執行下的程式碼邏輯,哪怕以catch塊邏輯變得複雜為代價,畢竟,進入catch塊內是一種“異常”情況的表現。

package com.cby.jvmOrder;

/**
 * Created by cby on 2018/10/16.
 */
public class Reordering {
    public static void main(String[] args) {
         int x, y;
         x = 1;
         try {
                 x = 2;
                 y = 0 / 0;
         } catch (Exception e) {

         } finally {
             System.out.println("x = " + x);
         }
     }
}
 

三、記憶體訪問重排序與記憶體可見性

      計算機系統中,為了儘可能地避免處理器訪問主記憶體的時間開銷,處理器大多會利用快取(cache)以提高效能。其模型如下圖所示。

     在這種模型下會存在一個現象,即快取中的資料與主記憶體的資料並不是實時同步的,各CPU(或CPU核心)間快取的資料也不是實時同步的。這導致在同一個時間點,各CPU所看到同一記憶體地址的資料的值可能是不一致的。從程式的視角來看,就是在同一個時間點,各個執行緒所看到的共享變數的值可能是不一致的。
     有的觀點會將這種現象也視為重排序的一種,命名為“記憶體系統重排序”。因為這種記憶體可見性問題造成的結果就好像是記憶體訪問指令發生了重排序一樣。
     這種記憶體可見性問題也會導致章節一中示例程式碼即便在沒有發生指令重排序的情況下的執行結果也還是(0, 0)。

四、記憶體訪問重排序與Java記憶體模型

      Java的目標是成為一門平臺無關性的語言,即Write once, run anywhere. 但是不同硬體環境下指令重排序的規則不盡相同。例如,x86下執行正常的Java程式在IA64下就可能得到非預期的執行結果。為此,JSR-1337制定了Java記憶體模型(Java Memory Model, JMM),旨在提供一個統一的可參考的規範,遮蔽平臺差異性。從Java 5開始,Java記憶體模型成為Java語言規範的一部分。
     根據Java記憶體模型中的規定,可以總結出以下幾條happens-before規則。Happens-before的前後兩個操作不會被重排序且後者對前者的記憶體可見。

  • 程式次序法則:執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B,其中,在程式中,所有的動作B都能出現在A之後。
  • 監視器鎖法則:對一個監視器鎖的解鎖 happens-before於每一個後續對同一監視器鎖的加鎖。
  • volatile變數法則:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
  • 執行緒啟動法則:在一個執行緒裡,對Thread.start的呼叫會happens-before於每個啟動執行緒的動作。
  • 執行緒終結法則:執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已經終結、或者從Thread.join呼叫中成功返回,或Thread.isAlive返回false。
  • 中斷法則:一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷。
  • 終結法則:一個物件的建構函式的結束happens-before於這個物件finalizer的開始。
  • 傳遞性:如果A happens-before於B,且B happens-before於C,則A happens-before於C

      Happens-before關係只是對Java記憶體模型的一種近似性的描述,它並不夠嚴謹,但便於日常程式開發參考使用,關於更嚴謹的Java記憶體模型的定義和描述,請閱讀JSR-133原文或Java語言規範章節17.4。

      除此之外,Java記憶體模型對volatile和final的語義做了擴充套件。對volatile語義的擴充套件保證了volatile變數在一些情況下不會重排序,volatile的64位變數double和long的讀取和賦值操作都是原子的。對final語義的擴充套件保證一個物件的構建方法結束前,所有final成員變數都必須完成初始化(的前提是沒有this引用溢位)。

      Java記憶體模型關於重排序的規定,總結後如下表所示。

      表中“第二項操作”的含義是指,第一項操作之後的所有指定操作。如,普通讀不能與其之後的所有volatile寫重排序。另外,JMM也規定了上述volatile和同步塊的規則盡適用於存在多執行緒訪問的情景。例如,若編譯器(這裡的編譯器也包括JIT,下同)證明了一個volatile變數只能被單執行緒訪問,那麼就可能會把它做為普通變數來處理。
      留白的單元格代表允許在不違反Java基本語義的情況下重排序。例如,編譯器不會對對同一記憶體地址的讀和寫操作重排序,但是允許對不同地址的讀和寫操作重排序。

      除此之外,為了保證final的新增語義。JSR-133對於final變數的重排序也做了限制。

    • 構建方法內部的final成員變數的儲存,並且,假如final成員變數本身是一個引用的話,這個final成員變數可以引用到的一切儲存操作,都不能與構建方法外的將當期構建物件賦值於多執行緒共享變數的儲存操作重排序。例如對於如下語句
      x.finalField = v; ... ;構建方法邊界sharedRef = x;
      v.afield = 1; x.finalField = v; ... ; 構建方法邊界sharedRef = x;
      這兩條語句中,構建方法邊界前後的指令都不能重排序。
    • 初始讀取共享物件與初始讀取該共享物件的final成員變數之間不能重排序。例如對於如下語句
      x = sharedRef; ... ; i = x.finalField;
      前後兩句語句之間不會發生重排序。由於這兩句語句有資料依賴關係,編譯器本身就不會對它們重排序,但確實有一些處理器會對這種情況重排序,因此特別制定了這一規則。

五、記憶體屏障

      記憶體屏障(Memory Barrier,或有時叫做記憶體柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和記憶體可見性問題。Java編譯器也會根據記憶體屏障的規則禁止重排序。
      記憶體屏障可以被分為以下幾種型別
LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。        在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。

       有的處理器的重排序規則較嚴,無需記憶體屏障也能很好的工作,Java編譯器會在這種情況下不放置記憶體屏障。
       為了實現上一章中討論的JSR-133的規定,Java編譯器會這樣使用記憶體屏障。

     為了保證final欄位的特殊語義,也會在下面的語句加入記憶體屏障。
     x.finalField = v; StoreStore; sharedRef = x;

六、Intel 64/IA-32架構下的記憶體訪問重排序

     Intel 64和IA-32是我們較常用的硬體環境,相對於其它處理器而言,它們擁有一種較嚴格的重排序規則。Pentium 4以後的Intel 64或IA-32處理的重排序規則如下。9

在單CPU系統中

  • 讀操作不與其它讀操作重排序。
  • 寫操作不與其之前的寫操作重排序。
  • 寫記憶體操作不與其它寫操作重排序,但有以下幾種例外
  • CLFLUSH的寫操作
  • 帶有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming寫入。
  • 字串操作
  • 讀操作可能會與其之前的寫不同位置的寫操作重排序,但不與其之前的寫相同位置的寫操作重排序。
  • 讀和寫操作不與I/O指令,帶鎖的指令或序列化指令重排序。
  • 讀操作不能重排序到LFENCE和MFENCE之前。
  • 寫操作不能重排序到LFENCE、SFENCE和MFENCE之前。
  • LFENCE不能重排序到讀操作之前。
  • SFENCE不能重排序到寫之前。
  • MFENCE不能重排序到讀或寫操作之前。

在多處理器系統中

  • 各自處理器內部遵循單處理器的重排序規則。
  • 單處理器的寫操作對所有處理器可見是同時的。
  • 各自處理器的寫操作不會重排序。
  • 記憶體重排序遵守因果性(causality)(記憶體重排序遵守傳遞可見性)。
  • 任何寫操作對於執行這些寫操作的處理器之外的處理器來看都是一致的。
  • 帶鎖指令是順序執行的。

     值得注意的是,對於Java編譯器而言,Intel 64/IA-32架構下處理器不需要LoadLoad、LoadStore、StoreStore屏障,因為不會發生需要這三種屏障的重排序。

七、一例Intel 64/IA-32架構下的程式碼效能優化

      現在有這樣一個場景,一個容器可以放一個東西,容器支援create方法來建立一個新的東西並放到容器裡,支援get方法取到這個容器裡的東西。我們可以較容易地寫出下面的程式碼。

 

package com.cby.jvmOrder;

/**
 * Created by cby on 2018/10/16.
 */
public class Container {
    public static class SomeThing {
          private int status;
              public SomeThing() {
                     status = 1;
                 }

              public int getStatus() {
                     return status;
                }
     }
     private SomeThing object;
     public void create() {
         object = new SomeThing();
     }
    public SomeThing get() {
            while (object == null) {
                //不加這句話可能會在此出現無限迴圈
               Thread.yield();
           }
        return object;
    }
}

      在單執行緒場景下,這段程式碼執行起來是沒有問題的。但是在多執行緒併發場景下,由不同的執行緒create和get東西,這段程式碼是有問題的。問題的原因與普通的雙重檢查鎖定單例模式(Double Checked Locking, DCL)10類似,即SomeThing的構建與將指向構建中的SomeThing引用賦值到object變數這兩者可能會發生重排序。導致get中返回一個正被構建中的不完整的SomeThing物件例項。為了解決這一問題,通常的辦法是使用volatile修飾object欄位。這種方法避免了重排序,保證了記憶體可見性,摒棄比使用同步塊導致的效能損失更小。但是,假如使用場景對object的記憶體可見性並不敏感的話(不要求一個執行緒寫入了object,object的新值立即對下一個讀取的執行緒可見),在Intel 64/IA-32環境下,有更好的解決方案。

     根據上一章的內容,我們知道Intel 64/IA-32下寫操作之間不會發生重排序,即在處理器中,構建SomeThing物件與賦值到object這兩個操作之間的順序性是可以保證的。這樣看起來,僅僅使用volatile來避免重排序是多此一舉的。但是,Java編譯器卻可能生成重排序後的指令。但令人高興的是,Oracle的JDK中提供了Unsafe. putOrderedObject,Unsafe. putOrderedInt,Unsafe. putOrderedLong這三個方法,JDK會在執行這三個方法時插入StoreStore記憶體屏障,避免發生寫操作重排序。而在Intel 64/IA-32架構下,StoreStore屏障並不需要,Java編譯器會將StoreStore屏障去除。比起寫入volatile變數之後執行StoreLoad屏障的巨大開銷,採用這種方法除了避免重排序而帶來的效能損失以外,不會帶來其它的效能開銷。
     我們將做一個小實驗來比較二者的效能差異。一種是使用volatile修飾object成員變數。

package com.cby.jvmOrder;

/**
 * Created by cby on 2018/10/16.
 */
public class Container {
    public static class SomeThing {
          private int status;
              public SomeThing() {
                     status = 1;
                 }

              public int getStatus() {
                     return status;
                }
     }
     private SomeThing object;
     public void create() {
         object = new SomeThing();
     }
    public SomeThing get() {
            while (object == null) {
                //不加這句話可能會在此出現無限迴圈
               Thread.yield();
           }
        return object;
    }
}
  

 

     一種是利用Unsafe. putOrderedObject在避免在適當的位置發生重排序。

 

package com.cby.jvmOrder;

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * Created by cby on 2018/10/16.
 */
public class ContainerO {
    public static class SomeThing {
          private int status;

              public SomeThing() {
                     status = 1;
                 }

              public int getStatus() {
                     return status;
                 }
     }
     private SomeThing object;

     private Object value;
     private static final Unsafe unsafe = getUnsafe();
     private static final long valueOffset;
     static {
             try {
                 valueOffset = unsafe.objectFieldOffset(Container.class.getDeclaredField("value"));
                 } catch (Exception ex) { throw new Error(ex); }
             }
             public void create() {
                 SomeThing temp = new SomeThing();
                 //將value賦null值只是一項無用操作,實際利用的是這條語句的記憶體屏障
                 unsafe.putOrderedObject(this, valueOffset, null);              
                 //unsafe.putObjectVolatile(this, valueOffset, null);
                 object = temp;
             }

             public SomeThing get() {
                while (object == null) {
                         Thread.yield();
                     }
                return object;
             }


             public static Unsafe getUnsafe() {
                 try {
                         Field f = Unsafe.class.getDeclaredField("theUnsafe");
                         f.setAccessible(true);
                         return (Unsafe)f.get(null);
                     } catch (Exception e) {
                     }
                 return null;
             }
}

 

     由於直接呼叫Unsafe.getUnsafe()需要配置JRE獲取較高許可權,我們利用反射獲取Unsafe中的theUnsafe來取得Unsafe的可用例項。
     unsafe.putOrderedObject(this, valueOffset, null)
     這句僅僅是為了借用這句話功能的防止寫重排序,除此之外無其它作用。

     利用下面的程式碼分別測試兩種方案的實際執行時間。在執行時開啟-server和 -XX:CompileThreshold=1以模擬生產環境下長時間執行後的JIT優化效果。

package com.cby.jvmOrder;

import java.util.ArrayList;
import java.util.List;

/**
 * Created by cby on 2018/10/16.
 */
public class Main {
    public static void main(String[] args) throws InterruptedException {
             final int THREADS_COUNT = 20;
             final int LOOP_COUNT = 100000;
             long sum = 0;
             long min = Integer.MAX_VALUE;
             long max = 0;
             for(int n = 0;n <= 100;n++) {
                     final Container basket = new Container();
                     List<Thread> putThreads = new ArrayList<Thread>();
                     List<Thread> takeThreads = new ArrayList<Thread>();
                     for (int i = 0; i < THREADS_COUNT; i++) {
                         putThreads.add(new Thread() {
                             @Override
                             public void run() {
                                 for (int j = 0; j < LOOP_COUNT; j++) {
                                     basket.create();
                                 }
                             }
                         });
                         takeThreads.add(new Thread() {
                             @Override
                             public void run() {
                                 for (int j = 0; j < LOOP_COUNT; j++) {
                                     basket.get().getStatus();
                                 }
                             }
                         });
                     }
                     long start = System.nanoTime();
                     for (int i = 0; i < THREADS_COUNT; i++) {
                             takeThreads.get(i).start();
                             putThreads.get(i).start();
                     }
                     for (int i = 0; i < THREADS_COUNT; i++) {
                             takeThreads.get(i).join();
                            putThreads.get(i).join();
                     }
                     long end = System.nanoTime();
                     long period = end - start;
                     if(n == 0) {
                        //由於JIT的編譯,第一次執行需要更多時間,將此時間不計入統計
                         continue;
                     }
                     sum += (period);
                     System.out.println(period);
                     if(period < min) {
                             min = period;
                      }
                     if(period > max) {
                            max = period;
                     }
                }
                System.out.println("Average : " + sum / 100);
                System.out.println("Max : " + max);
                System.out.println("Min : " + min);
         }
}

在筆者的計算機上執行測試,採用volatile方案的執行結果如下
Average : 62535770
Max : 82515000
Min : 45161000

採用unsafe.putOrderedObject方案的執行結果如下
Average : 50746230
Max : 68999000
Min : 38038000

      從結果看出,unsafe.putOrderedObject方案比volatile方案平均耗時減少18.9%,最大耗時減少16.4%,最小耗時減少15.8%.另外,即使在其它會發生寫寫重排序的處理器中,由於StoreStore屏障的效能損耗小於StoreLoad屏障,採用這一方法也是一種可行的方案。但值得再次注意的是,這一方案不是對volatile語義的等價替換,而是在特定場景下做的特殊優化,它僅避免了寫寫重排序,但不保證記憶體可見性。

 

復現重排序現象實驗程式碼

package com.cby.jvmOrder;

/**
 * Created by cby on 2018/10/16.
 */
public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                     //由於執行緒one先啟動,下面這句話讓它等一等執行緒two. 讀著可根據自己電腦的實際效能適當調整等待時間.
                     shortWait(100000);
                     a = 1;
                     x = b;
                 }
             });

            Thread other = new Thread(new Runnable() {
                 @Override
                 public void run() {
                    b = 1;
                    y = a;
                    }
            });
           one.start();other.start();
             one.join();other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                   System.err.println(result);
                    break;
                 } else {
                     System.out.println(result);
                 }
         }
    }


     public static void shortWait(long interval){
         long start = System.nanoTime();
         long end;
         do{
                end = System.nanoTime();
          }while(start + interval >= end);
     }
}

八 參考文獻

https://www.cnblogs.com/langtianya/p/3898760.html

https://stackoverflow.com/questions/9341083/how-to-use-xxunlockdiagnosticvmoptions-xxcompilecommand-print-option-with-j/9415368#9415368

https://blog.csdn.net/zyc88888/article/details/64905716