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,永久代被
元空間
(處於本地記憶體)取代了。
為什麼要用元空間
替換永久代呢?
- 為了融合 HotSpot JVM 與 JRockit VM,因為 JRockit 沒有永久代,所以不需要配置永久代。
- 永久代記憶體經常不夠用或發生記憶體溢出(應該是 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
OutOfMemoryError
出現在方法區無法滿足記憶體分配需求的時候,比如一直往常量池中加入數據,運行時常量池
就會溢出,從而報錯。
程式計數器(Program Counter Register)
程式計數器是一塊很小的記憶體空間,主要用來記錄各個執行緒執行的位元組碼的地址,例如,分支、循環、跳轉、異常、執行緒恢復等都依賴於計數器。
由於 Java 是多執行緒語言,當執行的執行緒數量超過 CPU 數量時,執行緒之間會根據時間片輪詢爭奪 CPU 資源。如果一個執行緒的時間片用完了,或者是其它原因導致這個執行緒的 CPU 資源被提前搶奪,那麼這個退出的執行緒就需要單獨的一個程式計數器,來記錄下一條運行的指令。
由此可見,程式計數器和上下文切換有關。
虛擬機棧(VM Stack)
虛擬機棧是執行緒私有的記憶體空間,它和 Java 執行緒一起創建。 當創建一個執行緒時,會在虛擬機棧中申請一個執行緒棧,用來保存方法的局部變數、操作數棧、動態鏈接方法和返回地址等資訊,並參與方法的調用和返回。 每一個方法的調用都伴隨著棧幀的入棧操作,方法的返回則是棧幀的出棧操作。
可以這麼理解,虛擬機棧針對當前 Java 應用中所有執行緒,都有一個其相應的執行緒棧,每一個執行緒棧都互相獨立、互不影響,裡面存儲了該執行緒中獨有的資訊。
涉及到的Error
StackOverflowError
出現在棧記憶體設置成固定值的時候,當程式執行需要的棧記憶體超過設定的固定值時會拋出這個錯誤。OutOfMemoryError
出現在棧記憶體設置成動態增長的時候,當JVM嘗試申請的記憶體大小超過了其可用記憶體時會拋出這個錯誤。
本地方法棧(Native Method Stack)
本地方法棧跟虛擬機棧的功能類似,虛擬機棧用於管理 Java 方法的調用,而本地方法棧則用於管理本地方法的調用。 但本地方法並不是用 Java 實現的,而是由 C 語言實現的。
也就是說,本地方法棧中並沒有我們寫的程式碼邏輯,其由native
修飾,由 C 語言實現。
總結
以上就是 JVM 記憶體模型的基本介紹,大致了解了一下5個分區及其相應的含義和功能,由此可以繼續延伸出 Java 記憶體模型、 GC 演算法等等,我也會在之後的文章中進行講解。如果你有什麼想法,歡迎在下方留言。