並發編程(三)volatile禁止重排序原理


上篇文章記錄到volatile在硬件層面怎么保證線程間可見性的,是通過lock鎖緩存行緩存一致性協議來實現的。但是這樣會有一個偽共享的問題。

首先緩存行在64bit機中一般為64字節,具體緩存行大小可以通過下面的命令查看:

cat /proc/cpuinfo

 

 假設有一個對象有兩個long類型的數據x,y,在java中long類型數據長度為8個字節,所以讀取一個對象數據后x,y會在一個緩存行中。有兩個CPU讀取這個對象如下圖的Core1,Core2,其中Core1只對x進行修改,Core2只對y進行修改。

根據緩存一致性協議知道,當Core1中數據修改后會導致Core2中的緩存行失效,Core2中的數據就要從主存中重新獲取,同理:當Core2中數據修改后會導致Core1中的緩存行失效,Core1中的數據就要從主存中重新獲取。兩核之間存在緩存行的競爭關系。

這會導致性能損耗。下面舉個例子看下這種情況。

 

public class HuanCun {
    public static void main(String[] args) throws InterruptedException {
        testPointer(new Pointer());
    }

    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.y++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(System.currentTimeMillis() - start);


    }
}
class Pointer {
    // 避免偽共享: @Contended +  jvm參數:-XX:-RestrictContended
    //@Contended
    volatile long x;
    //避免偽共享: 緩存行填充
    //long p1, p2, p3, p4, p5, p6, p7;
    volatile long y;
} 
View Code

 

上面的代碼執行結果:

 

 

  將近三秒執行。

為了避免讓x,y在同一個緩存行,我們可以把x,y分開在不同緩存行,可以通過在x變量后面添加七個long類型變量,這樣x,y就位於不同的緩存行上了。

如下:

 

 

 這樣運行結果看下:

 

 

 速度提升還是很明顯的。

但是上面消除偽共享的方法不太合適,還要再單獨定義 其它變量。在jdk8中有新的方式可以消除偽共享的情況。

就是:

避免偽共享: @Contended +  jvm參數:-XX:-RestrictContended

 

這種方式的執行時間也提升了很多。

 

 

 volatile的有序性

我上面以及上一篇都是在講volatile的可見性是如何保證的,volatile還有一種作用就是有序性。

我們可以從double  check中看到場景。

JMM中關於synchronized有如下規定,線程加鎖時,必須清空工作內存中共享變量的值,從而使用共享變量時需要從主內存重新讀取;線程在解鎖時,需要把工作內存中最新的共享變量的值寫入到主存,以此來保證共享變量的可見性。(ps這里是個泛指,不是說只有在退出synchronized時才同步變量到主存)。
那么加了 synchronized, myInstance的可見性已經保證了,為什么還要加上volatile呢?
    private volatile static SingletonFactory myInstance;

    public static SingletonFactory getMyInstance() {
        if (myInstance == null) {
synchronized (SingletonFactory.
class) { if (myInstance == null) { myInstance = new SingletonFactory(); } } } return myInstance; }

 

這個要說下對象的創建過程說起了,對象創建也就是說new這個操作JVM到底做了什么事,這個問題之前在CSDN上記錄過可以去看下:https://blog.csdn.net/A7_A8_A9/article/details/105730007

我把new操作的匯編貼一下:

 

 

JIT可能會對指令重排序,處理器也有可能是亂序執行,因為這樣可以提升性能。對於如何提升性能的我們可以從下面的偽代碼中窺探一二:

 

 

 右邊調整了順序之后可以減少i的加載和store,雖然例子只是一個int變量,如果是一個很大的對象/數據,這個節約的時間就是值得的。這種指令重排是JIT編譯之后的結果。處理器的亂序執行是當前指令在Load數據等待中,cpu可能先執行下面的指令了。

為什么JIT會進行重排序呢?我們知道我們java文件編譯成class文件,然后JVM編譯成指令序列再到匯編指令,JVM有兩個編譯方式,一種是讀取一行解釋一行,這種效率自然是很慢的,還有一種是JIT即時編譯它會檢測一些頻繁使用的代碼編譯成熱點代碼,這樣不用每次使用都要編譯了。

 

 指令重排的重現例子:

 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;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            /**
             *  x,y:
             *  每次循環,x,y,a,b都會賦初值0,兩個線程各執行一次,執行完,x,y可能的值有:
             *  0,1  1,0  1,1   這是我們按正常邏輯思考可能出現的結果,但是有一種情況是只會在重排序的情況出現
             *  就是 0,0   也就是這種情況下兩個線程變成了這樣的邏輯。
             *   thread1:
             *   x=b;
             *   a=1;
             *   thread2:
             *   y=a;
             *   b=1;
             *   我們執行看下是否會出現這樣的情況呢?
             */
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    shortWait(20000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("" + i + "次(" + x + "," + y + ")");
            if (x==0&&y==0){
                break;
            }

        }

    }

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

 

結果:

 

 指令重排出現了。

為了避免指令重排,可以給變量加上volatile關鍵字,禁止重排序,保證有序性也保證了可見性。我們從上篇知道,可見性它是靠Lock指令,觸發緩存一致性協議(MESI)保證的,通過總線嗅探機制把緩存數據的狀態變化廣播到所有的核。禁止指令重排也是靠這個Lock保證的,

它起到了內存屏障的作用,它雖然不是內存屏障指令。注意在linux_x86的系統中內存屏障mfence用lock來替代了。

 我們也可以手動添加內存屏障來禁止重排序,使用Unsafe類。

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

 

使用的地方。

 

 storefence是本地方法,我們可以去看下jdk的源碼。

 

可以看到 jvm屏蔽掉了不同的cpu。

 

 我以linux_x86為例來看。

查看orderAccess

我們看到定義了四種"內存屏障操作"但是對於x86處理器來說,它只關心sotreload這一種內存屏障,其它的它不管。這里我們只要看fence就可以了,

 

 

 

 if里判斷是否是多核處理架構,根據上面的注釋也可以知道使用  lock; addl來代替mfence。這是x86里面的內存屏障實現方式。

Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令

 

 為什么只關注StoreLoad指令呢,因為在JSR-133規范中定義了x86內核是如何對內存屏障和原子性進行支持的。

 

 

x86就是靠lock指令實現的。具體這個的解釋如下 ,參考了:https://www.zhihu.com/question/65372648

 

 

 

 先行發生模型--happens-before

JMM模型就是在先行發生模型上面的優化。線程的可見性,有序性,原子性都可以從先行發生模型來考慮。

我們從一個例子來看下:

我們約定線程A執行write(),線程B執行read(),且線程A優先於線程B執行(時間先后順序),那么線程B獲得結果是什么?這段代碼是線程安全的嗎

private int i = 0;
 
public void write(int j ){
    i = j;
}
 
public int read(){
    return i;
}

 

按上面的執行是線程不安全的,雖然線程A先於線程B執行,但是線程B對於線程A的執行結果確實不可見的,這也是先行發生模型強調的點,這個先行不是代碼執行時間上的先后順序而是執行結果是順序的。

線程內存模型有:

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM