JVM 内存结构与 GC 算法详解

Java 虚拟机

JVM 内存结构

JVM 内存结构主要有三大块:栈、堆内存、方法区。堆内存是 JVM 中最大的一块,由新生代和老年代组成,不包括永久代(方法区);而新生代内存又被分成 Eden 空间、From Survivor 空间、To Survivor 空间,默认情况下新生代按照 8:1:1 的比例来分配。方法区存储类信息、静态变量、常量、常量池等数据,是线程共享的区域,为了与 Java 堆区分,方法区还有一个别名 Non-Heap (非堆)。栈又分为 Java 虚拟机栈和本地方法栈,主要用于方法的执行。

java-jvm-architecture

堆内存

堆内存(Heap)是 Java 虚拟机所管理内存最大的一块,各个线程之间共享,在虚拟机启动时创建,此区域的唯一目的就是存放实例对象,几乎所有的实例对象都在这里分配内存。堆内存是垃圾收集器(GC)管理的主要区域,因此很多时候被称为 “GC 堆”。由于现在垃圾收集器基本采用分代收集算法,所以堆内存还可以被分为新生代和老年代,而新生代内存又被分成 Eden 空间、From Survivor 空间、To Survivor 空间。Java 虚拟机规范的规定,堆内存可以在物理不连续的内存空间上,只要逻辑上是连续的即可。如果在堆内存中没有足够的内存完成实例分配,并且堆内存也无法再扩展时,将会抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area)包含了类信息、静态变量、常量、常量池,是各个线程共享的内存区域。它存储已被虚拟机加载的类信息、静态变量、常量、常量池,即编译器编译后的代码等数据。为了与 Java 堆区分,方法区还有一个别名 Non-Heap (非堆)。对于习惯在 HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为” 永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样” 永久” 存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收 “成绩” 比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。方法区中的常量和静态变量引用的对象,可作为 GC Root。

Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks),是线程私有的,生命周期和线程相同。每个方法执行的同时,会创建一个栈帧(Stacks Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的执行,对应栈帧在虚拟机中入栈到出栈的过程(一句话总结:创建栈帧执行方法,程序计数器会指向栈顶)。局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、double、long)、对象引用(Reference 类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和 ReturnAddress 类型(指向了一条字节码指令的地址)。其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(Slot),其余的数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。在 Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都支持动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。Java 虚拟机栈引用的对象可作为 GC Root。

本地方法栈

本地方法栈(Native Method Stack),与 Java 虚拟机栈发挥的作用相似,它们之间的区别不过是 Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用的 Native 方法服务。Java 虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpot 虚拟机)直接就把本地方法栈和 Java 虚拟机栈合二为一。与 Java 虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。本地方法栈 Native 方法引用的对象可作为 GC Root。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器,即保证线程切换后恢复到正确的执行位置。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于 Java 虚拟机的多线程是通过线程切换并获取时间片的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,一般称这类内存区域为 “线程私有” 的内存。如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Natvie 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 异常情况的区域。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。这部分内容在类加载后进入方法区的运行时常量池存放。运行时常量池另一个重要特征就是具有动态性。Java 语言并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中,这种特性被开发人员利用的比较多的就是 String 类的 intern() 方法。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,而且也可能导致 OutOfMemoryError 异常。在 JDK1.4 中新加入的 NIO 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式。它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中提高性能,因为避免了 Java 堆和 Native 堆中来回复制数据。值得注意的是,本机的直接内存的分配不会受到 Java 堆大小的限制,但是会受到本机总内存的限制,这可能导致各个内存区域总和大于物理内存的限制,从而导致动态扩展时出现 OutOfMemoryError 异常。

通过参数来控制各区域的内存大小

java-jvm-memory

  • -Xms,设置堆内存的最小空间大小
  • -Xmx,设置堆内存的最大空间大小
  • -XX:NewSize,设置新生代最小空间大小
  • -XX:MaxNewSize,设置新生代最大空间大小
  • -XX:PermSize,设置永久代(方法区)最小空间大小
  • -XX:MaxPermSize,设置永久代(方法区)最大空间大小
  • -Xss,设置每个线程的堆栈大小

特别注意:JVM 没有提供直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小来间接控制,老年代空间大小 = 堆空间大小 - 新生代大空间大小

JVM 垃圾收集机制

