记一起Java大对象引起的FullGC事件及GC知识梳理

背景###

最近发生了一起 Java 大对象引起的 FullGC 事件。记录一下。

有一位商家刷单,每单内有 50+ 商品。然后进行订单导出。订单导出每次会从订单详情服务取100条订单数据。由于 100 条订单数据对象很大,导致详情 FullGC ,影响了服务的稳定性。

本文借此来梳理下 Java 垃圾回收算法及分析 JVM 垃圾回收运行的方法。


案例分析###

如果对GC不太熟悉,可以先看看“GC姿势”部分,对 JVM 垃圾回收有一个比较清晰的理解。

测定大小####

回头看这个案例,显然它很可能触犯了“大对象容易触发 FullGC ” 的忌讳。先来测定下,这个大数据量的订单大小究竟有多少?

“HBase指定大量列集合的场景下并发拉取数据时卡住的问题排查” 有一段可以用来计算对象 deep-size 的方法。用法如下:


try {
      ClassIntrospector.ObjectInfo objectInfo = new ClassIntrospector().introspect(orderDetailInfoList);
      logger.info("object-deep-size: {} MB", (double)objectInfo.getDeepSize() / 1024.0 / 1024.0);
    } catch (IllegalAccessException e) {
      logger.warn("failed to introspect object size");
    }

计算一个含有50个商品及优惠信息的订单,大小为 335KB,100 个就是 33M 这个商家导出了 4 次,每次有几百多单,会触发详情服务这边接受请求的几台服务器 FullGC ,进而影响详情服务的稳定性。


优化方法####

有两个方法可以组合使用:

  1. 检测这个订单是个大对象,将批量获取的条数改为更小,比如 10;

  2. 将大订单对象与小订单对象混合打散,降低大对象占用大量连续空间的概率。


可以做个问题抽象:有一个 0 与 1 组成的数组, 0 表示小对象, 1 表示大对象, 问题描述为:将一个 [0,1] 组成的数组打散,使得 1 的分布更加稀疏。 其中稀疏度可以如下衡量: 所有 1 之间的元素数目的平均值和方差。

这个问题看上去像洗牌,但实际是有区别的。洗牌是将有序的数排列打散变成无序,而现在是要使某些元素的分布更加均匀或稀疏。 一个简单的算法是:

STEP1: 遍历数组,将 0 和 1 分别放在列表 zeroList 和 oneList 里;

STEP2: 计算 0 与 1 的比值 ratio ; 创建一个结果列表 resultList ;

STEP3: 遍历 oneList ,对于每一个 1 , 将其加入 resultList ,同时加入 ratio 个 0 ;如果 0 不够,则仅返回剩余的 0 。

代码实现如下:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class DistributedUtil {

  /**
   * 一个列表,要求将满足条件 cond 的元素均匀分布到列表中。
   */
  public static <T> List<T> even(List<T> alist, Predicate<T> cond) {
    List<T> specialElements = alist.stream().filter(cond).collect(Collectors.toList());
    List<T> normalElements = alist.stream().filter(e -> !cond.test(e)).collect(Collectors.toList());

    int normalElemSize = normalElements.size();
    int specialElemSize = specialElements.size();

    if (normalElemSize == 0 || specialElemSize == 0) {
      return alist;
    }

    // 只要 normalElements 充足 , 每一个 specialElement 插入 ratio 个 normalElements
    int ratio = normalElemSize % specialElemSize ==
        0 ? (normalElemSize / specialElemSize) : (normalElemSize / specialElemSize + 1);

    List<T> finalList = new ArrayList<>();
    int pos = 0;
    for (T one: specialElements) {
      finalList.add(one);
      List<T> normalFetched = get(normalElements, ratio, pos);
      pos += normalFetched.size();
      finalList.addAll(normalFetched);

    }
    return finalList;
  }

  /**
   * 从指定位置 position 取出 n 个元素 , 不足返回剩余元素或空元素
   */
  public static <T> List<T> get(List<T> normalList, int n, int position) {
    int size = normalList.size();
    int num = size - position;
    int realNum = Math.min(num, n);
    return normalList.subList(position, position+realNum);
  }
}

写个简单的单测验证下:

import org.junit.Test
import spock.lang.Specification
import spock.lang.Unroll

import java.util.function.Predicate

class DistributedUtilTest extends Specification {

    @Unroll
    @Test
    def "testEven"() {
        expect:
        result == DistributedUtil.even(originList, { it == 1 } as Predicate)

        where:
        originList                        | result
        [1, 1, 1, 1, 1]                   | [1, 1, 1, 1, 1]
        [0, 0, 0, 0, 0]                   | [0, 0, 0, 0, 0]
        [1, 0, 0, 0, 0, 0]                | [1, 0, 0, 0, 0, 0]
        [1, 0, 1, 0, 0, 0, 0]             | [1, 0, 0, 0, 1, 0, 0]
        [1, 0, 1, 1, 0, 0, 0, 0]          | [1, 0, 0, 1, 0, 0, 1, 0]
        [1, 0, 1, 1, 1, 0, 0, 0, 0]       | [1, 0, 0, 1, 0, 0, 1, 0, 1]
        [1, 0, 1, 1, 1, 1, 0, 0, 0, 0]    | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
        [1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0] | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]


    }
}

