淺談Java的記憶體模型以及交互

来源:https://www.cnblogs.com/zhangweicheng/archive/2019/10/09/11638841.html
-Advertisement-
Play Games

本文的記憶體模型只寫虛擬機記憶體模型,物理機的不予描述。 Java記憶體模型 在Java中,虛擬機將運行時區域分成6中,如下圖: 由於類和方法的信息難以確定,不好設定大小,太大則影響年老代,太小容易記憶體溢出。 GC不好處理,回收效率低下,調優困難。 在上面的6種類型中,前三種是線程私有的,也就是說裡面存放 ...


本文的記憶體模型只寫虛擬機記憶體模型,物理機的不予描述。

 

Java記憶體模型

  在Java中,虛擬機將運行時區域分成6種,如下圖:

                

  1.  程式計數器:用來記錄當前線程執行到哪一步操作。在多線程輪換的模式中,噹噹前線程時間片用完的時候記錄當前操作到哪一步,重新獲得時間片時根據此記錄來恢復之前的操作。
  2. 虛擬機棧:這就是我們平時所說的棧了,一般用來儲存局部變數表、操作數表、動態鏈接等。
  3. 本地方法棧:這是另一個棧,用來提供虛擬機中用到的本地服務,像線程中的start方法,JUC包里經常使用的CAS等方法都是從這來的。
  4. 堆:主要的儲存區域,平時所創建的對象都是放在這個區域。其內部還分為新生代、老年代和永久代(也就是方法區,在Java8之後刪除了),新生代又分為兩塊Survivor和一塊Eden,平時創建的對象其實都是在Eden區創建的,不過這些之後再跟垃圾回收器寫在一篇文章。
  5. 方法區:儲存符號引用、被JVM載入的類信息、靜態變數的地方。在Java8之後方法區被移除,使用元空間來存放類信息,常量池和其他東西被移到堆中(其實在7的時候常量池和靜態變數就已經被移到堆中),不再有永久代一說。刪除的原因大致如下:
    1. 容易造成記憶體溢出或記憶體泄漏,例如 web開發中JSP頁面較多的情況。
    2. 由於類和方法的信息難以確定,不好設定大小,太大則影響年老代,太小容易記憶體溢出。

    3. GC不好處理,回收效率低下,調優困難。

  6. 常量池:存放final修飾的成員變數、直接定義的字元串(如 Sring s = "test";這種)還有6種數據類型包裝類型從-128~127對應的對象(這也解釋了我們new兩個在這區間的包裝類型對象時,為什麼他們是一樣的,布爾類型存放的是true和false兩種,浮點類型Double和Float因為精度問題不存入其中)等

 在上面的6種類型中,前三種是線程私有的,也就是說裡面存放的值其他線程是看不到的,而後面三種(真正意義上講只有堆一種)是線程之間共用的,這裡面的變數對於各個線程都是可見的。如下圖所示,前三種存放線上程記憶體中,大家都是相互獨立的,而主記憶體可以理解為堆記憶體(實際上只是堆記憶體中的對象實例數據部分,其他例如對象頭和對象的填充數據並不算入在內),為線程之間共用:

                      

Java記憶體之間的變數交互

  這裡的變數指的是可以放在堆中的變數,其他例如局部變數、方法參數這些並不算入在內。線程記憶體跟主記憶體變數之間的交互是非常重要的,Java虛擬機把這些交互規範為以下8種操作,每一種都是原子性的(非volatile修飾的Double和Long除外)操作。

  1. Lock(鎖)操作:操作對象為線程,作用對象為主記憶體的變數,當一個變數被鎖住的時候,其他線程只有等當前線程解鎖之後才能使用,其他線程不能對該變數進行解鎖操作。
  2. Unlock(解鎖)操作:同上,線程操作,作用於主記憶體變數,令一個被鎖住的變數解鎖,使得其他線程可以對此變數進行操作,不能對未鎖住的變數進行解鎖操作。
  3. Read(讀):線程從主記憶體讀取變數值,load操作根據此讀取的變數值為線程記憶體中的變數副本賦值。
  4. Load(載入):將Read讀取到的變數值賦到線程記憶體的副本中,供線程使用。
  5. Use(使用):讀取線程記憶體的作用值,用來執行我們定義的操作。
  6. Assign(賦值):線上程操作中變數的值進行了改變,使用此操作刷新線程記憶體的值。
  7. Store(儲存):將當前線程記憶體的變數值同步到主記憶體中,與write操作一起作用。
  8. Write(寫):將線程記憶體中store的值寫入到主記憶體中,主記憶體中的變數值進行變更。

  可能有同學會不理解read和load、store和write的區別,覺得這兩對的操作類似,可以這樣理解:一個是申請操作,另一個是審核通過(允許賦值)。例如:線程記憶體A向主記憶體提交了變更變數的申請(store操作),主記憶體通過之後修改變數的值(write操作)。可以通過下麵的圖來理解:

