JVM 垃圾回收(四)《GC调优/案例》

JVM 垃圾回收(1)《根对象/四种引用》
JVM 垃圾回收(2)《垃圾回收算法/分代回收》
JVM 垃圾回收(三)《垃圾回收器/G1》

GC调优

预备知识

掌握GC相关的VM参数,会基本的空间调整

有的时候,可能你不知道你用的是哪个垃圾回收器,还有设置的那些默认的堆大小、新生代大小也不知道,那么可以通过下面的命令去查看当前环境下的虚拟机参数。这里用“|”管道符去找到GC相关的参数。"C:\Program Files\Java\jdk1.8.0_91\bin\java" -XX:PrintFlagsFinal -version | findstr "GC" 在控制台运行后,会打印出很多垃圾回收相关的虚拟机参数。

网友1:如果JDK添加过环境变量的话,直接java命令就行。
网友2:Mac电脑,java -XX:+PrintFlagsFinal -version | grep -rn "GC"

掌握相关工具

由于GC调优是很枯燥的事情,你必须长时间的去观察程序的运行状况,比如在什么时候出现了GC,出现的频率是什么,然后根据这些现象去做分析判断,所以需要掌握相关的可视化工具。

明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

调优领域

GC调优是众多调优的一个方向,以后要想让你的应用程序性能全面的提升,你得从各个领域加以深入的分析和进行调优。不光是GC这块儿,当然GC这块他的影响是比较明显的,他主要是影响你的网络延迟,因为一旦发生了stw,那么你的应用程序的响应时间变得更长,所以这个还是很明显的。当然,除了GC以外,如果你想在调优领域有所了解,那么你还应该考虑应用程序中线程的锁竞争以及对CPU的占用以及IO的调优,这都是大家以后可能会涉及的一些调优方向。只有把这些方向综合起来才能对你的应用程序的整体性能有明显的提升。

确定目标(仅限GC方向)

  • 低延迟还是高吞吐量,选择合适的回收器
  • CMS,G1,ZGC
  • ParallelGC

你要确定调优的目标是什么,即你得清楚你的应用程序是用来干什么的,是做一些科学运算还是做互联网项目,如果是科学运算,那么他们追求的是高吞吐量,对于延长一点点的响应时间这个对于我来讲并不是太紧要,这种情况下我们要追求高吞吐量的垃圾回收器。但如果你做的是互联网项目,那你的响应时间就是一个非常重要的指标了,因为每次GC如果延长的你的响应时间,那么就会给用户造成不好的体验。

确定了目标以后,对于高吞吐量的垃圾回收器,我们没有太多的选择,就一个ParallelGC。那如果是追求低延迟,即响应时间优先,选择的可以有CMS,G1,ZGC。CMS还是目前业界广泛使用的低延迟的垃圾回收器,但最新的消息是JDK9中已经不推荐使用CMS了(网友1:JDK14已经完全移除了CMS),取而代之的是G1,G1在更大的内存下工作的比CMS更好。G1其实集成了CMS和ParallelGC二者之长,特别适合管理超大的堆内存,他既可以做到低延迟,也可以跟ParallelGC一样去确定他的吞吐量目标还是响应时间为目标,以这种定目标的方式做自我调整。G1经过多年的发展,也逐渐稳定和成熟,将来会取代CMS(网友1:已经取代了)。但并不是说CMS马上就没有了,因为现在很多的互联网企业他们用的垃圾回收器仍然是CMS。ZGC是Java12中引用的一个体验阶段的垃圾回收器,他的目标也是超低延迟,据说他的延迟非常非常低。

当然,你也可以不选择Oracle的这些Java虚拟机,还有一个业界比较出名的虚拟机是Zing,据说他的垃圾回收器据说对外宣称是零停顿,就是几乎没有stw,而且他也可以管理超大的堆内存。所以如果前面几个不能满足你的需求,不妨可以去试一试其他的虚拟机。