GC姿势###

Java 垃圾回收采用的算法主要是:分代垃圾回收。垃圾回收算法简称 GC ,下文将以 GC 代之。

分代 GC 的主要理念是:大部分生成的对象都是生命周期短暂的对象,可以被很快回收掉;很少的对象能活动比较久。因此,分代回收算法,将垃圾回收分为两个阶段:新生代 GC 和 老年代 GC。

新生代 GC 采用算法基于 GC 复制算法,老年代 GC 采用的算法基于 标记-清除算法。


基础概念####

变量的分配

栈与堆。

栈:临时变量,作用域结束或函数执行完成后即被释放;

堆: 数组与对象的存储,不会随函数执行完成而释放。

栈的变量引用堆中的数组与对象。栈的变量就是根引用。引用通过指针来实现。


根引用与活动对象

从根引用出发,遍历所能引用和抵达的所有对象,这些对象都是活动对象。而其他则是非活动对象。

GC 的目标就是销毁非活动对象,腾出内存空间分配给新的对象和活动对象。

根引用(引用自 MAT 工具的文档):

  • Class loaded by bootstrap/system class loader

  • Object referred to from a currently active thread block.

  • A started, but not stopped, thread.

  • Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.

  • Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.

  • A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.

NOTE ! GC 不仅仅是GC,还要与内存分配综合考虑。


四种引用

  • 强引用: 有强引用的对象不会被回收。

  • 软引用: 在空间不足时抛出OOM前会回收软引用的对象。内存敏感的缓存对象,比如cache的value对象

  • 弱引用: 当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。比如canonicalizing mappings

  • 虚引用:often used for scheduling pre-mortem cleanup actions in a more flexible way than is possible with the Java finalization mechanism . get 总是返回 null


算法指标

吞吐量: HEAP_SIZE / Cost(GCa+GCb+...+GCx)

最大暂停时间: max(GCi)

堆使用效率:HEAP_SIZE / Heap(GC)


分代回收算法####

  • 不同对象的活动周期不同;年轻代更快地回收,老年代回收频率相对少。分代回收 = YoungGC + OldGC

  • YoungGC: GC 复制算法。 比较频繁;

  • OldGC: GC 标记-清除算法。 频度低,回收慢。


GC复制算法

基本思路:

  1. 复制活动对象从From空间到To空间;复制活动对象也包括该活动对象引用所抵达的所有对象,是递归的。

  2. 吞吐量优秀(只需复制活动对象),堆利用率比较低。高速分配、无碎片化。

局部优化:

  • 迭代复制:避免栈溢出

  • 近似深度搜索复制

  • 多空间复制


GC标记-清除算法

就像插入排序,优点是:简单而且适合小数据量。

基本流程:

  1. 标记阶段: 从根引用出发,将所有可抵达的对象打上标记;

  2. 清理阶段: 遍历堆,将没有标记的对象清理回收。

耗费时间与堆大小成正比,堆使用效率最高。

就地回收 -> 碎片化问题 -> 分配效率问题

局部优化:

  • 多空闲链表: 不同分块,方便不同大小的分配。空间回收时创建和更新。

  • BiBOP:将堆分为相同大小的块【跳过】

  • 位图标记: 活动对象标记采用位图技术来标记

  • 延迟清除法: 分配空间时进行清除操作,减少最大暂停时间。


现实GC####

垃圾收集器#####

选择垃圾收集器时,需要考虑 新生代收集器与老生代收集的配合使用。

新生代收集器

  • Serial : 单线程, stop the world ; 简单高效,桌面应用场景下,停顿时间可控制在几十毫秒不超过一百毫秒, Client 模式下的默认;

  • ParNew: Serial 的多线程版本,Server 模式下的首选,可以与 CMS 收集器配合使用;

  • Parallel Scavenge: 基于复制算法,多线程; 其目标是达到好的吞吐量,即使“用户代码CPU时间/CPU总耗时”比值更大,吞吐量优先的收集器,适合后台任务。具有自适应调节参数控制,适合新用户使用。


老生代收集器

  • SerialOld: 单线程,基于 标记-清理 算法,Client 模式下的默认。若用于 Server 模式,可以与 收集Parallel Scavenge 搭配使用,以及作为 CMS 的预备(在并发收集发生 Concurrent Mode Failure 时使用)。

  • ParallalOld: 多线程,基于 标记-清理 算法,Server 模式, 可以与 Parallel Scavenge 配合使用,吞吐量及CPU时间敏感型应用。

  • CMS : 并发,基于 标记-清理 算法,目标是获取最短停顿时间,可以与用户线程同时工作;

  • G1:并发,基于 标记-整理 算法,可预测的停顿时间模型,“隐藏级收集器”。

