深入理解Java JMM模型

併發和並行的含義

目標都是最大化CPU的使用率
並行(parallel) 指在同一時刻,有多條指令在多個處理器上同時執行。 所以無論從微觀還是 從宏觀來看,二者都是一起執行的。

併發(concurrency):指在同一時刻只能有一條指令執行,但多個進程指令被快速的輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若干段,使多個進程快速交替的執行。

併發的三大特性

可見性:當一個線程修改了共享變量的值,其他線程能夠看到修改的值。

有序性:即程序執行的順序按照代碼的先後順序執行

原子性:一個或多個操作,要麼全部執行且在執行過程中不被任何因素打斷,要麼全部不執行。不採用任何的原子性保障的自主操作都不是原子性的。

如何保證可見性
通過 volatile 關鍵字保證可見性。
通過 內存屏障保證可見性。
通過 synchronized 關鍵字保證可見性。
通過 Lock保證可見性。
通過 final 關鍵字保證可見性
如何保證有序性
通過 volatile 關鍵字保證可見性。
通過 內存屏障保證可見性。
通過 synchronized關鍵字保證有序性。
通過 Lock保證有序性。
如何保證原子性
通過 synchronized 關鍵字保證原子性。
通過 Lock保證原子性。
通過 CAS保證原子性。
 
思考:在 32 位的機器上對 long 型變量進行加減操作是否存在併發隱患?

Chapter 17. Threads and Locks (oracle.com)

例一,可見性問題的深入分析

public class VisibilityTest {
    private boolean flag = true;

    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag");
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + "開始執行.....");
        int i = 0;
        while (flag) {
            i++;
        }
        System.out.println(Thread.currentThread().getName() + "跳出循環: i=" + i);
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();
        // 線程threadA模擬數據加載場景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();
        // 讓threadA執行一會兒
        Thread.sleep(1000);
        // 線程threadB通過flag控制threadA的執行時間
        Thread threadB = new Thread(() -> test.refresh(), "threadB");
        threadB.start();
    }
}

結果
threadA開始執行.....
threadB修改flag

由於可見性原因, 程序不會中斷

思考:上面例子中爲什麼多線程對共享變量的操作存在可見性問題?
JMM定義
Java虛擬機規範中定義了Java內存模型( Java Memory Model,JMM),用於屏蔽掉各 種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的併發效 果,JMM規範了Java虛擬機與計算機內存是如何協同工作的: 規定了一個線程如何和何時可 以看到由其他線程修改過後的共享變量的值,以及在必須時如何同步的訪問共享變量。 JMM 描述的是一種抽象的概念,一組規則,通過這組規則控制程序中各個變量在共享數據區域和私 有數據區域的訪問方式, JMM是圍繞原子性、有序性、可見性展開的。
 
JMM與硬件內存架構的關係
Java內存模型與硬件內存架構之間存在差異。硬件內存架構沒有區分線程棧和堆。對於硬 件,所有的線程棧和堆都分佈在主內存中。部分線程棧和堆可能有時候會出現在CPU緩存中和 CPU內部的寄存器中。如下圖所示,Java內存模型和計算機硬件內存架構是一個交叉關係:

內存交互操作
       關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、 如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作來完成:
  1. lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
  2. unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
  3. read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  4. load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  5. use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  6. assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  7. store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
  8. write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

 

代碼例一中問題的分析

當線程A啓動後,會從主存中經過 read、load 操作將變量加載到線程本地內存中。當線程B修改flag變量後,由於線程A執行的代碼是while(flag) ,所以本地內存中的變量副本緩存不會失效,拿不到最新數據最終導致不可見的問題。

在這個程序示例中,我們嘗試用幾種不同的方法解決可見性問題,然後分享一下以下幾種方法爲什麼能解決可見性問題。

public class VisibilityTest {
    //  storeLoad  JVM內存屏障  ---->  (彙編層面指令)  lock; addl $0,0(%%rsp)
    // lock前綴指令不是內存屏障的指令,但是有內存屏障的效果   緩存失效

//    private volatile boolean flag = true;
    private boolean flag = true;
//    private int count = 0;

    private Integer count = 0;
    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag:" + flag);
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + "開始執行.....");
        while (flag) {
            //TODO  業務邏輯
            count++;
            // 第一種, 內存屏障
            UnsafeFactory.getUnsafe().storeFence();
            // 第二種, 釋放時間片, 上下文切換加載上下文, 緩存失效重新從主存加載
            Thread.yield();
            // 第三種, println中用到了synchronized, 能夠跳出循環,內存屏障
            System.out.println(count);
            // 第四種, unpark也是用到了內存屏障
            LockSupport.unpark(Thread.currentThread());
            // 第五種, 使用一個方法模擬運行時間在1ms時發現本地方法副本緩存會失效, 當1000納秒時不會失效 
            shortWait(1000000); //1ms
            shortWait(1000);
            // 第五種, sleep方法中也用到了內存屏障
            try {
                Thread.sleep(1);   //內存屏障
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 第六種, 當count爲final修飾時, 也可以解決可見性問題, 原因未知
            //總結:  Java中可見性如何保證? 方式歸類有兩種:
            //1.  jvm層面 storeLoad內存屏障    ===>  x86   lock替代了mfence
            //2.  上下文切換   Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "跳出循環: count=" + count);
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest2 test = new VisibilityTest2();

        // 線程threadA模擬數據加載場景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 讓threadA執行一會兒
        Thread.sleep(1000);
        // 線程threadB通過flag控制threadA的執行時間
        Thread threadB = new Thread(() -> test.refresh(), "threadB");
        threadB.start();

    }


    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}
volatile在hotspot的實現
字節碼解釋器實現
JVM中的字節碼解釋器(bytecodeInterpreter),用C++實現了JVM指令,其優點是實現相對 簡單且容易理解,缺點是執行慢。
bytecodeInterpreter.cpp
判斷當前變量是不是被volatile修飾的

在linux系統x86中的實現
orderAccess_linux_x86.inline.hpp

如果是多核心處理器時,在x86的架構中內存屏障的實現方式使用lock前綴代替,因爲效率高

inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
	if (os::is_MP()) {
	// always use locked addl since mfence is sometimes expensive
	#ifdef AMD64
		__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
	#else
		__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
	#endif
	}
}
模板解釋器實現
模板解釋器(templateInterpreter),其對每個指令都寫了一段對應的彙編代碼,啓動時將每個 指令與對應彙編代碼入口綁定,可以說是效率做到了極致。
例如下面代碼,固定了putfield指令規定storeload和storestore一同使用
templateTable_x86_64.cpp
void TemplateTable::volatile_barrier(Assembler::Membar_mask_bitsorder_constraint) {
	// Helper function to insert a is‐volatile test and memory barrier 
	// 出入內存屏障
	if (os::is_MP()) { // Not needed on single CPU
		__ membar(order_constraint);
	}
}

// 負責執行putfield或putstatic指令
void TemplateTable::putfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
	// ...
	// Check for volatile store
	__ testl(rdx, rdx);
	__ jcc(Assembler::zero, notVolatile);
	putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
	volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |Assembler::StoreStore));
	__ jmp(Done);
	__ bind(notVolatile);
	putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
	__ bind(Done);
}


// Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint) {
	// We only have to handle StoreLoad
	// x86平臺只需要處理StoreLoad
	if (order_constraint & StoreLoad) {
		int offset = ‐VM_Version::L1_line_size();
		if (offset < ‐128) {
			offset = ‐128;
		}
		// 下面這兩句插入了一條lock前綴指令: lock addl $0, $0(%rsp)
		lock(); // lock前綴指令
		addl(Address(rsp, offset), 0); // addl $0, $0(%rsp)
	}
}

JMM內存屏障插入策略
1. 在每個volatile寫操作的前面插入一個StoreStore屏障
2. 在每個volatile寫操作的後面插入一個StoreLoad屏障
3. 在每個volatile讀操作的後面插入一個LoadLoad屏障
4. 在每個volatile讀操作的後面插入一個LoadStore屏障


JSR133規範
        x86處理器不會對讀-讀、讀-寫和寫-寫操作做重排序, 會省略掉這3種操作類型對應的內存屏障。僅會對寫-讀操作做重排序,所以volatile寫-讀操作只需要在volatile寫後插入StoreLoad屏障

lock前綴指令的作用

1. 確保後續指令執行的原子性。在Pentium及之前的處理器中,帶有lock前綴的指令在執行期間會鎖住總線,使得其它處理器暫時無法通過總線訪問內存,很顯然,這個開銷很
大。在新的處理器中,Intel使用緩存鎖定來保證指令執行的原子性,緩存鎖定將大大降低lock前綴指令的執行開銷。
2. LOCK前綴指令具有類似於內存屏障的功能,禁止該指令與前面和後面的讀寫指令重排序。
3. LOCK前綴指令會等待它之前所有的指令完成、並且所有緩衝的寫操作寫回內存(也就是將store buffer中的內容寫入內存)之後纔開始執行,並且根據緩存一致性協議,刷新
store buffer的操作會導致其他cache中的副本失效

彙編層面volatile的實現
添加下面的jvm參數查看之前可見性Demo的彙編指令,驗證了可見性使用了 lock前綴指令
‐XX:+UnlockDiagnosticVMOptions ‐XX:+PrintAssembly ‐Xcomp

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章