參照《深入理解Java虛擬機》

 

 

 

  對於普通的變數來說(非volatile修飾的變數),虛擬機要求read、load有相對順序即可,例如從主記憶體讀取i、j兩個變數,可能的操作是read i->read j->load j-> load i,並不一定是連續的。此外虛擬機還為這8種操作定製了操作的規則:

  • (read,load)、(store,write)不允許出現單獨的操作。也就是說這兩種操作一定是以組的形式出現的,有read就有load,有store就有write,不能讀取了變數值而不載入到線程記憶體中,也不能儲存了變數值而不寫到主記憶體中。
  • 不允許線程放棄最近的assign操作。也就是說當線程使用assign操作對私有記憶體的變數副本進行了變更的時候,其必須使用write操作將其同步到主記憶體當中去。
  • 不允許一個線程無原因地(沒有進行assign操作)將私有記憶體的變數同步到主記憶體中。
  • 變數必須從主記憶體產生,即不允許在私有記憶體中使用未初始化(未進行load或者assgin操作)的變數。也就是說,在use之前必須保證執行了load操作,在store之前必須保證執行了assign操作,例如有成員變數a和局部變數b,如果想進行a = b的操作,必須先初始化b。(一開始說了,變數指的是可以放在堆記憶體的變數)
  • 一個變數一次只能同時允許一個線程對其進行lock操作。一個主記憶體的變數被一個線程使用lock操作之後,在這個線程執行unlock操作之前,其他線程不能對此變數進行操作。但是一個線程可以對一個變數進行多次鎖,只要最後釋放鎖的次數和加鎖的次數一致才能解鎖。
  • 當線程使用lock操作時,清除所有私有記憶體的變數副本。
  • 使用unlock操作時,必須在此操作之前將變數同步到主記憶體當中。
  • 不允許對沒有進行lock操作的變數執行unlock操作,也不允許線程去unlock其他線程lock的變數。

改變規則的Volatile關鍵字

  對於關鍵字volatile,大家都知道其一般作為併發的輕量級關鍵字,並且具有兩個重要的語義

  1. 保證記憶體的可見性:使用volatile修飾的變數在變數值發生改變的時候,會立刻同步到主記憶體,並使其他線程的變數副本失效。
  2. 禁止指令重排序:用volatile修飾的變數在代碼語句的前後會加上一些記憶體屏障來禁止指令的重新排序。

但這兩個語義都是因為在使用volatile關鍵字修飾變數的時候,記憶體間變數的交互規則會發生一些變化:

  1. 在對變數執行use操作之前,其前一步操作必須為對該變數的load操作;在對變數執行load操作之前,其後一步操作必須為該變數的use操作。也就是說,使用volatile修飾的變數其read、load、use都是連續出現的,所以每次使用變數的時候都要從主記憶體讀取最新的變數值,替換私有記憶體的變數副本值(如果不同的話)。
  2. 在對變數執行assign操作之前,其後一步操作必須為store;在對變數執行store之前,其前一步必須為對相同變數的assign操作。也就是說,其對同一變數的assign、store、write操作都是連續出現的,所以每次對變數的改變都會立馬同步到主記憶體中。
  3. 在主記憶體中有變數a、b,動作A為當前線程對變數a的use或者assign操作,動作B為與動作A對應load或store操作,動作C為與動作B對應的read或write操作;動作D為當前線程對變數b的use或assign操作,動作E為與D對應的load或store操作,動作F為與動作E對應的read或write操作;如果動作A先於動作D,那麼動作C要先於動作F。也就是說,如果當前線程對變數a執行的use或assign操作在對變數buse或assign之前執行的話,那麼當前線程對變數a的read或write操作肯定要在對變數b的read或write操作之前執行。

從上面volatile的特殊規則中,我們可以知道1、2條其實就是volatile記憶體可見性的語義,第三條就是禁止指令重排序的語義。另外還有其他的一些特殊規則,例如對於非volatile修飾的double或者long這兩個64位的數據類型中,虛擬機允許對其當做兩次32位的操作來進行,也就是說可以分解成非原子性的兩個操作,但是這種可能性出現的情況也相當的小。因為Java記憶體模型雖然允許這樣子做,但卻“強烈建議”虛擬機選擇實現這兩種類型操作的原子性,所以平時不會出現讀到“半個變數”的情況。

