JVM(複習)java內存模型

JVM(複習)java內存模型


JVM(複習)java內存模型

java內存模型即JMM(java memory model)目的是爲了解決java多線程環境下的對共享數據的讀寫一致性問題,通過happens-before語義定義了java程序對數據的訪問規則,修正了由於讀寫衝突導致的cache數據不一致的問題,是一種邏輯抽象,並沒有真正的內存實體

1,併發編程中兩個關鍵問題

在多線程編程中解決的兩個最常見的問題:

  • 多線程之間如何操作同一變量
  • 多線程中如何處理同步問題

Java的併發採用的是共享內存模型。線程之間通過共享程序的公共狀態,通過寫-讀內存中的公共狀態來進行隱式通信。

在java中,共享變量是存儲在主內存中,即主內存是共享內存區域,所有線程都可以訪問,但是線程對共享變量的操作(讀取賦值。。。)都不能再主內存中直接操作,而是首先將共享變量拷貝一份回到每個線程私有的工作內存中,然後在進行相關操作,最後將操作完的變量值寫入到主內存中。線程只能訪問到主內存中的共享變量,不能訪問到每個線程私有的工作內存中的共享變量

img

而JMM就是決定一個線程對共享變量的寫入何時對另一個線程可見。

img

理解Java內存模型,是理解線程安全問題的基礎。知道JMM有主內存和工作內存之分之後,我們就很容易的理解多個線程操作同個共享變量可能引發的數據不一致的問題。假設有如下代碼:

    private static int a = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                a = a+1;
            }).start();
        }
        Thread.sleep(3000);
        System.out.println(a);
    }

這裏有一萬個線程去操作共享數據a,如果不存在併發問題的話,“預期的結果應該是10000”在這裏插入圖片描述

在運行一次

在這裏插入圖片描述

實際結果是沒法預測的,這跟主內存和工作內存有聯繫,假設a = 0,同時存在兩個線程對a進行++操作,則此時兩個線程的工作內存中都是1,在寫入到主內存時,主內存由0變成1,但是正確結果應該是2纔對

但是我對a變量加上volatile修飾後,結果就正確了:

    private static volatile int a = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                a = a+1;
            }).start();
        }
        Thread.sleep(1000);
        System.out.println(a);
    }

在這裏插入圖片描述

  • 當寫一個volatile變量時,JMM會把線程對應的工作內存中該共享變量值刷新回主內存,即在工作內存中操作完volatile修飾的共享變量後馬上將新的值刷新回主內存
  • 而在讀一個volatile變量時,JMM會把該線程對應的工作內存中的共享變量置爲無效,要從主內存中讀取該共享變量,所以對於一個volatile變量的讀,總能看到任意線程對這個變量最後的寫入

這就涉及到我們剛剛提到的JMM,JMM保證了一個線程對共享變量的寫入何時對另一個線程可見,通過控制主內存與每個線程的本地內存之間的交互,來爲提供內存可見性保證

2,可見性,原子性和有序性

2.1可見性

可見性指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值來實現可見性的

JMM中主要有三種操作實現可見性:

  • volatile:在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值
  • synchronize:對一個變量執行 unlock 操作之前,必須把變量值同步回主內存
  • final:被 final 關鍵字修飾的字段在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它線程通過 this 引用訪問到初始化了一半的對象),那麼其它線程就能看見 final 字段的值。

在JMM內部可見性的實現是通過:

  • 依賴於內存屏障
  • 禁止某些重排序的方式
  • happens-before規則

關於這三個概念可以看第3點,其實實現可見性就是通過內存屏障或者happens-before去禁止某些類型的重排序

2.2原子性

