【Java進(jìn)階筆記】Java內(nèi)存模型(內(nèi)存一致性、volatile原理)

1. JVM 內(nèi)存模型

.java文件會(huì)被編譯器編譯為.class文件,然后由JVM中的類加載器加載各個(gè)類的字節(jié)碼文件,加載完畢后,交由JVM執(zhí)行。JVM會(huì)用一段空間來存儲(chǔ)程序執(zhí)行期間需要的數(shù)據(jù)和相關(guān)信息,這段空間一般稱為Runtime Data Area運(yùn)行時(shí)數(shù)據(jù)區(qū),也就是JVM內(nèi)存。

image

1.1. 程序計(jì)數(shù)器

程序計(jì)數(shù)器是一個(gè)記錄著當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。

JVM 采用 CPU 時(shí)間片輪轉(zhuǎn)算法來調(diào)度多線程。當(dāng)被掛起的線程重新獲取到時(shí)間片時(shí),它必須知道上次執(zhí)行到哪里才能繼續(xù)執(zhí)行,因此程序計(jì)數(shù)器就是記錄某個(gè)線程的字節(jié)碼執(zhí)行位置。

  • 占用的內(nèi)存空間較小。
  • 線程私有,每個(gè)線程都有獨(dú)立程序計(jì)數(shù)器。
  • JVM 規(guī)范中唯一沒有規(guī)定 OutOfMemoryError 情況的區(qū)域。
  • 執(zhí)行 java 方法時(shí),程序計(jì)數(shù)器是有值的,且記錄的是正在執(zhí)行的字節(jié)碼指令的地址。
  • 執(zhí)行 native 本地方法時(shí),程序計(jì)數(shù)器的值為空(Undefined)。因?yàn)?native 方法是 java 通過 JNI 直接調(diào)用本地 C/C++ 庫,由于該方法是通過 C/C++ 實(shí)現(xiàn),無法產(chǎn)生相應(yīng)的字節(jié)碼,并且 C/C++ 執(zhí)行時(shí)的內(nèi)存分配是由自己語言決定的,而不是由 JVM 決定的。

1.2. 虛擬機(jī)棧

http://www.itdecent.cn/p/ecfcc9fb1de7

描述 Java 方法執(zhí)行的內(nèi)存模型,用于存儲(chǔ)棧幀。

  • 線程隔離性,每個(gè)線程都有獨(dú)立虛擬機(jī)棧。
  • 使用的內(nèi)存不需要保證是連續(xù)的。
  • JVM 規(guī)范既允許虛擬機(jī)棧被實(shí)現(xiàn)成固定大?。H萘吭诰€程創(chuàng)建時(shí)確定),也允許通過動(dòng)態(tài)擴(kuò)容和收縮來調(diào)整大小。

1.2.1. 棧幀

每個(gè)線程中調(diào)用一個(gè)相同或不同的方法,都會(huì)創(chuàng)建一個(gè)新的棧幀。調(diào)用的方法鏈越多,創(chuàng)建的棧幀越多(遞歸)。每個(gè)方法從調(diào)用到執(zhí)行完成的過程,就對(duì)應(yīng)入棧到出棧的過程。在 Running 線程中,所有的指令都只能針對(duì)當(dāng)前幀(位于棧頂?shù)膸┻M(jìn)行操作。

存儲(chǔ)局部變量表、操作數(shù)棧、動(dòng)態(tài)連接、方法返回地址、附加信息等信息。

  • 局部變量表:用于存放方法參數(shù)和方法內(nèi)部定義的局部變量。
  • 操作數(shù)棧:方法的執(zhí)行操作都在此完成,每一個(gè)字節(jié)碼指令往操作數(shù)棧進(jìn)行寫入和提取的過程,就是入棧和出棧的過程。JVM 的 JIT 引擎稱為 “基于棧的執(zhí)行引擎”,這里的 “?!?就是操作數(shù)棧。
  • 動(dòng)態(tài)連接:每個(gè)棧幀都包含一個(gè)指向運(yùn)行時(shí)常量池中該棧幀所屬性方法的引用,持有這個(gè)引用是為了支持方法調(diào)用過程中的動(dòng)態(tài)連接。
  • 方法返回地址:方法退出后,需要返回到被調(diào)用的位置程序才能繼續(xù)執(zhí)行。一般地,方法正常退出時(shí),返回地址可以是調(diào)用者的程序計(jì)數(shù)器的值,棧幀中很可能會(huì)保存這個(gè)計(jì)數(shù)器值。方法異常退出時(shí),返回地址要通過異常處理器表來確定,棧幀中一般不會(huì)保存這部分信息。
  • 附加信息:JVM 規(guī)范允許具體的虛擬機(jī)實(shí)現(xiàn)增加一些規(guī)范里沒有描述的信息到棧幀中,例如與調(diào)試相關(guān)的信息,這部分信息完全取決于具體的虛擬機(jī)實(shí)現(xiàn)。

1.2.2. 棧內(nèi)存溢出