volatile不具備原子性

  雖然volatile修飾的變數可以強制刷新記憶體,但是其並不具備原子性,稍加思考就可以理解,雖然其要求對變數的(read、load、use)、(assign、store、write)必須是連續出現,即以組的形式出現,但是這兩組操作還是分開的。比如說,兩個線程同時完成了第一組操作(read、load、use),但是還沒進行第二組操作(assign、store、write),此時是沒錯的,然後兩個線程開始第二組操作,這樣最終其中一個線程的操作會被覆蓋掉,導致數據的不准確。如果你覺得這是JOJO的奇妙比喻,可以看下麵的代碼來理解

public class TestForVolatile {

    public static volatile int i = 0;

    public static void main(String[] args) throws InterruptedException {
        // 創建四個線程,每個線程對i執行一定次數的自增操作
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
     // 睡眠一定時間確保四個線程全部執行完畢
        Thread.sleep(1000);
      // 最終結果為33555,沒有預期的4W System.out.println(i);
       } }

 結果圖:

 

  解釋一下:因為i++操作其實為i = i + 1,假設在主記憶體i = 99的時候同時有兩個線程完成了第一組操作(read、load、use),也就是完成了等號後面變數i的讀取操作,這時候是沒問題的,然後進行運算,都得出i+1=100的結果,接著對變數i進行賦值操作,這就開始第二組操作(assign、store、write),是不是同時賦值的無所謂,這樣一來,兩個線程都會以i = 100把值寫到主記憶體中,也就是說,其中一個線程的操作結果會被覆蓋,相當於無效操作,這就導致上面程式最終結果的不准確。

  如果要保證原子性的話可以使用synchronize關鍵字,其可以保證原子性記憶體可見性(但是不具備有禁止指令重排序的語義,這也是為什麼double-check的單例模式中,實例要用volatile修飾的原因);當然你也可以使用JUC包的原子類AtomicInteger之類的。

先行發生原則(happens-before)

  如果單靠volatilesynchronized來維持程式的有序性的話,那麼難免會變得有些繁瑣。然而大部分時候我們並不需要這樣做,因為Java中有一個“先行發生原則”:如果操作A先行發生於操作B,那麼進行B操作之前A操作的變化都能被B操作觀察到,也就是說B能看到A對變數進行的修改。 這裡的先後指的是執行順序的先後,與時間無關。例如在下麵偽代碼中:

// 線上程A執行,定為A操作
i = 0;

// 線程B執行,定義為B操作
j = i;

// 線程C執行,定義為C操作
i = 1;

  假設A操作先於B操作發生,暫時忽略C操作,那麼最終得到的結果必定是i = j = 1;但是如果此時加入C操作,並且跟A、B操作沒有確定先行發生關係,那麼最終的結果就變成了不確定,因為C可能在B之前執行也可能在B之後執行,所以此時就會出現數據不准確的情況。如果一開始沒有A操作先行與B操作這個前提的話,那麼就算沒有C操作,結果也是不確定的。

  當然,符合先行發生原則的並不一定按照這個規則來執行,只有在操作之間會有依賴的時候(即下一個操作用到上個操作的變數),此時的先行發生原則才一定適用。例如在下麵的偽代碼中,雖然符合先行發生原則,但是也不保證能有序執行。

// 同一線程執行以下操作
// A操作
int i = 0;
// B操作
int j = 1;

  這裡完全符合程式次序規則(先行發生原則的一種),但是兩個操作之間並沒有依賴,所以虛擬機完全可以對其進行重排序,使得B操作在A操作之前執行,當然這對程式的正確性並沒有影響。

  那麼該如何判斷是否符合先行發生原則呢?就連前面的例子都是通過假設來得出先行發生的。莫慌,Java記憶體模型為我們提供一些規則,只要符合這些規則之一,那就符合先行發生原則。可以類比為先行發生原則為介面,下麵的規則則為實現此介面的實現類。

  • 程式次序規則:在同一個線程中,代碼書寫在前面的操作先行發生於書寫在後面的操作。(以編譯後的class文件為準)
  • 管程鎖定規則:對於同一把鎖,unlock操作總是先行發生於後面對此鎖的lock操作之前。 後面指的是時間上的順序
  • volatile變數規則:對於volatile修飾的變數中,對此變數的寫操作總是先行發生於後面對此變數的讀操作。這裡的後面同樣指的是時間上的順序。
  • 線程啟動規則:一個線程的start()方法先行發生於該線程的每一個動作。
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到代碼中斷時間的發生。
  • 線程終止規則:線程的所有操作先行於該線程的終止檢測。
  • 對象終結規則:一個對象的初始化完成(構造函數執行完畢)先行於其finalize()方法的開始。
  • 傳遞性:如果A操作先行於B操作,B操作先行於C操作,那麼A操作先行於C操作。

  這8種就是Java提供的不需要任何同步器的自然規則了,只要符合在8條之一,那麼就符合先行發生原則;反之,則不然。可以通過下麵的例子理解:

// 對象中有一個變數i
private int i = 0;
public int getI() {
    return i;
}

public void setI(int i) {
    this.i = i;
}
// 線上程A執行set操作A 
setI(1);

// 線上程B執行相同對象的get操作B 
int j = getI();

  我們假設在時間上A操作先執行,然後再接著執行B操作,那麼B得到的i是多少呢?

  我們將上面的規則一個個的往裡套,不同線程,程式次序規則OUT;沒有加鎖和volatile關鍵字,管程鎖定和volatile變數規則OUT;關於線程的三個規則和對象終止規則也不符合,OUT;最後一個更不用提,OUT;綜上,這個操作並不符合先行發生原則,所以這個操作是沒法保證的,也就是說B得到的變數i為1為0都有可能,即是線程不安全的。所以判斷線程是否安全的依據是先行發生原則,跟時間順序並沒有太大的關係。

  像上面這種情況要修正的話,使其符合其中一條規則即可,例如加上volatile關鍵字或者加鎖(同一把鎖)都可以解決這個問題。

  暫時寫到這裡,其他關於重排序、記憶體屏障等內容後面再進行補充。如果文章有任何不對的地方望大家指出,感激不盡!


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 產品定位 騰訊bugly和fabric不僅僅是可以幫助運營人員分析用戶、優化推廣的數據分析平臺,也是移動開發者的異常上報平臺和應用更新平臺。可以同時為公司產品運營和開發人員提供服務。 產品功能 fabric在查看dashboard頁面之前必須在應用里集成SDK,所以這裡只對fabric官網上給出的功 ...
  • Java的日期類Date Date類 註意:是 java.util.Date ; 而非 java.sql.Date,此類是給資料庫訪問的時候使用的 示例 1 : 時間原點概念 所有的數據類型,無論是整數,布爾,浮點數還是字元串,最後都需要以數字的形式表現出來。 日期類型也不例外,換句話說,一個日期, ...
  • 這是 Java 網路爬蟲系列博文的第二篇,在上一篇 "Java 網路爬蟲,就是這麼的簡單" 中,我們簡單的學習了一下如何利用 Java 進行網路爬蟲。在這一篇中我們將簡單的聊一聊在網路爬蟲時,遇到需要登錄的網站,我們該怎麼辦? 在做爬蟲時,遇到需要登陸的問題也比較常見,比如寫腳本搶票之類的,但凡需要 ...
  • 一、楔子 你現在已經學會了寫python代碼,假如你寫了兩個python文件a.py和b.py,分別去運行,你就會發現,這兩個python的文件分別運行的很好。但是如果這兩個程式之間想要傳遞一個數據,你要怎麼做呢? 這個問題以你現在的知識就可以解決了,我們可以創建一個文件,把a.py想要傳遞的內容寫 ...
  • ① 向資料庫發送SQL查詢語句 首先使用Statement聲明一個SQL語句對象,然後讓已創建的連接對象con調用方法createStatement()創建SQL語句對象。 Statement sql = con.createStatement(); Connection con = null; S ...
  • 一、主方法註意 每一個類都可以編寫一個主方法,但是一般情況下,一個系統只有一個入口,所以主方法一般寫一個 二、Myeclipse的使用 1.在workspace中工作區中有一個文件夾.metadata:在該文件夾中存儲了當前MyEclipse的工作狀態 2.將.metadata文件夾刪除之後,下一次 ...
  • 本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫里查看 https://github.com/h2pl/Java Tutorial 喜歡的話麻煩點下Star哈 文章首發於我的個人博客: www.how2playlife.com <! more 從JVM結構開始 ...
  • 本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫里查看 https://github.com/h2pl/Java Tutorial 喜歡的話麻煩點下Star哈 文章首發於我的個人博客: www.how2playlife.com <! more 本文參考 htt ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...