深入探究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及性能调优的基础,读者需要深刻理解这些内容以及哪些区域会发生内存溢出(只有程序计数器不会内存溢出),另外关于运行时常量池和字符串常量池的内容也需要理解透彻。

posted @ 2020-07-21 09:28  夜勿语  阅读(799)  评论(0编辑  收藏  举报