如何确定一个对象是否会被回收

引用计数算法(Reference Counting)

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。它的思路是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。大部分场景下,这个算法都是不错,效率也比较高;但是 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
/**
* 引用计数算法的缺陷
*/
public class ReferenceCountingGC {
public Object instance = null;

public static final int _1MB = 1024 * 1024;

/**
* 占点内存,以便GC日志观看
*/
private byte[] bigSize = new byte[2 * _1MB];

public static void main(String[] args) {
testGC();
}

public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

//这里发生GC, objA 和 objB能否被回收?
System.gc();
}
}

上述代码最后面两句将 objA 和 objB 赋值为 null,也就是说 objA 和 objB 指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为 0,那么垃圾收集器就永远不会回收它们。

可达性分析算法(Reachability Analysis)

判断对象的引用链是否可达。它的思路是:通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的,如图所示:

jvm-reachability-analysis

在 Java 中,可作为 GC Root 的对象包括以下几种:

  • 方法区中常量引用的对象
  • 方法区中类静态属性引用的对象
  • Java 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

GC 算法

垃圾收集算法主要有:复制算法(Copying)、标记 - 清除算法(Mark-Sweep)、标记 - 整理算法(Mark-Compact)、分代收集算法(Generational Collection)。

标记 - 清除算法

“标记 - 清除” 算法是最基础的算法,它分为 “标记” 和” 清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它有两个不足:一个是效率问题,标记和清除两个过程的效率都不高(两次扫描,耗时严重);另一个是空间问题,标记清除之后会产生大量的不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存从而不得不提前触发另一次垃圾收集动作。

jvm-biaoji-qingchu

复制算法

为了解决效率问题,一种称为 “复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

jvm-copy

将现有的内存空间分为两快,每次只使用其中一块,在垃圾收集时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾收集。如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。因此在真正需要垃圾收集的时刻,复制算法的效率是很高的。又由于对象在垃圾收集过程中统一被复制到新的内存空间中,因此,可确保回收后的内存空间是没有碎片的。复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。该算法的缺点是将系统内存折半。

Java 虚拟机的新生代串行垃圾收集器中使用了复制算法的思想。新生代分为 Eden 空间、From Survivor 空间、To Survivor 空间。其中 From Survivor 空间和 To Survivor 空间可以视为用于复制的两块大小相同、地位相等,且可进行角色互换的空间块。From Survivor 和 To Survivor 空间也称为 Survivor 空间,即幸存者空间,用于存放未被回收的对象。在垃圾收集时,Eden 空间中的存活对象会被复制到未使用的 Survivor 空间中(假设是 To Survivor),正在使用的 Survivor 空间(假设是 From) 中的年轻对象也会被复制到 To Survivor 空间中 (大对象或者老年对象会直接进入老年代,如果 To Survivor 空间已满,则对象也会直接进入老年代)。此时,Eden 空间和 From Survivor 空间中的剩余对象就是垃圾对象,可以直接清空,To Survivor 空间则存放此次回收后的存活对象。这种改进的复制算法既保证了空间的连续性,又避免了大量的内存空间浪费。

标记 - 整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以老年代不能直接选用这种算法。标记整理算法中,标记过程仍然与 “标记 - 清除” 算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

jvm-biaoji-zhengli

分代收集算法

对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆内存中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样既可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记 - 清除” 或者 “标记 - 整理” 算法来进行回收。

GC 算法对比

复制算法:

  • 复制算法执行的速度较快,典型的空间换时间
  • 当对象的存活率很高的时候,不断的复制操作会显得耗时
  • 复制算法很明显的缺点就是浪费内存空间,因为将内存分为两块,一次只能使用一块,这也意味着分的块越大,浪费的内存越多

标记 - 清除算法:

  • 首先是速度慢,因为” 标记 - 清除算法” 在标记阶段需要使用递归的方式从根结点出发,不断寻找可达的对象;而在清除阶段又需要遍历堆内存中的所有对象,查看其是否被标记,然后再清除;并且在程序进行 GC 的时候,JVM 中所有的 Java 程序都要进行暂停,俗称 Stop-The-World,后面会提到。
  • 其次是其最大的缺点,使用这种算法进行清理而得的堆内存的空闲空间一般是不连续的,由于对象实例在堆内存中是随机存储的,所以在清理之后,会产生许多的内存碎片,如果这个时候来了一个很大的对象实例,尽管显示内存还足够,但是已经存不下这个大对象了,内存碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。再者,这种零散的碎片对于数组的分配也不是很方便。

