0. JVM 内存结构

目前主流的 JVM 都基于 Hotspot VM 来设计并且采用“分代回收”的算法。“分代回收”的原理:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。Hotspot VM 将内存划分为不同的物理区,就是“分代”思想的体现。如图所示,JVM 内存主要由新生代、老年代、永久代构成。

img

  1. Young Generation :大多数对象在新生代中被创建,其中大部分对象的生命周期很短。每次新生代的垃圾回收(Minor GC)后只有少量对象存活,所以选用标记-复制算法,只需要少量的复制成本就可以完成回收。

    1. 新生代内又分三个区:一个 Eden 区,两个 Survivor 区,大部分对象在 Eden 区中生成。当 Eden 区满时,还存活的对象将被复制到两个 Survivor 区(中的一个)。当这个 Survivor 区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个 Survivor 区。对象每经历一次 Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在 Serial 和 ParNew GC 两种回收器中,“晋升年龄阈值”通过参数 MaxTenuringThreshold 设定,默认值为15。
  2. Old Generation :在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到 Old Generation ,该区域中对象存活率高。老年代的垃圾回收(Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为 Full GC(HotSpot VM里,除了 CMS 之外,其它能收集老年代的 GC 都会同时收集整个 GC 堆,包括新生代)。

  3. Perm Generation:主要存放元数据,例如 Class、Method 的元信息,与垃圾回收要回收的 Java 对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。


常见垃圾回收器

  • Serial : 单线程的一个回收器,简单、易实现、效率高。

  • ParNew : Serial 的多线程版,可以充分的利用 CPU 资源,减少回收的时间。

  • Parallel Scavenge : 侧重于吞吐量的控制(吞吐量优先)。

  • CMS(Concurrent Mark Sweep) :一种以获取最短回收停顿时间为目标的回收器,该回收器是基于“标记-清除”算法实现的。

    CMS 的四个阶段:

    1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记 GC ROOT 能直接关联到的对象,所以很快。
    2. Concurrent-mark 并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
    3. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
    4. 并发清理,进行并发的垃圾清理。
    


问题总结

JVM引入动态年龄计算的原因:

  1. 如果固定按照 MaxTenuringThreshold 设定的阈值作为晋升条件:
    1. MaxTenuringThreshold 设置的过大,原本应该晋升的对象一直停留在 Survivor 区,直到 Survivor 区溢出,一旦溢出发生,Eden+Svuvivor 中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
    2. MaxTenuringThreshold 设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的 Major GC。分代回收失去了意义,严重影响 GC 性能。
  2. 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

CMS 准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?

Remark 阶段主要是通过扫描堆来判断对象是否存活。如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,图中,对象A因为引用存在新生代中,它在 Remark 阶段就不会被修正标记为可达,GC 时会被错误回收。 新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark 阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。

灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有 Minor GC 时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在 Minor GC 发生前不会被标记为不可达,CMS 也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了 Remark 阶段耗时。 分析GC日志可以得出同样的规律,Remark 耗时>500ms时,新生代使用率都在75%以上。这样降低 Remark 阶段耗时问题转换成如何减少新生代对象数量。

新生代中对象的特点是“朝生夕灭”,这样如果 Remark 前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在 Remark 前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过 2M 时启动,当然 2M 是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark 阶段需要扫描的对象就少了。

除此之外 CMS 为了避免这个阶段没有等到 Minor GC 而陷入无限等待,提供了参数 CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过 5s,不管发没发生 Minor GC,都会中止此阶段,进入 Remark。 例,,可中断的并发预清理执行了5.35s,超过了设置的 5s 被中断,期间没有等到 Minor GC ,所以 Remark 时新生代中仍然有很多对象。对于这种情况,CMS 提供 CMSScavengeBeforeRemark 参数,用来保证 Remark 前强制进行一次 Minor GC。

img

JVM是如何避免Minor GC时扫描全堆的

经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的

img

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。