天天看點

面試官:談談你對JVM記憶體結構的了解

作者:網際網路進階架構師

JVM 記憶體結構

Java 虛拟機的記憶體空間分為 5 個部分:

  • 程式計數器
  • Java 虛拟機棧
  • 本地方法棧
  • 方法區
面試官:談談你對JVM記憶體結構的了解

JDK 1.8 同 JDK 1.7 比,最大的差别就是:中繼資料區取代了永久代。元空間的本質和永久代類似,都是對 JVM 規範中方法區的實作。不過元空間與永久代之間最大的差別在于:中繼資料空間并不在虛拟機中,而是使用本地記憶體。

程式計數器(PC 寄存器)

程式計數器的定義

程式計數器是一塊較小的記憶體空間,是目前線程正在執行的那條位元組碼指令的位址。若目前線程正在執行的是一個本地方法,那麼此時程式計數器為Undefined。

程式計數器的作用

  • 位元組碼解釋器通過改變程式計數器來依次讀取指令,進而實作代碼的流程控制。
  • 在多線程情況下,程式計數器記錄的是目前線程執行的位置,進而當線程切換回來時,就知道上次線程執行到哪了。

程式計數器的特點

  • 是一塊較小的記憶體空間。
  • 線程私有,每條線程都有自己的程式計數器。
  • 生命周期:随着線程的建立而建立,随着線程的結束而銷毀。
  • 是唯一一個不會出現 OutOfMemoryError 的記憶體區域。

Java 虛拟機棧(Java 棧)

Java 虛拟機棧的定義

Java 虛拟機棧是描述 Java 方法運作過程的記憶體模型。

Java 虛拟機棧會為每一個即将運作的 Java 方法建立一塊叫做“棧幀”的區域,用于存放該方法運作過程中的一些資訊,如:

  • 局部變量表
  • 操作數棧
  • 動态連結
  • 方法出口資訊
  • ......
面試官:談談你對JVM記憶體結構的了解

壓棧出棧過程

當方法運作過程中需要建立局部變量時,就将局部變量的值存入棧幀中的局部變量表中。

Java 虛拟機棧的棧頂的棧幀是目前正在執行的活動棧,也就是目前正在執行的方法,PC 寄存器也會指向這個位址。隻有這個活動的棧幀的本地變量可以被操作數棧使用,當在這個棧幀中調用另一個方法,與之對應的棧幀又會被建立,新建立的棧幀壓入棧頂,變為目前的活動棧幀。

方法結束後,目前棧幀被移出,棧幀的傳回值變成新的活動棧幀中操作數棧的一個操作數。如果沒有傳回值,那麼新的活動棧幀中操作數棧的操作數沒有變化。

由于 Java 虛拟機棧是與線程對應的,資料不是線程共享的(也就是線程私有的),是以不用關心資料一緻性問題,也不會存在同步鎖的問題。

局部變量表

定義為一個數字數組,主要用于存儲方法參數、定義在方法體内部的局部變量,資料類型包括各類基本資料類型,對象引用,以及 return address 類型。

局部變量表容量大小是在編譯期确定下來的。最基本的存儲單元是 slot,32 位占用一個 slot,64 位類型(long 和 double)占用兩個 slot。

對于 slot 的了解:

  • JVM 虛拟機會為局部變量表中的每個 slot 都配置設定一個通路索引,通過這個索引即可成功通路到局部變量表中指定的局部變量值。
  • 如果目前幀是由構造方法或者執行個體方法建立的,那麼該對象引用 this,會存放在 index 為 0 的 slot 處,其餘的參數表順序繼續排列。
  • 棧幀中的局部變量表中的槽位是可以重複的,如果一個局部變量過了其作用域,那麼其作用域之後申明的新的局部變量就有可能會複用過期局部變量的槽位,進而達到節省資源的目的。

在棧幀中,與性能調優關系最密切的部分,就是局部變量表,方法執行時,虛拟機使用局部變量表完成方法的傳遞局部變量表中的變量也是重要的垃圾回收根節點,隻要被局部變量表中直接或間接引用的對象都不會被回收。