标记 - 整理算法:

  • 首先这种算法克服了” 标记 - 清除算法” 中会产生内存碎片的缺点,也解决了复制算法中内存减半使用的不足
  • 而其缺点则是速度也不是很快,不仅要遍历标记所有可达结点,还要一个个整理可达存活对象的地址,所以导致其效率不是很高

关于 Stop-The-World

在 GC 算法执行的时候,所有正在执行中的 Java 程序都会被挂起(被暂停),只有 Native 方法可以执行,但是也不能和 JVM 进行交互,这样一来似乎整个 Java 世界都停止了,这也就是为什么叫做 Stop-The-World;等到 GC 程序执行完毕后,Java 程序才会重新恢复执行。这个其实很好理解,因为 GC 程序是一个线程,Java 程序也是一个线程,它们操作的堆内存是一片共享的区域,假设一种情况,Java 程序 A 新建了一个对象 object,new Object()被存放在堆内存,但是很不巧的是,堆内存刚刚执行过复制算法,前一步存活的对象已经被转移到另一块空间了,而 new Object()就留在了原来的空间,无辜地被清除了。这显然是不可接受的,因为线程不安全。

内存分配策略

Java 的自动内存管理,最终可以归结为自动化地解决了两个问题:给对象分配内存、回收分配给对象的内存。对象的内存分配通常是在堆上分配(除此以外还有可能经过 JIT 编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的 Eden 空间上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是固定的,实际取决于垃圾收集器的具体组合以及虚拟机中与内存相关的参数的设置。下面以使用 Serial/Serial Old 收集器,介绍内存分配的策略。

对象优先在 Eden 空间分配

大多数情况下,对象在新生代的 Eden 空间中分配,当 Eden 空间没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的 Java 对象,最典型的大对象就是很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息(尤其是遇到朝生夕灭的 “短命大对象”,写程序时应避免),经常出现大对象容易导致内存还有不少空间时,就提前触发 GC 以获取足够的连续内存空间来安置它们。虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大小超过这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 空间及两个 Survivor 空间之间发生大量的内存复制(新生代采用复制算法来回收内存)。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 空间出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 空间容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 空间中每 “熬过” 一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续内存空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 的设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续内存空间是否大于历次晋升到老年代对象的平均大小;如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于或者 HandlePromotionFailure 的设置不允许冒险,那这时也要改为进行一次 Full GC。新生代使用复制算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。使用平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。

GC 的触发条件

Minor GC 的触发条件

对于 Minor GC,其触发条件非常简单,当新生代的 Eden 空间满时,就将触发一次 Minor GC。

Full GC 的触发条件

调用 System.gc ()

此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存,可通过 -XX:+ DisableExplicitGC 来禁止 RMI 调用 System.gc()

老年代空间不足

老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出如下错误: Java.lang.OutOfMemoryError: Java heap space,为避免以上两种状况引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

空间分配担保失败

在新生代使用复制算法的 Minor GC,需要老年代的内存空间作担保,如果出现了 HandlePromotionFailure 担保失败,则会触发 Full GC。

JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些类信息、静态变量、常量、常量池等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出错误信息 java.lang.OutOfMemoryError: PermGen space,为避免 Permanet Generation 占满造成 Full GC 现象,可采用的方法为增大 Permanet Generation 空间或转为使用 CMS GC。在 JDK 1.8 中用元空间替换了永久代作为方法区的实现,元空间是本地内存,因此减少了一种 Full GC 触发的可能性。

  • Java 虚拟机规范里,使用方法区作为默认实现
  • JDK 1.7 及以前,HotSpot 虚拟机中的方法区使用永久代实现
  • JDK 1.8,HotSpot 虚拟机中的方法区使用元空间实现
Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候空间不足是由于 CMS GC 执行时,当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

JVM 垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的垃圾收集器。下面的图中展示了 7 种作用于不同分代的垃圾收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

jvm-gc-collector

概念理解

吞吐量

  • 吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即:吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
  • 虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%

并发和并行

  • 这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们的解释如下
  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 核上

Minor GC 和 Full GC

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快
  • 老年代 GC(Full GC / Major GC):指发生在老年代的 GC,出现 Full GC 的时候,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接执行了 Full GC 的策略选择过程)。Full GC 的速度一般会比 Minor GC 慢 10 倍以上

