深入理解 Java 虚拟机(三)垃圾收集器与内存分配策略

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82669797

垃圾收集器

这里基于 JDK 1.7 Update 14 之后的 HotSpot 虚拟机进行讨论,这个虚拟机包含的所有收集器如图所示:

垃圾收集器

连线代表两个收集器可以配合使用

Serial 收集器

Serial 是最基本、历史最悠久的收集器,它是单线程的,在进行垃圾收集时,必须停顿其它所有的工作线程,直到收集结束,运行过程如图:

Serial - Serial Old 收集器

它的优点是简单而高效,在用户的桌面应用场景下,分配给虚拟机管理的内存一般不会很大,因此停顿时间不会太长,收集几十兆至一两百兆的新生代,停顿时间可以控制在几十毫秒以内。因此到目前为止(作者写书的时间),Serial 收集器依然是虚拟机运行在 Client 模式下的默认新生代收集器。

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其它行为包括所有的控制参数都和 Serial 收集器完全一样。

ParNew 收集器

ParNew 是运行在 Server 模式下的虚拟机首选的新生代收集器,其中有一个与性能无关的重要原因是只有它能与 CMS 收集器配合工作。在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可以认为具有划时代意义的收集器——CMS(Concurrent Mark Sweep),它是 HotSpot 第一款真正意义上的并发收集器,第一次实现了让垃圾收集过程和用户线程(基本上)同时工作。

ParNew 收集器在单 CPU 环境下效果没有 Serial 好,但随着 CPU 数量的增加,ParNew 收集器对于 GC 时系统资源的有效利用是很有好处的。

这里统一一下本文中并行、并发的概念:
并行:多条垃圾线程并行工作,用户线程仍然处于等待状态
并发:用户线程与垃圾收集线程同时工作(可能会交替执行),用户程序继续运行,垃圾收集线程运行于另一个 CPU 上

Parallel Scavenge 收集器

Parallel Scavenge 是使用复制算法的新生代收集器,它和其它收集器不同的地方在于,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户的停顿时间,Parallel Scavenge 的目标则是达到一个可控制的吞吐量。吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。停顿时间越短越适合需要与用户交互的程序,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合后台运行而不需要太多交互的任务。

Parallel Scavenge 提供了两个参数用于精准控制吞吐量:-XX:MaxGCPauseMillis(控制最大垃圾收集停顿时间)、-XX:GCTimeRatio(吞吐量大小)。其中,MaxGCPauseMillis 设置得小不代表垃圾收集速度变得更快,它会导致垃圾收集发生得更频率,从而降低吞吐量。

此外还有一个参数值得注意:-XX:+UserAdaptiveSizePolicy,这个参数打开之后,就不需要手工指定新生代的大小、Eden 与 Survivor 区的比例,虚拟机会自动调整,以提供最合适的停顿时间和最大的吞吐量,这种调节方式成为 GC 自适应的调节策略。

Paralleel Scavenge - Parallel Old 收集器

Serial Old 收集器

Serial Old 收集器是 Serial 的老年代版本,同样是单线程的,使用“标记-整理”算法,主要也是用于 Client 模式下的虚拟机。如果是在 Server 模式下,它还有两大用途:一是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 搭配使用;另一个是作为 CMS 收集器的后备方案。它的工作过程如图:

Serial - Serial Old 收集器

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-清除”算法,在注重吞吐量和 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old,它的工作过程如图:

Paralleel Scavenge - Parallel Old 收集器

CMS 收集器

CMS(Concurrent Mark Sweep) 是一种以获取最短回收停顿时间为目标的收集器,适合重视响应速度的程序。

顾名思义,CMS 是基于“标记-清除”算法实现的,它的运作过程分为四个步骤:
1) 初始标记
2) 并发标记
3) 重新标记
4) 并发清除

CMS 收集器

其中,初始标记只需标记一下 GC Roots 能直接关联到的对象,仍然需要 Stop The World;并发标记就是 GC Roots Tracing 的过程;而重新标记则是为了修正并发标记期间因用户程序继续运行而导致标记变动的对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长,但远比并发标记短。

由于整个过程中耗时最长的并发标记和并发清除过程可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程并发执行的。

CMS 的优点是并发收集、低停顿,缺点主要有四个:

1) 对 CPU 资源非常敏感。面向并发设计的程序对 CPU 资源都敏感,当 CPU 数量不足 4 个时,CMS 对用户程序的影响可能会变得很大。