操作數棧

  • 棧頂緩存技術:由于操作數是存儲在記憶體中,頻繁的進行記憶體讀寫操作影響執行速度,将棧頂元素全部緩存到實體 CPU 的寄存器中,以此降低對記憶體的讀寫次數,提升執行引擎的執行效率。
  • 每一個操作數棧會擁有一個明确的棧深度,用于存儲數值,最大深度在編譯期就定義好。32bit 類型占用一個棧機關深度,64bit 類型占用兩個棧機關深度操作數棧。
  • 并非采用通路索引方式進行資料通路,而是隻能通過标準的入棧、出棧操作完成一次資料通路。

方法的調用

  • 靜态連結:當一個位元組碼檔案被裝載進 JVM 内部時,如果被調用的目标方法在編譯期可知,且運作時期間保持不變,這種情況下降調用方的符号引用轉為直接引用的過程稱為靜态連結。
  • 動态連結:如果被調用的方法無法在編譯期被确定下來,隻能在運作期将調用的方法的符号引用轉為直接引用,這種引用轉換過程具備動态性,是以被稱為動态連結。
  • 方法綁定
    1. 早期綁定:被調用的目标方法如果在編譯期可知,且運作期保持不變。
    2. 晚期綁定:被調用的方法在編譯期無法被确定,隻能夠在程式運作期根據實際的類型綁定相關的方法。
  • 非虛方法:如果方法在編譯期就确定了具體的調用版本,則這個版本在運作時是不可變的,這樣的方法稱為非虛方法靜态方法。私有方法,final 方法,執行個體構造器,父類方法都是非虛方法,除了這些以外都是虛方法。
  • 虛方法表:面向對象的程式設計中,會很頻繁的使用動态配置設定,如果每次動态配置設定的過程都要重新在類的方法中繼資料中搜尋合适的目标的話,就可能影響到執行效率,是以為了提高性能,JVM 采用在類的方法區建立一個虛方法表,使用索引表來代替查找。
    1. 每個類都有一個虛方法表,表中存放着各個方法的實際入口。
    2. 虛方法表會在類加載的連結階段被建立,并開始初始化,類的變量初始值準備完成之後,JVM 會把該類的方法也初始化完畢。
  • 方法重寫的本質
    1. 找到操作數棧頂的第一個元素所執行的對象的實際類型,記做 C。如果在類型 C 中找到與常量池中描述符和簡單名稱都相符的方法,則進行通路權限校驗。
    2. 如果通過則傳回這個方法的直接引用,查找過程結束;如果不通過,則傳回 java.lang.IllegalAccessError 異常。
    3. 否則,按照繼承關系從下往上依次對 C 的各個父類進行上一步的搜尋和驗證過程。
    4. 如果始終沒有找到合适的方法,則抛出 java.lang.AbstractMethodError 異常。

Java 中任何一個普通方法都具備虛函數的特征(運作期确認,具備晚期綁定的特點),C++ 中則使用關鍵字 virtual 來顯式定義。如果在 Java 程式中,不希望某個方法擁有虛函數的特征,則可以使用關鍵字 final 來标記這個方法。

Java 虛拟機棧的特點

  • 運作速度特别快,僅僅次于 PC 寄存器。
  • 局部變量表随着棧幀的建立而建立,它的大小在編譯時确定,建立時隻需配置設定事先規定的大小即可。在方法運作過程中,局部變量表的大小不會發生改變。
  • Java 虛拟機棧會出現兩種異常:StackOverFlowError 和 OutOfMemoryError。
    1. StackOverFlowError 若 Java 虛拟機棧的大小不允許動态擴充,那麼當線程請求棧的深度超過目前 Java 虛拟機棧的最大深度時,抛出 StackOverFlowError 異常。
    2. OutOfMemoryError 若允許動态擴充,那麼當線程請求棧時記憶體用完了,無法再動态擴充時,抛出 OutOfMemoryError 異常。
  • Java 虛拟機棧也是線程私有,随着線程建立而建立,随着線程的結束而銷毀。
  • 出現 StackOverFlowError 時,記憶體空間可能還有很多。