新生代收集器

Serial 收集器

jvm-collector-serial

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。

特性:
这个收集器是一个单线程的收集器,但它的 “单线程” 的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束,即会造成 “Stop The World” 现象。

应用场景:
Serial 收集器是虚拟机运行在 Client 模式下的默认新生代收集器。

优势:
简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew 收集器

jvm-collector-parNew

特性:
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

应用场景:
ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器。很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器 - CMS 收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

Serial 收集器 VS ParNew 收集器:
ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。然而,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的情况下可使用 -XX:ParallerGCThreads 参数设置。

Parallel Scavenge 收集器

特性:
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

应用场景:
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 收集器 与 CMS 收集器:
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(Throughput)。由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为 “吞吐量优先” 收集器。另外值得注意的一点是,Parallel Scavenge 收集器无法与 CMS 收集器配合使用,所以在 JDK 1.6 推出 Parallel Old 之前,如果新生代选择 Parallel Scavenge 收集器,老年代只有 Serial Old 收集器能与之配合使用。

Parallel Scavenge 收集器 VS ParNew 收集器:
Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别是,前者具有 GC 自适应调节策略特性。Parallel Scavenge 收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 空间的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应调节策略(GC Ergonomics)。

老年代收集器

Serial Old 收集器

jvm-collector-serial-old

特性:
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用” 标记 - 整理算法”。

应用场景:

  • Client 模式下,Serial Old 收集器的主要意义也是在于给 Client 模式下的虚拟机使用。
  • Server 模式下,主要有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用;另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

jvm-collector-parallel-old

特性:
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记 - 整理” 算法。

应用场景:
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

Parallel Old 配合 Parallel Scavenge:
Parallel Old 收集器是在 JDK 1.6 中才开始提供的,在此之前新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。因为如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配合使用)。由于老年代 Serial Old 收集器在服务端应用性能上的 “拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集器(Serial Old)无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合 “给力”。直到 Parallel Old 收集器出现后,“吞吐量优先” 收集器终于有了比较名副其实的应用组合。

CMS 收集器

jvm-collector-cms

特性:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

运作流程:
CMS 收集器是基于 “标记 - 清除” 算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为以下 4 个步骤。由于在耗时最长的并发标记和并发清除整个过程中,收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

  • 初始标记(CMS initial mark):初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要 “Stop The World”
  • 并发标记(CMS concurrent mark):并发标记阶段就是进行 GC Roots Tracing 的过程
  • 重新标记(CMS remark):重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要 “Stop The World”
  • 并发清除(CMS concurrent sweep):并发清除阶段会清除对象

优点
CMS 是一款优秀的收集器,它的主要优点是并发收集、停顿时间短,因此 CMS 收集器也被称为” 并发低停顿收集器”(Concurrent Low Pause Collector)。

缺点:

  • CMS 收集器对 CPU 资源非常敏感,其实面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量 + 3)/ 4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(例如 2 个)时,CMS 对用户程序的影响就可能变得很大。
  • CMS 收集器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉,这一部分垃圾就称为 “浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集(Full GC),这样停顿时间就很长了。
  • CMS 收集器会产生大量空间碎片,CMS 是一款基于 “标记 - 清除” 算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续内存空间来分配当前对象,导致不得不提前触发一次 Full GC。

G1 收集器

jvm-collector-g1

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。

特性:

  • 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿的时间,部分其他收集器需要停顿 Java 线程来执行的 GC 动作,而 G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合:与 CMS 的 “标记 - 清除” 算法不同,G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 Full GC。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

横跨整个堆内存:
在 G1 之前的其他收集器进行收集的范围都是单独针对新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

建立可预测的时间模型:
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

避免全堆扫描 - Remembered Set:
G1 把 Java 堆分为多个 Region,就是 “化整为零”。但是 Region 不可能是孤立的,一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个 Java 堆才能保证准确性,这显然是对 GC 效率的极大伤害。为了避免全堆扫描的发生,虚拟机为 G1 中每个 Region 维护了一个与之对应的 Remembered Set。虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

执行过程:

  • 初始标记(Initial Marking):初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking):并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation):筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。