最近发生了一块儿 Java 大对象引发的 FullGC 事件。记录一下。html
有一位商家刷单,每单内有 50+ 商品。而后进行订单导出。订单导出每次会从订单详情服务取100条订单数据。因为 100 条订单数据对象很大,致使详情 FullGC ,影响了服务的稳定性。java
本文借此来梳理下 Java 垃圾回收算法及分析 JVM 垃圾回收运行的方法。算法
若是对GC不太熟悉,能够先看看“GC姿式”部分,对 JVM 垃圾回收有一个比较清晰的理解。
bootstrap
回头看这个案例,显然它极可能触犯了“大对象容易触发 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 ,进而影响详情服务的稳定性。安全
有两个方法能够组合使用:服务器
检测这个订单是个大对象,将批量获取的条数改成更小,好比 10;多线程
将大订单对象与小订单对象混合打散,下降大对象占用大量连续空间的几率。架构
能够作个问题抽象:有一个 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] } }
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复制算法
基本思路:
复制活动对象从From空间到To空间;复制活动对象也包括该活动对象引用所抵达的全部对象,是递归的。
吞吐量优秀(只需复制活动对象),堆利用率比较低。高速分配、无碎片化。
局部优化:
迭代复制:避免栈溢出
近似深度搜索复制
多空间复制
GC标记-清除算法
就像插入排序,优势是:简单并且适合小数据量。
基本流程:
标记阶段: 从根引用出发,将全部可抵达的对象打上标记;
清理阶段: 遍历堆,将没有标记的对象清理回收。
耗费时间与堆大小成正比,堆使用效率最高。
就地回收 -> 碎片化问题 -> 分配效率问题
局部优化:
多空闲链表: 不一样分块,方便不一样大小的分配。空间回收时建立和更新。
BiBOP:将堆分为相同大小的块【跳过】
位图标记: 活动对象标记采用位图技术来标记
延迟清除法: 分配空间时进行清除操做,减小最大暂停时间。
选择垃圾收集器时,须要考虑 新生代收集器与老生代收集的配合使用。
新生代收集器
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:PretenureSizeThreshold :大于这个设置值的大对象将直接进入老年代。
-XX:MaxTenuringThreshold=15 :在 Eden 区出生的对象,通过第一次 MinorGC 以后仍然存活,且被 Surivior 容纳,则年龄记为 1 ; 每通过一次依然能在 Surivior 年龄增加一 ;当到达 XX:MaxTenuringThreshold 指定的值时,就会进入老年代空间。
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 (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 : 一个是在老年代被用完以前不能完成对非活动对象的回收;一个是当新空间分配请求在老年代的剩余空间中不能获得知足。
线上的服务运行,会遇到各类的突发状况。好比大流量导出,多个大数据对象的订单导出,对于通用的处理措施来讲,经常会触发一些潜在的问题,亦能引导人收获一些新知。仅仅是知足功能服务要求是远远不够的。
然而, 反过来思考,为何老是要到问题发生的时候,才会意识到和去处理呢 ? 是否能够预知和处理问题呢 ? 这涉及到参悟本质: 事物的原理及关联。冥冥之中,因果早已注定,只是不少状况没有达到临界阈值,没有达到诱发条件。
深刻理解原理,审视现有的架构设计和实现,预知和解决问题,才是更上一层楼的方式。