彻底理解对象内存分配及 Minor GC 和 Full GC 全过程
线上的老年代频繁 Full GC 的案例,理解:
整个对象分配以及转移到老年代
Minor GC 和 Full GC 过程
案例
某数据计算系统,日处理数据量在上亿的规模。系统会不断从 MySQL 及其他数据源提取大量数据,加载到自己的 JVM 内存进行计算处理:
这数据计算系统不停通过 SQL 语句和其他方式从各种数据存储中提取数据到内存中来进行计算,大致当时的生产负载是每分钟大概需要执行 500 次数据提取和计算的任务。
但这是一套分布式运行的系统,所以生产环境部署了多台机器,每台机器大概每分钟负责执行 100 次数据提取和计算的任务。
每次会提取大概 1 万条左右的数据到内存里来计算,平均每次计算大概需要耗费 10s。
然后每台机器是 4 核 8G 的配置,JVM 内存给了 4G,其中新生代和老年代分别是 1.5G 的内存空间:
该系统到底多块会塞满新生代?
既然这个系统每台机器上部署的实例,每分钟会执行 100 次数据计算任务,每次是 1 万条数据需要计算 10 秒的时间,那每次 1 万条数据大概会占多大内存?
这里每条数据都较大,大概每条数据包含平均 20 个字段,可认为平均每条数据在 1KB 左右。那每次计算任务的 1 万条数据就对应了 10MB 的大小。
若新生代按 8:1:1 分配 Eden 和两块 Survivor 区域,那 Eden=1.2GB,每块 Survivor=100MB:
按此内存,每次执行一个计算任务,就会在 Eden 分配 10MB 左右对象,那一分钟大概对应 100 次计算任务。基本上一分钟过后,Eden 区里就满了。所以新生代里的 Eden 区,基本上 1min 左右就满了。
触发 Minor GC 时,会有多少对象进入老年代?
假设新生代 Eden 在 1min 后满了,然后接着继续执行计算任务时,必然导致需要进行 Minor GC,回收一部分垃圾对象。执行 Minor GC 之前会先进行检查:
第一步,老年代可用内存>新生代全部对象?
此时老年代空,有 1.5G 可用内存,新生代 Eden 大概算做有 1.2G 对象。
此时,即使一次 Minor GC,全部对象都存活,老年代也能放下,则此时就会直接执行 Minor GC。
那此时 Eden 有多少对象存活,无法被 GC 呢?
每个计算任务 1 万条数据,需计算 10s,假设此时 80 个计算任务都执行结束,但还有 20 个计算任务共计 200MB 数据还在计算,此时就是 200MB 对象是存活的,不能被 GC,然后有 1G 对象可 GC:
此时一次 Minor GC 就会回收 1G 对象,然后 200M 对象能放入 Survivor 区吗?
**不能!**因为任一块 Survivor 区实际上就 100M 空间,此时就会通过空间担保机制,让这 200MB 对象直接进入老年代,占用里面 200MB 内存空间,然后 Eden 区清空:
系统运行多久,老年代大概就会填满?
这系统大概运行多久,老年代会填满?按上述计算,每 min 都是个轮回,大概就是每 min 都会把新生代 Eden 填满,然后触发一次 Minor GC,然后大概都会有 200MB 数据进入老年代。
若 2min 过去了,此时老年代有 400M 被占用,只有 1.1G 可用,若第 3 分钟运行完毕,又要进行 Minor GC,会做什么检查呢?
先检查老年代可用空间 > 新生代全部对象?
此时老年代可用空间 1.1GB,新生代对象有 1.2GB,此时假设一次 Minor GC 过后新生代对象全部存活,老年代是放不下的,就得看“-XX:-HandlePromotionFailure”参数,一般都会打开
此时会进入第二步检查
老年代可用空间 > 历次 Minor GC 过后进入老年代的对象的平均大小
大概每 min 执行一次 Minor GC,每次大概 200M 对象进入老年代。那此时发现老年代 1.1G,大于每次 Minor GC 后平均的 200M。所以本次 Minor GC 后大概率还是有 200MB 对象进入老年代,1.1G 可用空间足够。所以此时就会放心执行一次 Minor GC,然后又是 200MB 对象进入老年代。
转折点大概在运行了 7min 后,7 次 Minor GC 后,大概 1.4G 对象进入老年代,老年代剩余空间就不到 100MB ,几乎快满:
系统运行多久,老年代会触发 1 次 Full GC?
大概在第 8min 运行结束时,新生代又满,执行 Minor GC 前进行检查,发现老年代只有 100M,比 200M 小,就会直接触发一次 Full GC。
Full GC 会把老年代的垃圾对象都给回收,假设此时老年代被占据的 1.4G 全都是可回收对象,则此时一次就会把这些对象都回收:
然后接着就会执行 Minor GC,此时 Eden 区情况,200MB 对象再次进入老年代,之前 Full GC 就是为这些新生代本次 Minor GC 要进入老年代的对象准备:
基本平均 7、8 分钟一次 Full GC,这频率相当高。因为每次 Full GC 很慢, 性能很差。
如何 JVM 调优?
因为这数据计算系统,每次 Minor GC 的时候,必然会有一批数据没计算完,但按现有内存模型,最大问题是每次 Survivor 区放不下存活对象。
所以增加新生代内存比例,3GB 堆内存,2GB 分给新生代, 1GB 给老年代。这样 Survivor 区大概 200M,每次刚好能放下 Minor GC 后存活对象:
只要每次 Minor GC 过后 200MB 存活对象可以放 Survivor 区域,等下次 Minor GC 时,这个 Survivor 区的对象对应的计算任务早就结束了,都可以回收。此时比如 Eden 区 1.6G 被占满,然后 Survivor1 有 200MB 上一轮 Minor GC 后存活的对象:
然后此时执行 Minor GC,就会把 Eden 1.6G 对象回收,Survivor1 里 200MB 对象也会回收,然后 Eden 区剩余 200MB 存活对象会放入 Survivor2 区:
以此类推,基本上就很少对象会进入老年代,老年代里的对象也不会太多。
通过这个优化,成功将生产系统的老年代 Full GC 频率从几 min 一次降低到几 h 一次,大幅提升系统性能,避免频繁 Full GC 对系统性能影响。
一个动态年龄判定升入老年代的规则,若:
就要直接升入老年代。所以此处优化仅是为说明:增加 Survivor 区大小,让 Minor GC 后的对象进入 Survivor 区中,避免进入老年代。
为避免动态年龄判定规则把 Survivor 区中的对象直接升入老年代,若新生代内存有限,可调整"- XX:SurvivorRatio=8"参数:默认 Eden 区比例 80%,也可降低 Eden 区比例,给两块 Survivor 区更多内存空间,然后让每次 Minor GC 后的对象进入 Survivor 区中,还可避免动态年龄判定规则直接把他们送入老年代。
垃圾回收器
在新生代和老年代进行垃圾回收的时候,都是要用垃圾回收器进行回收的,不同的区域用不同的垃圾回收器。
**Serial 和 Serial Old 垃圾回收器:**分别用来回收新生代和老年代的垃圾对象
工作原理就是单线程运行,垃圾回收的时候会停止我们自己写的系统的其他工作线程,让我们系统直接卡死不动,然后让他们垃圾回收,这个现在一般写后台 Java 系统几乎不用。
**ParNew 和 CMS 垃圾回收器:**ParNew 现在一般都是用在新生代的垃圾回收器,CMS 是用在老年代的垃圾回收器,他们都是多线程并发的机制,性能更好,现在一般是线上生产系统的标配组合。下周会着重分析这两个垃圾回收器。
**G1 垃圾回收器:**统一收集新生代 和老年代,采用了更加优秀的算法和设计机制,是下下周的重点,一周都会来分析 G1 垃圾回收器的工作原理和优缺点。
版权声明: 本文为 InfoQ 作者【JavaEdge】的原创文章。
原文链接:【http://xie.infoq.cn/article/9d4c15fe8065d4add40f6e1a0】。文章转载请联系作者。
评论