**原子性指的是一個操作是不可中斷的,即使是在多線程環境下,一個操作一旦開始就不會被其他線程影響。比如對於一個靜態變量int x,兩條線程同時對他賦值,線程A賦值爲1,而線程B賦值爲2,不管線程如何運行,最終x的值要麼是1,要麼是2,線程A和線程B間的操作是沒有干擾的,這就是原子性操作,不可被中斷的特點。**有點要注意的是,對於32位系統的來說,long類型數據和double類型數據(對於基本數據類型,byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫並非原子性的,也就是說如果存在兩條線程同時對long類型或者double類型的數據進行讀寫是存在相互干擾的,因爲對於32位虛擬機來說,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會導致一個線程在寫時,操作完前32位的原子操作後,輪到B線程讀取時,恰好只讀取到了後32位的數據,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它可能是“半個變量”的數值,即64位數據被兩個線程分成了兩次讀取。但也不必太擔心,因爲讀取到“半個變量”的情況比較少見,至少在目前的商用的虛擬機中,幾乎都把64位的數據的讀寫操作作爲原子操作來執行,因此對於這個問題不必太在意,知道這麼回事即可。

2.3有序性

有序性是指對於單線程的執行代碼,我們總是認爲代碼的執行是按順序依次執行的,這樣的理解並沒有毛病,畢竟對於單線程而言確實如此,但對於多線程環境,則可能出現亂序現象,因爲程序編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一致,要明白的是,在Java程序中,倘若在本線程內,所有操作都視爲有序行爲,如果是多線程環境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程內保證串行語義執行的一致性,後半句則指指令重排現象和工作內存與主內存同步延遲現象。

3.內存屏障,指令重排和happens-before

上文中我們提到,JMM實現可見性是通過內存屏障,禁止某些指令重排序,現在來具體看看這三個概念

3.1內存屏障

JMM定義了8個操作來完成主內存和工作內存的交互操作

img

  • read:把一個變量的值從主內存傳輸到工作內存中

  • load:在 read 之後執行,把 read 得到的值放入工作內存的變量副本中

  • use:把工作內存中一個變量的值傳遞給執行引擎

  • assign:把一個從執行引擎接收到的值賦給工作內存的變量

  • store:把工作內存的一個變量的值傳送到主內存中

  • write:在 store 之後執行,把 store 得到的值放入主內存的變量中

  • lock:作用於主內存的變量

  • unlock

所謂內存屏障其實就是一組處理器指令(上面的8個指令),用於實現對內存操作的順序限制,JMM 爲了保證在不同的編譯器和 CPU 上有相同的結果,通過插入特定類型的內存屏障來禁止特定類型的編譯器重排序和處理器重排序,插入一條內存屏障會告訴編譯器和 CPU:不管什麼指令都不能和這條 Memory Barrier 指令重排序。

一共有四種內存屏障:

  • LoadLoad
  • LoadStore
  • StoreLoad
  • StoreStore

在java編譯器生成指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序,讓程序按我們預想的流程去執行

img

3.2指令重排序

指令重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段

as-if-serial:

不管怎麼重排序,單線程程序執行結果不能被改變

  • 編譯器優化的重排序。編譯器在不改變單線程程序語義(as-if-serial )的前提下,可以重新安排語句的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction Level Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對機器指令的執行順序。
  • 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。 從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序

img

JMM對程序採取了不同的策略:

  • 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序
  • 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求(即允許這種重排序)
    • 在單線程程序中,對於存在控制依賴的操作重排序,不會改變執行結果
    • 但是在多線程程序中,對於存在控制依賴的操作重排序,可能會改變程序執行結果

所以:對於重排序可能會導致的多線程程序出現的內存可見性問題

  • 對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序
  • 對於處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序

3.3happens-before

JSR-133使用happens-before的概念來闡述操作之間的內存可見性,在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係(無論是否在一個線程之內)

  • **程序順序規則:**一個線程中的每個操作,happens-before於該線程中的任意後續操作
  • **監視器鎖規則:**對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖
  • **volatile變量規則:**對一個volatile變量的寫,happens-before於任意後續對這個volatile變量的讀
  • **傳遞性:**如果A happens-before B,B happens-before C那麼A happens-before C
  • **線程啓動法則:**在一個線程裏,對 Thread.start 的調用會 happens-before 於每一個啓動線程中的動作。
  • **線程終止法則:**線程中的任何動作都 happens-before 於其他線程檢測到這個線程已終結,或者從 Thread.join 方法調用中成功返回,或者 Thread.isAlive 方法返回false。
  • **中斷法則法則:**一個線程調用另一個線程的 interrupt 方法 happens-before 於被中斷線程發現中斷(通過拋出InterruptedException, 或者調用 isInterrupted 方法和 interrupted 方法)。
  • **終結法則:**一個對象的構造函數的結束 happens-before 於這個對象 finalizer 開始。

當一個變量被多個線程讀取且被至少一個線程寫入時,如果讀操作和寫操作之前沒有實現happens-before排序,則會產生數據競爭問題,產生錯誤的結果

happens-before和JMM關係圖:

img

呈現給程序員看到的只有剛剛提到的規則,底層則是由JMM幫我們去實現了對於某種類型的重排序的禁止

4.volatile內存語義

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存

  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效,該線程需要從主內存中讀取該共享變量的值

4.1 volatile特性

  1. 實現可見性:保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了共享變量的值能保證這個新值對其他線程是立馬可見的
  2. **實現有序性:**禁止進行指令重排序
  3. volatile只能保證單條讀或者寫命令的原子性,複合操作(i++)不能保證原子性

4.2volatile如何禁止指令重排序

volatile變量的可見性是通過內存屏障實現的,在java編譯器生成的指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序,讓程序按我們預想的流程去執行

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序,確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序,確保volatile讀之後的操作不會被編譯器重排序到讀之前
  • 當第一個是volatile寫,第二個是volatile讀時,都不能進行重排序

所以,需要通過在指令序列中插入內存屏障來保證執行順序

volatile插入內存屏障後生成的指令序列如圖:

在這裏插入圖片描述

5.鎖的內存語義

  • 當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中
  • 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使得被監視器保護的臨界區代碼必須從主內存讀取共享變量
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章