常見的運作時異常有:

  • NullPointerException - 空指針引用異常
  • ClassCastException - 類型強制轉換異
  • IllegalArgumentException - 傳遞非法參數異常
  • ArithmeticException - 算術運算異常
  • ArrayStoreException - 向數組中存放與聲明類型不相容對象異常
  • IndexOutOfBoundsException - 下标越界異常
  • NegativeArraySizeException - 建立一個大小為負數的數組錯誤異常
  • NumberFormatException - 數字格式異常
  • SecurityException - 安全異常
  • UnsupportedOperationException - 不支援的操作異常

本地方法棧(C 棧)

本地方法棧的定義

本地方法棧是為 JVM 運作 Native 方法準備的空間,由于很多 Native 方法都是用 C 語言實作的,是以它通常又叫 C 棧。它與 Java 虛拟機棧實作的功能類似,隻不過本地方法棧是描述本地方法運作過程的記憶體模型。

棧幀變化過程

本地方法被執行時,在本地方法棧也會建立一塊棧幀,用于存放該方法的局部變量表、操作數棧、動态連結、方法出口資訊等。

方法執行結束後,相應的棧幀也會出棧,并釋放記憶體空間。也會抛出 StackOverFlowError 和 OutOfMemoryError 異常。

如果 Java 虛拟機本身不支援 Native 方法,或是本身不依賴于傳統棧,那麼可以不提供本地方法棧。如果支援本地方法棧,那麼這個棧一般會線上程建立的時候按線程配置設定。

堆的定義

堆是用來存放對象的記憶體空間,幾乎所有的對象都存儲在堆中。

堆的特點

  • 線程共享,整個 Java 虛拟機隻有一個堆,所有的線程都通路同一個堆。而程式計數器、Java 虛拟機棧、本地方法棧都是一個線程對應一個。
  • 在虛拟機啟動時建立。
  • 是垃圾回收的主要場所。
  • 堆可分為新生代(Eden 區:From Survior,To Survivor)、老年代。
  • Java 虛拟機規範規定,堆可以處于實體上不連續的記憶體空間中,但在邏輯上它應該被視為連續的。
  • 關于 Survivor s0,s1 區: 複制之後有交換,誰空誰是 to。

不同的區域存放不同生命周期的對象,這樣可以根據不同的區域使用不同的垃圾回收算法,更具有針對性。

堆的大小既可以固定也可以擴充,但對于主流的虛拟機,堆的大小是可擴充的,是以當線程請求配置設定記憶體,但堆已滿,且記憶體已無法再擴充時,就抛出 OutOfMemoryError 異常。

Java 堆所使用的記憶體不需要保證是連續的。而由于堆是被所有線程共享的,是以對它的通路需要注意同步問題,方法和對應的屬性都需要保證一緻性。

新生代與老年代

  • 老年代比新生代生命周期長。
  • 新生代與老年代空間預設比例 1:2:JVM 調參數,XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整個堆的 1/3。
  • HotSpot 中,Eden 空間和另外兩個 Survivor 空間預設所占的比例是:8:1:1。
  • 幾乎所有的 Java 對象都是在 Eden 區被 new 出來的,Eden 放不了的大對象,就直接進入老年代了。

對象配置設定過程

  • new 的對象先放在 Eden 區,大小有限制
  • 如果建立新對象時,Eden 空間填滿了,就會觸發 Minor GC,将 Eden 不再被其他對象引用的對象進行銷毀,再加載新的對象放到 Eden 區,特别注意的是 Survivor 區滿了是不會觸發 Minor GC 的,而是 Eden 空間填滿了,Minor GC 才順便清理 Survivor 區
  • 将 Eden 中剩餘的對象移到 Survivor0 區
  • 再次觸發垃圾回收,此時上次 Survivor 下來的,放在 Survivor0 區的,如果沒有回收,就會放到 Survivor1 區
  • 再次經曆垃圾回收,又會将幸存者重新放回 Survivor0 區,依次類推
  • 預設是 15 次的循環,超過 15 次,則會将幸存者區幸存下來的轉去老年區 jvm 參數設定次數 : -XX:MaxTenuringThreshold=N 進行設定
  • 頻繁在新生區收集,很少在養老區收集,幾乎不在永久區/元空間搜集