2) 无法处理浮动垃圾。浮动垃圾即在并发清理阶段,伴随用户程序运行而产生的新垃圾,需要在下一次 GC 时再清理。

3) 可能出现“Concurrent Mode Failure”错误而导致另一次 Full GC 的产生。因为垃圾收集阶段用户线程还需要执行,因此 CMS 不能像其它收集器那样等到老年代几乎被填满了再进行收集,需要预留一部分空间。在 JDK 1.6 中,CMS 的启动阈值是 92%,如果预留的内存无法满足程序运行需要,则出现“Concurrent Mode Failure”错误,临时切换到 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

4) 基于“标记-清除”算法,收集结束时会有大量内存碎片产生。为了解决这个问题,CMS 提供参数 +UseCMSCompactAtFullCollection (默认开启),用于在 CMS 进行 Full GC 时开始内存碎片的合并整理过程,内存整理是无法并发的,因此停顿时间会变长。此外还提供参数 –XX:CMSFullGCsBeforeCompaction,用于设置执行多少次 Full GC 后才来一次内存整理。

G1 收集器

G1 是一款面向服务端的垃圾收集器,与其它收集器相比,G1 具备如下特点:
1) 并行与并发。
2) 分代收集。G1 不需要其它收集器配合即可独立管理整个堆
3) 空间整合。G1 从整体上看是采用“标记-整理”算法的,从局部(Region)看则基于“复制”算法实现,空间整合性好
4) 可预测的停顿。这是 G1 相较 CMS 的另一大优势,G1 能够明确指定在一个长度为 M 的毫秒时间内,消耗在垃圾收集上的时间不得超过 N 毫秒。

使用 G1 收集器时,Java 堆的内存布局与其它收集器有很大的区别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region 的集合。

G1 之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集,它会跟踪 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

看起来似乎 G1 能够以 Region 为单位进行垃圾收集,但这里面存在一个难点:不同 Region 之间的互相引用要如何进行可达性分析,是否还要扫描整个 Java 堆?这个问题在其它分代的垃圾收集器中也存在,比如新生代和老年代之间的互相引用。解决方案是在 Region 中使用 Remember Set,用于记录与其它 Region 有关联的引用的相关信息。

如果不计算 Remember Set,G1 收集器的运作大致可以分为以下几个步骤:
1) 初始标记
2) 并发标记
3) 最终标记
4) 筛选回收

G1 收集器

初始标记用于标记 GC Roots 能直接关联到的对象,并修改 TAMS(Next Top of Mark Start)的值,让下一阶段的用户程序并发运行时,能在正确的 Region 中创建新对象,这个阶段需要停顿线程,但耗时很短。

并发标记是从 GC Roots 开始对堆中对象进行可达性分析,找出存活的对象,耗时较长,但可与用户程序并发执行。

最终标记是修正并发标记期间因用户程序继续运作而导致标记产生变动的引用的标记记录,虚拟机将这部分变化记录在线程 Remember Set Logs 里面,最终标记阶段需要把 Remember Set Logs 合并到 Remember Set 中。该阶段需要停顿线程,但可并行执行。

筛选回收阶段首先会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。

目前(作者写书的时间) G1 成熟版本的发布时间还很短,测试报告不多,建议继续使用 CMS 收集器,如果追求低停顿,那么可以尝试 G1。

理解 GC 日志

日志示例:

[GC (Allocation Failure) [DefNew: 6449K->829K(9216K), 0.0055207 secs] 6449K->4925K(19456K), 0.0065751 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
def new generation   total 9216K, used 7210K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  77% used [0x00000000fec00000, 0x00000000ff23b718, 0x00000000ff400000)
  from space 1024K,  80% used [0x00000000ff500000, 0x00000000ff5cf430, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation   total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa00020, 0x00000000ffa00200, 0x0000000100000000)
Metaspace       used 3328K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 358K, capacity 388K, committed 512K, reserved 1048576K

GC、Full GC 说明了这次垃圾收集的停顿类型,Full GC 说明这次 GC 发生了 Stop The World

DefNew、Tenured、Metaspace 表示 GC 发生的区域,DefaNew 是 Serial 收集器新生代的名称,全程是 Default New Generation,如果是 ParNew 收集器,新生代名称会变为 ParNew,如果是 Parallel Scavenge,则是 PSYoungGen。Tenured 即老年代,在 HotSpot 虚拟机中,Metaspace 即方法区(以前是永久代 PermGen,已移除)。

方括号内部的 [6449K->829K(9216K)],代表 [GC 前该内存区域已使用容量 -> GC 后该内存区域已使用容量(该内存区域总容量)]

方括号之外的”6449K->4925K(19456K)”,代表 “GC 前 Java 堆已使用容量 -> GC 后Java堆已使用容量(Java 堆总容量)”

再往后,0.0065751 secs 表示该内存区域 GC 所占用的时间,单位是秒。有的收集器会给出具体的时间,比如 [Times: user=0.00 sys=0.00, real=0.01 secs],分别代表用户态消耗的 CPU 时间,内核态消耗的 CPU 时间,和操作从开始到结束所经过的墙钟时间。墙钟时间包括等待 I/O、等待线程阻塞的时间,CPU 时间不包含这些。当系统存在多个 CPU 或者多核时,user 或 sys 时间超过 real 时间是正常的。

内存分配与回收策略

对象优先在 Eden 分配

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

Minor GC:指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,因此 Minor GC 非常频率,一般回收速度也比较快。

老年代 GC(Major GC / Full GC):指发生在老年代的垃圾收集动作,Major GC 经常伴随一次 Minor GC,一般比 Minor GC 慢 10 倍以上。

代码示例:

private static final int _1MB = 1024 * 1024;

/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void testAllocation() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
}

-XX:+PrintGCDetails 用于打印收集器的日志参数。-Xms20M -Xmx20M -Xmn10M 这三个参数限制了 Java 堆大小为 20MB,且不可扩展。其中 10M 给新生代,10M 给老年代。-XX:SurvivorRatio=8 决定了新生代中 Eden 与 Survivor 的内存大小比例为 8:1。

注:Inteliij 可以在 Run -> Edit Configurations 中设置 VM 参数。Intellij 默认使用的是 Parallel Scavenge 收集器,如果要启用 Serial 收集器,需要添加参数 需要使用参数 -XX:-UseSerialGC。

代码的运行结果是:

[GC (Allocation Failure) [DefNew: 6449K->829K(9216K), 0.0055207 secs] 6449K->4925K(19456K), 0.0065751 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
def new generation   total 9216K, used 7210K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  77% used [0x00000000fec00000, 0x00000000ff23b718, 0x00000000ff400000)
  from space 1024K,  80% used [0x00000000ff500000, 0x00000000ff5cf430, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation   total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa00020, 0x00000000ffa00200, 0x0000000100000000)
Metaspace       used 3328K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 358K, capacity 388K, committed 512K, reserved 1048576K

上述代码在分配对象 allocation4 的时候,因为新生代空间已经不足了,所以需要移动一部分到老年代中。

大对象直接进入老年代

大对象,尤其是短命的大对象,对虚拟机而言是一个坏消息。虚拟机提供参数 –XX:PretenuredSizeThreshold(只对 Serial 和 ParNew 收集器有效),令大于这个值的对象直接在老年代中分配。

代码示例:

/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold() {
    byte[] allocation;
    allocation = new byte[4 * _1MB];  //直接分配在老年代中
}

运行结果:

def new generation   total 9216K, used 2681K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  32% used [0x00000000fec00000, 0x00000000fee9e450, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation   total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)
Metaspace       used 3308K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 356K, capacity 388K, committed 512K, reserved 1048576K

但发现如果使用 Parallel Scavenge 收集器,参数 -XX:PretenureSizeThreshold=3145728 不起作用。

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

虚拟机给每一个对象定义了一个对象年龄计数器,初始为 0,每经过一次 Minor GC,年龄增 1,增加到一定程序(默认为 15),就会被晋升到老年代中,年龄阈值可以通过 –XX:MaxTenuringThreashold 设置。

动态对象年龄判定

虚拟机并不总是要求到达年龄了才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和超过 Survivor 空间的一半,则年龄大于等于该年龄的对象就可以直接进入老年代。

空间分配担保

在发生 Minor GC 之前,虚拟机首先会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果成立,则 Minor GC 可以确保是安全的;如果不成立,则检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果成立,则继续执行 Minor GC,如果 Minor GC 后,大量对象仍然存活,Survivor 无法容纳,且老年代空间不足,则重新发起一次 Full GC。

总结

这里写图片描述

思维导图

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82669797
今日推荐