最快的GC是不发生GC

  • 查看fullGC前后的内存占用,考虑下面几个问题
    • 数据是不是太多?(比如resultSet)
    • 数据表示是否太臃肿?(比如对象图、对象大小)
    • 是否存在内存泄露(比如static Map map)

如果没有GC发生,就自然不会有stw,这样程序性能也会一直保持良好、快速的响应。如果你的虚拟机经常发生GC(FullGC),那么就应该先看一下是否自己代码写的有问题(网友1:你可以质疑我的人格,但不可以侮辱我的代码),比如是否加载了不必要的数据到内存,内存的数据太多,导致你GC频繁,堆的内存压力过大。

突然想到的是,学JDBC时的resultSet,比如执行查询 resultSet = statement.executeQuery
("select 星 from 大表 " ),MySQL里如果执行"select 星 from 大表"这样的查询,他可是会把所有的数据都从数据库通过JDBC读入你的Java内存,这样的话,你的内存再大他也架不住好多个这样的sql语句同时执行,多个数据不断的多次大量加载到堆内存中,这肯定会导致GC频繁发生,甚至最后也有可能出现outOfMemoryError异常。所以这种应该加limit来限制返回的记录总数,这样可以避免把无用的数据都放在Java的内存中。

还有一点是,你的数据表示是否太臃肿?也是比如你加载了一些不必要的数据,比如要查询用户,很多人直接会来个表连接,把用户相关的一些信息全部查出来了,用户的详情、订单之类的,但是这些数据查出来以后不一定最后都用得上,你在一次请求响应里也许只显示了这个用户的一部分信息,而不是所有,但是很多人不在意,他都是不是要什么查什么,而是一次性的把他都取出来,这样的话也会对你的堆内存造成不必要的浪费,即对象图,不是说关联的所有东西都查出来,用到哪个,查哪个其实更好。还有对象的大小对内存占用的影响,比如new一个最小的Object他都要占16个字节,尤其是经常使用的包装类型(Integer)他的一个对象头就占16个字节,加上他integer
里面的四个字节的整数值,还要去做一个对齐,大约是要占到24个字节,而int基本类型用4个字节,所以翻了六倍。所以你的内存占用过多,你应该考虑是不是我从一些对象上进行一个瘦身,比如能用基本类型的就不用他的包装类,这样的话也可以从另外一方面去减少GC的压力,这样的对象积少成多,能节约的内存还是相当可观的。最后一点就是你的代码中是否存在内存泄露,比如经常有不正确的做法就是比如定义一个静态的map集合变量,static Map map =,然后不断的往这个map里放置对象又不移除,会导致内存吃紧,这样的话肯定会造成频繁的GC,甚至可能会造成内存泄露,像这种长时间存活对象,建议大家用其他策略,一种是软、弱引用,软、弱引用在内存吃紧时都会回收,或者像这种类似于缓存的数据不建议直接使用Java中的实现,毕竟他们不是专业做缓存的,可以考虑一些第三方的缓存实现,比如Redis,他们都会考虑对象的过期,而且他们因为是第三方的缓存实现,Redis根本不会造成Java堆的压力,因为它根本就使用的是Redis他自己去做内存的管理,不受GC的影响,所以建议使用缓存时使用第三方的缓存实现,不要自己弄个Map,这样其实一个变成不当就会造成内存泄露。

新生代调优

  • 新生代的特点
    • 所有的new操作的内存分配非常廉价(TLAB)
    • 死亡对象的回收大家是零
    • 大部分对象用过即死
    • minor GC的时间远远低于fullGC

