Java 虛擬機(JVM)在執行 Java 程序的過程中會把它管理的內存劃分成若干個不同的數據區域。
Java運行時數據區域是指Java虛擬機(JVM)在執行Java程序時,為了管理內存而劃分的幾個不同作用域。這些區域各自承擔特定的任務,并且有著不同的生命周期。根據Java虛擬機規范,主要可以分為線程共享區域和線程私有區域兩大類:
1.程序計數器
程序計數器主要有兩個作用:
- 字節碼解釋器通過改變程序計數器來依次讀取指令,從而實現代碼的流程控制,如:順序執行、選擇、循環、異常處理。
- 在多線程的情況下,程序計數器用于記錄當前線程執行的位置,從而當線程被切換回來的時候能夠知道該線程上次運行到哪兒了。
注意:程序計數器是唯一一個不會出現 OutOfMemoryError 的內存區域,它的生命周期隨著線程的創建而創建,隨著線程的結束而死亡。
2.Java 虛擬機棧(簡稱棧)
棧絕對算的上是 JVM 運行時數據區域的一個核心,除了一些 Native 方法調用是通過本地方法棧實現的(后面會提到),其他所有的 Java 方法調用都是通過棧來實現的
(方法調用-壓入對應棧幀,調用結束,彈出棧幀)
方法調用的數據需要通過棧進行傳遞,每一次方法調用都會有一個對應的棧幀被壓入棧中,每一個方法調用結束后,都會有一個棧幀被彈出。
棧由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法返回地址。和數據結構上的棧類似,兩者都是先進后出的數據結構,只支持出棧和入棧兩種操作。
棧幀隨著方法調用而創建,隨著方法結束而銷毀。無論方法正常完成還是異常完成都算作方法結束。
棧幀構成
動態鏈接 主要服務一個方法需要調用其他方法的場景
當一個方法要調用其他方法,需要將常量池中指向方法的符號引用轉化為其在內存地址中的直接引用。
動態鏈接的作用就是為了將符號引用轉換為調用方法的直接引用,這個過程也被稱為 動態連接 。
當線程請求棧的深度超過當前 Java 虛擬機棧的最大深度的時候,就拋出 StackOverFlowError 錯誤
棧還可能會出現OutOfMemoryError錯誤,這是因為如果棧的內存大小可以動態擴展, 如果虛擬機在動態擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常
3. 本地方法棧
虛擬機棧為虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務
4. 堆
Java 虛擬機所管理的內存中最大的一塊,Java 堆是所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這里分配內存。
Java 世界中“幾乎”所有的對象都在堆中分配
(從 JDK 1.7 開始已經默認開啟逃逸分析,如果某些方法中的對象引用沒有被返回或者未被外面使用(也就是未逃逸出去),那么對象可以直接在棧上分配內存)
Java 堆是垃圾收集器管理的主要區域,因此也被稱作 GC 堆(Garbage Collected Heap)
在 JDK 7 版本及 JDK 7 版本之前,堆內存被通常分為下面三部分:新生代內存(Young Generation)老生代(Old Generation)永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空間) 取代,元空間使用的是本地內存。
5. 方法區(一個邏輯概念,由metaspace實現方法區
方法區屬于是 JVM 運行時數據區域的一塊邏輯區域,是各個線程共享的內存區域。
方法區會存儲已被虛擬機加載的 類信息、字段信息、方法信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。
為什么要將永久代 (PermGen) 替換為元空間 (MetaSpace) 呢?
1.整個永久代有一個 JVM 本身設置的固定大小上限,無法進行調整(也就是受到 JVM 內存的限制),而元空間使用的是本地內存,受本機可用內存的限制,雖然元空間仍舊可能溢出,但是比原來出現的幾率會更小。
2.元空間里面存放的是類的元數據,這樣加載多少類的元數據就不由 MaxPermSize 控制了, 而由系統的實際可用空間來控制,這樣能加載的類就更多了
3、在 JDK8,合并 HotSpot 和 JRockit 的代碼時, JRockit 從來沒有一個叫永久代的東西, 合并之后就沒有必要額外的設置這么一個永久代的地方了。
4、永久代會為 GC 帶來不必要的復雜度,并且回收效率偏低。
運行時常量池
Class 文件中除了有類的版本、字段、方法、接口等描述信息外,
還有用于存放編譯期生成的各種字面量(Literal)和符號引用(Symbolic Reference)的 常量池表(Constant Pool Table)
字面量(Literal)
字面量是源代碼中的固定值的表示法,即通過字面我們就能知道其值的含義。字面量包括整數、浮點數和字符串字面量。常見的符號引用包括類符號引用、字段符號引用、方法符號引用、接口方法符號。
運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 錯誤
字符串常量池
為了提升性能和減少內存消耗針對字符串(String 類)專門開辟的一塊區域,主要目的是為了避免字符串的重復創建
JDK1.7 字符串常量池和靜態變量從永久代移動了 Java 堆中。
為什么要將字符串常量池移動到堆中?
主要是因為永久代(方法區實現)的 GC 回收效率太低,只有在整堆收集 (Full GC)的時候才會被執行 GC。Java 程序中通常會有大量的被創建的字符串等待回收,將字符串常量池放到堆中,能夠更高效及時地回收字符串內存。
重點概念理清
運行時常量池、方法區、字符串常量池這些都是不隨虛擬機實現而改變的邏輯概念,是公共且抽象的,Metaspace、Heap 是與具體某種虛擬機實現相關的物理概念,是私有且具體的。
Java 堆是垃圾收集器管理的主要區域,因此也被稱作 GC 堆(Garbage Collected Heap)
在 JDK 7 版本及 JDK 7 版本之前,堆內存被通常分為下面三部分:新生代內存(Young Generation)老生代(Old Generation)永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空間) 取代,元空間使用的是本地內存。
直接內存
直接內存是一種特殊的內存緩沖區,并不在 Java 堆或方法區中分配的,而是通過 JNI 的方式在本地內存上分配的。
(JNI–java native interface
虛擬機在 Java 堆中對象創建過程
Step1:類加載檢查
虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到這個類的符號引用,并且檢查這個符號引用代表的類是否已被加載過、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。
Step2:分配內存。
對象所需的內存大小在類加載完成后便可確定,為對象分配空間的任務等同于把一塊確定大小的內存從 Java 堆中劃分出來。分配方式有 “指針碰撞” 和 “空閑列表” 兩種,選擇哪種分配方式由 Java 堆是否規整決定,而 Java 堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。
在實際開發過程中,創建對象是很頻繁的事情,作為虛擬機來說,必須要保證線程是安全的,通常來講,虛擬機采用兩種方式來保證線程安全:
CAS+失敗重試: CAS(compare and swap) 是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止。虛擬機采用 CAS 配上失敗重試的方式保證更新操作的原子性。
TLAB: 為每一個線程預先在 Eden 區分配一塊兒內存,JVM 在給線程中的對象分配內存時,首先在 TLAB 分配,當對象大于 TLAB 中的剩余內存或 TLAB 的內存已用盡時,再采用上述的 CAS 進行內存分配
Step3:初始化零值
內存分配完成后,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
Step4:設置對象頭
虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。 這些信息存放在對象頭中。
Step5:執行 init 方法
從虛擬機的視角來看,一個新的對象已經產生了,但從 Java 程序的視角來看,對象創建才剛開始, 方法還沒有執行,所有的字段都還為零。所以一般來說,執行 new 指令之后會接著執行 方法,把對象按照程序員的意愿進行初始化,這樣一個真正可用的對象才算完全產生出來
對象的內存布局
虛擬機中,對象在內存中的布局可以分為 3 塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)
對象的訪問方式
建立對象就是為了使用對象,我們的 Java 程序通過棧上的 reference 數據來操作堆上的具體對象。對象的訪問方式由虛擬機實現而定,目前主流的訪問方式有:使用句柄、直接指針。
使用句柄
直接指針
使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。使用直接指針訪問方式最大的好處就是速度快,它節省了一次指針定位的時間開銷