java虚拟机内存详解

本篇博客主要讲解了java虚拟机的内存区域

1. 前言

在正篇开始前,我们先来看看两张图:
C++垃圾收集机制
java垃圾收集机制
相信以上两张图大家都看过吧,对于C/C++程序员来说,每一个对象在创建的时候都需要自己手动去写 delete/free 操作来释放内存(如图一,是不是很形象),虽然这样可以保证每一个对象都被正确的释放内存,避免内存溢出和内存泄漏的问题。但是,这是一个相当耗费精气神的事情,有时自己因为疏忽未释放对象导致产生问题。java 则和C/C++不同,程序员把内存控制权利交给 Java 虚拟机,然而,虚拟机不是万能的,如果我们不了解虚拟机内存状态,一旦出现内存泄漏和溢出方面的问题,那么排查错误是非常困难的(如图二,工具不是万能的)。

2. java虚拟机运行时数据库

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,以下是根据《java虚拟机规范(SE 7版)》的规定下,java内存区域的划分。
JVM内存模型

2.1 程序计数器

程序计数器(Program Counter Register)是一块极小的内存空间,他可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值开选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

因为现在一个处理器(多核处理器来说是一个内核)只会执行一条程序指令,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域。

2.2 java虚拟机栈

java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同,虚拟机栈描述的是java方法执行的内存模型,每个方法在执行的时候会创建一个栈帧来存储局部变量表、操作数栈、动态链接、方法出入口等信息,每一个方法的执行到结束,实际都是一个栈帧在虚拟机栈中入栈到出栈的过程。

  1. 局部变量表:局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象,可能是一个指向对象起始地址的指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

  2. 操作数栈:操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。有关操作数栈的详细指令可参考:jvm字节码学习与理解

  3. 动态连接:在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。 这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。

  4. 方法返回:在方法正常完成退出或者异常完成退出后都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。当方法正常完成退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展(大部分虚拟机都可动态拓展,只是Java虚拟机规范中允许固定长度的虚拟机栈),那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

注意:在32位虚拟机上,long和double占用了2个word,因此对于long和double的操作是非原子性的,在64位虚拟机上,long和double的操作是原子性的,为了程序的可移植性,最好在涉及原子操作时使用volatile保障其原子性

2.3 java堆

Java堆(Heap)是虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

Java堆在没有内存完成实例分配,并且堆也无法再拓展的时候,会抛出OutOfMemoryError。

2.4 方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出(OutOfMemoryError)问题。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。

注意:我们要区分开JMM(java内存模型)和JVM虚拟机的区别,JMM定义了是 JVM 的规范,而后者则是 JVM 规范的一种实现,在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

2.5 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

注意:为了方便JDK1.8中永久代向元空间转变,在JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

2.6 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。虽然本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

3. 有关字符串的补充

3.1 String对象的创建:

1
2
3
4
5
public void test() {
String a = "abc";
String b = new String("abc");
System.out.println(a == b); // false
}

在图上例子中,a = “abc” 会首先在常量池中查找是否存在该字符串,如果存在则a直接指向常量池中的 “abc”;如果不存在,则在常量池中创建 “abc” ,之后将a指向该字符串。 b = new String(“abc”) 则是通过 new 关键字,直接在堆内分配内存创建 “abc”,之后再将 b 指向该字符串。另外,只要是通过 new 创建的对象,都是直接在堆内分配内存。因此,a == b 的结果是false,因为一个指向的是常量池中的字符串,一个指向的是堆中的字符串。

3.2 String.intern()

1
2
3
4
5
6
public void test() {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}

这段代码在JDK1.6中,会得到两个false,而在JDK1.7中,会得到一个true和一个false。产生差异的原因是:在JDK1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中(即永久代中的字符串常量池中),返回的也是永久代中这个字符串实例的引用。而StringBuilder创建的字符串实例在java堆上,所以必然不是一个引用,因此都返回false。

而在JDK1.7中的 intern() 不会再复制实例,只是在常量池中记录首次出现的实例医用,因此 intern() 返回的引用和由StringBuilder创建的那个字符串是同一个实例。对str2比较返回false的原因是因为“java”这个字符串在执行StringBuilder.toString()就出现过了(注:java在虚拟机启动的时候内部初始化并使用过),所以字符串常量池中已经有了它的引用,不符合“首次出现”的原则。而“计算机软件”这个字符串则是首次出现,因此返回true。

