目錄
前言
我們如果要對程序內存占用高的問題進行分析,首先我們需要了解具體是什么數據導致內存占用高,然后對具體的問題再具體分析。本文對JAVA運行時的數據區的基礎知識知識進行整理。參考了《深入理解Java虛擬機》、《深入解析Java虛擬機HotSpot》、《HotSpot實戰》三本書。
下面提到的虛擬機都特指JDK1.8版本的HotSpot VM,其他虛擬機的實現有可能不太一樣。
運行時數據區
JVM虛擬機規范要求,運行時數據分為七個區域:程序計數器、Java堆、方法區、虛擬機棧、本地方法棧、運行時常量池以及本地內存(也被稱為堆外內存或直接內存。
其中程序計數器、虛擬機棧和本地方法棧是線程私有的。由於Java支持多線程執行,因此每個線程需要保存當前線程的運行時信息,包括當前執行的代碼位置,以及其他運行時所必要的信息。
程序計數器
程序計數器在JAVA虛擬機規范中稱為Program Counter Register
,即為PC寄存器,它可以看作當前線程所執行的字節碼行號指示器,字節碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的字節碼指令。
行號指示器的行號,並不是我們直接理解的行號,實際是字節碼的索引號。JVM的字節碼占用1個字節,而常量池索引占用2個字節。因此下面的例子中,第一行字節碼共占用3個字節。所以第二行dup
的行號為3。
需要注意,只有執行的是非本地(Native)方法,程序寄存器才會記錄JAVA虛擬機正在執行的字節碼指令地址,若當前執行方法是本地方法,則程序計數器的值為空(Undefined)。
方法區
在編譯產生的class文件中,包含有類的類型信息和常量池等信息,這些信息在類加載時會被加載到運行時的方法區中。方法區主要用於存儲被虛擬機加載的類信息、靜態變量、JIT后的代碼字節碼緩存、運行池常量。虛擬機規范把方法區列為堆的一部分,但是虛擬機實現可以不實現方法區的自動垃圾回收,而是依賴於對常量池和類型的卸載來完成。
實現方式
在JDK1.7之前,HotSpot是使用GC的永久代來實現方法區,省去了專門編寫方法區的內存管理代碼。
從JDK1.8開始,使用元空間替代永久代來存放方法區的數據。元空間屬於本地內存。簡而言之使用了本地內存替換堆內存來存放方法區的數據。
若方法區內存空間不滿足內存分配的請求時,將拋出
OutOfMemoryError
異常。
類型信息
類型信息包括代碼中的類名、修飾符、字段描述符和方法描述符。
字段描述符
字段描述符用於表示類、實例和局部變量。比如用L
表示對象,用[
表示數組等。
字段描述符內部解釋表如下圖所示。
字段描述符 | 類型 | 含義 |
---|---|---|
B | byte | 有符號的字節型數 |
C | char | unicode字符碼點,UFT-16編碼 |
D | double | 雙精度浮點數 |
F | float | 單精度浮點數 |
I | int | 整型數 |
J | long | 長整數 |
L className | reference | className的類的實例 |
S | short | 有符號短整數 |
Z | boolean | 布爾值true/false |
[ | reference | 一維數組 |
方法描述符
方法描述符表示0個或多個參數描述符以及1個返回值描述符,用於表示方法的簽名信息。若返回值為void則用V表示。
方法描述符的格式: (參數描述符)
+ 返回值描述符
。
比如Object m(int i, double d, Thread t)(){}
方法可以表示為(IDLjava/lang/Thread;)Ljava/lang/Object;
。
I
是int
類型的字段描述符D
是double
類型的字段描述符Ljava/lang/Thread;
是Thread
類型的內部描述符Ljava/lang/Object;
是方法的返回值為object
類型
方法描述符分割各標識符的符號不用
.
,而用/
表示。
public class SymbolTest{
private final static String staticParameter = "1245";
public static void main(String[] args) {
String name = "jake";
int age = 54;
System.out.println(name);
System.out.println(age);
}
}
上面一個簡單的例子,編譯通過后,可以通過javap -s xxx.class
命令查看內部簽名。
D:\study\java\symbolreference\out\production\symbolreference>javap -s com.company.SymbolTest
Compiled from "SymbolTest.java"
public class com.company.SymbolTest {
public com.company.SymbolTest();
descriptor: ()V
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
}
可以看出無參構造函數的方法描述符為()V
,main方法的方法描述符為([Ljava/lang/String;)V
運行時常量池
運行時常量池保存了編譯期常量和運行期常量。編譯期常量是在編譯時編譯器生成的字面量和符號引用。在類加載時,會把編譯時的符號引用保存到符號表,字符串保存到字符串表,實際內容是保存到堆中。
字面量指的是代碼中直接寫的字符串或數值等常量或聲明為final
的常量值。比如string str="abc"
或int value = 1
這里的abc
和1
都屬於字面量。運行期常量值的是運行期產生的新的常量,比如String.intern()
方法產生的字符串常量會被保存到運行時常量池緩存起來復用。
運行時常量在方法區中分配,在加載類和接口到虛擬機后就會創建對應的運行時常量。若創建運行時常量所需的內存空間超過了方法區所能提供的最大值,則會拋出OutOfMemoryError
異常。
還是上面的代碼示例,通過javap -v
可以輸出包括運行時常量的附加信息。下面列出了了部分常量輸出內容。
D:\study\java\symbolreference\out\production\symbolreference>javap -v com.company.SymbolTest
...
Constant pool:
#1 = Methodref #7.#28 // java/lang/Object."<init>":()V
#2 = String #29 // jake
#3 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream;
...
#7 = Class #36 // java/lang/Object
#8 = Utf8 staticParameter
#9 = Utf8 Ljava/lang/String;
#10 = Utf8 ConstantValue
#11 = String #37 // 1245
#12 = Utf8 <init>
#13 = Utf8 ()V
...
#18 = Utf8 Lcom/company/SymbolTest;
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 args
#22 = Utf8 [Ljava/lang/String;
#23 = Utf8 name
#24 = Utf8 age
#25 = Utf8 I
#26 = Utf8 SourceFile
#27 = Utf8 SymbolTest.java
#28 = NameAndType #12:#13 // "<init>":()V
#29 = Utf8 jake
...
#35 = Utf8 com/company/SymbolTest
#36 = Utf8 java/lang/Object
#37 = Utf8 1245
...
通過輸出的靜態常量信息可以很清楚的看出JVM編譯時對字面量和符號引用的處理,包括類型名、變量名、方法等都用符號來代替了。比如第一個常量為對象類構造方法java/lang/Object."<init>":()V
。去除其他不相關的常量,最終的符號引用和字面量關系如下表。
索引 | 類型 | 值 |
---|---|---|
0 | Methodref | #7.#28(java/lang/Object."<init>":()V ) |
... | ||
7 | Class | #36 |
... | ||
12 | Utf8 | <init> |
13 | Utf8 | ()V |
... | ||
28 | NameAndType | #12:#13("<init>":()V ) |
... | ||
36 | Utf8 | java/lang/Object |
Java虛擬機棧和本地方法棧
JVM規范要求JVM線程要同時具有本地方法棧和java虛擬機棧。JVM自身執行使用本地方法棧,而執行java方法使用java虛擬機棧。不過在hotspot實現中,本地方法棧和java虛擬機棧,共用同一塊線程棧。
和程序計數器一樣,每一個JAVA虛擬機線程都有自己私有的JAVA虛擬機棧。Java虛擬機規范允許Java虛擬機棧被實現為固定大小,也允許動態擴展和收縮。
當線程請求的棧深度大於虛擬機允許的棧深度,則會拋出
StackOverflowError
異常。當棧動態擴展無法申請到足夠的內存時,則會排除OutOfMemoryError
異常。
棧幀
線程棧描述的是方法執行的線程的內存模型。那么如果進行描述呢?每個方法執行的時候當前執行線程會在Java虛擬機棧中分配當前方法的棧幀,棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機棧里面從入棧到出棧的過程。當方法執行完后,棧幀就會被丟棄,繼續執行下一個棧幀。
在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了。
局部變量表
局部變量表用於存儲基礎數據類型、對象引用和returnAddress類型。
局部變量表實際上就是一個數組,以一個局部變量槽(Slot)為最小單位,HotSpot VM的局部變量槽大小為32位。局部變量表所需的內存空間是在編譯時分配,運行時局部變量所占用的空間是確定的,也就是數組的槽數。基礎數據類型占用1個或2個槽,對象引用和returnAddress類型占用1個槽。
需要注意,在方法調用的時候,局部變量槽可以復用從而減少內存占用。許多情況部分局部變量作用域不會覆蓋到整個方法體,當某局部變量不再使用時,該槽即可給其他局部變量使用。用下面例子為例:
public static void main(String[] args){
int value = 2;
{
int doubleValue = value *2;
System.out.println(doubleValue);
}
int count = 1;
System.out.println(count);
}
通過javap -l
可以輸出本地變量表,從下面輸出信息可以看出 doubleValue
和count
變量的Slot值都為2,2號槽被重用了。
D:\study\java\symbolreference\target\classes\com\company>javap -l LocalVariableSlotReuse.class
Compiled from "LocalVariableSlotReuse.java"
public class com.company.LocalVariableSlotReuse {
public com.company.LocalVariableSlotReuse();
...
LocalVariableTable:
Start Length Slot Name Signature
6 7 2 doubleValue I
0 23 0 args [Ljava/lang/String;
2 21 1 value I
15 8 2 count I
}
不過局部變量槽重用雖然可以減少內存占用空間,但是可能會影響到垃圾回收。因為在作用域內的引用對象,即使離開了作用域,雖然已經沒有對象可達根,但是仍然被棧幀的局部變量表引用着。
java虛擬機定義了的若干種可達根(GC Roots),只要是根可達的對象就不能被垃圾回收,局部變量表就算其中之一。
基礎數據類型
JAVA有8個基礎數據類型:boolean、byte、char、short、int、float、long、double。其中long和double占用2個槽,其他基礎數據類型都占用1個槽。
局部變量表使用索引進行定位訪問。局部變量的索引值從0開始。調用實例方法時,第0個局部變量用於存儲當前對象實例(即this關鍵字)。局部變量從第1個開始;而調用靜態方法時,局部變量從第0個開始。
以下面代碼為例
public int add(int a ,int b ){
return a+b;
}
public int staticAdd(int a ,int b ){
return a+b;
}
通過javap -l
輸出本地變量表。可以發現實例方法第0個局部變量被this對象占用。
public int add(int, int);
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lcom/company/LocalVariableSlot0Use;
0 4 1 a I
0 4 2 b I
public static int staticAdd(int, int);
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 a I
0 4 1 b I
returnAddress
returnAddress
是一個指針,指向一條虛擬機指令的操作碼。這些操作碼包括jsr
、ret
和jsr_w
。在JDK 7之前,這些操作碼用於實現finally語句塊的跳轉和返回。從JDK 7開始,虛擬機已不允許這幾個操作碼了,finally與catch同樣使用異常表實現。與catch不同的是,每個catch會有指定的異常類型,而finally支持所有異常類型,即保證正常和異常最終都可以調用到finally代碼塊中。
void tryFinally(){
try{
tryItOut();
}finally{
wrapItUp();
}
}
當class文件版本小於或等於50時,jsr指令將下一條指令地址(第7條return)在跳轉前壓入操作數棧中,跳轉后通過a_store 2
將棧頂元素(即第7條return指令的地址)保存到第2個局部變量,再finally結束后,通過ret 2返回到局部變量2所指向的地址。
void tryFinally();
Code:
0: aload_0
1: invokevirtual #2 // Method tryItOut:()V
4: jsr 14
7:return
8: astore_1
9: jsr 14
12: aload_1
13: athrow
14: astore_2
15: aload_0
16: invokevirtual #3 // Method wrapItUp:()V
19: ret 2
Exception table:
from to target type
0 4 8 any
當class文件版本大於50時,使用異常表實現finally代碼塊。
void tryFinally();
Code:
0: aload_0
1: invokevirtual #2 // Method tryItOut:()V
4: aload_0
5: invokevirtual #3 // Method wrapItUp:()V
8: goto 18
11: astore_1
12: aload_0
13: invokevirtual #3 // Method wrapItUp:()V
16: aload_1
17: athrow
18: return
Exception table:
from to target type
0 4 11 any
操作數棧
每個棧幀內部都包含一個后進先出(LIFO)的操作數棧。操作數棧的最大深度由編譯期決定。操作數棧中保存了局部變量表或對象實例中的常量或變量值。在調用方法時,也保存調用方法的參數和返回值。
若局部變量是long或double類型,則需要占用2個單位的棧深度。
舉個例子,當執行以下代碼。右邊注釋的[]
表示操作數棧,左邊時棧底,右邊是棧頂。
// //[]
int a = 1; //[1]
int b = 2; //[1,2]
int c = a+b;//[3]->[]
注意:
c=a+b
,通過iadd
讀取棧頂的2個數相加后重新入到操作數棧,因此操作數棧中的內容為3
,然后從操作數棧中出棧保存到c變量中,操作數棧就空了。
棧幀中的布局是精心設計的,頭是操作數棧,尾是局部變量表。由於方法調用的參數也會保存在局部變量表,因此虛擬機在必要時會堆方法調用進行優化,將兩個棧幀的操作數棧和局部變量表進行共享,從而減少參數傳遞和內存占用。
動態連接
每個棧幀內部都包含當前方法所在類型的運行時常量池的引用,以便對當前方法的代碼實現動態連接。
在編譯時,會將調用的方法或成員變量通過符號引用的方式保存。這樣同樣的字符串就以同樣的符號保存,可以減少class文件大小。在類加載的解析階段,一部分可以直接確定運行時地址的符號會被轉換為直接引用,這種轉換成為靜態解析。另一部分在運行時才能確定實際方法地址的符號在運行期才會被轉換為直接引用,這部分被稱為動態連接(比如子類重寫方法、接口實現發方法等)。
符號引用也被稱為描述符(Descriptor),是通過特定的語法來表示的。調用的方法的符號引用稱為方法描述符(Method Descriptor),成員變量稱為字段描述符(Parameter Descriptor)。
動態連接確保了JVM在運行時可以對方法指向正確的方法調用(接口實現或方法重寫)或更合適的方法調用(方法重載)。
JVM在方法區中會建立一個虛方法表(vtable)和接口方法表(itable)。
虛方法表和接口方法表保存了方法的的實際入口地址,如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。
方法表一般在類加載的連接階段進行初始化,准備了類的變量初始值后,虛擬機會把該類的方法表也初始化完畢。
方法返回地址
當通過動態連接調用其他類方法時,棧幀中需要保存被調用的位置,以便方法調用完成后可以返回到被調用時的位置。
當方法正常調用完成后,則棧幀正常恢復局部變量表、操作數棧和調用者的程序計數器正確的位置,若有返回值,則將返回值壓入到調用者的棧幀的操作數棧中。
當方法異常調用完成后,則會導致Java虛擬機拋出異常,若當前方法沒有任何可以處理該異常的異常處理器,則當前方法的操作數棧和局部變量表都會被丟棄,隨后恢復到調用者的棧幀,此時不會有任何返回值壓入到調用者的操作數棧中。同時將異常交易給調用者的異常處理器處理。
Java堆
Java虛擬機中,Java堆用於保存各種對象實例,是Java虛擬機所管理的內存中最大的一塊,並且該內存被所有線程所共享。
Java棧由線程自動創建和銷毀,棧幀由方法的創建和銷毀自動管理。而Java堆則由垃圾收集器進行自動收集並回收。垃圾收集器在不同場景下通過最優的垃圾收集算法對垃圾進行收集。
為了提高垃圾收集性能,在JDK1.8及以前,大部分垃圾回收器采用分代模型。這種模型的理論依據是,大部分對象都是朝生夕滅的,而存活越久的對象越不容易被垃圾回收。Java堆將空間分為新生代、老年代。新生代又被分為Eden區和Survivor區。通過分區將不同“紈絝子弟”存放與老年代,老年代只有在FullGC時才會回收,大部分情況下只需要回收新生代即可,從而提高垃圾回收效率,避免堆整個堆進行分析。
隨着程序內存占用變大,使用分代模型無法滿足大堆的垃圾回收效率,后面出現了分區模型,將堆分為若干小塊,每個區域通常情況下可以單獨做垃圾回收而不影響其他區域。這種模型化整為零,比如G1垃圾回收器就采用這種模型。具體垃圾回收算法不在這里討論
本地內存
在JDK1.4引入了NIO,使得Java可以很方便的使用本地內存。本地內存可以脫離堆內存的大小限制,避免由於堆內存達到最大堆閾值導致OOM。不過本地內存依然受限於程序所支持的最大內存。
不過在日常編碼時如果我們自己要使用本地內存,則需要注意,本地內存不受GC管控,在使用完時必須手動釋放。
在NIO中,可以使用Native函數庫直接分配堆外內存,然后通過一個存儲在Java堆里面的
DirectByteBuffer
對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在Java堆和Native堆中來回復制數據。在DirectByteBuffer
分配時,會通過Cleaner
機制將DirectByteBuffer
對象和本地內存進行關聯,當DirectByteBuffer
對象被垃圾回收后,就會通知Cleaner
線程執行本地內存釋放。
參考文檔
- JVM jsr和ret指令始終理解不了?returnAddress又怎么理解呢?
- 如何理解ByteCode、IL、匯編等底層語言與上層語言的對應關系?
- The Java Virtual Machine Instruction Set
- JVM(hotspot)棧幀實現
- Java虛擬機底層原理和流程,看懂你就掌握60%JVM
- 《深入理解Java虛擬機》
- 《Java虛擬機規范(Java SE 8版)》
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/13426728.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及鏈接。