面试时必问的JVM运行时数据区详解
作者:程序员囧辉
前言
Java 虚拟机的运行时数据区经常在面试中被拿来提问,很多概念在市面上有各种各样的说法,搞的不少同学应该是懵逼的。
当我们陷入不知道哪个说法是正确的情况时,最好的参考就是源码和规范。
在面试中,当面试官反问你:为什么某某是这样?的时候,如果你回答:因为规范是这么写的、因为源码是这么写的。
这个回答是非常有说服力的。
因此,本文在描述一些有争议的问题上,优先以《Java 虚拟机规范》的说法为准。
正文
1、运行时数据区(Run-Time Data Areas)
Java 虚拟机定义了若干种在程序执行期间会使用到的运行时数据区域。
其中一些数据区域在 Java 虚拟机启动时被创建,随着虚拟机退出而销毁。也就是线程间共享的区域:堆、方法区、运行时常量池。
另外一些数据区域是按线程划分的,这些数据区域在线程创建时创建,在线程退出时销毁。也就是线程间隔离的区域:程序计数器、Java虚拟机栈、本地方法栈。
1)程序计数器(Program Counter Register)
Java 虚拟机可以支持多个线程同时执行,每个线程都有自己的程序计数器。在任何时刻,每个线程都只会执行一个方法的代码,这个方法称为该线程的当前方法(current method)。
如果线程正在执行的是 Java 方法(不是 native 的),则程序计数器记录的是正在执行的 Java 虚拟机字节码指令的地址。如果正在执行的是本地(native)方法,那么计数器的值是空的(undefined)。
2)Java虚拟机栈(Java Virtual Machine Stacks)
每个 Java 虚拟机线程都有自己私有的 Java 虚拟机栈,它与线程同时创建,用于存储栈帧。
Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
3)本地方法栈(Native Method Stacks)
本地方法栈与 Java 虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是 Java 虚拟机栈为虚拟机执行 Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地(Native)方法服务。
4)堆(Heap)
堆是被各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。
堆在虚拟机启动时创建,堆存储的对象不会被显示释放,而是由垃圾收集器进行统一管理和回收。
5)方法区(Method Area)
方法区是被各个线程共享的运行时内存区域。方法区类似于传统语言的编译代码的存储区。它存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据,构造函数和普通方法的字节码内容,还包括一些用于类、实例、接口初始化用到的特殊方法。
6)运行时常量池(Run-Time Constant Pool)
运行时常量池是 class 文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式。
它包含了若干种常量,从编译时已知的数值字面量到必须在运行时解析后才能获得的方法和字段引用。运行时常量池的功能类似于传统编程语言的符号表(symbol table),不过它包含的数据范围比通常意义上的符号表要更为广泛。
2、Java 中有哪几种常量池?
现在我们经常提到的常量池主要有三种:class 文件常量池、运行时常量池、字符串常量池。
3、class 文件常量池
class 文件常量池(class constant pool)属于 class 文件的其中一项,class 类文件包含:魔数、类的版本、常量池、访问标志、字段表集合、方发表等信息。
常量池用于存放编译期间生成的各种字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
符号引用则属于编译原理方面的概念。符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。符号引用主要包括下面几类常量:
- 被模块导出或开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
常量池中每一项常量都是一个表,截至JDK 13,常量表中分别有17种不同类型的常量。17种常量类型所代表的具体含义如图所示。
关于 class 文件常量池的更多内容可以阅读周志明的《深入理解Java虚拟机》6.3.2 章节。
4、运行时常量池
class 文件常量池是在类被编译成 class 文件时生成的。而当类被加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时常量池中。
Java 虚拟机规范中对运行时常量池的定义如下:
A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a class file.
运行时常量池是 class 文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式。
因此,根据规范定义,可以说运行时常量池是 class 文件常量池的运行时表示,每个类在运行时都有自己的一个独立的运行时常量池。
5、字符串常量池
简单来说,HotSpot VM 里的字符串常量池(StringTable)是个哈希表,全局只有一份,被所有的类共享。
StringTable 具体存储的是 String 对象的引用,而不是 String 对象实例自身。String 对象实例在 JDK 6 及之前是在永久代里,从JDK 7 开始放在堆里。
根据 Java 虚拟机规范的定义,堆是存储 Java 对象的地方,其他地方是不会有 Java 对象实体的,如果有的话,根据规范定义,这些地方也要算堆的一部分。
6、字符串常量池是否属于方法区?
我认为是不属于的。
在读本文之前,我相信很多同学会有如下观点:因为运行时常量池属于方法区,所以很多同学认为字符串常量池也应该属于方法区。
但是相信看了上面的内容后,会开始意识到,运行时常量池和字符串常量池其实是不同的两个东西,当然它们在字符串解析时会有关联。
Java 虚拟机规范中对方法区的定义如下:
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the "text" segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization
在 Java 虚拟机中,方法区是被各个线程共享的运行时内存区域。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的文本段。它存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据,构造函数和普通方法的字节码内容,还包括一些用于类、实例、接口初始化用到的特殊方法。
这边的关键在于 “它存储了每一个类的结构信息”,而字符串常量池并不属于某个类,字符串常量是全局共享的,因此,根据规范定义,我们可以说字符串常量池不属于方法区。
那字符串常量池(StringTable)究竟存在哪里了?
StringTable 本体是存储在 native memory(本地内存)里,不是在永久代里,不是在方法区里,当然,更不是在堆里。
7、运行时常量池和字符串常量池的关联?
上面说了,运行时常量池和字符串常量池在字符串解析时会有关联,具体如下。
类的运行时常量池中有 CONSTANT_String_info(见题3表格)类型的常量,CONSTANT_String_info 类型的常量的解析(resolve)过程如下:
首先到字符串常量池(StringTable)中查找是否已经有了该字符串的引用,如果有,则直接返回字符串常量池的引用;如果没有,则在堆中创建 String 对象,并在字符串常量池驻留其引用,然后返回该引用。
也就说,运行时常量池里的 CONSTANT_String_info 类型的常量,经过解析(resolve)之后,同样存的是字符串的引用,并且和 StringTable 驻留的引用的是一致的。
8、String#intern 方法
在 JDK 7 及之后的版本中,该方法的作用如下:如果字符串常量池中已经有这个字符串,则直接返回常量池中的引用;如果没有,则将这个字符串的引用保存一份到字符串常量池,然后返回这个引用。
下面的例子可以进行简单的验证:
public static void main(String args[]) { // 创建2个对象,str持有的是new创建的对象引用 // 1)驻留(intern)在字符串常量池中的对象 // 2)new创建的对象 String str = new String("joonwhee"); // 字符串常量池中已经有了,返回字符串常量池中的引用 String str2 = "joonwhee"; // false,str为new创建的对象引用,str2为字符创常量池中的引用 System.out.println(str == str2); // str修改为字符串常量池的引用,所以下面为true str = str.intern(); // true System.out.println(str == str2); }
9、永久代(PermGen)
永久代在 Java 8 被移除。根据官方提案的描述,移除的主要动机是:要将 JRockit 和 Hotspot 进行融合,而 JRockit 并没有永久代。
而据我们所了解的,还有另外一个重要原因是永久代本身也存在较多的问题,经常出现OOM,还出过不少bug。
根据官方提案的描述,永久代主要存储了三种数据:
1)Class metadata(类元数据),也就是方法区中包含的数据,除了编译生成的字节码被放在 native memory(本地内存)。
2)interned Strings,也就是字符串常量池中驻留引用的字符串对象,字符串常量池只驻留引用,而实际对象是在永久代中。
3)class static variables,类静态变量。
移除永久代后,interned Strings 和 class static variables 被移动了堆中,Class metadata 被移动到了后来的元空间。
10、永久代和方法区的关系?
方法区是 Java 虚拟机规范中定义的一种逻辑概念,而永久代是对方法区的实现。但是永久代并不等同于方法区,方法区也不等同于永久代。
永久代中的 interned Strings 并不属于方法区,按规范:堆是存储 Java 对象的地方 ,这部分应该属于堆,因此永久代并不是只用于实现方法区。
方法区中 JIT 编译生成的代码并不是存放在永久代,而是在 native memory 中,因此可以说方法区也并不只是由永久代来实现。
11、元空间(metaspace)
元空间在 Java 8 移除永久代后被引入,用来代替永久代,本质和永久代类似,都是对方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存(native memory)。
元空间主要用于存储 Class metadata(类元数据),根据其命名其实也看得出来。
可以通过 -XX:MaxMetaspaceSize 参数来限制元空间的大小,如果没有设置该参数,则元空间默认限制为机器内存。
12、为什么引入元空间?
在 Java 8 之前,Java 虚拟机使用永久代来存放类元信息,通过-XX:PermSize、-XX:MaxPermSize 来控制这块内存的大小,随着动态类加载的情况越来越多,这块内存变得不太可控,到底设置多大合适是每个开发者要考虑的问题。
如果设置小了,容易出现内存溢出;如果设置大了,又有点浪费,尽管不会实质分配这么大的物理内存。
而元空间可以较好的解决内存设置多大的问题:当我们没有指定 -XX:MaxMetaspaceSize 时,元空间可以动态的调整使用的内存大小,以容纳不断增加的类。
13、元空间能彻底解决内存溢出(Out Of Memory)问题吗?
很遗憾,答案是不行的。
元空间无法彻底解决内存溢出的问题,只能说是有所缓解。当内存使用完毕后,元空间一样会出现内存溢出的情况,最典型的场景就是出现了内存泄漏时。
总结
本篇文章就到这里了,希望能给你带来帮助,也希望您能够多多关注脚本之家的更多内容!