前言
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() {}}
如上所示的方法调用入栈出栈的过程如下:
-
栈帧执行原理
局部变量表
局部变量表(Local Variables Table)用来保存方法open in new window中的局部变量,以及方法参数。当 Java 源代码文件被编译成 class 文件的时候,局部变量表的最大容量就已经确定了。
我们来看这样一段代码。
public class LocalVaraiablesTable {private void write(int age) {String name = "沉默王二";}
}
write()
方法有一个参数 age,一个局部变量 name。
然后用 Intellij IDEA 的 jclasslib 查看一下编译后的字节码文件 LocalVaraiablesTable.class。可以看到 write()
方法的 Code 属性中,Maximum local variables(局部变量表的最大容量)的值为 3。
按理说,局部变量表的最大容量应该为 2 才对,一个 age,一个 name,为什么是 3 呢?
当一个成员方法(非静态方法)被调用时,第 0 个变量其实是调用这个成员方法的对象引用,也就是那个大名鼎鼎的 thisopen in new window。调用方法 write(18)
,实际上是调用 write(this, 18)
。
点开 Code 属性,查看 LocalVaraiableTable 就可以看到详细的信息了。
第 0 个是 this,类型为 LocalVaraiablesTable 对象;第 1 个是方法参数 age,类型为整型 int;第 2 个是方法内部的局部变量 name,类型为字符串 String。
当然了,局部变量表的大小并不是方法中所有局部变量的数量之和,它与变量的类型和变量的作用域有关。当一个局部变量的作用域结束了,它占用的局部变量表中的位置就被接下来的局部变量取代了。
来看下面这段代码。
public static void method() {// ①if (true) {// ②String name = "沉默王二";}// ③if(true) {// ④int age = 18;}// ⑤
}
method()
方法的局部变量表大小为 1,因为是静态方法,所以不需要添加 this 作为局部变量表的第一个元素;- ②的时候局部变量有一个 name,局部变量表的大小变为 1;
- ③的时候 name 变量的作用域结束;
- ④的时候局部变量有一个 age,局部变量表的大小为 1;
- ⑤的时候局 age 变量的作用域结束;
关于局部变量的作用域,《Effective Java》 中的第 57 条建议:
将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。
在此,我还有一点要提醒大家。为了尽可能节省栈帧耗用的内存空间,局部变量表中的槽是可以重用的,就像 method()
方法演示的那样,这就意味着,合理的作用域有助于提高程序的性能。是不是很有意思?
局部变量表的容量以槽(slot)为最小单位,一个槽可以容纳一个 32 位的数据类型(比如说 int,当然了,《Java 虚拟机规范》中没有明确指出一个槽应该占用的内存空间大小,但我认为这样更容易理解),像 float 和 double 这种明确占用 64 位的数据类型会占用两个紧挨着的槽。
来看下面的代码。
public void solt() {double d = 1.0;int i = 1;
}
用 jclasslib 可以查看到,solt()
方法的 Maximum local variables 的值为 4。
为什么等于 4 呢?带上 this 也就 3 个呀?
查看 LocalVaraiableTable 就明白了,变量 i 的下标为 3,也就意味着变量 d 占了两个槽。
操作数栈
同局部变量表一样,操作数栈(Operand Stack)的最大深度也在编译的时候就确定了,被写入到了 Code 属性的 maximum stack size 中。当一个方法刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和取出数据,也就是入栈和出栈操作。
来看下面这段代码。
public class OperandStack {public void test() {add(1,2);}private int add(int a, int b) {return a + b;}
}
OperandStack 类共有 2 个方法,test()
方法中调用了 add()
方法,传递了 2 个参数。用 jclasslib 可以看到,test()
方法的 maximum stack size 的值为 3。
这是因为调用成员方法的时候会将 this 和所有参数压入栈中,调用完毕后 this 和参数都会一一出栈。通过 「Bytecode」 面板可以查看到对应的字节码指令。
- aload_0 用于将局部变量表中下标为 0 的引用类型的变量,也就是 this 加载到操作数栈中;
- iconst_1 用于将整数 1 加载到操作数栈中;
- iconst_2 用于将整数 2 加载到操作数栈中;
- invokevirtual 用于调用对象的成员方法;
- pop 用于将栈顶的值出栈;
- return 为 void 方法的返回指令。
字节码指令open in new window前面我们已经讲过了,忘记的球友open in new window可以再回顾一下。再来看一下 add()
方法的字节码指令。
- iload_1 用于将局部变量表中下标为 1 的 int 类型变量加载到操作数栈上(下标为 0 的是 this);
- iload_2 用于将局部变量表中下标为 2 的 int 类型变量加载到操作数栈上;
- iadd 用于 int 类型的加法运算;
- ireturn 为返回值为 int 的方法返回指令。
操作数中的数据类型必须与字节码指令匹配,以上面的 iadd 指令为例,该指令只能用于整型数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能出现一个 long 型和一个 double 型的数据进行 iadd 命令相加的情况。
动态链接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。
图片来源于网络,作者浣熊say
①、前面open in new window我们就讲过,方法区是 JVM 的一个运行时内存区域,属于逻辑定义,不同版本的 JDK 都有不同的实现,但主要的作用就是用于存储已被虚拟机加载的类信息、常量、静态变量,以及即时编译器编译后的代码等。
②、运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号引用——在类加载后进入运行时常量池。关于方法区open in new window我们也会在后面进行详细地讲解。
来看下面这段代码。
public class DynamicLinking {static abstract class Human {protected abstract void sayHello();}static class Man extends Human {@Overrideprotected void sayHello() {System.out.println("男人哭吧哭吧不是罪");}}static class Woman extends Human {@Overrideprotected void sayHello() {System.out.println("山下的女人是老虎");}}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();man.sayHello();woman.sayHello();man = new Woman();man.sayHello();}
}
大家对 Java 重写open in new window有了解的话,应该能看懂这段代码的意思。Man 类和 Woman 类继承了 Human 类,并且重写了 sayHello()
方法。来看一下运行结果:
男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎
这个运行结果很好理解,man 的引用类型为 Human,但指向的是 Man 对象,woman 的引用类型也为 Human,但指向的是 Woman 对象;之后,man 又指向了新的 Woman 对象。
从面向对象编程的角度,从多态的角度,我们对运行结果是很好理解的,但站在 Java 虚拟机的角度,它是如何判断 man 和 woman 该调用哪个方法的呢?
用 jclasslib 看一下 main 方法的字节码指令。
- 第 1 行:new 指令创建了一个 Man 对象,并将对象的内存地址压入栈中。
- 第 2 行:dup 指令将栈顶的值复制一份并压入栈顶。因为接下来的指令 invokespecial 会消耗掉一个当前类的引用,所以需要复制一份。
- 第 3 行:invokespecial 指令用于调用构造方法进行初始化。
- 第 4 行:astore_1,Java 虚拟机从栈顶弹出 Man 对象的引用,然后将其存入下标为 1 局部变量 man 中。
- 第 5、6、7、8 行的指令和第 1、2、3、4 行类似,不同的是 Woman 对象。
- 第 9 行:aload_1 指令将第局部变量 man 压入操作数栈中。
- 第 10 行:invokevirtual 指令调用对象的成员方法
sayHello()
,注意此时的对象类型为com/itwanger/jvm/DynamicLinking$Human
。 - 第 11 行:aload_2 指令将第局部变量 woman 压入操作数栈中。
- 第 12 行同第 10 行。
注意,从字节码的角度来看,man.sayHello()
(第 10 行)和 woman.sayHello()
(第 12 行)的字节码是完全相同的,但我们都知道,这两句指令最终执行的目标方法并不相同。
究竟发生了什么呢?
还得从 invokevirtual
这个指令着手,看它是如何实现多态的。根据《Java 虚拟机规范》,invokevirtual 指令在运行时的解析过程可以分为以下几步:
- ①、找到操作数栈顶的元素所指向的对象的实际类型,记作 C。
- ②、如果在类型 C 中找到与常量池中的描述符匹配的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;否则返回
java.lang.IllegalAccessError
异常。 - ③、否则,按照继承关系从下往上一次对 C 的各个父类进行第二步的搜索和验证。
- ④、如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常。
也就是说,invokevirtual 指令在第一步的时候就确定了运行时的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法版本,这个过程就是 Java 重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态链接(运行时多态的实现原理)。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 正常退出,可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型根据方法返回的指令来决定,像之前提到的 ireturn 用于返回 int 类型,return 用于 void 方法;还有其他的一些,lreturn 用于 long 型,freturn 用于 float,dreturn 用于 double,areturn 用于引用类型。
- 异常退出,方法在执行的过程中遇到了异常open in new window,并且没有得到妥善的处理,这种情况下,是不会给它的上层调用者返回任何值的。
无论是哪种方式退出,在方法退出后,都必须返回到方法最初被调用时的位置,程序才能继续执行。一般来说,方法正常退出的时候,PC 计数器的值会作为返回地址,栈帧中很可能会保存这个计数器的值,异常退出时则不会。
PC 计数器:JVM 运行时数据区的一部分,跟踪当前线程执行字节码的位置。
方法退出的过程实际上等同于把当前栈帧出栈,因此接下来可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值,找到下一条要执行的指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。
#StackOverflowError
下面这段代码在运行的时候会抛出 StackOverflowError 异常。
public class StackOverflowErrorTest {public static void main(String[] args) {StackOverflowErrorTest test = new StackOverflowErrorTest();test.testStackOverflowError();}public void testStackOverflowError() {testStackOverflowError();}
}
我们来看一下异常的堆栈信息。
之所以抛出 StackOverflowError 异常,是因为在执行 testStackOverflowError()
方法的时候,会创建一个栈帧,然后调用 testStackOverflowError()
方法,又会创建一个栈帧,然后调用 testStackOverflowError()
方法,又会创建一个栈帧……这样一直循环下去,直到栈内存溢出。
我们来简单改造了一下代码,看一下异常的堆栈信息。
public class StackOverflowErrorTest1 {private static AtomicInteger count = new AtomicInteger(0);public static void main(String[] args) {while (true) {testStackOverflowError();}}public static void testStackOverflowError() {System.out.println(count.incrementAndGet());testStackOverflowError();}
}
在 10924 次的时候,抛出了 StackOverflowError 异常。大家可以试试自己的本地环境,看多少次的时候会抛出异常。
#小结
栈帧是 JVM 中用于方法执行的数据结构,每当一个方法被调用时,JVM 会为该方法创建一个栈帧,并在方法执行完毕后销毁。
- 局部变量表:存储方法的参数和局部变量,由基本数据类型或对象引用组成。
- 操作数栈:后进先出(LIFO)的栈结构,用于存储操作数和中间计算结果。
- 动态链接:关联到方法所属类的常量池,支持动态方法调用。
- 方法返回地址:记录方法结束后控制流应返回的位置。
栈帧是线程私有的,每个线程有自己的 JVM 栈。方法调用时,新栈帧被推入栈顶;方法完成后,栈帧出栈。
栈帧的局部变量表的大小和操作数栈的最大深度在编译时就已确定。栈空间不足时可能引发 StackOverflowError
。理解栈帧对于深入理解 Java 程序的运行机制至关重要。
实际例子
-
public class Test01 {public static void main(String[] args){int i=1;i=i++;int j=i++;int k=i+ ++i * i++;out.println("i="+i);out.println("j="+j);out.println("k="+k);}
0 iconst_1
- 1 istore_1
- 2 iload_1
- 3 iinc 1 by 1
- 6 istore_1
- 7 iload_1
- 8 iinc 1 by 1
- 11 istore_2
- 12 iload_1
- 13 iinc 1 by 1
- 16 iload_1
- 17 iload_1
- 18 iinc 1 by 1
- 21 imul
- 22 iadd
- 23 istore_3
所以,我们现在解释一下上面的代码。int i = 1;发生了两个过程,iconst_1 是将 int 型的 1 推送至栈顶。istore_1 把栈顶的元素弹出,并赋值给局部变量表中位置为“1”的变量,此时指变量i。这两句就相当于 int i = 1; i = i++; 代码解释:iload_1 把局部变量表中位置为“1”的变量加载到栈顶,即把 i 的值加载到栈顶。iinc 1 by 1,将局部变量表中位置为“1”的 i 加 1(i++是直接在局部变量表加的,没有在栈里运算),此时局部变量表中 i 的结果为 2。然后 istore_1 把栈顶的元素弹出,并赋值给局部变量表中位置为“1”的变量。所以 i 的值又被改为了 1。 int j = i++; 代码解释:iload_1 把局部变量表中位置为“1”的变量加载到栈顶,即把 i 的值加载到栈顶。iinc 1 by 1 将局部变量表中位置为“1”的 i 加 1,此时结果为 2,也就是局部变量表中 i 的结果为 2。istore_2 把栈顶的元素弹出并赋值给局部变量表中位置为“2”的 j。所以 j 是 1,但是 i 的值已经为 2。 int k = i + ++i * i++; 这个是最复杂的,我们直接看 JVM 指令即可。iload_1 把局部变量表中位置为“1”的变量加载到栈顶,即把 i 的值加载到栈顶,注意 i 的值此时是 2。iinc 1 by 1,i 自增,然后 i 就变成 3 了。接着两个 iload_1、iload_1分别把局部变量 i 压到栈了。所以栈中现在是 3、3、2。然后执行 iinc 1 by 1,i 又自增了,这时把局部变量表中的 i 就变成 4 了,注意这个 4 并未压入栈。之后 imul 进行乘法计算,栈中的前两个元素计算后是 9,之后执行 iadd 指令,也就是 9 + 2,结果为 11。最后 istore_3 把 11 从栈顶弹出,并赋值给 k,也就是局部变量表中位置为“3”的 k 的值是 11。
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的大小和数量,即对该区域难以设置一个合理的大小,所以将其直接放到本地内存中是非常有用且合理的。
与永久代不同,元空间具有一些优点,例如:
- 它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。
- 元空间使用本机内存,而不是 JVM 堆内存,这可以避免堆内存的碎片化问题。
- 元空间中的垃圾收集与堆中的垃圾收集是分离的,这可以避免应用程序在运行过程中因为进行类加载和卸载而频繁地触发 Full GC。
在方法区中还存在常量池(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及性能调优的基础,读者需要深刻理解这些内容以及哪些区域会发生内存溢出(只有程序计数器不会内存溢出),另外关于运行时常量池和字符串常量池的内容也需要理解透彻。