Full GC /Major GC 觸發條件

  • 顯示調用System.gc(),老年代的空間不夠,方法區的空間不夠等都會觸發 Full GC,同時對新生代和老年代回收,FUll GC 的 STW 的時間最長,應該要避免
  • 在出現 Major GC 之前,會先觸發 Minor GC,如果老年代的空間還是不夠就會觸發 Major GC,STW 的時間長于 Minor GC

逃逸分析

  • 标量替換
  1. 标量不可在分解的量,java 的基本資料類型就是标量,标量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在 JAVA 中對象就是可以被進一步分解的聚合量
  2. 替換過程,通過逃逸分析确定該對象不會被外部通路,并且對象可以被進一步分解時,JVM 不會建立該對象,而會将該對象成員變量分解若幹個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上配置設定空間。
  • 對象和數組并非都是在堆上配置設定記憶體的
  • 《深入了解 Java 虛拟機中》關于 Java 堆記憶體有這樣一段描述:随着 JIT 編譯期的發展與逃逸分析技術逐漸成熟,棧上配置設定,标量替換優化技術将會導緻一些變化,所有的對象都配置設定到堆上也漸漸變得不那麼"絕對"了。
  • 這是一種可以有效減少 Java 記憶體堆配置設定壓力的分析算法,通過逃逸分析,Java Hotspot 編譯器能夠分析出一個新的對象的引用的使用範圍進而決定是否要将這個對象配置設定到堆上。
  • 當一個對象在方法中被定義後,它可能被外部方法所引用,如作為調用參數傳遞到其他地方中,稱為方法逃逸。
  • 再如指派給類變量或可以在其他線程中通路的執行個體變量,稱為線程逃逸
  • 使用逃逸分析,編譯器可以對代碼做如下優化:
    1. 同步省略:如果一個對象被發現隻能從一個線程被通路到,那麼對于這個對象的操作可以不考慮同步。
    2. 将堆配置設定轉化為棧配置設定:如果一個對象在子程式中被配置設定,要使指向該對象的指針永遠不會逃逸,對象可能是棧配置設定的候選,而不是堆配置設定。
    3. 分離對象或标量替換:有的對象可能不需要作為一個連續的記憶體結構存在也可以被通路到,那麼對象的部分(或全部)可以不存儲在記憶體,而是存儲在 CPU 寄存器中。
public static StringBuffer createStringBuffer(String s1, String s2) {

    StringBuffer s = new StringBuffer();
​
    s.append(s1);
​
    s.append(s2);
​
    return s;
}           

s 是一個方法内部變量,上邊的代碼中直接将 s 傳回,這個 StringBuffer 的對象有可能被其他方法所改變,導緻它的作用域就不隻是在方法内部,即使它是一個局部變量,但還是逃逸到了方法外部,稱為方法逃逸。

還有可能被外部線程通路到,譬如指派給類變量或可以在其他線程中通路的執行個體變量,稱為線程逃逸。

  • 在編譯期間,如果 JIT 經過逃逸分析,發現有些對象沒有逃逸出方法,那麼有可能堆記憶體配置設定會被優化成棧記憶體配置設定。
  • jvm 參數設定,-XX:+DoEscapeAnalysis :開啟逃逸分析 ,-XX:-DoEscapeAnalysis : 關閉逃逸分析
  • 從 jdk 1.7 開始已經預設開始逃逸分析。

