深入探究JVM之內存結構及字符串常量池


前言

Java作為一種平台無關性的語言,其主要依靠於Java虛擬機——JVM,我們寫好的代碼會被編譯成class文件,再由JVM進行加載、解析、執行,而JVM有統一的規范,所以我們不需要像C++那樣需要程序員自己關注平台,大大方便了我們的開發。另外,能夠運行在JVM上的並只有Java,只要能夠編譯生成合乎規范的class文件的語言都是可以跑在JVM上的。而作為一名Java開發,JVM是我們必須要學習了解的基礎,也是通向高級及更高層次的必修課;但JVM的體系非常龐大,且術語非常多,所以初學者對此非常的頭疼。本系列文章就是筆者自己對於JVM的核心知識(內存結構、類加載、對象創建、垃圾回收等)以及性能調優的學習總結,另外未特別指出本系列文章都是基於HotSpot虛擬機進行講解。

正文

JVM包含了非常多的知識,比較核心的有內存結構類加載類文件結構垃圾回收執行 引擎性能調優監控等等這些知識,但所有的功能都是圍繞着內存結構展開的,因為我們編譯后的代碼信息在運行過程中都是存在於JVM自身的內存區域中的,並且這塊區域相當的智能,不需要C++那樣需要我們自己手動釋放內存,它實現了自動垃圾回收機制,這也是Java廣受喜愛的原因之一。因此,學習JVM我們首先就得了解其內存結構,熟悉包含的東西,才能更好的學習后面的知識。

內存結構

在這里插入圖片描述

如上圖所示,JVM運行時數據區(即內存結構)整體上划分為線程私有線程共享區域,線程私有的區域生命周期與線程相同,線程共享區域則存在於整個運行期間 。而按照JVM規范細分則分為程序計數器虛擬機棧本地方法棧方法區五大區域(直接內存不屬於JVM)。

1. 程序計數器

如其名,這個部件就是用來記錄程序執行的地址的,循環、跳轉、異常等等需要依靠它。為什么它是線程私有的呢?以單核CPU為例,多線程在執行時是輪流執行的,那么當線程暫停后恢復就需要程序計數器恢復到暫停前的執行位置繼續執行,所以必然是每個線程對應一個。由於它只需記錄一個執行地址,所以它是五大區域中唯一一個不會出現OOM(內存溢出)的區域。另外它是控制我們JAVA代碼的執行的,在調用native方法時該計數器就沒有作用了,而是會由操作系統的計數器控制。

2. 虛擬機棧

虛擬機棧是方法執行的內存區域,每調用一個方法都會生成一個棧幀壓入棧中,當方法執行完成才會彈出棧。棧幀中又包含了局部變量表操作數棧動態鏈接方法出口。其中局部變量表就是用來存儲局部變量的(基本類型值對象的引用),每一個位置32位,而像long/double這樣的變量則需要占用兩個槽位;操作數棧則類似於緩存,用於存儲執行引擎在計算時需要用到的局部變量;動態鏈接這里暫時不講,后面的章節會詳細分析;方法出口則包含異常出口正常出口以及返回地址。下面來看三個方法示例分別展示棧幀的運行原理。

  • 入棧出棧過程
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() {}

}

如上所示的方法調用入棧出棧的過程如下:
在這里插入圖片描述

  • 棧幀執行原理
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命令反編譯后看看生成的字節碼:

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方法中,挨個來解釋(字節碼指令釋義可以參照這篇文章):執行引擎首先通過iconst_3將常量3存入到操作數棧中,然后通過istore_1將該值從操作數棧中取出並存入到局部變量表的1號位(注意局部變量表示從0號開始的,但0號位默認存儲了this變量);接着常量5執行同樣的操作,完成后局部變量表中就存了3個變量(this、3、5);之后通過iload指令將局表變量表對應位置的變量加載到操作數棧中,因為這里有括號,所以先加載兩個變量到操作數棧並執行括號中的加法,即調用iadd加法指令(所有二元算數指令會從操作數棧中取出頂部的兩個變量進行計算,計算結果自動加入到棧中);接着又將常量10壓入到棧中,繼續調用imul乘法指令,完成后需要通過istore命令再將結果存入到局部變量表中,最后通過ireturn返回(不管我們方法是否定義了返回值都會調用該指令,只是當我們定義了返回值時,首先會通過iload指令加載局部變量表的值並返回給調用者)。以上就是棧幀的運行原理。
該區域同樣是線程私有,每個線程對應會生成一個棧,並且每個棧默認大小是1M,但也不是絕對,根據操作系統不同會有所不一樣,另外可以用-Xss控制大小,官方文檔對該該參數解釋如下:
在這里插入圖片描述
既然可以控制大小,那么這塊區域自然就會存在內存不足的情況,對於棧當內存不足時會出現下面兩種異常:

  • 棧溢出(StackOverflowError)
  • 內存溢出(OutOfMemoryError)

