JMM在X86下的原理與實現

JMM在X86下的原理與實現

Java的happen-before模型

  • 衆所周知 Java有一個happen-before模型,能夠幫助程序員隔離各個平臺多線程併發的複雜性,只要Java程序員遵照happen-before模型就不用擔憂多線程內存排序或者緩存可見性的問題java

  • 摘自周志明老師的JMM章節git

程序次序規則(Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操做先行 發生於書寫在後面的操做。注意,這裏說的是控制流順序而不是程序代碼順序,由於要考慮分支、循 環等結構。程序員

管程鎖定規則(Monitor Lock Rule):一個unlock操做先行發生於後面對同一個鎖的lock操做。這 裏必須強調的是「同一個鎖」,而「後面」是指時間上的前後。github

volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操做先行發生於後面對這個變量 的讀操做,這裏的「後面」一樣是指時間上的前後。ubuntu

線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每個動做。緩存

線程終止規則(Thread Termination Rule):線程中的全部操做都先行發生於對此線程的終止檢 測,咱們能夠經過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止 執行。sass

線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程 的代碼檢測到中斷事件的發生,能夠經過Thread::interrupted()方法檢測到是否有中斷髮生。bash

對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的 finalize()方法的開始。多線程

傳遞性(Transitivity):若是操做A先行發生於操做B,操做B先行發生於操做C,那就能夠得出 操做A先行發生於操做C的結論。併發

一個來自技術交流羣裏的提問

  • 問題

https://club.perfma.com/question/2079981

筆者根據問題擴展的3個demo

  • DEMO1 死循環
public class ThreadNumberDemo {

    static int num = 0;

    public static void main(String[] args) {
        new Thread(()->{
            System.out.println("Child:" + num);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num++;
            System.out.println("Child End:" + num);
        }).start();

        System.out.println("Main:" + num);
        while(num == 0){
        }
        System.out.println("Main exit");
    }
}
  • DEMO2 退出循環
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadNumberDemo2 {

    static int num = 0;

    static AtomicInteger flushCache = new AtomicInteger(0);

    public static void main(String[] args) {
        new Thread(()->{
            System.out.println("Child:" + num);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num++;
            System.out.println("Child End:" + num);
        }).start();
        System.out.println("Main:" + num);
        while(num == 0){
            flushCache.getAndAdd(1) ;
        }
        System.out.println("Main exit");
    }
}
  • DEMO3 退出循環
public class ThreadNumberDemo3 {

    static int num = 0;

    volatile static int flushCache = 0;

    public static void main(String[] args) {
        new Thread(()->{
            System.out.println("Child:" + num);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num++;
            System.out.println("Child End:" + num);
        }).start();

        System.out.println("Main:" + num);
        while(num == 0){
            flushCache ++;
        }
        System.out.println("Main exit");
    }
}
  • 筆者爲何不按常理出牌直接在DEMO1的基礎上給num加上volatile?

  • 若是在num變量上加上volatile 則知足了 周志明老師所介紹的 HappenBefore 規則3,而對原子變量跟volatile變量flushCache的操做並不知足任何所謂的happen-before狀況,由於在DEMO2 DEMO3整個程序只有主線程訪問了flushCache這個變量

volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操做先行發生於後面對這個變量 的讀操做,這裏的「後面」一樣是指時間上的前後。

Volatile與原子變量的原理

反編譯與調試

  • 筆者憑着好奇心決定嘗試反編譯看看源碼

筆者的Linux跟JDK環境

Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.1 LTS
Release:	20.04
Codename:	focal

openjdk 11.0.9.1 2020-11-04
OpenJDK Runtime Environment (build 11.0.9.1+1-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.9.1+1-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)
  • 讀者若是想親自動手實驗 請按照下面兩個教程 將hsdis-amd64.so放到對應的JDK目錄下

https://juejin.cn/post/6844903656806940686
https://github.com/liuzhengyang/hsdis

  • 請讀者注意,每次啓動的Java進程內存地址都會變化,下面全部的地址都是筆者調試時的地址,
    讀者要根據本身生成的信息 自行更改彙編代碼的地址

  • 步驟1 編譯java文件

javac ThreadNumberDemo.java

獲得ThreadNumberDemo.class文件

  • 步驟2 執行以下命令
java ThreadNumberDemo
  • 步驟3 觀察

此時程序並未退出,以下圖中 佔用筆者大量CPU資源

  • 步驟4 使用反編譯插件 + GDB調試
    退出剛纔的Java進程,執行以下命令
java -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation 
-XX:LogFile=/tmp/log -XX:+PrintAssembly -XX:PrintAssemblyOptions=intel 
-XX:-BackgroundCompilation -XX:+UnlockDiagnosticVMOptions ThreadNumberDemo
  • 步驟5 觀察/tmp/log文件 查看反編譯生成的文件信息 當看到main函數相關的彙編代碼以及註釋生成後 執行以下命令
sudo gdb -p {pid}

上面的{pid} 請讀者以本身機器上運行的Java進程pid爲準,筆者這裏前面展現的圖片中有兩個Java進程的pid,讀者能夠分別用gdb attach上去嘗試調試

  • 步驟6 附加後 查看/tmp/log 以及Java源文件

附加java進程後直接跳過

GDB 執行

set disassembly-flavor intel
  • 步驟7 反編譯對比 /tmp/log

GDB 執行

disass 0x00007f27a0371b7f,0x00007f27a0371b89

讀者須要自行根據 /tmp/log文件中的信息(經過在/tmp/log 搜索Java源文件文件中對應的行號 便可看到對應的彙編代碼), 決定disass 後面兩個地址,注意中間有一個 ,符號

  • 步驟8 設置breakpoint 跟進代碼
break *0x00007f27a0371b7f
break *0x00007f27a0371b86
break *0x00007f27a0371b89

接下來使用c調試 發現死循環以下圖

經過GDB 能夠看到r10寄存器內的指針指向的內存地址存儲的變量爲0 eax寄存器中存儲的值一樣爲0

筆者根據上圖顯示的結果猜想主線程並無觀測到main函數建立的子線程對num的寫操做,從r10指針 0x7f27b7848000 (num變量的地址) 來打印num,
幾回循環下來均爲0,num == 0 這個條件一直成立是致使主線程不斷循環的緣由佔用CPU的緣由。

  • 步驟9 筆者經過GDB 以下設置PC指針跳出循環 驗證程序正常退出 以下圖
set var $pc=0x00007f27a0371b8b

  • DEMO1小結 死循環的根本緣由在於主線程沒法觀測到子線程對num的更新的值,據筆者推測是多線程緩存可見性的問題

  • DEMO2 以下圖

  • DEMO3 以下圖

總結

DEMO2 DEMO3 反彙編後均找到lock指令,基本上能夠判斷JVM在X64機器上對原子變量跟volatile的實現都使用了X86彙編語言lock指令的語義,
根據筆者在Stack Overflow上的一些資料瀏覽得出結論--lock語義具備內存柵欄的功能,能解決DEMO1(num變量)內存不可見的問題,
另外DEMO2 DEMO3均未使用Happen-Before模型,僅使用了X86的lock彙編指令的語義。

一點補充

相關文章
相關標籤/搜索