導(dǎo)致棧內(nèi)存溢出的情況:

  • 壓入的棧幀過多(遞歸調(diào)用)。
  • 壓入的棧幀過大(不容易出現(xiàn))。

1.3. 本地方法棧

與虛擬機(jī)棧幾乎相同,對(duì)象是Native方法。為虛擬機(jī)使用到的 Native 方法服務(wù)。JVM 規(guī)范中對(duì)本地方法棧沒有強(qiáng)制規(guī)定,不同虛擬機(jī)可以自由實(shí)現(xiàn)。

image
  • 本地方法棧是一個(gè)后入先出棧。
  • 由于是線程私有的,生命周期隨著線程,線程啟動(dòng)而產(chǎn)生,線程結(jié)束而消亡。
  • 本地方法棧會(huì)拋出 StackOverflowErrorOutOfMemoryError 錯(cuò)誤。

1.4. 堆

最大的內(nèi)存空間,被所有線程共享,用來存儲(chǔ)對(duì)象實(shí)例及數(shù)組內(nèi)容。幾乎所有的對(duì)象實(shí)例都會(huì)存儲(chǔ)在堆中分配。

  • 從內(nèi)存分配的角度看,線程共享的 Java 堆中可能劃分出多個(gè)線程私有的線程本地分配緩存區(qū)(Thread Local Allocation Buffer,TLAB)。
  • JVM 規(guī)范規(guī)定,Java 堆可以物理不連續(xù),只要邏輯連續(xù)即可。既可以是固定大小,也可以是可擴(kuò)展大小,主流虛擬機(jī)都是按照可擴(kuò)展實(shí)現(xiàn)。
  • 如果是可擴(kuò)展大小,如果嘗試擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存,那 JVM 將拋出 OutOfMemoryError 異常。

1.5. 方法區(qū)

JVM 規(guī)范把方法區(qū)描述為堆的一個(gè)邏輯部分,但它有一個(gè)別名 Non-Heap(非堆),目的是與 Java 堆區(qū)分開來。

  • 方法區(qū)與 Java 堆一樣,是所有線程共享的內(nèi)存區(qū)域。
  • JDK7 之前(永久代)用于存放已經(jīng)被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。
  • 運(yùn)行時(shí)常量池是方法區(qū)的一部分。Class 文件中除了有類的版本 / 字段 / 方法 / 接口等描述信息外,還有一項(xiàng)信息是常量池,用于存放編譯期生成的各種字面量和符號(hào)引用,這部分內(nèi)容將類在加載后進(jìn)入方法區(qū)的運(yùn)行時(shí)常量池中存放。運(yùn)行期間也可能將新的常量放入池中,這種特性被開發(fā)人員利用得比較多的是 String.intern() 方法。受方法區(qū)內(nèi)存的限制,當(dāng)常量池?zé)o法再申請(qǐng)到內(nèi)存時(shí)會(huì)拋出 OutOfMemoryError 異常。
  • Java 中包裝類 Byte、Short、IntegerLongCharacterBoolean 都實(shí)現(xiàn)了常量池技術(shù), FloatDouble 則沒有實(shí)現(xiàn)。 ByteShort、Integer、LongCharacter 這 5 種整型的包裝類也只是在對(duì)應(yīng)值在 -128~127 之間時(shí)才可使用對(duì)象池。

1.5.1. 組成結(jié)構(gòu)

【JDK 6】

永久代物理上是堆的一部分,和新生代,老年代地址是連續(xù)的。

Coroutine.drawio

【JDK 8】

元空間屬于本地內(nèi)存。

image

1.5.2. 方法區(qū)內(nèi)存溢出

  • JDK 8之前會(huì)導(dǎo)致永久代內(nèi)存溢出 java.lang.OutOfMemoryError: PermGen space
  • JDK 8之后會(huì)導(dǎo)致永久代內(nèi)存溢出 java.lang.OutOfMemoryError: Metaspace。


2. 逃逸分析

逃逸分析是 Java 虛擬機(jī)中的一種優(yōu)化技術(shù),但它并不是直接優(yōu)化代碼,而是為其他優(yōu)化手段提供優(yōu)化依據(jù)的分析技術(shù)。JDK8 默認(rèn)開啟。

逃逸分析的基本行為就是分析對(duì)象動(dòng)態(tài)作用域,當(dāng)一個(gè)對(duì)象在方法中被定義后,它可能被外部方法所引用,稱為方法逃逸;也可能被外部線程訪問到,稱為線程逃逸。

對(duì)象的三種逃逸狀態(tài):

  • 全局逃逸 GlobalEscape: 一個(gè)對(duì)象的引用逃出了方法或者線程:
    • 對(duì)象的引用賦值給一個(gè)類變量(成員變量或靜態(tài)成員變量)。
    • 對(duì)象的引用存儲(chǔ)在已逃逸的對(duì)象中。
    • 對(duì)象的引用作為方法的返回值返回。
  • 參數(shù)逃逸 ArgEscape: 在方法調(diào)用過程中傳遞對(duì)象的引用給調(diào)用方法。
  • 沒有逃逸 NoEscape: 一個(gè)可以進(jìn)行標(biāo)量替換的對(duì)象,可以不將這種對(duì)象分配在堆上。