内存调优建议大家从新生代开始,先简单回顾一下新生代的特点。当你new一个对象时,这个对象首先会在伊甸园中分配,这个分配速度是非常非常快的(为什么这么说呢?这里有新的知识点,即TLAB[thread-local allocation buffer],每个线程他都会在伊甸园中给他分配一块儿私有的区域,就是这个TLAB,当我new一个对象时他首先会检查这个TLAB缓冲区中有没有可用内存,如果有,就优先在这块儿区域分配对象,为什么这么做呢,因为对象的分配其实也有一个线程安全的问题,比如说线程1要用这段内存,他在分配还没结束的过程中,线程2不能说一上来就用这块儿内存,那就会造成内存的分配混乱,因此我们在做对象的内存分配时,也要做线程的并发安全的保护,当然这个操作是JVM帮我们做的。那能不能减少线程之间对这个内存分配时的并发冲突呢,就是刚刚提到的TLAB。他的作用就是让每个线程用自己私有的这块儿伊甸园内存来进行对象的分配,这样的话多个线程即使同时进行创建对象时,也不会产生对内存占用的干扰。),从这里就能看得出,伊甸园中创建对象效率还是非常高的。

第二点是在新生代死亡对象的回收代价是零,比如新生代发生垃圾回收时,我们以前介绍的所有垃圾回收器他们采用的都是复制算法,复制算法的特点是要把伊甸园还有幸存区from中的幸存对象给他复制到幸存区to中去,复制完了以后,伊甸园和幸存区from他们的内存都释放出来了,因此死亡对象的回收代价是零。

第三点是新生代中大部分的对象都是用过即死,就是当垃圾回收时,绝大部分的新生代对象都会被回收,只有少数存活下来,因此正因为第三点,才会有第四点,即幸存对象很少,又采用的是拷贝算法,那么就导致了minor GC的时间远远低于因老年代内存空间不足而触发的full GC。所以要做整个Java堆的内存调优,就从新生代开始。

那如何对新生代内存调优呢?可能有人会说,把新生代的内存加大呗。当然,这是一个最有效的方式,但是我们也要注意,新生代在调大的情况下,会存在一些问题。

新生代是不是越大越好

-Xmn这个虚拟机参数是设置整个堆内存中新生代的初始和最大值,那新生代设的小,肯定是不太好,因为新生代小的话,我的可用空间少,创建新对象时一旦发现新生代的空间不足,就会触发MinorGC,这样的话就会造成stw,就会有短暂的暂停。那设的非常大的话也不一定是号,如果你新生代的内存设的太大,那必然老年代的可用空间就会相对少了,那老年代的空间少的话,那将来新生代觉得自己空间很多,新创建的对象都不会触发垃圾回收,但是老年代的空间紧张,那再触发垃圾回收可就是full GC了,fullGC的暂停时间比minor GC的暂停时间更长,这样就会占用更长的时间才能完成垃圾回收。所以Oracle建议,他给了一个折中,即新生代大小大于整个堆的25%,小于堆的50%,也就是占到整个堆的四分之一以上~二分之一以下。当然我们真正实践的时候,发现新生代的内存他跟吞吐量之间的曲线图,大概是长下面样子的。(图片转自黑马,以下同)
JVM 垃圾回收(四)《GC调优/案例》_第1张图片
横轴是新生代的大小,纵轴是吞吐量(也就是单位时间响应的请求数,吞吐量当然是越高越好【吞吐量=程序运行时间/程序运行时间+垃圾回收时间】),随着新生代空间越来越大,吞吐量就会越来越高,因为垃圾回收占用CPU计算的时间比例少了,就是吞吐量高了嘛,因此吞吐量也就越来越高,但是到了一定的大小以后,他会有一个下降,这是因为新生代虽然空间大,空间大就意味着回收的时间较长,所以从这个曲线可以看得出,新生代也不是越大越好,但是也要吞吐量和新生代的交点中找到最优的地方。但是总的原则是,还是要将新生代调的尽可能的大,有些人可能会反问,新生代空间越大,垃圾回收时间就会变长了呀?话是这么说,但我们还没有考虑到一个因素,即新生代垃圾的回收都是复制算法,那复制算法也是分成两个阶段的,第一个阶段是标记,第二个阶段是去进行复制,那么这两个阶段哪个阶段花费的时间更多呢?其实是复制。因为复制牵扯到对象的内存占用块儿的移动,另外你要更新其他引用对象的地址,所以这个速度相对会更耗时一些,而新生代的对象绝大部分都是用过即死的,也就是说最终只有少量的对象会存活下来,所以既然是少量的对象存活,那他复制所占用的时间其实也是相对较短的,而这个标记时间相对于复制时间来说,显得不是那么重要了,所以我们新生代调大的情况下因为主要耗费的时间还是在复制上,所以即使增的很大,那么他这个效率也不会有特别明显的下降,这是对刚才新生代大小设置的补充。

