JVM(一)运行时数据区域和垃圾回收
虚拟机和Java语言一开始的设计是独立的,这也产生了很多面向虚拟机的语言,如Groovy等,它们最终生成格式标准的字节码文件被虚拟机装载和解析。本文对虚拟机的一些核心内容进行介绍,包括运行时数据区域和垃圾回收。
运行时数据区域(Run-Time Data Areas)
Java虚拟机定义了在程序执行期间使用的各种运行时数据区域,其中一些数据区域是在Java虚拟机启动时创建的,仅在Java虚拟机退出时销毁。还有一些数据是每个线程拥有,这些线程数据区域是在线程退出时创建和销毁线程时创建的。我们先来看看一张图(Java Garbage Collection Basics):

从图中可以看到,总共分为5个区域,方法区和堆是所有线程共享的,其余三个区域是线程私有的。
1.程序计数器Program counter register
每个线程都拥有自己的pc Register,可以简单的理解为当前线程的执行位置。在任意时刻,一条JVM线程只会执行一个方法的代码,该方法称为该线程的当前方法(Current Method), 如果该方法是Java方法,那计数器保存JVM正在执行的字节码指令的地址,如果该方法是Native,那PC寄存器的值为空(Undefined)。
2.栈Java Stacks
每个线程都私有一个线程栈,存储线程使用的局部变量等信息。当前栈的大小可以通过命令java -XX:+PrintFlagsInitial | grep ThreadStackSize查看,通过-XX:ThreadStackSize 或者 -Xss设置栈大小。
栈描述了Java的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。在主内存中的共享变量在每个线程栈中都有个副本,它们之间会进行同步。
关于栈定义了如下两种异常:
- StackOverflowError:栈调用深度超过了限制
- OutOfMemoryError:栈空间不足
3.本地方法栈Native Method Stacks
本地方法栈也是每个线程私有的,用来支持native方法的执行,它和Java栈很相似,异常定义也是一致的。
4.堆Heap
堆是所有线程共享的区域,所有类的实例和数组都会分配到此区域。线程的数据区域是伴随着线程的创建和销毁,而共享区域堆是随着JVM启动的时候创建的。堆中对象存储管理是一种自动存储管理系统:垃圾收集机制。
堆的大小有以下参数可以设置:
- -Xms 或者 -XX:InitialHeapSize:堆的初始化大小,必须是1024的倍数且大于1M
- -Xmx 或者 -XX:MaxHeapSize:堆的最大小,必须是1024的倍数且大于2M,如果你知多大的堆会使你的程序工作良好,可以xms和xm通常设成一样的值,服务端一般都是如此设置。
虚拟机中的堆又根据垃圾回收策略不同,分为年轻代(young generation)和老年代(old generation),年轻代又分为Eden区和两个Survivor区,关于每个区的具体作用,会在下文垃圾收集描述中介绍。
调节年轻代大小的JVM参数是:
- -XX:NewSize 设置堆内年轻代初始化大小
- -XX:MaxNewSize 设置堆内年轻代最大大小
- -Xmn 设置年轻代初始化和最大大小
年轻代在堆中的主要作用是用来存储新new的对象,这个区域的G较其它区域更频繁。如果年轻代设置过小,那么就会频繁的GC,如果设置过大,那么产生FullGC的时候又很耗时,一般推荐年轻代是整个堆大小的二分之一或者四分之一,默认比例是1/3,可以通过参数调节:
- -XX:NewRatio Old/Young的空间比值,默认值为2
- -XX:SurvivorRatio Eden/Survivor空间比值,默认值为8
通过下面这张图可以很容易体会到这种比值关系:

关于堆,定义了如下异常:
- OutOfMemoryError:堆空间不足
5.Metaspace-方法区Method Area
方法区是所有线程共享的区域,主要存储类的结构信息,比如类信息、属性和方法数据、方法代码、运行时常量池等,方法区是伴随着JVM启动而创建的。
JDK1.7以前,方法区被称为永久代(PermGen),1.8后永久代被废弃,转而被MetaSpace区取代。那么相比较PerGen而言,MetaSpace又有什么进步呢?
MetaSpace空间默认是无上限的,这样就避免永久代经常出现的OutOfMemoryError:PermGen异常,MetaSpace空间也是可以通过参数来调节的,它有个很重要的特点就是会自动调节大小,这是永久代做不到的。
- -XX:MetaspaceSize 初始化大小,首次GC阀值大小。
- -XX:MaxMetaspaceSize 自动增长的上限,默认无上限,但是当本地内存占用过多,会影响其它程序。
Metaspace和PermGen都是方法区在具体虚拟机中实现,但是它们存储的信息是有区别的,PermGen会存储运行时常量池,但是Metaspace不包含常量池,这些常量池存储在堆Heap中。
Metaspace区定义了如下异常:
- OutOfMemoryError:Metaspace空间不足
运行时常量池Run-Time Constant Pool
Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面常量和符号引用Class的基础信息存储在Metaspace中,而常量池存储在堆中,我们来看看常量池中的常量类型:
- CONSTANT_Class
- CONSTANT_Fieldref
- CONSTANT_Methodref
- CONSTANT_InterfaceMethodref
- CONSTANT_String
- CONSTANT_Integer
- CONSTANT_Float
- CONSTANT_Long
- CONSTANT_Double
- CONSTANT_NameAndType
- CONSTANT_Utf8
- CONSTANT_MethodHandle
- CONSTANT_MethodType
- CONSTANT_InvokeDynamic
在JDK 1.7 以前,由于常量池在PermGen区,当永久代空间太小,加入常量池的字符串过多,就会导致OutOfMemoryError:PermGen,在JDK1.8以后当堆空间过小就会抛出异常:java.lang.OutOfMemoryError: Java heap space。
String.intern()方法是将字符串加到常量池,如果常量池已经存在该字符串则不操作,最终返回常量池中字符串地址,这个特性在JDK1.6和JDK1.8也是有区别的,我们直接分析1.8的现象:
@Test
public void testStringPool1() {
String s3 = new String("hello") + new String("Sayi");
String s5 = s3.intern();
System.out.println(s3 == s5);
String s4 = "helloSayi";
System.out.println(s3 == s4);
System.out.println(s5 == s4);
}
这段代码最终的输出是:
true
true
true
可以看到,s3是堆内的一个空间,s3.intern()加入常量池后返回的引用s5和原先s3是一致的,即加入常量池中的字符常量地址就是堆内的创建字符串常量地址。我们把s4和s5换一个位置重新运行下:
@Test
public void testStringPool2() {
String s3 = new String("hello") + new String("Sayi");
String s4 = "helloSayi";
String s5 = s3.intern();
System.out.println(s3 == s5);
System.out.println(s3 == s4);
System.out.println(s5 == s4);
}
输出结果是:
false
false
true
因为s4是加入到常量池,s3.intern就会返回s4的引用地址,即s4==s5。
垃圾回收算法
我们先来看看如何判断一个对象已经是垃圾了?通常使用的算法是引用计数法和可达性分析法,引用计数法无法解决循环依赖的问题,虚拟机采用了可达性分析法,判断对象与GC Roots根节点之间是否可达,不可达的对象就可能会被垃圾回收(参考finalize()方法)。
1.Mark-Sweep标记-清除算法
Mark-Sweep算法分为两个阶段:标记阶段和清除阶段。首先标记那些可达对象,然后清除未被引用的垃圾对象。
这个算法会产生内存碎片。
2.Copying复制算法
Copying算法是将内存区域分为两块,每次使用其中一块,垃圾回收时,将所有存活对象复制到内存的另一块区域,然后清除当前区域的对象。
由于采用了整体复制的算法,不会产生内存碎片,适合在垃圾对象较多存活对象较少的情形,但是缺点是当前可用只有部分内存。
3.Mark-Compact标记-整理算法
Mark-Compact算法是在Mark-Sweep的基础上作了优化,分为三个阶段:标记阶段、整理阶段、清除阶段。整理阶段是将所有的存活对象压缩到内存的一端,这样就避免了碎片的产生。
4.分代回收算法
前面已经知道,堆分为老年代和年轻代,老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,分代回收算法就是根据不同代的特点采取最适合的收集算法。
-
年轻代
采取Copying算法,划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间,当Survivor空间不足或者如此往复复制某些对象超过一定次数(新生代存活年龄)后就会进入老年代,对于一些大对象的创建可能会直接进入老年代。 -
老年代
采取Mark-Compact算法。
垃圾收集器
垃圾收集器是虚拟机中对垃圾回收算法的具体实现,它们有的用在年轻代,有的用在老年代,有的都可以用。关于如何组合使用可以参考一些虚拟机的书籍,我们来看看虚拟机的三种GC和两个指标。
- Minor GC:年轻代的垃圾回收
- Major GC VS Full GC:Major GC指老年代的垃圾回收,Full GC指整个堆的垃圾回收
但是每次Major发生时,都至少会出现一次Minor GC,所以这两个概念并没有严格的区分,我们更应该关注指标:
- Stop the world(STW) event:GC会导致暂停用户线程,对于交互频繁的应用来说,意味着用户等待,所以这个数值越小越好。
- Throughput:这个指标重点没有放在STW上,而是放在吞吐量上,如果系统运行了 100min,GC 耗时 1min,那么系统的吞吐量就是 (100-1)/100=99%。
接下来我们看看具体的垃圾收集器。
Serial GC和Serial Old
这是历史最悠久的垃圾收集器,单线程收集器,GC时会发生STW。Serial GC采用Coping算法,Serial Old采用Mark-Compact算法。
| JVM Options | 功能 |
|---|---|
| -XX:+UseSerialGC | 使用单线程收集器 |
ParNew GC和CMS(Concurrent Mark-Sweep)
为了减少STW的时间,出现了Serial GC的并发版本,称为ParNew GC,采用Coping算法,适用于多核CPU情况。
老年代回收通常使用CMS收集器,一个并发的标记-清除算法,它的GC几乎可以做到和用户线程同时进行,极大的减少了STW的时间。主要分为五步:
- 初始标记(STW initial mark)
- 并发标记(Concurrent marking)
- 并发预清理(Concurrent precleaning)
- 重新标记(STW remark)
- 并发清理(Concurrent sweeping)
其中只有步骤1和步骤4会产生STW事件,而步骤1初始标记产生的STW微乎其微。
| JVM Options | 功能 |
|---|---|
| -XX:+UseParNewGC | 新生代使用ParNew收集器 |
| -XX:+UseConcMarkSweepGC | 新生代使用ParNew(无需指定,默认设置),老年代使用CMS |
Parallel Scavenge和Parallel Old
相比其它收集器关注STW,这是一组更加关注吞吐量的组合。
| JVM Options | 功能 |
|---|---|
| -XX:+UseParallelGC | 新生代使用ParallelGC,老年代默认使用ParallelOldGC |
| -XX:+UseParallelOldGC | 老年代默认使用ParallelOldGC,新生代默认使用ParallelGC |
| -XX:ParallelGCThreads | 并发GC线程数 |
G1收集器
G1(garbage-first)是最前沿的虚拟机成果,引入Region的概念,内存块逻辑连续但是物理上可以不连续,年轻代和老年代都使用G1收集器。
| JVM Options | 功能 |
|---|---|
| -XX:+XX:+UseG1GC | 使用G1收集器 |
总结
了解完本文后,我们希望利用知识去解决更具实际意义的问题,下一篇文章将会分析和解决JVM故障。