為什么會有兩種異常呢?在周志明的《深入理解Java虛擬機》一書中講到,在單線程環境下只會出現StackOverflowError異常,即棧幀填滿了棧或局部變量表過大;而OutOfMemoryError只有當多線程情況下,無節制的創建多個棧才會出現,因為操作系統對於每個進程是有內存限制的,即超出了進程可用的內存,無法創建新的棧。

  • 棧幀共享機制

通過上文我們知道同一個線程內每個方法的調用會對應生成相應的棧幀,而棧幀又包含了局部變量表操作數棧等內容,那么當方法間傳遞參數時是否可以優化,使得它們共享一部分內存空間呢?答案是肯定的,像下面這段代碼:

    public int work(int x) throws Exception{
        int z =(x+5)*10;// 參數會按照順序放到局部變量表
        Thread.sleep(Integer.MAX_VALUE);
        return  z;
    }
    public static void main(String[] args)throws Exception {
        JVMStack jvmStack = new JVMStack();
        jvmStack.work(10);//10  放入操作數棧
    }

在main方法中首先會把10放入操作數棧然后傳遞給work方法,作為參數,會按照順序放入到局部變量表中,所以x會放到局部變量表的1號位(0號位是this),而此時通過HSDB工具查看這時的棧調用信息會發現如下情況:
在這里插入圖片描述
如上圖所示,中間一小塊用紅框圈起來的就是兩個棧幀共享的內存區域。

3. 本地方法棧

和虛擬機棧是一樣的,只不過該區域是用來執行本地本地方法的,有些虛擬機甚至直接將其和虛擬機棧合二為一,如HotSpot。(通過上面的圖也可以看到,最上面顯示了Thread.sleep()的棧幀信息,並標記了native)

4. 方法區

該區域是線程共享的區域,用來存儲已被虛擬機加載的類信息常量靜態變量即時編譯器編譯后的代碼等數據。該區域在JDK1.7以前是以永久代方式實現的,存在於堆中,可以通過-XX:PermSize(初始值)、-XX:MaxPermSize(最大值)參數設置大小;而1.8以后以元空間方式實現,使用的是直接內存(但運行時常量池靜態變量仍放在堆中),可以通過-XX:MetaspaceSize(初始值)、-XX:MaxMetaspaceSize(最大值)控制大小,如果不設置則只受限於本地內存大小。為什么會這么改變呢?因為方法區和堆都會進行垃圾回收,但是方法區中的信息相對比較靜態,回收難以達到成效,同時需要占用的空間大小更多的取決於我們class的大小和數量,即對該區域難以設置一個合理的大小,所以將其直接放到本地內存中是非常有用且合理的。
在方法區中還存在常量池(1.7后放入堆中),而常量池也分了幾種,常常讓初學者比較困惑,比如靜態常量池運行時常量池字符串常量池靜態常量池就是指存在於我們的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

靜態常量池中就是存儲了類和方法的信息符號引用以及字面量等東西,當類加載到內存中后,JVM就會將這些內容存放到運行時常量池中,同時會將符號引用(可以理解為對象方法的定位描述符)解析為直接引用(即對象的內存地址)存入到運行時常量池中(因為在類加載之前並不知道符號引用所對應的對象內存地址是多少,需要用符號替代)。而字符串常量池網上爭議比較多,我個人理解它也是運行時常量池的一部分,專門用於存儲字符串常量,這里先簡單提一下,稍后會詳細分析字符串常量池。

5. 堆

這個區域是垃圾回收的重點區域,對象都存在於堆中(但隨着JIT編譯器的發展和逃逸分析技術的成熟,對象也不一定都是存在於堆中),可以通過-Xms(最小值)、-Xmx(最大值)、-Xmn(新生代大小)、-XX:NewSize(新生代最小值)、-XX:MaxNewSize(新生代最大值)這些參數進行控制。
在堆中又分為了新生代老年代,新生代又分為Eden空間From Survivor空間To Survivor空間。詳細內容后面文章會詳細講解,這里不過多闡述。

