從 JVM 記憶體模型談執行緒安全

江湖人稱小白哥發表於2017-03-03

作為一個三個多月沒有去工作的獨立開發者而言,今天去小米麵試了一把.怎麼說呢,無論你水平如何,請確保在面試之前要做準備,就像其中一位面試官說的一樣,我知道你水平不錯,但是無論如何也是要準備下的,不然你怎麼會連這個方法也忘記了?

此刻,我突然覺得我是一個假程式設計師.為什麼這麼說呢,作為一個從12年就開始寫程式碼的程式設計師來說,忘記某個方法太可恥了.等趕明寫一篇文章就叫做”我是個假程式設計師”來談談這些有趣的事兒.

話不多說,今天要談的主題是相對較深,較廣,但我努力的讓他看起來清晰明瞭.

儲存器層次結構

對於開發者來說,儲存器的層次結構應該是非常熟悉的,大體如下:

這裡寫圖片描述

其中暫存器,L1,L2,L3都被封裝在CPU晶片中,作為應用開發者而言我們很少去注意和使用它.之所以引入L1,L2,L3高速暫存器,其根本是為了解決訪問運算器和記憶體速度不匹配.但快取的引入也帶來兩個問題:

  1. 快取命中率:快取的資料都是主存中資料的備份,如果指令所需要的資料恰好在快取中,我們就說快取命中,反之,需要從主存中獲取.一個好的快取策略應該儘可能的提高命中率,如何提高卻是一件非常困難的事情.
  2. 快取一致性問題:我們知道快取是主存資料的備份,但每個核心都有自己的快取,當快取中的資料和記憶體中的資料不一致時,應該以誰的資料為準呢,這就是所謂快取一致性問題.

上面只是展示儲存器的層次結構,現在我們來更形象的來看一下CPU晶片與記憶體之間聯絡,以Intel i5雙核處理器為例:

這裡寫圖片描述

通過上圖我們能明顯的看出各個快取之間的聯絡,在隨後的JVM記憶體模型剖析中,你同樣會發現類似的結構.關於儲存器層次結構到這裡已經足夠,畢竟我們不是專門做作業系統的,下面我們來聊聊主存,更確切的說抽象的虛擬記憶體.

虛擬記憶體

談起記憶體的時候,每個人的腦海中都會呈現出記憶體條的形象,在很多時候,這種實物給我們對記憶體最直觀的理解,對於非開發者這麼理解是可以接受的,但是對於從事開發開發工作的工程師而言,我們還要加深一點.

這裡寫圖片描述

從硬體的角度來看,記憶體就是一塊有固定容量的儲存體,與該硬體直接打交道的是我們的作業系統.我們知道系統的程式都是共享CPU和記憶體資源的,現代作業系統為了更有效的管理記憶體,提出了記憶體的抽象概念,稱之為虛擬記憶體.換言之,我們在作業系統中所提到的記憶體管理談的都是虛擬記憶體.虛擬記憶體的提出帶來幾個好處:

  1. 虛擬記憶體將主存看成是一個儲存在磁碟上的地址空間的告訴快取.應用在未執行之前,只是儲存在磁碟上二進位制檔案,執行後,該應用才被複制到主存中.
  2. 它為每個程式提供了一致的地址空間,簡化了記憶體管理機制.簡單點來看就是每個程式都認為自己獨佔該主存.最簡單的例子就是一棟樓被分成許多個房間,每個房間都是獨立,是戶主專有,每個戶主都可以從零開始自助裝修.另外,在未徵得其他戶主的同意之前,你是無法進入其他房間的.

虛擬記憶體的提出也改變了記憶體訪問的方式.之前CPU訪問主存的方式如下:

這裡寫圖片描述

上圖演示了CPU直接通過實體地址(假設是2)來訪問主存的過程,但如果有了虛擬記憶體之後,整個訪問過程如下:

這裡寫圖片描述

CPU給定一個虛擬地址,然後經過MMU(記憶體管理單元,硬體)將虛擬地址翻譯成真正的實體地址,再訪問主存.比如現在虛擬地址是4200經過MMU的翻譯直接變成真正的實體地址2.

這裡來解釋下什麼是虛擬記憶體地址.我們知道虛擬記憶體為每個程式提供了一個假象:每個程式都在獨佔地使用主存,每個程式看到的記憶體都是一樣的,這稱之為虛擬地址空間.舉個例子來說,比如我們記憶體條是1G的,即最大地址空間210,這時某個程式需要4G的記憶體,那麼作業系統可以將其對映成更大的地址空間232,這個地址空間就是所謂的虛擬記憶體地址.關於如何對映,有興趣的可以自行學習.用一張圖來抽象的表示:

這裡寫圖片描述

