Java 併發編程 ④ - Java 內存模型

原文地址: Java 併發編程 ④ - Java 內存模型

轉載請註明出處!java

往期文章:編程

前言

Java內存模型(Java Memory Model ,JMM)就是一種符合內存模型規範的,屏蔽了各類硬件和操做系統的訪問差別的,保證了Java程序在各類平臺下對內存的訪問都能獲得一致效果的機制及規範數組

JMM與Java內存區域是兩個容易混淆的概念,這二者既有差異又有聯繫:緩存

  • 區別

二者是不一樣的概念層次Java 內存模型是抽象的,他是用來描述一組規則,經過這個規則來控制各個變量的訪問方式,圍繞原子性、有序性、可見性等展開的。而Java運行時內存的劃分是具體的,是JVM運行Java程序時,必要的內存劃分。多線程

  • 聯繫

都存在私有數據區域和共享數據區域。通常來講,JMM中的主內存屬於共享數據區域,他是包含了堆和方法區;一樣,JMM中的本地內存屬於私有數據區域,包含了程序計數器、本地方法棧、虛擬機棧。併發

在學習Java 內存模型時,咱們常常會提到3個特性:函數

  • 可見性 - Visibility
  • 原子性 - Atomicity
  • 有序性 - Ordering

Java內存模型就是圍繞着在併發過程當中如何處理這3個特性來創建的。本文也會按照這三個特性講述。性能

1、Java 共享變量的內存可見性問題

在討論以前,須要先重溫一下,JVM運行時內存區域:學習

線程私有變量不會在線程之間共享,也就不會有內存可見性的問題,也不受內存模型的影響。而在堆中的變量是共享的,這一塊的數據也稱爲共享變量,內存可見性問題針對的就是共享變量。優化


好了,弄清楚問題的主體以後,咱們再來思考一個問題。

爲何堆上的變量會存在內存可見性的問題呢?

JMM對硬件層面緩存訪問的抽象

其實,這就要涉及到計算機硬件的緩存訪問操做了。

現代計算機中,處理器上的寄存器的讀寫的速度比內存快幾個數量級,爲了解決這種速度矛盾,在它們之間加入了高速緩存。

Java的內存訪問操做與上述的硬件緩存具備很高的可比性:

Java內存模型中,規定了:

  • 全部的變量都存儲在主內存中。
  • 每一個線程還有本身的工做內存,存儲了該線程以讀、寫共享變量的副本。
  • 本地內存(或者叫工做內存)是Java內存模型的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩衝區、寄存器等。
  • 線程只能直接操做工做內存中的變量,不一樣線程之間的變量值傳遞須要經過主內存來完成。

從抽象的角度來講,JMM定義了線程和主內存之間的抽象關係

按照上述對於JMM的描述,當一個線程操做共享變量時,它首先從主內存複製共享變量到本身的工做內存,而後對工做內存裏的變量進行處理,處理完後將變量值更新到主內存。

Cache(工做內存)的存在就會帶來共享變量的內存不可見的問題(也能夠叫作緩存一致性問題),具體能夠看下面的例子:

  • 假設如今主內存中有共享變量X=0;
  • 線程A首先獲取共享變量X的值,因爲Cache中沒有命中,因此去加載主內存中變量X的值,把X=0的值緩存到工做內存中,線程A執行了修改操做X++,而後將其寫入工做內存中,而且刷新到主內存中。
Thread-A工做內存中 X=1
  主內存中           X=1
  • 線程B開始獲取共享變量,因爲Cache沒有命中,因此去加載主內存中變量X的值,把X=1的值緩存到工做內存中。而後線程B執行了修改操做X++,而後將其寫入工做內存中,而且刷新到主內存中。
Thread-B工做內存中 X=2
  Thread-A工做內存中 X=1
  主內存中           X=2

明明線程B已經把X的值修改成了2,爲什麼線程A獲取的仍是1呢?這就是共享變量的內存不可見問題,也就是線程B寫入的值對線程A不可見。

