深入解析JVM之內存結構及字符串常量池(推薦)
前言
Java作為一種平臺無關性的語言,其主要依靠于Java虛擬機——JVM,我們寫好的代碼會被編譯成class文件,再由JVM進行加載、解析、執(zhí)行,而JVM有統(tǒng)一的規(guī)范,所以我們不需要像C++那樣需要程序員自己關注平臺,大大方便了我們的開發(fā)。另外,能夠運行在JVM上的并只有Java,只要能夠編譯生成合乎規(guī)范的class文件的語言都是可以跑在JVM上的。而作為一名Java開發(fā),JVM是我們必須要學習了解的基礎,也是通向高級及更高層次的必修課;但JVM的體系非常龐大,且術語非常多,所以初學者對此非常的頭疼。本系列文章就是筆者自己對于JVM的核心知識(內存結構、類加載、對象創(chuàng)建、垃圾回收等)以及性能調優(yōu)的學習總結,另外未特別指出本系列文章都是基于HotSpot虛擬機進行講解。
正文
JVM包含了非常多的知識,比較核心的有內存結構、類加載、類文件結構、垃圾回收、執(zhí)行 引擎、性能調優(yōu)、監(jiān)控等等這些知識,但所有的功能都是圍繞著內存結構展開的,因為我們編譯后的代碼信息在運行過程中都是存在于JVM自身的內存區(qū)域中的,并且這塊區(qū)域相當?shù)闹悄?,不需要C++那樣需要我們自己手動釋放內存,它實現(xiàn)了自動垃圾回收機制,這也是Java廣受喜愛的原因之一。因此,學習JVM我們首先就得了解其內存結構,熟悉包含的東西,才能更好的學習后面的知識。
內存結構

如上圖所示,JVM運行時數(shù)據(jù)區(qū)(即內存結構)整體上劃分為線程私有和線程共享區(qū)域,線程私有的區(qū)域生命周期與線程相同,線程共享區(qū)域則存在于整個運行期間 。而按照JVM規(guī)范細分則分為程序計數(shù)器、虛擬機棧、本地方法棧、方法區(qū)和堆五大區(qū)域(直接內存不屬于JVM)。注意這只是規(guī)范定義需要存在的區(qū)域,具體的實現(xiàn)則不在規(guī)范的定義中。
1. 程序計數(shù)器
如其名,這個部件就是用來記錄程序執(zhí)行的地址的,循環(huán)、跳轉、異常等等需要依靠它。為什么它是線程私有的呢?以單核CPU為例,多線程在執(zhí)行時是輪流執(zhí)行的,那么當線程暫停后恢復就需要程序計數(shù)器恢復到暫停前的執(zhí)行位置繼續(xù)執(zhí)行,所以必然是每個線程對應一個。由于它只需記錄一個執(zhí)行地址,所以它是五大區(qū)域中唯一一個不會出現(xiàn)OOM(內存溢出)的區(qū)域。另外它是控制我們JAVA代碼的執(zhí)行的,在調用native方法時該計數(shù)器就沒有作用了,而是會由操作系統(tǒng)的計數(shù)器控制。
2. 虛擬機棧
虛擬機棧是方法執(zhí)行的內存區(qū)域,每調用一個方法都會生成一個棧幀壓入棧中,當方法執(zhí)行完成才會彈出棧。棧幀中又包含了局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口。其中局部變量表就是用來存儲局部變量的(基本類型值和對象的引用),每一個位置32位,而像long/double這樣的變量則需要占用兩個槽位;操作數(shù)棧則類似于緩存,用于存儲執(zhí)行引擎在計算時需要用到的局部變量;動態(tài)鏈接這里暫時不講,后面的章節(jié)會詳細分析;方法出口則包含異常出口和正常出口以及返回地址。下面來看三個方法示例分別展示棧和棧幀的運行原理。
入棧出棧過程
public class ClassDemo1 {
public static void main(String[] args) {
new ClassDemo1().a();
}
static void a() { new ClassDemo1().b(); }
static void b() { new ClassDemo1().c(); }
static void c() {}
}
如上所示的方法調用入棧出棧的過程如下:

棧幀執(zhí)行原理
public class ClassDemo2 {
public int work() {
int x = 3;
int y = 5;
int z = (x + y) * 10;
return z;
}
public static void main(String[] args) {
new ClassDemo2().work();
}
}
上面只是一簡單的計算程序,通過javap -c ClassDemo2.class命令反編譯后看看生成的字節(jié)碼:
public class cn.dark.ClassDemo {
public cn.dark.ClassDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int work();
Code:
0: iconst_3
1: istore_1
2: iconst_5
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class cn/dark/ClassDemo
3: dup
4: invokespecial #3 // Method "<init>":()V
7: invokevirtual #4 // Method work:()I
10: pop
11: return
}
主要看到work方法中,挨個來解釋(字節(jié)碼指令釋義可以參照這篇文章):執(zhí)行引擎首先通過iconst_3將常量3存入到操作數(shù)棧中,然后通過istore_1將該值從操作數(shù)棧中取出并存入到局部變量表的1號位(注意局部變量表示從0號開始的,但0號位默認存儲了this變量);接著常量5執(zhí)行同樣的操作,完成后局部變量表中就存了3個變量(this、3、5);之后通過iload指令將局表變量表對應位置的變量加載到操作數(shù)棧中,因為這里有括號,所以先加載兩個變量到操作數(shù)棧并執(zhí)行括號中的加法,即調用iadd加法指令(所有二元算數(shù)指令會從操作數(shù)棧中取出頂部的兩個變量進行計算,計算結果自動加入到棧中);接著又將常量10壓入到棧中,繼續(xù)調用imul乘法指令,完成后需要通過istore命令再將結果存入到局部變量表中,最后通過ireturn返回(不管我們方法是否定義了返回值都會調用該指令,只是當我們定義了返回值時,首先會通過iload指令加載局部變量表的值并返回給調用者)。以上就是棧幀的運行原理。
該區(qū)域同樣是線程私有,每個線程對應會生成一個棧,并且每個棧默認大小是1M,但也不是絕對,根據(jù)操作系統(tǒng)不同會有所不
一樣,另外可以用-Xss控制大小,官方文檔對該該參數(shù)解釋如下:

既然可以控制大小,那么這塊區(qū)域自然就會存在內存不足的情況,對于棧當內存不足時會出現(xiàn)下面兩種異常:
- 棧溢出(StackOverflowError)
- 內存溢出(OutOfMemoryError)
為什么會有兩種異常呢?在周志明的《深入理解Java虛擬機》一書中講到,在單線程環(huán)境下只會出現(xiàn)StackOverflowError異常,即棧幀填滿了棧或局部變量表過大;而OutOfMemoryError只有當多線程情況下,無節(jié)制的創(chuàng)建多個棧才會出現(xiàn),因為操作系統(tǒng)對于每個進程是有內存限制的,即超出了進程可用的內存,無法創(chuàng)建新的棧。
- 棧幀共享機制
通過上文我們知道同一個線程內每個方法的調用會對應生成相應的棧幀,而棧幀又包含了局部變量表和操作數(shù)棧等內容,那么當方法間傳遞參數(shù)時是否可以優(yōu)化,使得它們共享一部分內存空間呢?答案是肯定的,像下面這段代碼:
public int work(int x) throws Exception{
int z =(x+5)*10;// 參數(shù)會按照順序放到局部變量表
Thread.sleep(Integer.MAX_VALUE);
return z;
}
public static void main(String[] args)throws Exception {
JVMStack jvmStack = new JVMStack();
jvmStack.work(10);//10 放入操作數(shù)棧
}
在main方法中首先會把10放入操作數(shù)棧然后傳遞給work方法,作為參數(shù),會按照順序放入到局部變量表中,所以x會放到局部變量表的1號位(0號位是this),而此時通過HSDB工具查看這時的棧調用信息會發(fā)現(xiàn)如下情況:

如上圖所示,中間一小塊用紅框圈起來的就是兩個棧幀共享的內存區(qū)域,即work的局部變量表和main的操作數(shù)棧的一部分。
3. 本地方法棧
和虛擬機棧是一樣的,只不過該區(qū)域是用來執(zhí)行本地本地方法的,有些虛擬機甚至直接將其和虛擬機棧合二為一,如HotSpot。(通過上面的圖也可以看到,最上面顯示了Thread.sleep()的棧幀信息,并標記了native)
4. 方法區(qū)
該區(qū)域是線程共享的區(qū)域,用來存儲已被虛擬機加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù)。該區(qū)域在JDK1.7以前是以永久代方式實現(xiàn)的,存在于堆中,可以通過-XX:PermSize(初始值)、-XX:MaxPermSize(最大值)參數(shù)設置大??;而1.8以后以元空間方式實現(xiàn),使用的是直接內存(但運行時常量池和靜態(tài)變量仍放在堆中),可以通過-XX:MetaspaceSize(初始值)、-XX:MaxMetaspaceSize(最大值)控制大小,如果不設置則只受限于本地內存大小。為什么會這么改變呢?因為方法區(qū)和堆都會進行垃圾回收,但是方法區(qū)中的信息相對比較靜態(tài),回收難以達到成效,同時需要占用的空間大小更多的取決于我們class的大小和數(shù)量,即對該區(qū)域難以設置一個合理的大小,所以將其直接放到本地內存中是非常有用且合理的。
在方法區(qū)中還存在常量池(1.7后放入堆中),而常量池也分了幾種,常常讓初學者比較困惑,比如靜態(tài)常量池、運行時常量池、字符串常量池。靜態(tài)常量池就是指存在于我們的class文件中的常量池,通過javap -v ClassDemo.class反編譯上面的代碼可以看到該常量池:
Constant pool: #1 = Methodref #5.#26 // java/lang/Object."<init>":()V #2 = Class #27 // cn/dark/ClassDemo #3 = Methodref #2.#26 // cn/dark/ClassDemo."<init>":()V #4 = Methodref #2.#28 // cn/dark/ClassDemo.work:()I #5 = Class #29 // java/lang/Object #6 = Utf8 <init> #7 = Utf8 ()V #8 = Utf8 Code #9 = Utf8 LineNumberTable #10 = Utf8 LocalVariableTable #11 = Utf8 this #12 = Utf8 Lcn/dark/ClassDemo; #13 = Utf8 work #14 = Utf8 ()I #15 = Utf8 x #16 = Utf8 I #17 = Utf8 y #18 = Utf8 z #19 = Utf8 main #20 = Utf8 ([Ljava/lang/String;)V #21 = Utf8 args #22 = Utf8 [Ljava/lang/String; #23 = Utf8 MethodParameters #24 = Utf8 SourceFile #25 = Utf8 ClassDemo.java #26 = NameAndType #6:#7 // "<init>":()V #27 = Utf8 cn/dark/ClassDemo #28 = NameAndType #13:#14 // work:()I #29 = Utf8 java/lang/Object
靜態(tài)常量池中就是存儲了類和方法的信息、符號引用以及字面量等東西,當類加載到內存中后,JVM就會將這些內容存放到運行時常量池中,同時會將符號引用(可以理解為對象方法的定位描述符)會被解析為直接引用(即對象的內存地址)存入到運行時常量池中(因為在類加載之前并不知道符號引用所對應的對象內存地址是多少,需要用符號替代)。而字符串常量池網(wǎng)上爭議比較多,我個人理解它也是運行時常量池的一部分,專門用于存儲字符串常量,這里先簡單提一下,稍后會詳細分析字符串常量池。
5. 堆
這個區(qū)域是垃圾回收的重點區(qū)域,對象都存在于堆中(但隨著JIT編譯器的發(fā)展和逃逸分析技術的成熟,對象也不一定都是存在于堆中),可以通過-Xms(最小值)、-Xmx(最大值)、-Xmn(新生代大?。?、-XX:NewSize(新生代最小值)、-XX:MaxNewSize(新生代最大值)這些參數(shù)進行控制。
在堆中又分為了新生代和老年代,新生代又分為Eden空間、From Survivor空間、To Survivor空間。詳細內容后面文章會詳細講解,這里不過多闡述。
6. 直接內存
直接內存也叫堆外內存,不屬于JVM運行時數(shù)據(jù)區(qū)的一部分,主要通過DirectByteBuffer申請內存,該對象存在于堆中,包含了對堆外內存的引用;另外也可以通過Unsafe類或其它JNI手段直接申請內存。它的大小受限于本地內存的大小,也可以通過-XX:MaxDirectMemorySize設置,所以這一塊也會出現(xiàn)OOM異常且較難排查。
字符串常量池
這個區(qū)域不是虛擬機規(guī)范中的內容,所有官方的正式文檔中也沒有明確指出有這一塊,所以這里只是根據(jù)現(xiàn)象推導出結論。什么現(xiàn)象呢?有一個關于字符串對象的高頻面試題:下面的代碼究竟會創(chuàng)建幾個對象?
String str = "abc";
String str1 = new string("cde");
我們先不管這個面試題,先來思考下面代碼的輸出結果是怎樣的(以下試驗基于JDK8,更早的版本結果會有所不同):
tring s1 = "abc";
String s2 = "ab" + "c";
String s3 = new String("abc");
String s4 = new StringBuilder("ab").append("c").toString();
System.out.println("s1 == s2:" + (s1 == s2));
System.out.println("s1 == s3:" + (s1 == s3));
System.out.println("s1 == s4:" + (s1 == s4));
System.out.println("s1 == s3.intern:" + (s1 == s3.intern()));
System.out.println("s1 == s4.intern:" + (s1 == s4.intern()));
輸出結果如下:
s1 == s2:true
s1 == s3:false
s1 == s4:false
s1 == s3.intern:true
s1 == s4.intern:true
上面的輸出結果和你想象的是否一樣呢?為什么呢?一個個來分析。
- s1 == s2:字面量“abc”會首先去字符串常量池找是否有"abc"這個字符串,如果有直接返回引用,如果沒有則創(chuàng)建一個新對象并返回引用;s2你可能會覺得會創(chuàng)建"ab"、"c"和“abc”三個對象,但實際上首先會被編譯器優(yōu)化為“abc”,所以等同于s1,即直接從字符串常量池返回s1的引用。
- s1 == s3:s3是通過new創(chuàng)建的,所以這個String對象肯定是存在于堆的,但是其中的char[]數(shù)組是引用字符創(chuàng)常量池中的s1,如果在這之前沒有定義的話會先在常量池中創(chuàng)建“abc”對象。所以這里可能會創(chuàng)建一個或兩個對象。
- s1 == s4:s4通過StringBuilder拼接字符串對象,所以看起來理所當然的s1 != s4,但實際上也沒那么簡單,反編譯上面的代碼會可以發(fā)現(xiàn)這里又會被編譯器優(yōu)化為s4 = "ab" + "c"。猜猜這下會創(chuàng)建幾個對象呢?拋開前面創(chuàng)建的對象的影響,這里會創(chuàng)建3個對象,因為與s2不同的是s4是編譯器優(yōu)化過后還存在“+”拼接,因此會在字符創(chuàng)常量池創(chuàng)建“ab”、"c"以及“abc”三個對象。前兩個可以反編譯看字節(jié)碼指令或是通過內存搜索驗證,而第三個的驗證稍后進行。
- s1 == s3.intern/s4.intern:這兩個為什么是true呢?先來看看周志明在《深入理解Java虛擬機》書中說的:
使用String類的intern方法動態(tài)添加字符串常量到運行時常量池中(intern方法在1.6和1.7及以后的實現(xiàn)不相同,1.6字符串常量池放于永久代中,intern會把首次遇到的字符串實例復制永久代中并返回永久代中的引用,而1.7及以后常量池也放入到了堆中,intern也不會再復制實例,只是在常量池中記錄首次出現(xiàn)的實例引用)。
上面的意思很明確,1.7以后intern方法首先會去字符串常量池尋找對應的字符串,如果找到了則返回對應的引用,如果沒有找到則先會在字符串常量池中創(chuàng)建相應的對象。因此,上面s4和s4調用intern方法時都是返回s1的引用。
看到這里,相信各位讀者基本上也都能理解了,對于開始的面試題應該也是心中有數(shù)了,最后再來驗證剛剛說的“第三個對象”的問題,先看下面代碼:
String s4 = new StringBuilder("ab").append("c").toString();
System.out.println(s4 == s4.intern());
這里結果是true。為什么呢?別急,再來看另外一段代碼:
String s3 = new String("abc");
String s4 = new StringBuilder("ab").append("c").toString();
System.out.println(s3 == s3.intern());
System.out.println(s4 == s4.intern());
這里結果是兩個false,和你心中的答案是一致的么?上文剛剛說了intern會先去字符串常量池找,找到則返回引用,否則在字符創(chuàng)常量池創(chuàng)建一個對象,所以第一段代碼結果等于true正好說明了通過StringBuilder拼接的字符串會存到字符串常量池中;而第二段代碼中,在StringBuilder拼接字符串之前已經(jīng)優(yōu)先使用new創(chuàng)建了字符串,也就會在字符串常量里創(chuàng)建“abc”對象,因此s4.intern返回的是該常量的引用,和s4不相等。你可能會說是因為優(yōu)先調用了s3.intern方法,但即使你去掉這一段,結果還是一樣的,也剛好驗證了new String("abc")會創(chuàng)建兩個對象(在此之前沒有定義“abc”字面量,就會在字符串常量池創(chuàng)建對象,然后堆中創(chuàng)建String對象并引用該常量,否則只會創(chuàng)建堆中的String對象)。
總結
本文是JVM系列的開篇,主要分析JVM的運行時數(shù)據(jù)區(qū)、簡單參數(shù)設置和字節(jié)碼閱讀分析,這也是學習JVM及性能調優(yōu)的基礎,讀者需要深刻理解這些內容以及哪些區(qū)域會發(fā)生內存溢出(只有程序計數(shù)器不會內存溢出),另外關于運行時常量池和字符串常量池的內容也需要理解透徹。
到此這篇關于深入探究JVM之內存結構及字符串常量池的文章就介紹到這了,更多相關JVM內存結構字符串常量池內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
SpringBoot項目整合MybatisPlus并使用SQLite作為數(shù)據(jù)庫的過程
SQLite是一個緊湊的庫,啟用所有功能后,庫大小可以小于 750KiB, 具體取決于目標平臺和編譯器優(yōu)化設置, 內存使用量和速度之間需要權衡,這篇文章主要介紹了SpringBoot項目整合MybatisPlus并使用SQLite作為數(shù)據(jù)庫,需要的朋友可以參考下2024-07-07
Java 數(shù)組獲取最大和最小值的實例實現(xiàn)
這篇文章主要介紹了Java 數(shù)組獲取最大和最小值的實例實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-09-09
mybatis參數(shù)String與Integer類型的判斷方式
這篇文章主要介紹了mybatis參數(shù)String與Integer類型的判斷方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03
Java實戰(zhàn)之晚會抽獎系統(tǒng)的實現(xiàn)
這篇文章主要介紹了如何利用Java語言編寫一個晚會抽獎系統(tǒng),文中采用到的技術有Jdbc、Servlert、JavaScript、JQuery、Ajax等,感興趣的可以學習一下2022-03-03