3.2 有关字符串相加编译器的优化

前景提要:本代码的编译环境为JDK1.8,用到了上一节 String.intern() 的知识点

1
2
3
4
5
6
7
8
9
10
public void test() {
String sbBc = new StringBuilder("b").append("c").toString();
String str1 = "a" + "bc";
String str2 = "abc";
// System.out.println(sbBc.intern() == sbBc); // true
String str3 = str1 + "b" + "c";
System.out.println(str1 == str2); // true
System.out.println(str2 == str3); // false
// System.out.println(sbBc.intern() == sbBc); // false
}

编译器在进行编译的时候会有一些优化操作,例如从”str1 == str2” 的输出是true的情况中我们可以看出,编译器在两个常量直接相加的时候,会直接将常量编译成一个整体,即将 “a” + “bc” 直接编译成 “abc”。 因此,str1 == str2 输出为 true。为了验证我们可以把第四行被注释的输出语句放开,我们会发现他的结果是true,这说明在str1的赋值中,是没有生成 “bc” 这个常量的,而是直接编译成了 “abc”。

而在有变量的情况下,编译器无法识别变量 a, 但是编译器依然会将其优化成 StringBuild.append() 进行相加之后调用 toString() 方法。并且,编译器还能智能的识别到”b” + “c”这个可以直接编译成 “bc”,因此在 append() 调用的时候,编译器会直接优化成 append(“bc”) 而不是append(“b”).append(“c”) ,验证的时候,我们只需要注释掉第一个打印输出,放开最后一个打印输出,你会发现最终输出的结果是false,这能够证明 “bc” 常量是在优化成 append(“bc”) 的时候生成在常量池中的。

当然,我还会从java编译后的字节码中进一步验证这个知识点,上述代码生成的字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
  public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/StringBuilder
3: dup
4: ldc #3 // String b
6: invokespecial #4 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: ldc #5 // String c
11: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
17: astore_1
18: ldc #8 // String abc
20: astore_2
21: ldc #8 // String abc
23: astore_3
24: new #2 // class java/lang/StringBuilder
27: dup
28: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
31: aload_2
32: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
35: ldc #10 // String bc
37: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
40: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
43: astore 4
45: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
48: aload_1
49: invokevirtual #12 // Method java/lang/String.intern:()Ljava/lang/String;
52: aload_1
53: if_acmpne 60
56: iconst_1
57: goto 61
60: iconst_0
61: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
64: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
67: aload_2
68: aload_3
69: if_acmpne 76
72: iconst_1
73: goto 77
76: iconst_0
77: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
80: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
83: aload_3
84: aload 4
86: if_acmpne 93
89: iconst_1
90: goto 94
93: iconst_0
94: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
97: return
}

字节码我就不解释了,大家有兴趣可以看参考这个网站:jvm字节码学习与理解。字节码对应的java代码解释如下:

1
2
3
4
5
6
7
8
9
10
0-6:String sbBc = new StringBuilder("b")
9-11:sbBc.append("c")
14: sbBc.toString() // 至此,整体对应为:String sbBc = new StringBuilder("b").append("c").toString()。
17-18 String str1 = "abc"
20-21 String str2 = "abc"
23-28 String str3 = new StringBuilder()
31-32 str3.append(a)
35 将bc入栈放入常量池
37 str3.append("bc")
40 str3.toString()

在 17-18 和 20-21 中我们可以发现str1和str2生成的字节码完全一样,证明了编译器的第一点优化。而 23-40 所对应的字节码我们可以发现,编译器加字符串相加优化成了StringBuilder的 append() 调用,同时编译器还将将后面的 “b” + “c” 优化成了 “bc”。

4 来点哲学?

最近空间看到一条说说:“有的人没有得到什么,却希望别人能够得到;有的人没有得到什么,却希望别人也得不到。”看完之后,还是有点感触的。我向来都是支持人性本恶的观点,一切的“善”不过是一种道德思维以及修养的学习与提升。因为恶往往是与生俱来对自我的放纵,所以我们要学做人,学处事之道。而此道并无终点,有些人总是徘徊在原点嘲笑别人无止境的前行,企图将别人和自己捆绑在“地狱”之中。这和学生时期不肯学习的人嘲笑别人学习竟如出一辙,或许是同一批人长大了吧。