到現在我們明白原來原來我們所談作業系統中談的記憶體其實是虛擬記憶體,如果你是C語言開發者,那對此的感受可能更深.既然每個程式都擁有自己的虛擬地址空間,那麼它的佈局是如何的呢?以Linux系統為例,來看一下它的程式空間地址的佈局:

這裡寫圖片描述

到現在為止,我們終於走到了程式這一步.我們知道,每個JVM都執行在一個單獨的程式當中,和普通應用不同,JVM相當於一個作業系統,它有著自己的記憶體模型.下面,就切入到JVM的記憶體模型中.

併發模型(執行緒)

如果Java沒有多執行緒的支援,沒有JIT的存在,那麼也不會有現在JVM記憶體模型.為什麼這麼說呢?首先我們從JIT說起,JIT會追蹤程式的執行過程,並對其中可能的地方進行優化,其中有一項優化和處理器的亂序執行類似,不過這裡叫做指令重排.如果沒有多執行緒,也就不會存在所謂的臨界資源,如果這個前置條件不存在當然也就不會存在資源競爭這一說法了.這樣一來,可能Java早已經被拋棄在歷史的長河中.

儘管Java語言不像C語言能夠直接操作記憶體,但是掌握JVM記憶體模型仍然非常重要.對於為什麼要掌握JVM記憶體模型得先從Java的併發程式設計模型說起.

在併發模型中需要處理兩個關鍵問題:執行緒之間如何通訊以及執行緒之間如何同步.所謂的通訊指的是執行緒之間如何交換訊息,而同步則用於控制不同執行緒之間操作發生的相對順序.

從實現的角度來說,併發模型一般有兩種方式:基於共享記憶體和基於訊息傳遞.兩者實現的不同決定了通訊和同步的行為的差異.在基於共享記憶體的併發模型中,同步是顯示的,通訊是隱式的;而在基於訊息傳遞的併發模型中,通訊是顯式的,同步是隱式的.我們來具體解釋一下.

在共享記憶體的併發模型中,任何執行緒都可以公共記憶體進行操作,如果不加以顯示同步,那麼執行順序將是不可知的,也恰是因為哪個執行緒都可以對公共記憶體操作,所以通訊是隱式的.而在基於訊息傳遞的併發模型中,由於訊息的傳送一定是在接受之前,因此同步是隱式的,但是執行緒之間必須通過明確的傳送訊息來進行通訊.

在最終併發模型選擇方案上,java選擇基於共享記憶體的併發模型,也就是顯式同步,隱式通訊.如果在編寫程式時,不處理好這兩個問題,那在多執行緒會出現各種奇怪的問題.因此,對任何Java程式設計師來說,熟悉JVM的記憶體模型是非常重要的.

JVM記憶體結構

對於JVM記憶體,主要包含兩方面:JVM記憶體結構和JVM記憶體模型.兩者之間的區別在於模型是一種協議,規定對特定記憶體或快取的讀寫過程,千萬不要弄混了.

很多人往往對JVM記憶體結構和程式的記憶體結構感到困惑,這裡我將幫助你梳理一下.

JVM本質上也是一個程式,只不過它又有著類似作業系統的特性.當一個JVM例項開始執行時,此時在Linux程式中,其記憶體佈局如下:

這裡寫圖片描述

JVM在程式堆空間的基礎上再次進行劃分,來簡單看一下.此時的永生代本質上就是Java程式程式的程式碼區和資料區,而年輕代和老年代才是Java程式真正使用的堆區,也就是我們經常掛在嘴邊的.但是此時的堆區和程式上的堆卻又很大的區別:在呼叫C程式的malloc函式時,會引起一次系統級的呼叫;在使用free函式釋放記憶體時,同樣也會引起一次系統級的呼叫,但是JVM中堆區並非如此:JVM一次性向系統申請一塊連續的記憶體區域,作為Java程式的堆,當Java程式使用new申請記憶體時,JVM會根據需要在這段記憶體區域中為其分配,而不需要除非一次系統級別的呼叫.可以看出JVM其實自行實現了一條堆記憶體的管理機制,這種管理方式有以下好處:

  1. 減少系統級別的呼叫.大部分記憶體申請和回首不需要觸發系統函式,僅僅只在Java堆大小發生變化時才會引起系統函式的呼叫.相比系統級別的呼叫,JVM實現記憶體管理成本更低.
  2. 減少記憶體洩漏情況的發生.通過JVM接管記憶體管理過程,可以避免大多情況下的記憶體洩漏問題.

現在已經簡單介紹了JVM記憶體結構,希望這樣能幫助你打通上下.當然,為了好理解,我省略了其中一些相對不重要的點,如有興趣可以自行學習.講完了JVM記憶體結構,下一步該是什麼呢?

