淺談計算機架構與java內存模型

計算機:輔助人腦的好工具

計算機的定義:java

接受使用者輸入指令與數據, 經由中央處理器的數學與邏輯單元運算處理後,以產生或儲存成有用的信息

咱們的我的電腦也是計算機的一種,,依外觀來看這傢伙主要分三部分:程序員

  1. 輸入單元:包括鍵盤、鼠標、讀卡機、掃描器、手寫板、觸控螢幕等等一堆;
  2. 主機部分:這個就是系統單元,被主機機殼保護住了,裏面含有 CPU 與主內存等;
  3. 輸出單元:例如螢幕、打印機等等

中央處理器(Central Processing Unit)

而咱們今天研究的主題就是計算機其中的主機部分。整部主機的重點在於中央處理器(cpu),cpu是一個具備特定功能的芯片,
裏面含有不少微指令集,計算機全部的功能都須要微指令集的支持才能夠完成。cpu的主要做用在於管理和運算,所以cpu內部又可分爲兩個單元,分別爲:算數邏輯單元和控制單元。其中算數邏輯單元主要負責程序運算和邏輯判斷,控制單元主要負責和各周邊主件與各單元之間的工做。
圖片描述緩存

上圖所展現的系統單元其實就是主機的主要組件,其中的核心就是cpu和主內存。基本上全部數據都要通過主內存,至因而流入仍是流出則是cpu所發佈的控制指令,而cpu實際要處理的數據則所有來自於主內存!安全


  • cpu的外頻與倍頻

cpu做爲計算機的大腦,由於許多運算和邏輯都在cpu裏處理,因此須要其擁有很強大的處理能力,但外部組件的速度和cpu的速度相差實在太多,才啊有了所謂的外頻和倍頻。
所謂外頻指的是cpu與外部組件進行數據傳輸的速度。倍頻則是cpu內部用來加速工做的一個倍數。二者相乘纔是cpu本身的主頻。多線程


  • 高速緩存

程序的啓動和運轉有着一個重要的問題,即系統花費了大量的時間把信息從一個地方挪到另外一個地方。數據最初放在磁盤上,當程序被加載時,將其移動到主內存,當程序運行時,指令又從內存複製到cpu上。從程序員的角度來看,這些複製就是開銷,是減慢了程序運行速度的罪魁禍首。所以,系統設計者設計了高速緩存來使這些複製操做盡量快地完成。
一個系統上磁盤驅動器可能比主內存大100倍,可是對處理器來講,從磁盤驅動器讀取一個字的開銷比從主內存讀取的開銷大1000萬倍。相似的,一個寄存器只能夠儲存幾百字節的信息,而主內存裏能夠放幾十億字節。然而寄存器的速度大約是主內存的100倍。並且,隨着半導體技術的進步,這種處理器與主存之間的差距還在持續增大。
針對這種處理器與主存之間的差別,系統設計者採用了更小更快的存儲設備,稱爲高速緩存存儲器。其中又分爲L一、L二、L3高速緩存,限於篇幅,在這裏就不給你們詳細介紹了.系統經過讓高速緩存裏存放可能常常訪問的數據,大部分的內存操做都能在快速的高速緩存中完成。
圖片描述架構

主機架構與java內存模型

多任務處理器在現代計算機系統中幾乎已經是一項必備的功能了。全部的運算任務至少都要與主內存交互才能完成,因爲計算機的存儲設備和處理器的運算速度之間存在着幾個數量級的差距。因此現代計算機系統都不得不加入一層讀寫速度儘量接近於處理器的高速緩存來做爲內存與處理器之間的緩衝:將運算須要使用的數據複製到緩存中,讓運算高速進行,當運算結束後,再將緩存中的結果複製到主內存中。這樣處理器就不須要等待緩慢的內存讀寫了。以下圖所示:
圖片描述併發

看似很美好,實際上並無想象中的那麼容易。在計算機系統中,可能存在多個處理器,每一個處理器都有本身的高速緩存,而他們又共享同一主內存。當多個處理器的運算任務涉及到統一塊內存區域,將可能致使高速緩存間的不一致,那同步到主內存以哪一個爲準呢?爲了解決一致性問題,須要各個處理器訪問緩存須要遵循一些一致性協議來進行操做。java內存模型定義的內存訪問操做和硬件的訪問操做是有可比性的。
java虛擬機規範試圖定義一種java內存模型來屏蔽掉各類硬件和操做系統的訪問差別,以實現讓java程序在任何機器上都能達到一致的相同效果。所以定義java內存模型是一件很是麻煩的事,既要足夠嚴謹,讓java的併發操做不會發生歧義;但也必須足夠寬鬆,使虛擬機的實現有足夠的自由空間去利用硬件的各類特性(寄存器、高速緩存等)來獲取更好的執行速度。內存模型以下圖所示:
圖片描述app