如何保證內存的可見性

那麼如何保證內存的可見性,主要有三種實現方式:

  • volatile 關鍵字

    該關鍵字能夠確保對一個變量的更新對其餘線程立刻可見。當一個變量被聲明爲volatile時,線程在寫入變量時不會把值緩存在寄存器或者其餘地方,而是會把值刷新回主內存

  • sychronized 關鍵字

    一個線程在獲取到監視器鎖之後才能進入 synchronized 控制的代碼塊,一旦進入代碼塊,首先,該線程對於共享變量的緩存就會失效,所以 synchronized 代碼塊中對於共享變量的讀取須要從主內存中從新獲取,也就能獲取到最新的值

    退出代碼塊的時候,會將該線程寫緩衝區中的數據刷到主內存中,因此在 synchronized 代碼塊以前或 synchronized 代碼塊中對於共享變量的操做隨着該線程退出 synchronized 塊,會當即對其餘線程可見(固然前提是線程會去主內存讀取最新值)。

  • final 關鍵字

    在對象的構造方法中設置 final 屬性,同時在對象初始化完成前,不要將此對象的引用寫入到其餘線程能夠訪問到的地方(不要讓引用在構造函數中逸出)。若是這個條件知足,當其餘線程看到這個對象的時候,那個線程始終能夠看到正確初始化後的對象的 final 屬性。(final 字段所引用的對象裏的字段或數組元素可能在後續還會變化,若沒有正確同步,其它線程也許不能看到最新改變的值,但必定能夠看到徹底初始化的對象或數組被 final 字段引用的那個時刻的對象字段值或數組元素。)

    final 的場景比較偏,通常就是前面兩種方式

    延伸連接:JSR-133:JavaTM 內存模型與線程規範

volatile 和 sychronized 是我認爲比較重要的內容,會有單獨的章節來說。

2、原子性

JMM 內存交互操做

Java 內存模型定義了 8 個操做來完成主內存和工做內存的交互操做

  • read:把一個變量的值從主內存傳輸到線程的工做內存中
  • load:在 read 以後執行,把 read 獲得的值放入線程的工做內存的變量副本中
  • use:把線程的工做內存中一個變量的值傳遞給執行引擎
  • assign:把一個從執行引擎接收到的值賦給工做內存的變量
  • store:把工做內存的一個變量的值傳送到主內存中
  • write:在 store 以後執行,把 store 獲得的值放入主內存的變量中
  • lock:做用於主內存的變量,把一個變量標識成一條線程獨佔的狀態
  • unlock: 做用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定。
JMM關於內存交互的定義規則很是的嚴謹和繁瑣,爲了方便理解,Java設計團隊將Java內存模型的操做簡化爲read、write、lock和unlock四種,但這只是語言描述上的等價化簡,Java內存模型的基礎設計並未改變。

JMM 對於原子性的規定

所謂原子性操做,是指執行一系列操做時,這些操做要麼所有執行,要麼所有不執行,不存在只執行其中一部分的狀況。

Java 內存模型保證了 readloaduseassignstorewritelockunlock 操做具備原子性,例如對一個 int 類型的變量執行 assign 賦值操做,這個操做就是原子性的。可是 Java 內存模型容許虛擬機將沒有被 volatile 修飾的 64 位數據(longdouble)的讀寫操做劃分爲兩次 32 位的操做來進行,也就是說基本數據類型的訪問讀寫是原子性的,除了longdouble是非原子性的,loadstoreread write 操做能夠不具有原子性。 在《深刻理解Java 虛擬機》書中提醒咱們只須要知道有這麼一回事,真的要用到這個知識點的場景十分罕見。

共享變量的原子性問題

這裏放一個很經典的例子,併發條件下的計數器自增。

/**
 * 內存模型三大特性 - 原子性驗證對比
 *
 * @author Richard_yyf
 */