6. 直接內存

直接內存也叫堆外內存,不屬於JVM運行時數據區的一部分,主要通過DirectByteBuffer申請內存,該對象存在於堆中,包含了對堆外內存的引用;另外也可以通過Unsafe類或其它JNI手段直接申請內存。它的大小受限於本地內存的大小,也可以通過-XX:MaxDirectMemorySize設置,所以這一塊也會出現OOM異常且較難排查。

字符串常量池

這個區域不是虛擬機規范中的內容,所有官方的正式文檔中也沒有明確指出有這一塊,所以這里只是根據現象推導出結論。什么現象呢?有一個關於字符串對象的高頻面試題:下面的代碼究竟會創建幾個對象?

String str = "abc";
String str1 = new string("cde");

我們先不管這個面試題,先來思考下面代碼的輸出結果是怎樣的(以下試驗基於JDK8,更早的版本結果會有所不同):

 String 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"這個字符串,如果有直接返回引用,如果沒有則創建一個新對象並返回引用;s2你可能會覺得會創建"ab"、"c"和“abc”三個對象,但實際上首先會被編譯器優化為“abc”,所以等同於s1,即直接從字符串常量池返回s1的引用。
  • s1 == s3:s3是通過new創建的,所以這個String對象肯定是存在於堆的,但是其中的char[]數組是引用字符創常量池中的s1,如果在這之前沒有定義的話會先在常量池中創建“abc”對象。所以這里可能會創建一個或兩個對象。
  • s1 == s4:s4通過StringBuilder拼接字符串對象,所以看起來理所當然的s1 != s4,但實際上也沒那么簡單,反編譯上面的代碼會可以發現這里又會被編譯器優化為s4 = "ab" + "c"。猜猜這下會創建幾個對象呢?拋開前面創建的對象的影響,這里會創建3個對象,因為與s2不同的是s4是編譯器優化過后還存在“+”拼接,因此會在字符創常量池創建“ab”、"c"以及“abc”三個對象。前兩個可以反編譯看字節碼指令或是通過內存搜索驗證,而第三個的驗證稍后進行。
  • s1 == s3.intern/s4.intern:這兩個為什么是true呢?先來看看周志明在《深入理解Java虛擬機》書中說的:

使用String類的intern方法動態添加字符串常量到運行時常量池中(intern方法在1.6和1.7及以后的實現不相同,1.6字符串常量池放於永久代中,intern會把首次遇到的字符串實例復制永久代中並返回永久代中的引用,而1.7及以后常量池也放入到了堆中,intern也不會再復制實例,只是在常量池中記錄首次出現的實例引用)。

上面的意思很明確,1.7以后intern方法首先會去字符串常量池尋找對應的字符串,如果找到了則返回對應的引用,如果沒有找到則先會在字符串常量池中創建相應的對象。因此,上面s3和s4調用intern方法時都是返回s1的引用。
看到這里,相信各位讀者基本上也都能理解了,對於開始的面試題應該也是心中有數了,最后再來驗證剛剛說的“第三個對象”的問題,先看下面代碼:

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會先去字符串常量池找,找到則返回引用,否則在字符創常量池創建一個對象,所以第一段代碼結果等於true正好說明了通過StringBuilder拼接的字符串會存到字符串常量池中;而第二段代碼中,在StringBuilder拼接字符串之前已經優先使用new創建了字符串,也就會在字符串常量里創建“abc”對象,因此s4.intern返回的是該常量的引用,和s4不相等。你可能會說是因為優先調用了s3.intern方法,但即使你去掉這一段,結果還是一樣的,也剛好驗證了new String("abc")會創建兩個對象(在此之前沒有定義“abc”字面量,就會在字符串常量池創建對象,然后堆中創建String對象並引用該常量,否則只會創建堆中的String對象)。

總結

本文是JVM系列的開篇,主要分析JVM的運行時數據區、簡單參數設置和字節碼閱讀分析,這也是學習JVM及性能調優的基礎,讀者需要深刻理解這些內容以及哪些區域會發生內存溢出(只有程序計數器不會內存溢出),另外關於運行時常量池和字符串常量池的內容也需要理解透徹。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM