-
前言
JVM 是 Java Virtual Machine(Java 虚拟机)的缩写,它也是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来完成的。Java 语言最重要的特性之一的自动垃圾回收机制,也是基于 JVM 实现的,而且在我们日常开发中经常会遇到内存溢出、内存泄漏、程序卡顿等问题,要针对这些问题进行排查和性能调优,就需要我们了解 JVM 的垃圾回收机制(Garbage Collection,GC),那么垃圾回收机制到底是如何实现的呢?接下来让我们一探究竟。
一. 运行时数据区域
在了解 JVM 的垃圾回收机制之前,我们需要先了解 JVM 运行时的数据区域是怎么划分的,才能知道在哪里产生和回收垃圾。
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范(Java SE 7 版)》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域,如图所示。
1. 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的, 在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
2. Java 虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用( reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。 其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(Slot),其余的数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 在 Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
3. 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
4. Java堆
对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。 Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。 Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“ GC 堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。 根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
5. 方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。 Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。 根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
6. 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存 Class 文件中描述的符号引用外, 还会把翻译出来的直接引用也存储在运行时常量池中。 运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
7. 直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。 在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。 显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制), 从而导致动态扩展时出现 OutOfMemoryError 异常。
二. 怎么找出“垃圾”
前面说过,在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”,这些垃圾对象可以简单的理解为 不可能再被任何途径使用的对象 ,怎么区分出这些死亡和存活的对象呢,就需要用到垃圾判断算法,主要有 引用计数法 和 可达性分析法 。
1. 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为 0 的对象就是不可能再被使用的。这些引用计数为 0 的对象,就可以称之为垃圾,可以被收集。 微软公司的 COM(Component Object Model)技术、使用 ActionScript 3 的 FlashPlayer、Python 语言和在游戏脚本领域被广泛应用的 Squirrel 中都使用了引用计数算法进行内存管理。
- 优点:实现简单,判定效率也很高。
- 缺点:需要额外的空间来存储计数器,难以检测出对象之间的循环引用。
现在主流的 Java 虚拟机都没有使用引用计数法,最主要的原因就是它很难解决对象之间互相循环引用的问题。在两个对象循环引用时,引用计数器都为 1,当对象周期结束后应该被回收却无法回收,造成内存泄漏,代码如下:
public class TestReferenceCounter {
public static void main(String[] args) {
TestObject obj1 = new TestObject();
TestObject obj2 = new TestObject();
obj1.instance = obj2;
obj2.instance = obj1;
obj1 = null;
obj2 = null;
}
static class TestObject {
Object instance;
}
}
2. 可达性分析法(Reachability Analysis)
在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的 Lisp )的主流实现中,都是通过可达性分析来判定对象是否存活的。这个算法的基本思路就是:
- 通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索;
- 搜索所走过的路径称为引用链(Reference Chain);
- 当一个对象到 GC Roots 没有任何引用链相连 (用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
如下图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到 GC Roots 是不可达 的,所以它们将会被判定为是可回收的对象。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
可达性分析法的优缺点:
- 优点:可以解决循环引用的问题,不需要占用额外的空间 ;
- 缺点:实现比较复杂,需要分析大量数据,消耗大量时间;分析过程需要 GC 停顿(引用关系不能发生变化),即停顿所有 Java 执行线程(称为 "Stop The World",是垃圾回收重点关注的问题);
3. 引用关系
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。
3.1. JDK 1.2 以前 Java 的引用定义:
如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义太过狭隘,无法描述更多信息。
3.2. JDK 1.2 之后,Java 对引用的概念进行了扩充,分为 强、软、弱、虚引用,这 4 种引用强度依次逐渐减弱。
- 强引用(Strong Reference) 强引用就是指在程序代码之中普遍存在的,类似 “Object obj = new Object()” 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
测试代码:
Object object = new Object();
System.out.println("Strong Reference object = " + object);
System.gc();
Thread.sleep(500);
System.out.println("Strong Reference gc object = " + object);
运行结果:
Strong Reference object = java.lang.Object@39ba5a14
Strong Reference gc object = java.lang.Object@39ba5a14
- 软引用(Soft Reference) 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
测试代码:
Object object = new Object();
SoftReference<Object> softReference = new SoftReference<>(object);
object = null;
System.out.println("Soft Reference object = " + softReference.get());
System.gc();
Thread.sleep(500);
System.out.println("Soft Reference gc object = " + softReference.get());
运行结果:
Soft Reference object = java.lang.Object@39ba5a14
Soft Reference gc object = java.lang.Object@39ba5a14
- 弱引用(Weak Reference) 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。
测试代码:
WeakReference<Object> weakReference = new WeakReference<>(new Object());
System.out.println("Weak Reference object = " + weakReference.get());
System.gc();
Thread.sleep(500);
System.out.println("Weak Reference gc object = " + weakReference.get());
运行结果:
Weak Reference object = java.lang.Object@511baa65
Weak Reference gc object = null
- 虚引用(Phantom Reference) 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
虚引用 主要用来 跟踪对象 被垃圾回收器 回收 的活动。 虚引用 与 软引用 和 弱引用 的一个区别在于:
虚引用必须和引用队列 (ReferenceQueue) 联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会 在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
ReferenceQueue<Object> phantomReferenceQueue = new ReferenceQueue<>();
// 创建虚引用,要求必须与一个引用队列关联
PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), phantomReferenceQueue);
程序可以通过判断引用 队列 中是否已经加入了 虚引用,来了解被引用的对象是否将要进行 垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的 内存被回收之前 采取必要的行动。
4. 生存还是死亡
在可达性分析算法中被标记为不可达的对象,也不一定会被回收,它还有第二次重生的机会。每一个对象在被回收之前要进行两次标记,一次是 没有关联引用链 会被标记一次,第二次是 判断该对象是否覆盖 finalize() 方法,如果 没有覆盖则真正的被定了“死刑”。
如果这个对象被 jvm 判定为有必要执行 finalize() 方法,那么这个对象会被放入 F-Queue 队列中,并在稍后由一个由虚拟机自动创建的、低优先级的 finalizer 线程去执行它。但是这里的“执行”是指虚拟机会触发这个方法,但是并不代表会等它运行结束。虚拟机在此处是做了优化的,因为如果某个对象在 finalize 方法中长时间运行或者发生死循环,将可能导致 F-Queue 队列中其他对象永远处于等待,甚至可能会导致整个内存回收系统崩溃。
在 finalize() 方法中我们可以实现对这个对象的重生,代码如下:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes,i am still alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
// 对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为 finalize 方法优先级很低,所以暂停 0.5 秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为 finalize 方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead");
}
}
}
运行结果:
finalize method executed!
yes,i am still alive
no,i am dead
从运行结果可以看到,第一次拯救成功,第二次拯救失败,所以需要注意的是 finalize() 方法只会被系统调用一次,多次被 gc 只有第一次会被调用,因此只有一次的重生机会。
5. 回收方法区
假如一个字符串 “abc” 已经进入了常量池中,但是当前系统没有任何一个 String 对象是 “abc” ,那么这个对象就应该回收。方法区( HotSpot 虚拟机中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。比如上述的 “abc” 就是属于废弃常量,那么哪些类是无用的类呢?
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
- 加载该类的 ClassLoader 已经被回收;
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
三. 垃圾收集算法
通过上面的介绍,我们已经知道了什么是垃圾以及如何判断一个对象是否是垃圾。那么接下来,我们就来了解如何回收垃圾,这就是垃圾回收算法和垃圾回收器需要做的事情了。
1. 标记-清除(Mark-Sweep)算法
最基础的收集算法是 “标记-清除”(Mark-Sweep)算法,分为 “标记” 和 “清除” 两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析法中判定垃圾对象的标记过程。
下图为 “标记-清除” 算法的执行过程 :
优点:
- 不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:
- 标记和清理的两个过程效率都不高;
- 容易产生内存碎片,碎片空间太多可能导致无法存放大对象。
2. 标记-整理(Mark-Compact)算法
标记-整理 算法标记的过程与 “标记-清除” 算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。
下图为 “标记-整理” 算法的示意图:
优点:
- 经过整理之后,新对象的分配只需要通过指针碰撞便能完成,比较简单;使用这种方法,空闲区域的位置是始终可知的,也不会再有碎片的问题了。
缺点:
- GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。
3. 复制(Copying)算法
复制 算法的提出是为了 解决效率问题 和 堆碎片 的垃圾回收。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
下图为 复制 算法的示意图:
优点:
- 标记阶段和复制阶段可以同时进行;
- 每次只对一块内存进行回收,运行高效;
- 只需移动栈顶指针,按顺序分配内存即可,实现简单;
- 内存回收时不用考虑内存碎片的出现。
缺点:
- 可用内存缩小了一半。
复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。
4. 分代收集(Generational Collector)算法
分代收集 算法是将堆内存划分为新生代、老年代 和 永久代,在 jdk8 以后取消了永久代的说法,而是用元空间取代。新生代又被进一步划分为 Eden 和 Survivor 区,其中 Survivor 由 FromSpace(Survivor0)和 ToSpace(Survivor1)组成。一般年轻代使用复制算法(对象存活率低),老年代使用标记整理算法(对象存活率高)。
新生代(Young Generation):
几乎所有新生成的对象首先都是放在年轻代的,新生代内存按照 8:1:1 的比例分为一个 Eden 区和两个 **Survivor(Survivor0,Survivor1)**区。新生代对象生命周期如下:
- 大部分对象在 Eden 区中生成。
- 当新对象生成,Eden 空间申请失败(因为空间不足等),则会发起一次 GC(Minor GC / Scavenge GC)。
- 回收时先将 Eden 区存活对象复制到一个 Survivor0 区,然后清空 Eden 区。
- 当这个 Survivor0 区也存放满了时,则将 Eden 区和 Survivor0 区存活对象复制到另一个 Survivor1 区,然后清空 Eden 和这个 Survivor0 区,此时 Survivor0 区是空的,然后将 Survivor0 区和 Survivor1 区交换,即保持 Survivor1 区为空, 如此往复。
- 当 Survivor1 区不足以存放 Eden 和 Survivor0 的存活对象时,就将存活对象直接存放到老年代。
- 当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1。
- 默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。
- 若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。
老年代(Old Generation):
在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。老年代有以下特点:
- 内存比新生代大很多(大概比例是 1:2)。
- 作为 Eden 区的 空间分配担保,当 MinorGC 时,如果存活对象过多,无法完全放入 Survivor 区,就会向老年代借用内存存放对象。
- 动态对象年龄判定,如果 Survivor 区中相同年龄所有对象的大小总和大于 Survivor 区空间一半,年龄大于或者等于该年龄的对象在 MinorGC 时将复制到老年代。
- 当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率高。
- 出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对),Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
- 一般大对象直接进入老年代。所谓的大对象是指需要 大量连续存储空间 的对象,最常见的一种大对象就是大数组。
- 长期存活的对象将进入老年代,默认为 15 岁。
对于晋升老年代的分代年龄阈值,我们可以通过 -XX:MaxTenuringThreshold 参数进行控制,前面提到晋升老年代的年龄阈值默认的 15 岁,为什么不是 16 或者 17 呢?
这里就涉及到对象的内存布局了,在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
实际上,HotSpot 虚拟机的 对象头 其中一部分用于存储对象自身的 运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳 等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为 Mark word。
在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 Mark Word 的 32bit 空间中的 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0,如下表所示:
在 64 位系统及 64 位 JVM 下,开启指针压缩,那么头部存放 Class 指针的空间大小还是 4 字节,而 Mark Word 区域会变大,变成 8 字节,也就是头部最少为 12 字节,如下表所示:
可以看到,对象的分代年龄占 4 位,也就是从 0000 到 1111 ,而其值最大为 15,所以分代年龄也就不可能超过 15 这个数值了。
永久代(Permanent Generation):
用于存放静态文件(class 类、方法)和常量等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。永久代在 Java SE8 特性中已经被移除了,取而代之的是元空间(MetaSpace),因此也不会再出现java.lang.OutOfMemoryError: PermGen error的错误了。
卡表(Card Table)提升 GC 效率
在某些场景下,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为 GC Roots,这样岂不是每次进行 Minor GC 时也要进行全堆的扫描?
HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术,该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。如下图:
在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。卡表能用于减少老年代的全堆空间扫描,这能很大的提升 GC 效率。
四. 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于JDK 1.7 Update 14之后的 HotSpot 虚拟机(在此版本中正式提供了商用的G1收集器,之前G1仍处于实验状态) ,这个虚拟机包含的所有收集器如图所示。
图中展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
1. Serial 收集器
Serial 收集器是最基本、发展历史最悠久的收集器,看名字就会知道,这个收集器是一个单线程的收集器,Serial 收集器采用单线程方式进行收集,且在 GC 线程工作时,系统不允许应用线程打扰。此时,应用程序进入暂停状态,即 Stop-the-world。Stop-the-world 暂停时间的长短,是衡量一款收集器性能高低的重要指标。
- 回收区域:新生代;
- 采用算法:复制算法;
- 运行环境:运行在 Client 模式下的默认新生代收集器。
2. ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如图所示:
3. Parallel Scavenge 收集器
Parallel Scavenge 是针对新生代的垃圾回收器,采用“复制”算法,和 ParNew 类似,但更注重吞吐率。在 ParNew 的基础上演化而来的 Parallel Scanvenge 收集器被誉为“吞吐量优先”收集器。吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。如虚拟机总运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是99%。
Parallel Scanvenge 收集器在 ParNew 的基础上提供了一组参数,用于配置期望的收集时间或吞吐量,然后以此为目标进行收集。通过 VM 选项可以控制吞吐量的大致范围:
-XX:MaxGCPauseMills:期望收集时间上限,用来控制收集对应用程序停顿的影响。 -XX:GCTimeRatio:期望的 GC 时间占总时间的比例,用来控制吞吐量。 -XX:UseAdaptiveSizePolicy:自动分代大小调节策略。
但要注意停顿时间与吞吐量这两个目标是相悖的,降低停顿时间的同时也会引起吞吐的降低。因此需要将目标控制在一个合理的范围中。
4. Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,单线程收集器,采用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。
5. Parallel Old 收集器
Parallel Old 是 Parallel Scanvenge 收集器的老年代版本,多线程收集器,采用“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge 收集器无法与CMS收集器配合工作)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作过程如图所示:
6. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含 “Mark Sweep” )上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要 “Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC RootsTracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。
优点:
- 并发收集,低停顿。
缺点:
- CPU 资源非常敏感;
- 无法处理浮动垃圾;
- 是基于“标记-清除”算法,该算法的缺点都有。
CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面已经介绍过“标记-清除”算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。
7. G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款 面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点:
- 并行与并发:G1 能够重发利用多 CPU、多核环境下的优势,使用多个 CPU 来缩短 Stop-The-World 停顿时间。
- 分代收集:与其他收集器一样,分代概念在 G1 中依然存在。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
- 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”来实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能够提供整体的可用内存。
- 可预测停顿:G1 除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
横跨整个堆内存
使用G1收集器时,Java堆的内存布局与其他收集器有很大的区别,它将 整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留着新生代和老年代的概念,但 新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。
建立可预测的时间模型
G1 收集器之所以能够建立可预测的停顿时间模型,是因为它可以 有计划地避免在整个Java对中进行全区域的垃圾收集。G1 跟踪各个 Region 里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也是 Garbage-First 名称的由来)。G1 收集的运作过程大致如下:
- 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收(Live Data Counting and Evacuation):首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
通过下图可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段(Safepoint处):
G1 的 GC 模式可以分为两种,分别为:
Young GC: 在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。 Mixed GC: 当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC 概念,需要 Full GC 时,调用 Serial Old GC 进行全堆扫描。
总结:
查看 JVM 使用的默认垃圾收集器,可以在 Mac 终端或者 Windows 的 CMD 执行如下命令:
java -XX:+PrintCommandLineFlags -version
以我的电脑为例,执行结果为:
-XX:G1ConcRefinementThreads=10 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=267050880 -XX:MaxHeapSize=4272814080 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC -XX:-UseLargePagesIndividualAllocation
java version "12.0.1" 2019-04-16
Java(TM) SE Runtime Environment (build 12.0.1+12)
Java HotSpot(TM) 64-Bit Server VM (build 12.0.1+12, mixed mode, sharing)
垃圾收集器参数总结:
- UseSerialGC: 虚拟机运行在 Client 模式下的默认值,打开次开关后,使用 Serial + Serial Old 的收集器组合进行内存回收。
- UseParNewGC: 打开次开关后,使用 ParNew + Serial Old 的收集器组合进行内存回收。
- UseConcMarkSweepGC: 打开次开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old 收集器将作为 CMS 收集器出现 Concurrent Mode Failure 失败后的备用收集器使用。
- UseParallelGC: 虚拟机运行在 Server 模式下的默认值,打开此开关后,使用 Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收。
- UseParallelOldGC: 打开此开关后,使用 Parallel Scavenge + Parallel Old 的收集器组合进行内存回收。
- SurvivorRatio: 新生代中 Eden 区域与 Survivor 区域的容量比值,默认为 8 ,代表 Eden :Survivor = 8 : 1。
- PretenureSizeThreshold: 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。
- MaxTenuringThreshold: 晋升到老年代的对象年龄。每个对象在坚持过一次 Minor GC 后,年龄就增加 1 ,当超过这个参数值时就进入老年代。
- UseAdaptiveSizePolicy: 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄。
- HandlePromotionFailure: 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况。
- ParallelGCThreads: 设置并行 GC 时进行内存回收的线程数。
- GCTimeRatio: GC 时间占总时间的比例,默认值为99,即允许 1% 的 GC 时间。仅在使用 Parallel Scavenge 收集器时生效。
- MaxGCPauseMillis: 设置 GC 的最大停顿时间。仅在使用 Parallel Scavenge 收集器时生效。
- CMSInitiatingOccupancyFraction: 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集。默认值为 68%,仅在使用 CMS 收集器时生效。
- UseCMSCompactAtFullCollection: 设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用 CMS 收集器时生效。
- CMSFullGCsBeforeCompaction: 设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用 CMS 收集器时生效。
参考资料:
《深入理解 Java 虚拟机》 周志明 著