摘录自《深入理解Java虚拟机》(周志明著)


运行参数#####

堆内存

  • -Xms 初始堆大小 ; -Xmx 初始堆大小最大值;

  • -Xmn 新生代(包括Eden和两个Surivior)的堆大小 ;-XX:SurvivorRation=N来调整Eden Space及SurvivorSpace的大小,表示 Eden 与一个 SurvivorSpace 的比值是 N:1

  • -XX:NewRatio=N : 新生代与老年代的比值 1: N , 年轻代的空间占 1/(N+1)

  • -Xss : 每个线程的栈大小


收集器

  • -XX:+UseParNewGC : 使用 ParNew 收集器 ; -XX:+UseParallelOldGC 使用 ParallalOld 收集器;

  • -XX:MaxGCPauseMillis=N : 可接受最大停顿时间,毫秒数 ;-XX:GCTimeRatio=N : 可接受GC时间占比(目标吞吐量), 1 / (N+1), 吞吐量=1-1/(1+N)

  • -XX:+UseConcMarkSweepGC : 使用 CMS 收集器 ; -XX:+UseCMSCompactAtFullCollection :FullGC 后对老年代进行压缩整理,减少碎片化;-XX:+CMSInitiatingOccupancyFraction=80 老年代占用内存 80% 以上时,触发 FullGC。

  • -XX:+UseParallelGC : 并行收集器的线程数

  • -XX:+ DisableExplicitGC : 禁止RMI调用System.gc

  • -XX:PretenureSizeThreshold :大于这个设置值的大对象将直接进入老年代。
    -XX:MaxTenuringThreshold=15 :在 Eden 区出生的对象,经过第一次 MinorGC 之后仍然存活,且被 Surivior 容纳,则年龄记为 1 ; 每经过一次依然能在 Surivior 年龄增长一 ;当到达 XX:MaxTenuringThreshold 指定的值时,就会进入老年代空间。

GC事件#####
  • MinorGC : 大多数情况,新生代对象直接分配在 Eden 区。 当 Eden 区没有足够空间分配时,将发生一次 MinorGC 。 特点是: 频繁,回收快。

  • MajorGC / FullGC: 老年代GC,特点是:很少, 慢。 FullGC 指 MajorGC 中 stop the world 的部分,是需要尽量避免的事件。

  • 大对象触发的 FullGC :大对象,是指需要大量连续内存空间的java对象,例如很长的对象列表。此类对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。

  • promotion failed和concurrent mode failure 触发 FullGC : 采用 CMS 进行老年代 GC,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 两种状况,当这两种状况出现时可能会触发 Full GC。promotion failed 是在进行 Minor GC 时,survivor space 放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是 CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)。

  • 空间分配担保触发 FullGC: 在进行 MinorGC 之前,虚拟机会检查老年代连续最大可用空间是否大于新生代所有活动对象总大小。如果大于,则可以保证 MinorGC 是安全的;如果不成立,会查看 HandlePromotionFailure 是否允许担保失败;如果可以,则会检查老年代连续最大可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则会进行有风险的 MinorGC ;否则,会进行一次 FullGC 。

  • System.gc()方法的调用来建议触发 FullGC 。


GC日志#####
  • GC (Allocaion Failure) : 当在新生代中没有足够空间分配对象时会发生 Allocaion Failure,触发Young GC。 [ParNew: 1887487K->209664K(1887488K), 0.0814271 secs]表示 新生代 ParNew 收集器,GC 前该内存区域使用了 1887487K ,GC 后该内存区域使用了 209664K ,回收了 1677823K , 总容量 1887488K ; 该内存区域 GC 耗时 0.0814271 secs 。 3579779K->2056421K(3984640K), 0.0822273 secs 表示 堆区 GC 前 3579779K, GC 后 2056421K ,回收了 1523358K,GC 耗时 0.0822273 secs 。

  • concurrent mode failure : 一个是在老年代被用完之前不能完成对非活动对象的回收;一个是当新空间分配请求在老年代的剩余空间中不能得到满足。


小结###

线上的服务运行,会遇到各种的突发情况。比如大流量导出,多个大数据对象的订单导出,对于通用的处理措施来说,常常会触发一些潜在的问题,亦能引导人收获一些新知。仅仅是满足功能服务要求是远远不够的。

然而, 反过来思考,为什么总是要到问题发生的时候,才会意识到和去处理呢 ? 是否可以预知和处理问题呢 ? 这涉及到参悟本质: 事物的原理及关联。冥冥之中,因果早已注定,只是很多情况没有达到临界阈值,没有达到诱发条件。

深入理解原理,审视现有的架构设计和实现,预知和解决问题,才是更上一层楼的方式。

参考资料###

posted @ 2019-07-13 16:14  琴水玉  阅读(14841)  评论(2编辑  收藏  举报