JVM 实战—如何分析 jstat 统计来定位 GC
1.使用 jstat 了解线上系统的 JVM 运行状况
(1)JVM 的整体运行原理简单总结
一.对象优先在 Eden 区分配
二.Young GC 的触发时机和执行过程
三.对象进入老年代的时机
四.Full GC 的触发时机和执行过程
接下来介绍如何使用工具分析运行的系统:
一.对象增长的速率
二.Young GC 的触发频率
三.Young GC 的耗时
四.每次 Young GC 后有多少对象存活下来
五.每次 Young GC 后有多少对象进入老年代
六.老年代对象增长的速率
七.Full GC 的触发频率
八.Full GC 的耗时
(2)功能强大的 jstat
如果平时要对运行中的系统,检查其 JVM 的整体运行情况。比较实用的工具之一就是 jstat,它可以轻易让我们看到当前 JVM 内:Eden 区、S 区、老年代的内存情况,以及 YGC 和 FGC 的执行次数和耗时。
通过这些指标,我们就可以轻松分析出当前系统的运行情况。从而判断当前系统的内存使用压力和 GC 压力,以及内存分配是否合理。
(3)jstat -gc PID
首先使用 jps 命令在生产机器 Linux 上,找出 Java 进程的 PID。接着就针对我们的 Java 进程执行:jstat -gc PID,这样就可以看到这个 Java 进程的内存和 GC 情况了。运行这个命令后会看到如下指标的信息:
这些指标都是非常实用的 JVM GC 分析指标。
(4)其他的 jstat 命令
除了上面的 jstat -gc 命令是最常用的以外,jstat 还有一些命令可以看到更多详细的信息,如下所示:
但最完整、最常用、最实用的还是 jstat -gc 命令,jstat -gc 命令基本足够分析 JVM 的运行情况了。
(5)到底该如何使用 jstat 工具
首先需要明确,分析线上的 JVM 进程,最想要知道的信息有哪些?最想要知道的信息会包括如下:
一.新生代对象增长的速率
二.Young GC 的触发频率
三.Young GC 的耗时
四.每次 Young GC 后有多少对象存活下来
五.每次 Young GC 后有多少对象进入老年代
六.老年代对象增长的速率
七.Full GC 的触发频率
八.Full GC 的耗时
只要知道了这些信息,我们就可以结合前面介绍的 JVM GC 优化的方法:合理分配内存,尽量让对象留在年轻代不进入老年代,避免频繁 FGC。这就是对 JVM 最好的性能优化了。
(6)新生代对象增长的速率
需要了解 JVM 的第一个信息就是:随着系统运行,每秒会在新生代的 Eden 区分配多少对象。
要获取该信息,只需要在 Linux 机器上运行命令:jstat -gc PID 1000 10,该命令意思:每隔 1 秒更新最新 jstat 统计信息,一共执行 10 次 jstat 统计。通过这行命令,可以灵活地对线上机器,通过固定频率输出统计信息。从而观察出每隔一段时间,JVM 中 Eden 区的对象占用变化。
比如执行这行命令后:第一秒显示出 Eden 区使用了 200M 内存,第二秒显示出 Eden 区使用了 205M 内存,第三秒显示出 Eden 区使用了 209M 内存,以此类推。此时我们就可以推断出来,这个系统大概每秒钟会新增 5M 左右的对象。
而且这里可以根据自己系统的情况灵活多变地使用,如果系统负载很低,则不一定每秒进行统计,可以每分或每 10 分来统计,以此查看系统每隔 1 分钟或者 10 分钟大概增长多少对象。
此外,系统一般有高峰和日常两种状态,比如系统高峰期用户很多,我们应该在系统高峰期去用上述命令看看高峰期的对象增长速率,然后再在非高峰的日常时间段内看看对象的增长速率,这样就可以了解清楚系统的高峰和日常时间段内的对象增长速率了。
(7)Young GC 的触发频率和每次耗时
接着需要了解 JVM 的第二个信息是:大概多久会触发一次 YGC,以及每次 YGC 的耗时。其实多久触发一次 YGC 是很容易推测出来的,因为系统高峰和日常时的对象增长速率都知道了,根据对象增长速率和 Eden 区大小,就可以推测出:高峰期多久发生一次 YGC,日常期多久发生一次 YGC。
比如 Eden 区有 800M 内存:如果发现高峰期每秒新增 5M 对象,那么大概 3 分钟会触发一次 YGC。如果发现日常期每秒新增 0.5M 对象,那么大概半小时才触发一次 YGC。
那么每次 Young GC 的平均耗时呢?jstat 会展示迄今为止系统已经发生了多少次 YGC 以及这些 YGC 的总耗时。比如系统运行 24 小时后共发生 260 次 YGC,总耗时为 20s。那么平均每次 YGC 大概耗时几十毫秒,我们由此可以大概知道每次 YGC 时会导致系统停顿几十毫秒。
(8)每次 Young GC 后有多少对象进入老年代
接着要了解 JVM 的第三个信息是:每次 YGC 后有多少存活对象,即有多少对象会进入老年代。
其实每次 YGC 过后有多少对象会存活下来,只能大致推测出来。假设已经推算出高峰期多久会发生一次 YGC,比如 3 分钟会有一次 YGC。那么此时就可以执行下述 jstat 命令:jstat -gc PID 180000 10。这就相当于让 JVM 每隔三分钟执行一次统计,连续执行 10 次。观察每隔三分钟发生一次 YGC 时,Eden、Survivor、老年代的对象变化。
正常来说:Eden 区肯定会在几乎放满后又变得很少对象,比如 800M 只使用几十 M。Survivor 区肯定会放入一些存活对象,老年代可能会增长一些对象占用。所以这时观察的关键,就是观察老年代的对象增长速率。
正常来说:老年代不太可能不停快速增长的,因为普通系统没那么多长期存活对象。如果每次 YGC 后,老年代对象都要增长几十 M,则可能存活对象太多了。存活对象太多可能会导致放入 S 区后触发动态年龄判定规则进入老年代,存活对象太多也可能导致 S 区放不下,大部分存活对象需要进入老年代。
如果老年代每次在 YGC 过后就新增几百 K 或几 M 的对象,这个还算正常。但如果老年代对象快速过快增长,那一定是不正常的。
所以通过上述观察策略,就可以知道每次 YGC 后有多少对象是存活的,也就是 Survivor 区里增长的 + 老年代增长的对象,就是存活的对象。
通过 jstat -gc 也可以知道老年代对象的增长速率,比如每隔 3 分钟一次 YGC,每次会有 50M 对象进入老年代,于是老年代对象的增长速率就是每隔 3 分钟增长 50M。
(9)Full GC 的触发时机和耗时
只要知道老年代对象的增长速率,那么 Full GC 的触发时机就很清晰了。比如老年代有 800M,每 3 分钟新增 50M,则每 1 小时就会触发一次 FGC。
根据 jstat 输出的系统运行迄今为止的 FGC 次数以及总耗时,就能计算出每次 FGC 耗时。比如一共执行了 10 次 FGC,共耗时 30s,那么每次 FGC 大概耗费 3s 左右。
2.使用 jmap 和 jhat 了解线上系统的对象分布
(1)jstat 总结
通过 jstat 可以非常轻松便捷的了解到线上系统的运行状况,如新生代对象和老年代对象增速、YGC 和 FGC 触发频率以及耗时等,通过 jstat 可以完全了解线上系统的 JVM 运行情况,为优化做准备。
接下来介绍两个在工作中非常实用的工具:jmap 和 jhat。这两个工具可以观察线上 JVM 中的对象分布,能更加细致了解系统运行。即了解系统运行过程中,哪些对象占据了大部分,占据了多少内存空间。
(2)使用 jmap 了解系统运行时的内存区域
其实如果只是了解 JVM 运行状况,然后进行 GC 优化,通常 jstat 就够用了。但有时会发现 JVM 新增对象速度很快,想知道什么对象占那么多内存。从而可以优化对象在代码中的创建时机,避免对象占用内存过大。
首先看一个命令:jmap -heap PID。这个命令可以打印出一系列信息,这些信息大概就是:堆内存相关的一些参数设置、当前堆内存里各个区域的情况。比如:Eden 区的总容量、已经使用的容量、剩余的空间容量,两个 Survivor 区的总容量、已经使用的容量、剩余的空间容量,老年代的总容量、已经使用的容量、剩余的容量。
但是这些信息其实 jstat 已经有了,所以一般不会用 jmap 去获取这些信息。毕竟 jmap 的这种信息还没 jstat 全面,比如 jmap 就没有 GC 相关的统计。
(3)使用 jmap 了解系统运行时的对象分布
jmap 中比较有用的一个命令是:jmap -histo PID。这个命令会打印出类似下面的信息:按各对象占用内存空间大小降序排列,把占用内存最多的对象放在最上面。
所以想简单了解当前 JVM 中的对象内存占用情况,可用 jmap -histo 命令。该命令可快速了解当前内存里到底哪个对象占用了大量的内存空间。
(4)使用 jmap 生成堆内存转储快照
如果想查看对象占用内存的具体情况,那么可以使用 jmap 命令生成一个堆内存转储快照到文件里:
jmap -dump:live,format=b,file=dump.hprof PID
这个命令会在当前目录下生成一个 dump.hrpof 文件,该文件是二进制的格式,不能直接打开看,这个命令会把这一时刻 JVM 堆内存里所有对象的快照放到文件里。
(5)使用 jhat 在浏览器中分析堆转出快照
可以使用 jhat 去分析堆内存快照,jhat 内置了 web 服务器,支持通过 web 界面来分析堆内存转储快照。
使用如下命令即可启动 jhat 服务器,可以指定 HTTP 的端口,默认的 HTTP 端口为 7000。
jhat -port 7000 dump.hprof
接着就可以在浏览器上访问当前这台机器的 7000 端口号,这样就可以通过图形化的方式去分析堆内存里的对象分布情况了。
3.如何分析 JVM 运行状况并合理优化
(1)开发好系统后的预估性优化
什么叫预估性优化?就是估算系统每秒多少请求、每个请求创建多少对象、占用多少内存。机器应选用什么配置、新生代应给多少内存、老年代应给多少内存。Young GC 触发的频率、对象进入老年代的速率、Full GC 触发的频率。
这些信息其实都是可以根据系统代码,大致合理地进行预估的。在预估完成后,就可以采用前面的优化思路,先设置初始的 JVM 参数。比如堆内存大小、新生代大小、Eden 和 Survivor 的比例、老年代大小、大对象的阈值、大龄对象进入老年代的阈值等。
优化思路就是:尽量让每次 YGC 后的存活对象小于 S 区的 50%,可以都留在新生代里。尽量别让对象进入老年代,减少 FGC 的频率、避免频繁 FGC 影响性能。
(2)系统压测时的 JVM 优化
通常一个新系统开发完毕后,会经过一连串的测试,本地单元测试->系统集成测试->测试环境功能测试->预发环境压力测试。总之要保证系统的功能正常,在一定压力下稳定性和并发能力也都正常,最后才会部署到生产环境运行。
这里非常关键的一个环节就是预发布环境的压力测试,通常该环节会使用一些压力测试工具模拟如 1000 个用户同时访问系统。模拟每秒 500 个请求压力,然后看系统能否支撑住每秒 500 请求的压力。同时看系统各接口响应延时是否在比如 200ms 内,即接口性能不能太慢。或者在数据库中模拟出百万级单表数据,然后看系统是否还能稳定运行。
具体如何进行系统压测,可以搜 Java 压力测试,会有很多开源工具。通过这些工具可以轻松模拟出 N 个用户同时访问你系统的场景,同时还能生成压力测试报告:每秒可支撑多少请求、接口的响应延时等。
在该环节,压测工具会对系统发起持续不断的请求。这些请求通常会持续很长时间,如几小时甚至几天。所以可以在该环节,对测试机器运行的系统,采用 jstat 工具来进行分析。在模拟真实环境的压力测试下,通过 jstat 命令获取 JVM 的整体运行状态。
前面已具体介绍了如何使用 jstat 来分析以下 JVM 的关键运行指标:新生代对象增长的速率、YGC 的触发频率、YGC 的耗时、每次 YGC 后有多少对象存活下来、每次 YGC 后有多少对象进入老年代、老年代对象增长的速率、FGC 的触发频率、FGC 的耗时。
然后根据压测环境中的 JVM 运行状况:如果发现对象过快进入老年代,可能是因为年轻代太小导致频繁 YGC;也可能因为很多对象存活,而 S 区太小,导致很多对象频繁进入老年代;此时就需要采用前面介绍的优化思路:合理调整新生代、老年代、Eden、Survivor 各个区域的内存大小,保证对象尽量留在年轻代、不要过快进入老年代中。
有很多人会胡乱搜索网上 JVM 优化博客,看人家怎么优化就怎么优化。比如很多博客说新生代和老年代的占比一般是 3:8,这其实是很片面的。每个系统都是不一样,特点不同、复杂度不同。真正的优化,一定是在实际观察我们的系统后,合理调整内存分布。真正的优化,并没有固定的 JVM 优化模板。当优化好压测环境的 JVM 参数,观察 YGC 和 FGC 频率都很低,就可上线。
(3)对线上系统进行 JVM 监控
当系统上线后,就需要对线上系统的 JVM 进行监控,这个监控通常来说有两种办法。
第一种方法:
每天在高峰期和低峰期用 jstat、jmap、jhat 看线上 JVM 运行是否正常。有没有频繁 FGC 问题,如果有就优化,没有就每天定时或每周定时去看。
第二种方法:
部署专门的监控系统,常见的有 Zabbix、OpenFalcon、Ganglia 等。可以将线上系统的 JVM 统计项发送到这些监控系统里去,这样就可以在这些监控系统可视化界面里,看到需要的所有指标:包括各内存区域的对象占用变化曲线、可直接看到 Eden 区的对象增速、YGC 发生的频率以及耗时、老年代的对象增速、FGC 的频率和耗时。而且这些工具通常还允许设置监控告警,比如 10 分钟之内发生 5 次以上 FGC,就需要发送告警给我们。
4.使用 jstat 分析模拟的 BI 系统 JVM 运行情况
(1)服务于百万级商家的 BI 系统是什么
作为一个电商平台,可能会有数十万到百万的商家在平台上做生意。电商平台每天会产生大量数据,需要基于这些数据为商家提供数据报表。比如:每个商家每天有多少访客、有多少交易、付费转化率是多少。
BI 系统其实就是把商家日常经营的数据收集起来进行分析,然后提供各种数据报表给商家的一套系统。
这样的一个 BI 系统,其运行逻辑如下:首先电商平台会提供一个业务平台给商家进行日常使用交互,该业务平台会采集到商家的很多日常经营数据。根据这些日常经营数据,通过 Hadoop、Spark 等技术计算各种数据报表,这些数据报表会被放入存储到 MySQL、Elastcisearch、HBase 中。最后基于 MySQL、HBase、ES 中存储的数据报表,开发出一个 BI 系统。通过这个 BI 系统就能把各种存储好的数据展示给商家进行筛选和分析。
(2)刚开始上线 BI 系统时的部署架构
刚开始系统上线时,这个 BI 系统使用的商家是不多的,比如几千个商家,所以刚开始系统部署得非常简单,就是用几台机器来部署上述的 BI 系统,机器都是普通的 4 核 8G 配置。在这个配置下,会给堆内存新生代分配 1.5G 内存,Eden 区大概 1G 左右,如下图示:
(3)实时自动刷新报表 + 大数据量报表
刚开始在少数商家的情况下,这个系统没多大问题,运行得非常良好。但使用系统的商家开始越来越多,商家数量级达到几万时就有问题了。
首先说明一下此类 BI 系统的特点;就是在 BI 系统中有一种实时数据报表,它支持前端页面有一个 JS 脚本,该 JS 脚本每隔几秒就会自动发送请求到后台刷新一下数据。如下图示:
虽然只有几万商家使用该系统,但可能同时打开实时报表的商家有几千。每个商家打开报表后,前端都会每隔几秒发送请求到后台加载最新数据。于是出现部署 BI 系统的每台机器每秒请求达几百个,假设每秒 500 请求。
然后每个请求会加载出一张报表所需要的大量数据,因为 BI 系统可能还要针对这些数据在内存中进行计算加工,才能返回。根据测算,每个请求大概会从 MySQL 中加载出 100K 的数据进行计算。因此每秒 500 个请求,就要加载 50M 数据到内存中进行计算。
(4)没什么大影响的频繁 Young GC
在上述系统运行模型下,由于每秒会加载 50M 的数据到 Eden 区中,所以只要 200s 就会填满 Eden 区,然后触发 YGC 对新生代进行垃圾回收。当然 1G 左右的 Eden 进行 YGC 速度是比较快的,可能几十 ms 就搞定了。所以每 200s 频繁执行一次 YGC 其实对系统性能影响并不大,而且上述 BI 系统场景下,基本上每次 YGC 后存活对象可能会有几十 M。
因此可能会看到如下场景:BI 系统每运行几分钟就会卡顿 10ms,但对用户和系统性能几乎没影响。
(5)模拟程序的 JVM 参数设置
接下来用一段程序模拟出上述 BI 系统那种频繁 YGC 的场景,此时 JVM 参数如下所示:
上述把堆内存设置为了 200M,把年轻代设置为 100M。然后 Eden 区是 80M,每块 Survivor 区是 10M,老年代也是 100M。
(6)模拟程序
下面是我们的模拟程序:
上述这段模拟程序的一些说明:
一.第一行代码为 Thread.sleep(30000),为什么刚开始要先休眠 30s?因为程序刚启动休眠 30s 可方便我们找到这个程序的 PID,即进程 ID,然后再执行 jstat 命令来观察程序运行时的 JVM 状态。
二.接着 loadData()方法内的代码会循环 50 次,模拟每秒 50 个请求。然后每次请求会分配一个 100K 的数组,模拟每次请求从数据存储中加载出 100K 的数据。接着会休眠 1 秒,模拟这一切都是发生在 1 秒内的。其实这些对象都是短生存周期的对象,方法运行结束这些对象都是垃圾。
三.然后在 main()方法里有一个 while(true)循环,模拟系统按照每秒 50 个请求,每个请求加载 100K 数据的方式不停运行。除非我们手动终止程序,否则永不停止。
(7)通过 jstat 观察程序的运行状态
一.接着我们使用预定的 JVM 参数启动程序
此时程序会先进入一个 30 秒的休眠状态,于是尽快执行 jps 命令,查看启动程序的进程 ID。如下所示:
二.此时会发现我们运行的 Demo 这个程序的 JVM 进程 ID 是 1170
然后尽快执行下述 jstat 命令:
这行命令的意思是:针对 1170 进程统计 JVM 运行状态,每隔 1 秒打印一次统计信息,连续打印 1000 次。
然后执行 jstat 开始统计,每隔一秒都会打印一行新的统计信息,过了几十秒后可看到如下所示的统计信息:
三.接着先看如下图示的一段 EU 信息
这个 EU 就是 Eden 区被使用的容量,可发现刚开始是 5M 左右的使用量。接着程序开始运行,每秒都会有对象增长:从 5M 到 10M,接着 15M,20M,25M,每秒都会新增 5M 左右的对象。这个跟上面的代码是完全吻合的,代码也是每秒会增加 5M 左右的对象。
四.然后当 Eden 区使用量达到 80M 左右时,再要分配 5M 对象就失败了
此时就会触发一次 Young GC,如下图示:
上面红圈的内容:Eden 区的使用量从将近 80M 降低为 3M 多,这是因为一次 YGC 回收掉了大部分对象。
五.所以针对这个模拟代码,可以清晰的从 jstat 中看出如下信息:
对象增速大致为每秒 5M 左右,大致每十几秒会触发一次 YGC。下图可以看到,YGC 的触发频率,以及每次 YGC 的耗时。
上图清晰告诉我们:一次 YGC 回收 70 多 M 对象,大概花费 1 毫秒。所以 YGC 其实是很快的,即使回收 800M 的对象,也就 10 毫秒左右。所以如果是线上系统,Eden 区 800M 的话,每秒新增对象 50M。十多秒一次 YGC 耗时 10 毫秒左右,系统卡顿 10 毫秒几乎没什么大影响。
在这个模拟代码中:80M 的 Eden 区,每秒新增对象 5M。大概十多秒触发一次 YGC,每次 YGC 耗时在 1 毫秒左右。那么 YGC 回收 1G 大小的 Eden 区,耗时大概会在 15 毫秒左右,毫秒级别。
六.那么每次 YGC 过后会存活多少对象
上图 S1U 就是 Survivor 中被使用的内存,S1U 之前一直是 0,在一次 YGC 过后变成了 633K。所以一次 YGC 后存活了 633K 的对象而已,可轻松放入 10M 的 Survivor。
而且注意上图的 OU,就是老年代被使用的内存量,在 YGC 前后都是 0。这说明这个系统运行良好,YGC 不会导致对象进入老年代。所以这个系统就几乎不需要什么优化了,因为老年代对象增速几乎为 0,FGC 发生频率趋向于 0,对系统无影响。
因此通过这个模拟程序的运行,我们可以使用 jstat 分析出以下信息的:新生代对象增长的速率、YGC 的触发频率、YGC 的耗时、每次 YGC 后有多少对象是存活的、每次 YGC 后有多少对象进入了老年代、老年代对象增长的速率、FGC 的触发频率、FGC 的耗时。
5.使用 jstat 分析模拟的计算系统 JVM 运行情况
(1)一个日处理上亿数据的计算系统
当时团队里自研的一个数据计算系统,日处理数据量在上亿的规模。这个系统会不停的从 MySQL 数据库以及其他数据源里提取大量的数据,然后加载到自己的 JVM 内存里来进行计算处理,如下图示:
这个数据计算系统会不停的通过 SQL 语句和其他方式,从各种数据存储中提取数据到内存中来进行计算,大致当时的生产负载是每分钟需要执行 500 次数据提取和计算的任务。
由于这是一套分布式运行的系统,所以生产环境部署了多台机器。每台机器大概每分钟负责执行 100 次数据提取和计算的任务(15 个线程),每次会提取大概 1 万条数据到内存计算,平均每次计算大概耗费 10 秒。然后每台机器 4 核 8G,新生代和老年代分别是 1.5G 和 1.5G 的内存空间。如下图示:
(2)这个系统多久会塞满新生代
现在明确了一些核心数据,那么该系统多久会塞满新生代的内存空间。既然每台机器上部署的该系统实例,每分钟会执行 100 次数据计算任务。每次 1 万条数据需要计算 10 秒时间,该台机器大概开启 15 个线程去执行。
那么先来看看每次 1 万条数据大概会占用多大的内存空间:这里每条数据都是比较大的,每条数据大概包含了 20 个字段,可认为平均每条数据的大小在 1K 左右,那么每次计算任务提取的 1 万条数据就对应 10M 大小。
所以如果新生代按照 8:1:1 的比例来分配 Eden 和两块 Survivor 的区域,按照新生代和老年代分别是 1.5G 和 1.5G 的内存空间可知,Eden 区就是 1.2G,每块 Survivor 区在 100M 左右。如下图示:
由于每次执行一个计算任务,就要提取 1 万条数据到内存,每条数据 1K。所以每次执行一个计算任务,JVM 会在 Eden 区里分配 10M 的对象。那么由于一分钟需要执行大概 100 次计算任务,所以新生代里的 Eden 区,基本上 1 分钟左右就会被迅速填满。
(3)触发 YGC 时会有多少对象进入老年代
假设新生代的 Eden 区在 1 分钟后都塞满对象了,在继续执行计算任务时,必然会导致需要进行 YGC 回收部分垃圾对象。
一.在执行 YGC 前会先进行检查
首先会看老年代的可用内存空间是否大于新生代全部对象。此时老年代是空的,大概有 1.5G 的可用内存空间,而新生代的 Eden 区大概有 1.2G 对象。
于是会发现老年代的可用内存空间有 1.5G,新生代的对象总共有 1.2G。即使一次 YGC 过后,即时全部对象都存活,老年代也能放的下的,所以此时会直接执行 YGC。
二.执行 YGC 后,Eden 区里有多少对象是存活的无法被垃圾回收的
由于新生代的 Eden 区在 1 分钟就塞满对象需要 YGC 了,而 1 分钟内会执行 100 次任务,每个计算任务处理 1 万条数据需要 10 秒钟。
假设执行 YGC 时,有 80 个计算任务都执行结束了,但还有 20 个计算任务共计 200M 的数据还在计算中。那么此时就有 200M 的对象是存活的,不能被垃圾回收掉。所以总共有 1G 对象可以进行垃圾回收,200M 对象存活无法被垃圾回收。如下图示:
三.此时执行一次 YGC 会回收 1G 对象,然后出现 200M 的存活对象
这 200M 的存活对象并不能放入 S 区,因为一块 S 区就 100M 大小,此时老年代会通过空间担保机制,让这 200M 对象直接进入老年代中。于是需要占用老年代里的 200M 内存空间,然后对 Eden 区进行清空。
(4)系统运行多久老年代就会被填满
按照上述计算,每分钟都是一个轮回,大概算下来是每分钟都会把新生代的 Eden 区填满。然后触发一次 YGC,接着大概会有 200M 左右的数据进入老年代。
假设 2 分钟过去了,老年代已有 400M 内存被占用,只有 1.1G 内存可用,此时老年代的可用内存空间已经少于新生代的内存大小了。所以如果第 3 分钟运行完毕,又要进行 YGC,会做什么检查呢?如下图示:
一.首先会检查老年代可用空间是否大于新生代全部对象
此时老年代的可用空间是 1.1G,新生代对象的大小有 1.2G,如果这次 YGC 过后新生代对象全部存活,那么老年代是放不下的。
二.接着就得检查 HandlePromotionFailure 参数是否打开
如果"-XX:-HandlePromotionFailure"参数被打开了,一般都会打开。此时会进入下一个检查:老年代可用空间是否大于历次 YGC 过后进入老年代的对象的平均大小。
前面已计算出大概每分钟会执行一次 YGC,每次 200M 对象进入老年代。此时老年代可用 1.1G,大于每次 YGC 进入老年代的对象平均大小 200M。所以可推测本次 YGC 后大概率还是有 200M 对象进入老年代,1.1G 足够。因此这时就可以放心执行一次 YGC,然后又有 200M 对象进入老年代。
三.转折点大概在运行了 7 分钟后
执行了 7 次 YGC 后,大概 1.4G 对象进入老年代。老年代剩余空间不到 100M,几乎满了。如下图示:
(5)这个系统运行多久老年代会触发 1 次 Full GC
大概在第 8 分钟运行结束时,新生代又满了。执行 YGC 之前进行检查,发现老年代此时只有 100M 的可用内存空间。小于历次 YGC 后进入老年代的 200M 对象,于是就会直接触发一次 FGC,FGC 会把老年代的垃圾对象都给回收掉。
假设此时老年代被占据的 1.4G 空间里,全部都是可以回收的对象,那么此时就会一次性把这些对象都给回收掉。如下图示:
然后执行完 FGC 后,还会继续执行 YGC,又有 200M 对象进入老年代。之前的 FGC 就是为这次新生代 YGC 后要进入老年代的对象准备的。如下图示:
所以根据这个运行模型,该系统平均八分钟会发生一次 FGC,这个频率就很高了,而每次 FGC 速度都是很慢的、性能很差。
(6)该案例应该如何进行 JVM 优化
通过上述这个案例,可以清楚看到:新生代和老年代应该如何配合使用,什么情况下会触发 Young GC 和 Full GC,什么情况下会导致频繁的 Young GC 和 Full GC。
如果要对这个系统进行优化:由于该系统是数据计算系统,每次 YGC 时都会有一批数据没计算完毕。所以按现有的内存模型,最大问题就是每次 YGC 后 S 区放不下存活对象。
所以可以对生产系统进行调整:增加新生代的内存比例,3G 堆内存的 2G 给新生代,1G 给老年代。这样 S 区大概就是 200M,每次刚好能放得下 YGC 过后存活的对象。如下图示:
只要每次 YGC 过后 200M 存活对象可以放进 Survivor 区域,那么等下次 YGC 时,这个 S 区的对象对应的计算任务早就结束可回收了。比如此时 Eden 区里 1.6G 空间被占满了,然后 S1 区里有 200M 上一轮 YGC 后存活的对象。如下图示:
此时执行 YGC 后:就会把 Eden 区里 1.6G 对象回收掉,S1 区里的 200M 对象也会回收掉,然后 Eden 区里剩余的 200M 存活对象会放入 S2 区。如下图示:
以此类推,基本就很少有对象进入老年代了,老年代的对象也不会太多。这样就把生产系统老年代 FGC 的频率从几分钟一次降低到几小时一次,大幅度提升了系统的性能,避免了频繁 FGC 对系统运行的影响。
前面说过一个动态年龄判定升入老年代的规则:如果 S 区中的同龄对象大小超过 S 区内存的一半,就要直接升入老年代。
所以这里的优化仅仅是做一个示例说明而已,实际 S 区 200M 还是不够。但表达的是要增加 S 区大小,让 YGC 后的对象进入 S 区,避免进入老年代。
实际上为了避免触发动态年龄判定规则,把 S 区中的对象直接升入老年代,如果新生代内存有限,那么可以调整"-XX:SurvivorRatio=8"参数。比如降低 Eden 区的比例(默认 80%),给两块 S 区更多的内存空间。让每次 YGC 后的对象进入 S 区,避免触发动态年龄规则把它们升入老年代。
(7)模拟程序用的 JVM 参数
把堆内存设置为 200M,把年轻代设置为 100M。然后 Eden 区 80M,每块 Survivor 区 10M,老年代 100M。接着通过-XX:PretenureSizeThreshold,把大对象阈值修改为 20M,避免模拟程序里分配的大对象直接进入老年代。
(8)模拟程序
上面模拟程序的含义:
一.每秒钟都会执行一次 loadData()方法。
二.loadData()首先会分配 4 个 10M 的数组,但都会马上成为垃圾。接着会有两个 10M 的数组会被变量 data1 和 data2 引用,必须存活,此时 Eden 区已经占用了六七十 M 的空间了。
三.接着是 data3 变量会依次指向两个 10M 的数组,1s 内会触发 YGC。
(9)基于 jstat 分析程序运行的状态
接下来启动程序后马上采用 jstat 监控其运行状态:
可以看到如下的信息:
下面分析这个 JVM 的运行状态。
一.首先看如下图示
在最后一行可清晰看到,程序运行后,突然在一秒内就发生了一次 YGC。因为按照上述的模拟代码,它一定会在一秒内触发一次 YGC 的。
YGC 后,可以发现 S1U 中有 536K 的存活对象,这应该就是那些未知对象。然后明显看到在 OU 中多出 30M 左右的对象。因此可以确定,在这次 YGC 时,有 30M 的对象存活了。因为此时 YGC 后的存活对象在 Survivor 区放不下,所以直接进入老年代。
二.接着看如下图示
上图中红圈部分:很明显每秒会发生一次 YGC,每次会导致 10M~30M 的对象进入老年代。因每次 YGC 都存活这么多对象,但 S 区放不下,所以才直接进入老年代。
此时可以看到老年代的对象占用从 30M 一路升到 60M。然后突然在 60M 后的下一秒,明显发生了一次 FGC,对老年代进行回收,因为此时老年代重新变成 30M 了。
为什么会这样?因为老年代总共就 100M 左右,已占 60M 了。此时如果发生一次 YGC,有 30M 存活对象要放入老年代,是明显不够的。此时必须要进行 FGC,回收掉之前 60M 对象,然后再放入 30M 存活对象。
所以可以看到:按照模拟代码,几乎是每秒新增 80M 对象,每秒触发 1 次 YGC。每次 YGC 后存活 20M~30M 的对象,老年代每秒新增 20M~30M 的对象,于是几乎每三秒触发一次老年代 FGC。
这和上面的实时分析引擎的场景很类似:YGC 太频繁,而且每次 GC 后存活对象太多,频繁进入老年代,从而频繁触发老年代的 GC。
三.YGC 和 FGC 的耗时如下图示
可以发现:28 次 YGC,结果耗费了 120 毫秒,平均下来一次 YGC 要 5 毫秒左右。但是 14 次 FGC 才耗费 24 毫秒,平均下来一次 FGC 才耗费一两毫秒。这是为什么呢,为什么 YGC 比 FGC 还久?
因为按照上述模拟程序:
每次 FGC 都是由 YGC 触发的,所以是可能出现 YGC 比 FGC 还慢的情形的。因为 YGC 后存活对象太多要放入老年代,老年代内存不够才触发 FGC。所以必须等 FGC 执行完毕,YGC 才能把存活对象放入老年代,才算结束,从而导致 YGC 比 FGC 还慢。
(10)对 JVM 性能进行优化
接着按照前面介绍的思路对 JVM 进行优化,这次模拟程序最大的问题就是每次 YGC 过后存活对象太多,导致频繁进入老年代,频繁触发 FGC。所以只需要调大新生代的内存空间,增加 Survivor 区的内存即可。调整为如下 JVM 参数:
把堆大小调大为 300M,新生代给 200M。同时-XX:SurvivorRatio=2 表明,Eden:Survivor:Survivor 的比例为 2:1:1。所以 Eden 区是 100M,每个 Survivor 区是 50M,老年代也是 100M。
接着用这个 JVM 参数运行程序,用 jstat 来监控其运行状态如下:
从上图可见:每秒的 YGC 后,都会有 20M 左右的存活对象进入 Survivor 区。但由于每个 S 区都是 50M,因此可以轻松容纳且不会触发动态年龄判定。
因此可以清晰看到每秒触发 YGC 后,几乎就没有对象会进入老年代。最终只有 493K 的对象进入了老年代;同时只有 YGC,没有 FGC。而且 12 次 YGC 才 55 毫秒,没有 FGC 干扰后,YGC 的性能极高。
这样这个模拟程序就被成功优化了,同样的程序只调整了内存比例,就能大幅提升 JVM 性能,几乎消灭 FGC。
6.问题汇总
问题一:
系统如何尽量减少 Full GC?
(1)什么情况下发生 FGC
一.YGC 前:
情形一:新生代对象大小 > 老年代可用内存 && 没开通内存分配担保。
情形二:新生代对象大小 > 老年代可用内存 && 开通内存分配担保 && 历次新生代 GC 进入老年代大小平均值 > 老年代可用内存大小。
二.YGC 后:
老年代放不下 YGC 后存活的对象。
(2)如何避免 FGC
可以让每次 YGC 后,存活的对象尽量能放在 S 区,不要进入老年代。
一.调大 Survivor 区的大小
二.如果系统运算时间比较长导致对象年龄比较大,那么可以调大-XX:MaxTenuringThreshold 参数,使得对象年龄大一些再进入老年代,这样也可以减少进入老年代的对象。
问题二:
在 Tomcat 中启动一个 war,这是启动了一个 JVM 进程吗,还是多个?
答:Tomcat 自己就是一个 JVM 进程,开发的 war 包不过就是一些类而已。Tomcat 会将 war 包加载到自己的 JVM 进程里去执行类的代码逻辑。
问题三:
生产服务器的堆大小 2G,其他都是默认。jmap 看新生代 Eden 区和 Survivor 区的比例为 8,按这比例,Survivor 区应该是新生代的十分之一。但 Eden 区有 680M,Survivor 区只有 10M,且每次 YGC 后 Eden 区和 Survivor 区的总大小都在变化,为什么呢?
答:因为设置了允许堆大小动态调整了,这个需要禁止掉的,就是让-Xmx 和-Xms 需要一样的值。
文章转载自:东阳马生架构
评论