JVM記憶體模型

Java採用的是基於共享記憶體的併發模型,使得JVM看起來非常類似現代多核處理器:在基於共享記憶體的多核處理器體系架構中,每個處理器都有自己的快取,並且定期與主記憶體進行協調.這裡的執行緒同樣有自己的快取(也叫工作記憶體),此時,JVM記憶體模型呈現出如下結構:

這裡寫圖片描述

上圖展示JVM的記憶體模型,也稱之為JMM.對於JMM有以下規定:

  • 1. 所有的變數都儲存在主記憶體(Main Memory)
  • 2. 每個執行緒也有用自己的工作記憶體(Work Memory)
  • 3. 工作記憶體中的變數是主記憶體變數的拷貝,執行緒不能直接讀寫主記憶體的變數,而只能操作自己工作記憶體中的變數
  • 4. 執行緒間不共享工作記憶體,如果執行緒間需要通訊必須藉助主記憶體來完成

共享變數所在的記憶體區域也就是共享記憶體,也稱之為堆記憶體,該區域中的變數都可能被共享,即被多執行緒訪問.說的再通俗點就是在java當中,堆記憶體是線上程間共享的,而區域性變數,形參和異常程式引數不在堆記憶體,因此就不存在多執行緒共享的情況.

與JMM規定相對應,我們定義了以下四個原子性操作來實現變數從主記憶體拷貝到工作記憶體的過程:

  1. read:讀取主記憶體的變數,並將其傳送到工作記憶體
  2. load:把read操作從主記憶體得到的變數值放入到工作記憶體的拷貝中
  3. store:把工作記憶體中的一個變數值傳送到主記憶體當中,以便用於後面的write操作
  4. write:把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中.

可以看出,從主記憶體到工作記憶體的過程其實是要經過read和load兩個操作的,反之需要經過store和write兩個操作.

現在我們來看一段程式碼,並用結合上文談談下多執行緒安全問題:

public class ThreadTest {

    public static void main(String[] args) throws InterruptedException {
        ShareVar ins = new ShareVar();
        List<Thread> threadList = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            Thread thread;
            if (i % 2 == 0) {
                thread = new Thread(new AddThread(ins));

            } else {
                thread = new Thread(new SubThread(ins));

            }

            thread.start();
            threadList.add(thread);

        }

        for (Thread thread : threadList) {
            thread.join();
        }

        System.out.println(Thread.currentThread().getId() + "   " + ins.getCount());

    }

}

class ShareVar {
    private int count;

    public void add() {
        try {
            Thread.sleep(100);//此處為了更好的體現多執行緒安全問題
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;

    }

    public void sub() {
        count--;
    }

    public int getCount() {
        return count;
    }
}

class AddThread implements Runnable {
    private ShareVar shareVar;

    public AddThread(ShareVar shareVar) {
        this.shareVar = shareVar;
    }

    @Override
    public void run() {
        shareVar.add();
    }
}

class SubThread implements Runnable {
    private ShareVar shareVar;

    public SubThread(ShareVar shareVar) {
        this.shareVar = shareVar;
    }

    @Override
    public void run() {
        shareVar.sub();
    }
}

理想情況下,最後應該輸出0,但是多次執行你會先可能輸出-1或者-2等.為什麼呢?

在建立的這10個執行緒中,每個執行緒都有自己工作記憶體,而這些執行緒又共享了ShareVar物件的count變數,當執行緒啟動時,會經過read-load操作從主記憶體中拷貝該變數至自己的工作記憶體中,隨後每個執行緒會在自己的工作記憶體中操作該變數副本,最後會將該副本重新寫會到主記憶體,替換原先變數的值.但在多個執行緒中,但由於執行緒間無法直接通訊,這就導致變數的變化不能及時的反應線上程當中,這種細微的時間差最終導致每個執行緒當前操作的變數值未必是最新的,這就是所謂的記憶體不可見性.

現在我想你已經完全明白了多執行緒安全問題的由來.那該怎麼解決呢?最簡單的方法就是讓多個執行緒對共享物件的讀寫操作程式設計序列,也就是同一時刻只允許一個執行緒對共享物件進行操作.我們將這種機制成為鎖機制,java中規定每個物件都有一把鎖,稱之為監視器(monitor),有人也叫作物件鎖,同一時刻,該物件鎖只能服務一個執行緒.

有了鎖物件之後,它是怎麼生效的呢?為此JMM中又定義了兩個原子操作:

  1. lock:將主記憶體的變數標識為一條執行緒獨佔狀態
  2. unlock:解除主記憶體中變數的執行緒獨佔狀態

在鎖物件和這兩個原子操作共同作用下而成的鎖機制就可以實現同步了,體現在語言層面就是synchronized關鍵字.上面我們也說道Java採用的是基於共享記憶體的併發模型,該模型典型的特徵是要顯式同步,也就是說在要人為的使用synchronized關鍵字來做同步.現在我們來改進上面的程式碼,只需要為add()和sub()方法新增syhcronized關鍵字即可,但在這之前,先來看看這兩個方法對應的位元組碼檔案:

  public void add();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=1
         0: ldc2_w        #2                  // long 100l
         3: invokestatic  #4                  // Method java/lang/Thread.sleep:(J)V
         6: goto          14
         9: astore_1
        10: aload_1
        11: invokevirtual #6                  // Method java/lang/InterruptedException.printStackTrace:()V
        14: aload_0
        15: dup
        16: getfield      #7                  // Field count:I
        19: iconst_1
        20: iadd
        21: putfield      #7                  // Field count:I
        24: return

  public void sub();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #7                  // Field count:I
         5: iconst_1
         6: isub
         7: putfield      #7                  // Field count:I
        10: return
      LineNumberTable:
        line 18: 0
        line 19: 10

現在我們使用synchronized來讓著兩個方法變得安全起來:

class ShareVar {
    private int count;

    public synchronized void add() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;

    }

    public synchronized void sub() {
        count--;
    }

    public int getCount() {
        return count;
    }
}

此時這段程式碼在多執行緒中就會表現良好.再來看看它的位元組碼檔案發生了什麼變化:

  public synchronized void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=2, args_size=1
         0: ldc2_w        #2                  // long 100l
         3: invokestatic  #4                  // Method java/lang/Thread.sleep:(J)V
         6: goto          14
         9: astore_1
        10: aload_1
        11: invokevirtual #6                  // Method java/lang/InterruptedException.printStackTrace:()V
        14: aload_0
        15: dup
        16: getfield      #7                  // Field count:I
        19: iconst_1
        20: iadd
        21: putfield      #7                  // Field count:I
        24: return

  public synchronized void sub();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #7                  // Field count:I
         5: iconst_1
         6: isub
         7: putfield      #7                  // Field count:I
        10: return
      LineNumberTable:
        line 18: 0
        line 19: 10

通過位元組碼不難看出最大的變化在於方法的flags中增加了ACC_SYNCHRONIZED標識,虛擬機器在遇到該標識時,會隱式的為方法新增monitorenter和monitorexit指令,這兩個指令就是在JMM的lock和unlock操作上實現的.

其中monitorenter指令會獲取物件的佔有權,此時有以下三種可能:

  1. 如果該物件的monitor的值0,則該執行緒進入該monitor,並將其值標為1,表明物件被該執行緒獨佔.
  2. 同一個執行緒,如果之前已經佔有該物件了,當再次進入時,需將該物件的monitor的值加1.
  3. 如果該物件的monitor值不為0,表明該物件被其他執行緒獨佔了,此時該執行緒進入阻塞狀態,等到該物件的monitor的值為0時,在嘗試獲取該物件.

而monitorexit的指令則是已佔有該物件的執行緒在離開時,將monitor的值減1,表明該執行緒已經不再獨佔該物件.

用synchronized修飾的方法叫做同步方法,除了這種方式之外,還可以使用同步程式碼塊的形式:

package com.cd.app;

class ShareVar {
    private int count;

    public void add() {
        synchronized (this) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count++;
        }

    }

    public void sub() {
        synchronized (this) {
            count--;
        }

    }

    public int getCount() {
        return count;
    }
}

接下來同樣是看一下他的位元組碼,主要看add()和sub()方法:

 public void add();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field count:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field count:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return

public void sub();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field count:I
         9: iconst_1
        10: isub
        11: putfield      #2                  // Field count:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return

同步程式碼塊和同步方法的實現原理是一致的,都是通過monitorenter/monitorexit指令,唯一的區別在於同步程式碼塊中monitorenter/monitorexit是顯式的載入位元組碼檔案當中的.

上面我們通過synchronized解決了記憶體可見性問題,另外也可以認為凡是被synchronized修飾的方法或程式碼塊都是原子性的,即一個變數從主記憶體到工作記憶體,再從工作記憶體到主記憶體這個過程是不可分割的.

正如我們在談亂序執行和記憶體屏障所提到的,javac編譯器和JVM為了提高效能會通過指令重排的方式來企圖提高效能,但是在某些情況下我們同樣需要阻止這過程,由於synchronized關鍵字保證了持有同一個鎖的的兩個同步方法/同步塊只能序列進入,因此無形之中也就相當阻止了指令重排.

總結

希望這麼從下往上,再從上往下的解釋能讓各位同學對JVM記憶體模型以及多執行緒安全問題有個更通透的理解.

相關文章