private Object o;
private HashMap<Integer, Object> map = new HashMap<>();

// 給成員變量賦值,發(fā)生全局逃逸
public void test1() {
    o = new Object();
}

// 存儲(chǔ)在已逃逸對(duì)象中,發(fā)生全局逃逸
public void test2() {
    Object o = new Object();
    map.put(0, o);
}

// 作為方法返回值,發(fā)生全局逃逸
public Object test3() {
    return new Object();
}

// 實(shí)例引用傳遞,發(fā)生參數(shù)逃逸
public void test4() {
    Object o = methodPointerEscape();
}

// 純粹的局部作用域,沒有逃逸
public void test5() {
    Object o = new Object();
}

2.1. 標(biāo)量替換

把一個(gè) Java 對(duì)象拆散,根據(jù)程序訪問的情況,將其使用到的成員變量恢復(fù)到基本數(shù)據(jù)類型來訪問,就叫標(biāo)量替換。

【標(biāo)量】

一個(gè)數(shù)據(jù)無法再分解為更小的數(shù)據(jù)來表示了,Java 虛擬機(jī)中的基本數(shù)據(jù)類型 byteshort、intlong、boolean、char、float、double 以及 reference 類型等,都不能再進(jìn)一步分解了,這些就可以稱為標(biāo)量。

【聚合量】

一個(gè)數(shù)據(jù)可以繼續(xù)分解,就稱為聚合量。對(duì)象就是最典型的聚合量。

【替換過程】

如果一個(gè)對(duì)象沒有逃逸,則運(yùn)行時(shí)可能不創(chuàng)建這個(gè)對(duì)象,而改為直接創(chuàng)建它的若干個(gè)被這個(gè)方法使用到的成員變量來替代。

將對(duì)象拆分后,除了可以讓對(duì)象的成員變量在棧上分配和讀寫外(棧上存儲(chǔ)的數(shù)據(jù),有很大概率會(huì)被虛擬機(jī)分配至物理機(jī)器的高速寄存器中存儲(chǔ)),還可以為后續(xù)的進(jìn)一步優(yōu)化手段創(chuàng)造條件。

class User {
    int age;
    int id;
}

public void test() {
    // 由于User對(duì)象沒有逃逸,且User對(duì)象可以被拆分為兩個(gè)標(biāo)量
    // 因此這個(gè)User對(duì)象可以被分配在棧中
    User user = new User();
    // user.id = 1;
}

2.2. 棧上分配

基于逃逸分析和標(biāo)量替換。JDK8 默認(rèn)開啟。

【原理】

方法內(nèi)局部變量對(duì)象未發(fā)生逃逸,則使用標(biāo)量替換將該對(duì)象分解,并在棧上分配內(nèi)存,不在堆中分配,分配完成后,繼續(xù)在調(diào)用棧內(nèi)執(zhí)行。

方法執(zhí)行完后自動(dòng)銷毀,線程結(jié)束后??臻g被回收,局部變量對(duì)象也被回收,不需要 GC ,提高系統(tǒng)性能。

public static void alloc() {
    byte[] b = new byte[2];
    b[0] = 1;
}

public static void main(String[] args) {
    // 短時(shí)間內(nèi)在堆內(nèi)存中大量創(chuàng)建和銷毀對(duì)象,會(huì)頻繁GC,引發(fā)內(nèi)存抖動(dòng),最終的執(zhí)行時(shí)間約900ms左右
    // 使用棧上分配可以完全避免堆內(nèi)存的內(nèi)存抖動(dòng),最終的執(zhí)行時(shí)間約6ms左右
    for (int i = 0; i < 100000000; i++) {
         alloc();
    }
}

【使用場(chǎng)景】

對(duì)于大量的零散小對(duì)象,棧上分配的速度快,可以避免 GC 帶來的 Stop The World。但??臻g比較小,因此大對(duì)象不適合進(jìn)行棧上分配。

2.3. 同步消除

如果一個(gè)對(duì)象沒有逃逸,對(duì)這個(gè)變量的同步措施就可以消除掉。單線程中是沒有鎖競(jìng)爭(zhēng)。(即鎖和鎖塊內(nèi)的對(duì)象不會(huì)逃逸出線程,就可以把這個(gè)同步塊取消)

public static void alloc() {
    byte[] b = new byte[2];
    // 不會(huì)線程逃逸,所以該同步鎖可以去掉
    // 開啟使用同步消除執(zhí)行時(shí)間 10 ms左右
    // 關(guān)閉使用同步消除執(zhí)行時(shí)間 3870 ms左右
    synchronized (b) {
         b[0] = 1;
    }
}

public static void main(String[] args) {
    for (int i = 0; i < 100000000; i++) {
         alloc();
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容