重排序

在講重排序以前,咱們先來看一段代碼:jvm

public class ReOrderTest {

    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;
            CountDownLatch latch = new CountDownLatch(1);

            Thread one = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                b = 1;
                y = a;
            });
            one.start();other.start();
            latch.countDown();
            one.join();other.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }


}

看完這段代碼,或許沒有接觸太重排序的同窗會認爲這是一個死循環,其輸出結果只會有(1,1),(1,0),(0,1)三種結果。但實際上只須要運行幾秒鐘,就會break出來,出現x=0;y=0的狀況。
重排序由如下幾種機制引發的:工具

  • 編譯器優化:對於沒有數據依賴關係的操做,編譯器在編譯的過程當中會進行必定程度的重排。
    解釋:編譯器能夠將線程1的a=1和x=b互換下位置的,由於他們不存在數據依賴,同理線程2也能夠互換位置,就 能夠獲得x=0,y=0的結果了
  • 指令重排序:CPU 優化行爲,也是會對不存在數據依賴關係的指令進行必定程度的重排。
    解釋:這個和編譯器優化是一個道理,代碼編譯成指令,不存在依賴關係也就有可能進行重排
  • 內存系統重排序:內存系統沒有重排序,可是因爲有緩存的存在,使得程序總體上會表現出亂序的行爲。
    解釋:線程1執行a=1,將其寫入緩存但可能尚未同步到主內存,這個時候線程2訪問a的值固然就是0了。同理線程2對b的賦值操做也有可能沒有刷新到主內存當中

內存可見性

剛纔再講重排序的時候,就提到了內存可見性。線程1執行a=1,這個結果對於線程2來講不必定可見。這種不可見不是因爲多處理器形成的,而是因爲多緩存形成的。如今每一個處理器上都會有寄存器,L一、L二、L3緩存等等,問題就發生在每一個處理器都獨佔一個緩存,數據修改刷入緩存,而後從緩存刷入內存,因此就會致使有些處理器讀到的是過時的值。java做爲高級語言,爲咱們抽象jmm模型,定義了讀寫數據的規範,使咱們不用關心緩存的概念,可是jmm也同時給咱們抽象出了工做內存和主內存。(ps:這裏說的工做內存是對寄存器,L一、L二、L3緩存等的一個抽象)

happens-before(先行發生原則)

happens-before是理解jmm最核心的概念。對於java程序員來講,若是你想理解並寫好併發程序,happens-before是理解jmm模型的關鍵。
《JSR-133:Java Memory Model and Thread Specification》對happens-before關係的定義以下:
1)若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。
2)兩個操做之間存在happens-before關係,並不意味着Java平臺的具體實現必需要按照happens-before關係指定的順序來執行。若是重排序以後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM容許這種重排序)。

public static int getz() {
        int x=1;            //A
        int y=1;            //B
        int z=x+y;          //C
        return z;
    }

上面的代碼示例存在了3個happens-before規範:

  • Ahappens-beforeB
  • Bhappens-beforeC
  • Ahappens-beforeC

其中二、3是必須的,而1不是必需的。所以jmm又把happens-before要求禁止的重排序分爲了如下兩種:

  • 會改變程序結果的重排序(jmm要求編譯器和處理器嚴格禁止這種重排序)
  • 不會改變程序結果的重排序(容許,指的是單線程程序或者通過正確同步的多線程程序)

happens-before規則

《JSR-133:Java Memory Model and Thread Specification》定義了以下happens-before規則。
1)程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的
讀。
4)傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
5)start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的
ThreadB.start()操做happens-before於線程B中的任意操做。
6)join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做
happens-before於線程A從ThreadB.join()操做成功返回。

咱們其中最多見的就是一、二、三、4.其中一、4的狀況在前面已經討論過。3)將會在volatile的內存語義中進行討論。如今咱們來看下鎖的釋放-獲取創建的happens-before關係:

int a=0;
    
    public synchronized void read(){//1
        a++;//2
    }//3

    public synchronized void writer(){//4
        int i=a+1;//5
    }//6

由程序順序規則來判斷:1happens-before2,2happens-before3,4happens-before5,5happens-before6.
由監視器鎖規則來判斷:3happens-before4
由傳遞性來判斷:1happens-before2,2happens-before3,3happens-before4,4happens-before5,5happens-before6
怎麼實現的呢?進入鎖的時候將會使工做內存失效,讀取變量必須從主內存中讀取。釋放鎖得時候會將該變量刷新回主內存。這裏的鎖包括conuurent包下的鎖.

volatile的內存語義

關於volatile,你們只須要牢記兩點:內存可見和禁止重排序.
關於volatile的可見性,常常被你們誤解。認爲volatile變量對全部線程是當即可見的,對volatile變量全部的寫操做都能馬上反映到其餘縣城中,換句話說,volatile變量的運算在併發下是安全的。這個結論是錯誤的,雖然volatile變量能夠保證可見性,可是java裏面的運算並不是原子操做,致使volatile變量的運算在併發下同樣是不安全的。請看代碼示例:

public class BubbleSort {

    static volatile int a;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                public void run() {
                    for (int x = 0; x < 10000; x++) {
                        add();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("a=" + a);
    }
    
    private static void add() {
        a++;
    }
    
}
輸出結果:a=159957

結果具備不肯定性,緣由就是a++自增運算,不是一個原子性操做。經過javap -c BubbleSort.class反編譯這段代碼獲得add()的字節碼文件,以下圖所示:
圖片描述

能夠看到a++這個運算操做產生了4條字節碼(return 不是a++產生的),volatile只能保證getstatic時得到到a的值是正確的,當執行其餘指令時,頗有可能a已是過時數據了。事實上這樣分析是不太嚴謹的,由於字節碼最終會變成cpu指令執行,即便只編譯出一條字節碼指令也不能保證這個指令就是原子操做。因此若是當咱們進行運算的時候,仍要經過加鎖或者使用concurrent併發包下的原子類才能保證其原子性。
禁止重排序有一個很是經典的例子,就是DCL單例模式.關於這篇文章,大神們早已發過文章對此進行闡述了,這裏搬運一下:

來膜拜下文章署名中的大神們:David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, Jeremy Manson, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer,至少 Joshua Bloch 和 Doug Lea 你們都不陌生吧。

話很少說,上例子:

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {
        this.v = 3;
    }

    public static Singleton getInstance() {
        if (instance == null) { 
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

jvm接收到new指令時,簡單分爲3步(實際更多,可參考深刻理解虛擬機),1分配內存2實例化對象3將內存地址指向引用。java的內存模型並不限制指令的重排序,也就說當執行步驟從1-》2-》3變成1-》3-》2。當線程a訪問走到第2步,未完成實例化對象前,線程b訪問此對象的返回一個引用,但如果進行其餘操做,由於對象並無實例化,會形成this逃逸的問題。解決的方法很簡單,就是加上volatile關鍵字。
volatile小結

  1. volatile 修飾符適用於如下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其餘線程能夠當即獲得修改後的值。在併發包的源碼中,它使用得很是多。
  2. volatile 屬性的讀寫操做都是無鎖的,它不能替代 synchronized,由於它沒有提供原子性和互斥性。由於無鎖,不須要花費時間在獲取鎖和釋放鎖上,因此說它是低成本的。
  3. volatile 只能做用於屬性,咱們用 volatile 修飾屬性,這樣 compilers 就不會對這個屬性作指令重排序。
  4. volatile 提供了可見性,任何一個線程對其的修改將立馬對其餘線程可見。volatile 屬性不會被線程緩存,始終從主存中讀取。
  5. volatile 提供了 happens-before 保證,對 volatile 變量 v 的寫入 happens-before 全部其餘線程後續對 v 的讀操做。
  6. volatile 可使得 long 和 double 的賦值是原子的,前面在說原子性的時候提到過。

小結

描述該類知識須要很是嚴謹的描述,雖然我仔細檢查了好幾遍,但仍擔憂會出錯,一來受限於有限的知識儲備,二來受限於蹩腳的文字表達能力。但願讀者能夠幫助我指正表達錯誤的地方.

相關文章
相關標籤/搜索