TLAB

  • TLAB 的全稱是 Thread Local Allocation Buffer,即線程本地配置設定緩存區,是屬于 Eden 區的,這是一個線程專用的記憶體配置設定區域,線程私有,預設開啟的(當然也不是絕對的,也要看哪種類型的虛拟機)
  • 堆是全局共享的,在同一時間,可能會有多個線程在堆上申請空間,但每次的對象配置設定需要同步的進行(虛拟機采用 CAS 配上失敗重試的方式保證更新操作的原子性)但是效率卻有點下降
  • 是以用 TLAB 來避免多線程沖突,在給對象配置設定記憶體時,每個線程使用自己的 TLAB,這樣可以使得線程同步,提高了對象配置設定的效率
  • 當然并不是所有的對象都可以在 TLAB 中配置設定記憶體成功,如果失敗了就會使用加鎖的機制來保持操作的原子性
  • -XX:+UseTLAB使用 TLAB,-XX:+TLABSize 設定 TLAB 大小

四種引用方式

  • 強引用:建立一個對象并把這個對象賦給一個引用變量,普通 new 出來對象的變量引用都是強引用,有引用變量指向時永遠不會被垃圾回收,jvm 即使抛出 OOM,可以将引用指派為 null,那麼它所指向的對象就會被垃圾回收。
  • 軟引用:如果一個對象具有軟引用,記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些對象的記憶體。隻要垃圾回收器沒有回收它,該對象就可以被程式使用。
  • 弱引用:非必需對象,當 JVM 進行垃圾回收時,無論記憶體是否充足,都會回收被弱引用關聯的對象。
  • 虛引用:虛引用并不會決定對象的生命周期,如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。

方法區

方法區的定義

Java 虛拟機規範中定義方法區是堆的一個邏輯部分。方法區存放以下資訊:

  • 已經被虛拟機加載的類資訊
  • 常量
  • 靜态變量
  • 即時編譯器編譯後的代碼

方法區的特點

  • 線程共享。 方法區是堆的一個邏輯部分,是以和堆一樣,都是線程共享的。整個虛拟機中隻有一個方法區。
  • 永久代。 方法區中的資訊一般需要長期存在,而且它又是堆的邏輯分區,是以用堆的劃分方法,把方法區稱為“永久代”。
  • 記憶體回收效率低。 方法區中的資訊一般需要長期存在,回收一遍之後可能隻有少量資訊無效。主要回收目标是:對常量池的回收;對類型的解除安裝。
  • Java 虛拟機規範對方法區的要求比較寬松。 和堆一樣,允許固定大小,也允許動态擴充,還允許不實作垃圾回收。

運作時常量池

方法區中存放:類資訊、常量、靜态變量、即時編譯器編譯後的代碼。常量就存放在運作時常量池中。

當類被 Java 虛拟機加載後, .class 檔案中的常量就存放在方法區的運作時常量池中。而且在運作期間,可以向常量池中添加新的常量。如 String 類的 intern() 方法就能在運作期間向常量池中添加字元串常量。

直接記憶體(堆外記憶體)

直接記憶體是除 Java 虛拟機之外的記憶體,但也可能被 Java 使用。

操作直接記憶體

在 NIO 中引入了一種基于通道和緩沖的 IO 方式。它可以通過調用本地方法直接配置設定 Java 虛拟機之外的記憶體,然後通過一個存儲在堆中的DirectByteBuffer對象直接操作該記憶體,而無須先将外部記憶體中的資料複制到堆中再進行操作,進而提高了資料操作的效率。

直接記憶體的大小不受 Java 虛拟機控制,但既然是記憶體,當記憶體不足時就會抛出 OutOfMemoryError 異常。

直接記憶體與堆記憶體比較

  • 直接記憶體申請空間耗費更高的性能
  • 直接記憶體讀取 IO 的性能要優于普通的堆記憶體
  • 直接記憶體作用鍊: 本地 IO -> 直接記憶體 -> 本地 IO
  • 堆記憶體作用鍊:本地 IO -> 直接記憶體 -> 非直接記憶體 -> 直接記憶體 -> 本地 IO

伺服器管理者在配置虛拟機參數時,會根據實際記憶體設定-Xmx等參數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大于實體記憶體限制,進而導緻動态擴充時出現OutOfMemoryError異常。

作者:啵啵腸

連結:https://juejin.cn/post/7195543992690720828

來源:稀土掘金

繼續閱讀