Java面試- JVM 內存模型講解

  • 2019 年 11 月 2 日
  • 筆記

經常有人會有這麼一個疑惑,難道 Java 開發就一定要懂得 JVM 的原理嗎?我不懂 JVM ,但我照樣可以開發。確實,但如果懂得了 JVM ,可以讓你在技術的這條路上走的更遠一些。

JVM 的重要性

首先你應該知道,運行一個 Java 應用程序,我們必須要先安裝 JDK 或者 JRE 。這是因為 Java 應用在編譯後會變成位元組碼,然後通過位元組碼運行在 JVM 中,而 JVM 是 JRE 的核心組成部分。

優點

JVM 不僅承擔了 Java 位元組碼的分析(JIT compiler)和執行(Runtime),同時也內置了自動內存分配管理機制。這個機制可以大大降低手動分配回收機制可能帶來的內存泄露和內存溢出風險,使 Java 開發人員不需要關注每個對象的內存分配以及回收,從而更專註於業務本身。

缺點

這個機制在提升 Java 開發效率的同時,也容易使 Java 開發人員過度依賴於自動化,弱化對內存的管理能力,這樣系統就很容易發生 JVM 的堆內存異常、垃圾回收(GC)的不合適以及 GC 次數過於頻繁等問題,這些都將直接影響到應用服務的性能。

內存模型

JVM 內存模型共分為5個區:堆(Heap)方法區(Method Area)程序計數器(Program Counter Register)虛擬機棧(VM Stack)本地方法棧(Native Method Stack)

其中,堆(Heap)方法區(Method Area)線程共享程序計數器(Program Counter Register)虛擬機棧(VM Stack)本地方法棧(Native Method Stack)線程隔離

堆(Heap)

堆是 JVM 內存中最大的一塊內存空間,該內存被所有線程共享,幾乎所有對象和數組都被分配到了堆內存中。

堆被劃分為新生代和老年代,新生代又被進一步劃分為 Eden 區和 Survivor 區,最後 Survivor 由 From Survivor 和 To Survivor 組成。

隨着 Java 版本的更新,其內容又有了一些新的變化:

在 Java6 版本中,永久代在非堆內存區;到了 Java7 版本,永久代的靜態變量和運行時常量池被合併到了堆中;而到了 Java8,永久代被元空間(處於本地內存)取代了。

為什麼要用元空間替換永久代呢?

  1. 為了融合 HotSpot JVM 與 JRockit VM,因為 JRockit 沒有永久代,所以不需要配置永久代。
  2. 永久代內存經常不夠用或發生內存溢出(應該是 JVM 中佔用內存最大的一塊),產生異常 java.lang.OutOfMemoryError: PermGen。在 JDK1.7 版本中,指定的 PermGen 區大小為 8M,由於 PermGen 中類的元數據信息在每次 FullGC 的時候都可能被收集,回收率都偏低,成績很難令人滿意;還有,為 PermGen 分配多大的空間很難確定,PermSize 的大小依賴於很多因素,比如,JVM 加載的 class 總數、常量池的大小和方法的大小等。

看到這兒,自然就想到了 GC 回收算法,不用急,我會在之後的文章中進行講解,現在還是以 JVM 內存模型為主。

方法區(Method Area)

什麼是方法區?

方法區主要是用來存放已被虛擬機加載的類相關信息,包括類信息常量池(字符串常量池以及所有基本類型都有其相應的常量池)、運行時常量池。這其中,類信息又包括了類的版本、字段、方法、接口和父類等信息。

類信息

JVM 在執行某個類的時候,必須經過加載、連接、初始化,而連接又包括驗證、準備、解析三個階段。

在加載類的時候,JVM 會先加載 class 文件,而在 class 文件中便有類的版本、字段、方法和接口等描述信息,這就是類信息

常量池

在 class 文件中,除了類信息,還有一項信息是常量池 (Constant Pool Table),用於存放編譯期間生成的各種字面量符號引用

字面量符號引用又是什麼呢?

字面量包括字符串(String a=「b」)、基本類型的常量(final 修飾的變量),符號引用則包括類和方法的全限定名(例如 String 這個類,它的全限定名就是 Java/lang/String)、字段的名稱和描述符以及方法的名稱和描述符。