那究竟把他设成多大比较合适呢?

新生代能容纳所有【并发量 ×(请求-响应)】的数据

即理想情况是新生代能够容纳一次请求和响应过程中所产生的对象乘上你的并发量,比如我一次请求响应过程中可能会创建很多新的对象,那这些新的对象加起来比如说大约占到了512k的内存,那这时候我的并发量大约1000,也就是同一时刻有1000个用户过来访问我,那么这时候我的新生代他的一个比较理想的大小是你的每一个请求响应占用的内存乘于并发量,即512k×1000,大约是512兆。为什么设成这么大就叫做理想的状态呢?这是因为你这一次请求响应的过程以后其中大部分对象都会被回收,而只要这一次请求加上你的并发量所占用的内存不超过我新生代的内存,他就不会触发或者说会较少的触发新生代的垃圾回收了。这样呢,就可以大约估算出新生代内存占用到底划分成多少比较合理。

新生代里面还有幸存区,幸存区的内存设置要遵从几个规则。

幸存区大到能保留【当前活跃对象+需要晋升对象】

幸存区中你可以把他看成有两类对象,第一类对象他是声明周期较短,也许下一次垃圾回收就要把他回收掉了,但是由于现在还正在使用他,暂时不能回收,所以他就留在幸存区中。另一类是他肯定将来会被晋升到老年代,因为大家都在用他,但是由于他的年龄还不够,所以他暂时也是存在幸存区当中,还没有被晋升。幸存区可以看成存储的都是【当前活跃对象+需要晋升对象】这两类对象,所以幸存区的大小要大到这两类对象都能容纳。为什么这么说呢?这里要提到幸存区的晋升规则,如果幸存区比较小,他就会由JVM动态的去调整这个晋升的阈值,也许你本来有些对象轮不到他晋升,寿命还不够,但是由于幸存区的内存太小,导致我会提前把一些对象晋升到老年代去,比如也许是存活时间较短的当前活跃对象提前被晋升到老年代。那这样有什么问题(或缺点)呢?如果你本来一个存活时间短的对象被晋升到了老年代,那就意味着他得等到老年代的内存不足时即触发fullgc时才能把他当成垃圾进行回收,所以这就变相的延长了这个对象的生存时间。所以这就不太好了,我们最好是实现这种存活时间短的对象就在下一次新生代垃圾回收里就把他回收掉,真正需要长时间存活的对象我才把他晋升到老年代。

晋升阈值配置得当,让长时间存货对象尽快晋升

事务凡事都有两面性,我们一方面希望存活时间短的对象让他留在幸存区在下一次垃圾回收能把他回收掉,而另一方面我们又希望要长时间存活的对象他应该尽快的被晋升,为什么这么说呢,因为如果你是一个长时间存活的对象,你把它留在幸存区,只能够耗费幸存区的内存,并且因为新生代垃圾回收都是复制算法,他要把幸存区中的这些对象要是存活了又要把他进行复制,从from复制到to,新生代复制算法主要的好费时间就是对象的复制上,如果有大量的这些长时间存活对象他们不能及早的晋升,那么他们相当于留在幸存区被复制来复制去,这样对性能反而是一种负担。所以我们遇到这种情况应该设置一下晋升阈值,把晋升阈值调的比较小,让这些长时间存活的对象能够尽快的晋升到老年代去,这个参数是-XX:MaxTenuringThreshold=threshold,可以调整最大晋升阈值,有的时候我们还需要晋升的详细信息显示出来,这样便于我去判断到底应该把晋升阈值设置多少更为合适,相关的参数是 -XX:+PrintTenuringDistribution,这个参数带上以后,在每次垃圾回收时,把幸存区中的存活对象详情显示出来,比如如下:

Desired survivor size 48286924 bytes, new threshold 10 (max 10)
- age 1: 28992024 bytes, 28992024 total
- age 2: 1366864 bytes, 30358888 total
- age 3: 1425912 bytes, 31784800 total
...

第一列就是对象的年龄,比如年龄为1的(刚逃过一次minor gc的)的对象占用的空间大小是28992024 bytes,年龄为2的对象占了1366864 bytes…后面的total是累计整合,比如age1和age2加起来是30358888 total,age1 age2 age3加起来是31784800 total。通过幸存区中不同年龄的对象他所占用的空间打印,我们可以更细致的去决定到底把最大的晋升阈值调成多少比较合适,让那些长时间存活对象(比如这个例子中的 age 3: 1425912 bytes)能够尽早的晋升。

老年代调优(以CMS为例)

CMS的老年代内存越大越好

CMS垃圾回收器是低响应时间的,并且是并发的垃圾回收器,也就是说他的垃圾回收线程工作的同时其他的用户线程也能够并发的执行,但是因为你垃圾回收同时其他的用户线程也在运行,所以他就会产生新的垃圾,称之为浮动垃圾,如果是浮动垃圾产生了,又导致内存不足,这时候事就大了,就会造成CMS并发失败,那么CMS就不能正常工作了,他就会退化为串行的老年代的垃圾回收器,这个效率就特别低了,一下子因为stw,导致响应时间变的特别长。所以在给老年代规划内存时,可能需要把他规划的更大一些,越大越好,这样是为了预留更多的空间避免浮动垃圾引起的并发失败。

先尝试不做调优,如果没有full GC那么已经…,否则先尝试调试新生代

大家在做老年代调优之前,先要尝试一下不要调优,因为如果你这个程序正常运行了一段时间以后,你并没有发现fullgc,也就是不是由于老年代的空间不足引起的垃圾回收,那说明我们的老年代这个空间很充裕,那你这个系统工作的已经是很Ok了,所以你先别尝试做老年代的调优。即使是发生了full gc,你也应该先尝试调优新生代,等这些手段都用了,还是经常发生full gc,你再回过头来看看老年代的设置。

观察发生fullGC时老年代内存占用,将老年代内存预设调大1/4~1/3

一旦发现老年代的fullGC该怎么办呢?你观察一下fullgc时老年代是由于超过了多大的内存导致了fullgc的发生,那可以在原有fullgc的内存的基础上,给他调大1/4~1/3,这样呢就相当于我划分更合理更大的内存给老年代使用,减少老年代的fullgc产生。