public class AtomicExample {

    private static AtomicInteger atomicCount = new AtomicInteger();

    private static int count = 0;

    private static void add() {
        atomicCount.incrementAndGet();
        count++;
    }

    public static void main(String[] args) {
        final int threadSize = 1000;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executor.execute(() -> {
                add();
                countDownLatch.countDown();
            });
        }
        System.out.println("atomicCount: " + atomicCount);
        System.out.println("count: " + count);

        ThreadPoolUtil.tryReleasePool(executor);
    }
}

輸出結果:

atomicCount: 1000
count: 997

能夠看到,雖然有1000個線程執行了count++操做,最終獲得的結果卻不是預期的1000。

至於緣由呢,就是由於count++這行代碼,並非一個原子性操做。能夠藉助下圖幫助理解。

count++這個簡單的操做根據上面的原理分析,能夠知道內存操做實際分爲讀寫存三步;由於讀寫存這個總體的操做,不具有原子性,count被兩個或多個線程讀入了一樣的舊值,讀到線程內存當中,再進行寫操做,再存回去,那麼就可能出現主內存被重複set同一個值的狀況,如上圖所示,兩個線程進行了count++,實際上只進行了一次有效操做。

如何保證原子性

想要保證原子性,能夠嘗試如下幾種方式:

  • CAS:使用基於CAS實現的原子操做類(例如AtomicInteger)
  • synchronized 關鍵字:可使用synchronized 來保證限定臨界區內操做的原子性。它對應的內存間交互操做爲:lock 和 unlock,在虛擬機實現上對應的字節碼指令爲 monitorenter 和 monitorexit
前者是樂觀鎖(讀多寫少場景),後者是悲觀鎖(讀少寫多場景)

3、有序性

重排序

計算機在執行程序時,爲了提升性能,編譯器和處理器會對指令作重排。

重排序由如下幾種機制引發:

  • 編譯器優化重排

    編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。

  • 指令並行重排

    現代處理器採用了指令級並行技術來將多條指令重疊執行。若是不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器能夠改變語句對應的機器指令的執行順序。

  • 內存系統重排

    因爲處理器使用緩存和讀寫緩存衝區,這使得加載(load)和存儲(store)操做看上去多是在亂序執行,由於三級緩存的存在,致使內存與緩存的數據同步存在時間差。

如何保證有序性

Java內存模型容許編譯器和處理器對指令重排序以提升運行性能,而且只會對不存在數據依賴性的指令重排序。意思就是說,在Java內存模型的規定下,對編譯器和處理器來講,只要不改變程序的執行結果(單線程程序和正確同步了的多線程程序),編譯器和處理器怎麼優化都行。 在單線程下,能夠保證重排序優化以後最終執行的結果與程序順序執行的結果一致(咱們常說的as-if-serial語義),可是在多線程下就會存在問題。

重排序在多線程下會致使非預期的程序執行結果,想要保證可見性,能夠考慮如下實現方式:

  • volatile

    volatile產生內存屏障,禁止指令重排序

  • synchronized

    保證每一個時刻只有一個線程進入同步代碼塊,至關因而讓線程順序執行同步代碼。

小結

Java內存模型的一系列運行規則看起來有點繁瑣,但總結起來,是圍繞原子性、可見性、有序性特徵創建。歸根究底,是爲實現共享變量的在多個線程的工做內存的數據一致性,是的在多線程併發、指令重排序優化的環境中程序能如預期運行。

本文介紹了Java內存模型,以及其圍繞的有序性、內存可見性以及原子性相關的知識。不得不說,關於Java內存模型,真的要深究估計能夠寫出一本小書,有興趣的讀者能夠參閱其餘資料作更深的瞭解。

上文中提到的valotilesynchronized,是比較重要的內容,會有單獨的章節。

參考

  • 《Java 併發編程之美》
  • 《深刻理解Java虛擬機》
  • JSR133中文
相關文章
相關標籤/搜索