運行時常量池

當類加載到內存後,JVM 就會將 class 文件常量池中的內容存放到運行時常量池中;在解析階段,JVM 會把符號引用替換為直接引用(對象的索引值)。

例如:

類中的一個字符串常量在 class 文件中時,存放在 class 文件常量池中的。 在 JVM 加載完類之後,JVM 會將這個字符串常量放到運行時常量池中,並在解析階段,指定該字符串對象的索引值。

運行時常量池是全局共享的,多個類共用一個運行時常量池,因此,class 文件中常量池多個相同的字符串在運行時常量池只會存在一份。

講到這裡,大家是不是有些頭暈了,說實話,我在看到這些內容的時候,也是雲里霧裡的,這裡舉個例子幫助大家理解:

    public static void main(String[] args) {          String str = "Hello";          System.out.println((str == ("Hel" + "lo")));            String loStr = "lo";          System.out.println((str == ("Hel" + loStr)));            System.out.println(str == ("Hel" + loStr).intern());      }

其運行結果為:

true  false  true

第一個為 true,是因為在編譯成 class 文件時,能夠識別為同一字符串的, JVM 會將其自動優化成字符串常量,引用自同一 String 對象。

第二個為 false,是因為在運行時創建的字符串具有獨立的內存地址,所以不引用自同一 String 對象。

最後一個為 true,是因為 String 的 intern() 方法會查找在常量池中是否存在一個相等(調用 equals() 方法結果相等)的字符串,如果有則返回該字符串的引用,如果沒有則添加自己的字符串進入常量池。

涉及到的Error

  1. OutOfMemoryError出現在方法區無法滿足內存分配需求的時候,比如一直往常量池中加入數據,運行時常量池就會溢出,從而報錯。

程序計數器(Program Counter Register)

程序計數器是一塊很小的內存空間,主要用來記錄各個線程執行的位元組碼的地址,例如,分支、循環、跳轉、異常、線程恢復等都依賴於計數器。

由於 Java 是多線程語言,當執行的線程數量超過 CPU 數量時,線程之間會根據時間片輪詢爭奪 CPU 資源。如果一個線程的時間片用完了,或者是其它原因導致這個線程的 CPU 資源被提前搶奪,那麼這個退出的線程就需要單獨的一個程序計數器,來記錄下一條運行的指令。

由此可見,程序計數器和上下文切換有關。

虛擬機棧(VM Stack)

虛擬機棧是線程私有的內存空間,它和 Java 線程一起創建。 當創建一個線程時,會在虛擬機棧中申請一個線程棧,用來保存方法的局部變量、操作數棧、動態鏈接方法和返回地址等信息,並參與方法的調用和返回。 每一個方法的調用都伴隨着棧幀的入棧操作,方法的返回則是棧幀的出棧操作。

可以這麼理解,虛擬機棧針對當前 Java 應用中所有線程,都有一個其相應的線程棧,每一個線程棧都互相獨立、互不影響,裏面存儲了該線程中獨有的信息。

涉及到的Error

  1. StackOverflowError出現在棧內存設置成固定值的時候,當程序執行需要的棧內存超過設定的固定值時會拋出這個錯誤。
  2. OutOfMemoryError出現在棧內存設置成動態增長的時候,當JVM嘗試申請的內存大小超過了其可用內存時會拋出這個錯誤。

本地方法棧(Native Method Stack)

本地方法棧跟虛擬機棧的功能類似,虛擬機棧用於管理 Java 方法的調用,而本地方法棧則用於管理本地方法的調用。 但本地方法並不是用 Java 實現的,而是由 C 語言實現的。

也就是說,本地方法棧中並沒有我們寫的代碼邏輯,其由native修飾,由 C 語言實現。

總結

以上就是 JVM 內存模型的基本介紹,大致了解了一下5個分區及其相應的含義和功能,由此可以繼續延伸出 Java 內存模型、 GC 算法等等,我也會在之後的文章中進行講解。如果你有什麼想法,歡迎在下方留言。