还有一个参数是控制了老年代的空间占用达到老年代整个内存的百分之多少的时候我就使用CMS进行垃圾回收,-XX:CMSInitiatingOccupancyFraction=percent,这个值越低老年代垃圾回收的触发时机越早。(在某次演讲中,tt的工程师在他的演讲里建议把这个值设置为0,意思是我只要老年代有垃圾,我就会回收,他就不会等到他占满整个老年代内存的百分之多少以后才会进行回收。当然,这是一个比较极端的值,也对你服务器的cpu有较高的要求,你必须有一个始终空闲的cpu来做CMS的垃圾回收,因为它要并行的跟其他用户线程一起运行,所以得需要一个空闲的CPU来做这件事。一般来讲,我们都会把这个比例设置为75% - 80%之间,意思就是说我预留20 - 25%的空间给那些浮动垃圾预留。

案例

full GC和Minor GC频繁

比如当程序运行期间,GC特别频繁,尤其是minor gc甚至达到了一分钟上百次,遇到这种GC特别频繁的现象说明我们的空间紧张,那究竟是哪部分空间紧张呢,可以进一步分析。如果是新生代空间紧张,那么当业务高峰期来了,大量的对象被创建,很快就把新生代空间塞满了,塞满了还会造成一个后果,就是幸存区空间紧张了他的对象的晋升阈值就会降低,导致本来生存周期很短的对象也会被晋升到老年代去,这样情况就进一步恶化了,老年代大量存储这种生存周期很短的对象,然后进一步触发老年代的fullgc的频繁发生,这是针对刚才现象的分析。

经过分析,用检测工具去观察堆空间的大小,比如确实发现新生代空间设置的太小了,根据之前的调优指导,内存优化应该先从新生代开始,所以解决方案是先试着增大新生代内存,之后内存充裕了,新生代的gc就变得不那么频繁了,同时增大幸存区的空间以及晋升的阈值,这样的话就能让很多生命周期较短的对象尽可能的被留在新生代,而不要晋升到老年代,这样进一步就让老年代的fullgc也不频繁出现了。

请求高峰期发生full gc,单次暂停时间特别长(CMS)

这个案例是因为业务需求要的是低延迟,所以在垃圾回收器选择了CMS。那单次暂停时间特别长,就要去分析看他到底是哪一部分时间耗费的较长,因为我们已经确定了他的垃圾回收器使用的是CMS,所以就去查看GC日志,看看CMS的哪个阶段耗费的时间较长。

JVM 垃圾回收(四)《GC调优/案例》_第2张图片
CMS的阶段分别是初始标记、并发标记、重新标记、并发清理,其中初始标记、并发标记都是比较快的,比较慢的是在重新标记,我们通过查看GC日志他会把每个阶段耗费的时间在GC日志里可找到,确实发现在重新标记阶段他的耗时1妙多,接近了2秒,所以问题就定位到出现在重新标记阶段。在CMS做重新标记的时候,他会扫描整个的堆内存,他不光是要扫描老年代的对象,也要同时扫描新生代的对象,如果在业务高峰的时候新生代对象个数比较多,那么这个扫描时间标记时间就会非常多,因为它要根据这个对象再去找他的引用,他是一种遍历算法耗时太多了。那能不能在重新标记之前就把一些新生代的对象先做一次垃圾回收,减少新生代对象的数量,这样就可以减少我在重新标记阶段他所耗费的时间,这个参数是 -XX:+CMSScavengeBeforeRemark,即就在重新标记发生之前,先对我新生代的这些对象做一次垃圾清理,清理之后存活对象少了,那在重新标记阶段需要查找和标记的对象也变得比以前要少的多,这样就可以解决这个问题。通过打开这个参数,我们发现重新标记的时间从接近2秒减少到了300毫秒左右,这样就达到了对响应时间的要求,即解决了单次暂停时间特别长的问题。

老年代充裕情况下,发生full gc(CMS jdk1.7)

该案例的垃圾回收器也是采用了CMS,但是他是在老年代的内存相当充裕的情况下发生了full gc。之前介绍过,CMS是由于空间不足导致并发失败,或者是空间碎片比较多都会产生full gc。但是经过排查,在GC日志里都没有并发失败或碎片过多这样的错误提示,这说明我的老年代的空间是充裕的,即不是由于老年代空间不足产生的fullgc。后来就想到了这个应用程序部署的JDK的版本是1.7,我们1.8是有元空间的作为方法区的实现,而1.7及以前的jdk采用的是永久代作为方法区的实现,那在1.7及以前的jdk版本里如果永久代的空间不足也会导致full gc(由于1.8是用了元空间,他的垃圾回收不是Java来控制的,而元空间的空间默认使用了操作系统的内存空间,所以空间的容量一般是充裕的,不会发生元空间的空间不足问题,而1.7及以前,永久代空间若设小了,他就会触发整个堆的full gc),所以初步定位到了是由于永久代的内存不足导致的fullgc(网友1:所以解决方法是下载一个jdk1.8版本),所以后面增大了永久代的初始值和最大值,保证了fullgc不会再发生。

你可能感兴趣的:(JVM,JVM基础,JVM,GC调优,GC调优)