GuavaCache与物模型大对象引起的内存暴涨分析

背景介绍

首先对物联网平台的几个概念做下名词解释

名词

描述

产品

设备的集合,通常指一组具有相同功能的设备

设备

归属于某个产品下的具体设备。

设备可以直接连接物联网平台,也可以作为子设备通过网关连接物联网平台。

物模型

物模型是对设备在云端的功能描述,包括设备的属性、服务和事件。

物模型是阿里云物联网平台为产品定义的数据模型,用于描述产品的功能。


总结一下

产品是一类设备的集合,物模型描述了这一类设备的功能,包括属性、事件、服务。


比如创维电视是一个产品,而每户家庭中的一个个创维电视则是具体设备,这些电视(设备)都具有相同的功能,即在创维电视这个产品上定义的功能。比如当前电视的频道、亮度、音量,这些都是具体的属性;比如如果电视的温度高于50摄氏度,则可以上报报警事件;比如可以通过服务调用的方式,来控制电视的打开和关闭,等等。


从以上的示例中,可以总结出创维电视这款产品的物模型定义,包括属性、事件、服务

属性 - 电视状态(开/关)、频道、亮度、音量等等

事件 - 电视温度过高事件

服务 - 控制电视开/关、调整电视亮度


具体的物模型是非常复杂的,部分复杂的产品可能包含几百几千个属性、事件、服务,因此完整的物模型是非常巨大的。


对于设备每次上报的属性、事件等,物联网平台都会查询出相应的物模型,对设备上报的数据进行校验。


本文记录线上环境,大量设备上报数据,进行物模型校验引起的一次内存告警分析

以一台单机进行分析

GuavaCache与物模型大对象引起的内存暴涨分析


如上图所示,十几分钟的时间,内存从50%一路飙升到75%,最终稳定在77%左右不再上涨。

通过监控分析,在13:40开始,系统流量有所增长,且都来自于一个租户

该租户是一个测试租户在压测,与相关同学联系后,停止压测,集群重启后内存恢复正常。


问题分析

Dump分析

GuavaCache与物模型大对象引起的内存暴涨分析


可以看到,占内存的基本是guava cache,本地缓存导致了内存疯狂上涨。

为什么guava cache导致内存上涨?


guava cache本地缓存了物模型对象,size=1000,缓存时间为一分钟。

关于物模型本地缓存,已经上线运行了两周,运行比较稳定,为什么此次突然出现内存上涨?


分析该租户下有1000个产品下的设备同时上报,且持续在上报,一个产品对应一个物模型。

本地缓存时,key=产品唯一标识符,value=物模型

每个产品的物模型非常大,有130个属性,单是文本大小已经达到70KB,实际Java对象占用内存更大。

实际Java对象到底有多大?


GuavaCache与物模型大对象引起的内存暴涨分析


shallow heap表示这个对象本身大小

retained heap表示这个对象所有引用对象

对于一个json或map对象,想计算该对象所引用的所有对象大小,应该关注的是retained heap

看上图,一个guava cache的entry占用内存 1508096 B ≈ 1508 KB ≈ 1.5 MB

为什么会这么大?有1.5 M

展开来看


GuavaCache与物模型大对象引起的内存暴涨分析


entry内部对象有next、valueReference、key等

其中next其实是下一个entry的大小了,图中显示为856512 B ≈ 856 KB,这里不过多关注

实际重点关注valueReference

引用了一个JSONObject,这是缓存TSL对象的主要内存占用,大小为 651384 B ≈ 651 KB

即一个物模型对象在内存中的大小约为651 KB

一个物模型对象就如此之大,那么1000个产品的物模型,如果都在本地缓存,势必占用非常大的内存空间。

但是即便如此,为什么会造成内存的持续上涨?为什么GC没有回收掉?


GC日志分析


查看GC日志,经过一定处理后如下

GuavaCache与物模型大对象引起的内存暴涨分析


分水岭


GuavaCache与物模型大对象引起的内存暴涨分析


可以看到

13:40之前,每次YGC后,老年代内存增量平均值为10K左右

13:40之后,每次YGC后,老年代内存增量平均值为35000K左右

直接增长了3500倍

通过上面的GC日志,可以看到,老年代的内存在持续上涨,也就是说,每次YGC后,都有相当一部分对象晋升到了老年代。这是导致内存持续增长的根本原因。


线上JVM配置


-Xms5334m 

-Xmx5334m 

-Xmn2000m

-XX:MetaspaceSize=256m 

-XX:MaxMetaspaceSize=512m

-XX:MaxDirectMemorySize=1g 

-XX:SurvivorRatio=10


-Xmn2000m 表示新生代总大小为2000M,从ParNew的GC日志看,新生代总大小实际为1877376K,与2000M有一定偏差。

且eden: survivor1 : survivor2 = 10:1:1

按新生代总大小2000M计算,survivor大小约为170M

按新生代总大小1877376K计算,survivor大小约为156M


垃圾回收 - 复制算法


新生代分为Eden和2个survivor,其中两个survivor分别叫From Survior和To Survior。

每次使用Eden和From Survivor。

YGC时,将Eden和From Survivor中存活的对象复制到To Survivor空间,最后清理掉Eden和From Survivor空间。

YGC后,From Survivor和To Survivor两块区域会调换,也就是原先的To Survivor会变成下次YGC时的From Survivor区,原先的From Survivor区会变成下次YGC时的To Survivor区。


GuavaCache与物模型大对象引起的内存暴涨分析


图一:初始状态

图二:在新生代创建对象

图三:YGC,Eden和From Survivor中存活的对象移到To Survivor中,然后回收Eden和From Survivor的空间。

图四:转换From Survivor和To Survivor。

循环上面的步骤


内存分配策略

对象优先在Eden区分配

大多数情况下,对象在先新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次YGC

大对象直接进入老年代

JVM提供了阈值参数-XX:PretenureSizeThreshold,大于参数设置的阈值的对象直接在老年代分配。

默认值为0,代表不管多大都是先在Eden中分配内存。

经排查,该参数未设置,默认是0,表示对象都在Eden分配。

对象什么时候进入老年代

策略一:大对象直接进入老年代

有一些占用大量连续内存空间的对象在被加载伊始就会直接进入老年代。这样的大对象一般是一些数组,长字符串之类的对象。

-XX:PretenureSizeThreshold

我们可以通过这个参数设置。

这种case可以排除,因为目前默认为0,表示对象都在新生代分配。

策略二:长期存活的对象将进入老年代

在对象的对象头信息中存储着对象的年龄,如果每次YGC后对象存活了下来,则年龄会增加。当这个年龄达到15后,这个对象将会晋升到老年代。

-XX:MaxTenuringThreshold

我们可以通过这个参数设置这个年龄值,默认15次存活进入老年代。


GuavaCache与物模型大对象引起的内存暴涨分析


这种case可以排除,因为guava cache中对象活不过15次YGC。这个之前仔细验证过。

cache size=1000,失效时间为1分钟。

线上一分钟内YGC 2 ~ 5次,也就是说,缓存中的对象年龄一分钟内最多会增加到5,但是一分钟后缓存失效,这些对象失去了引用,下次回收就可以回收掉这些对象了,因而在年龄没有达到15之前,会被回收掉,失去了达到15后晋升到老年代的机会。

线上做过实验。

如果失效时间改为5分钟,则会造成内存持续上涨,5分钟的时候这些对象年龄达到了15,晋升到了老年代。晋升到老年代后再被淘汰或者过期失效,YGC已经回收不掉,除非是fullgc

如果失效时间改为1分钟后,内存平稳,不再出现持续上涨。



策略三:对象动态年龄判断

此策略发生在Survivor区。虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄的对象大小大于survivor空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold要求的年龄。


GuavaCache与物模型大对象引起的内存暴涨分析


这种case存在可能性,guava cache中对象,在失效前必然存在于survivor中,如果这些对象的总大小超过了survivor空间的一半,就会晋升到老年代,无须年龄达到15

但是从GC日志来看,每次老年代的增量为35M左右,没有达到survivor空间的一半(survivor空间有170M,一半有85M左右),因此这种case也可以排除。



策略四:YGC后进行移区,survivor无法容纳的对象将进入老年代。

这是针对复制算法的。当前YGC使用的ParNew收集器,正是使用的复制算法。

新生代分为Eden和2个survivor,每次使用Eden和其中一块survivor。YGC时,将Eden和survivor中还存活的对象一次性复制到另一个survivor空间,最后清理掉Eden和刚才使用的survivor空间。如果复制的时候,需要复制的对象总大小超过了survivor空间,则survivor无法容纳的对象将进入老年代。


GuavaCache与物模型大对象引起的内存暴涨分析


GuavaCache与物模型大对象引起的内存暴涨分析


这种case存在很大可能性,基本可以确定就是这种case引起的内存暴涨。

查看上面的GC日志,每次YGC后,新生代剩余大小在170M左右,基本就是survivor填满了,而老年代内存增长了,大概率就是YGC后存活的对象,survivor中放不下了,于是直接进入老年代。


为什么内存上涨到75%后不继续上涨了

75%后,发生了fullgc,回收掉了老年代中已经过期和已经被淘汰的TSL对象。


GuavaCache与物模型大对象引起的内存暴涨分析


可以看到,每次fullgc后,堆内存都大幅度下降。

GuavaCache与物模型大对象引起的内存暴涨分析

从日志看,确实发生了fullgc,且fullgc耗时较短。

老年代使用的CMS回收器,包括4个步骤

初始标记(CMS initial mark)

并发标记(CMS concurrent mark)

重新标记(CMS remark)

并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要Stop The World

从日志看,初始标记耗时0.04秒,重新标记耗时0.33秒,STW总时间为0.37秒,对应用影响不大。


为什么fullgc堆内存降低后应用内存没有降低

使用CMS垃圾收集器,Java应用不会把内存还给操作系统。

因此从上面图片可以看到,fullgc后,堆内存明显降低了,但是应用内存还是维持在75%不变。

为什么普通的物模型没有问题,只有这次特殊租户压测出问题了

因为普通的物模型对象大小有限,根本达不到650KB,且线上不会出现同时有数千个产品上报且这些产品的物模型对象都非常大,之前是不存在这种场景的。

从之前的GC日志来看

每次YGC后,新生代剩余空间(某个survivor)在50M左右。由于存活的对象大小没有达到survivor空间的一半,因此不会触发策略三。

每次YGC后,survivor空间只有50M左右,说明survivor有足够的空间容纳存活的对象,因此不会触发策略四。

而此次特殊租户,是同时出现了1000个产品下的设备上报数据,每次会产生1000个物模型大对象,而不只是几个,而且是在持续上报。

从GC日志分析,触发了策略四。

为什么物模型本地缓存的size设置为1000,失效时间设置成一分钟

线上的产品数量非常多,常用的有数万个,随着业务增长,数量会更多。

本地缓存难以全部缓存这些产品的物模型,占用的内存空间太大,只能缓存一部分热点数据,因此size设置为1000

如果失效时间设置较长,则这些物模型对象会活过15次YGC,进入老年代。而实际上,这些物模型对象并不是静态数据,也是会发生变化的,存在主动失效、LRU失效、缓存过期失效这3种情况,失效后这些对象在老年代,必须等fullgc才能回收。而业务上又会产生新的物模型对象,不断进入老年代,这样会造成老年代空间持续上涨。

问题总结

通过上面的分析,可以总结问题的原因

1、大量产品下的设备同时上报,且每个产品的物模型对象都非常大。

2、guava cache引用了这些大对象,每次YGC移区时,survivor空间放不下这些大对象,直接进入了老年代。

3、持续的设备上报数据,导致不断的有大对象进入老年代。

4、物模型对象进入老年代后,尽管缓存失效时间到了,但是已经处在老年代,YGC回收不掉,除非FullGC


后续Action

该问题是由于本地缓存和大对象引起,因此后续将从本地缓存和大对象这两个维度分别进行优化。

本地缓存调优

本地缓存务必弄清楚使用场景

为什么需要本地缓存,size设置多大,失效时间设置为多少,大概占用多大的内存,这些都是要仔细评估的。

从热点数据和静态数据分别分析一下。

本地缓存热点数据

场景:大量的数据存在redis缓存中,数据量大,数据会变化,可能部分数据存在热点问题。

本地缓存使用:设置本地缓存max num、过期时间。

本地缓存作用之一是防止redis热点,之前线上出现过多次物模型redis热点,尽管对于redis服务端只是单个节点抖动,但是对于应用来说却是每台机器redis连接池都有可能被打满,这会影响整个集群的机器,如果持续时间长,将会引发严重后果。

因此本地缓存有必要。

单个survivor空间大小约为156M ~ 170M

1、约束本地缓存失效时间,不能让本地缓存中对象抗住15次YGC,从而晋升到老年代。(如果进入老年代后才被淘汰或失效,此时YGC已无法回收,必须FULL GC才行)

2、约束本地缓存总大小不超过survivor空间的一半,这样不会触发策略三,即对象动态年龄判断。

3、至于是否触发了策略四,每次调优后,需要密切观察GC日志,查看每次YGC后新生代剩余对象大小,以及老年代的增量。


在放热点的场景下,可以考虑将本地缓存中的K-V设置为弱引用,guava cache支持设置弱引用。一旦设置成弱引用,则在每次YGC时会将这些弱引用对象回收,确保不会进入老年代。


本地缓存静态数据

场景:静态数据缓存,数据量不大(或者有一个大概可接受的总量),数据基本不会变化。

本地缓存使用:缓存所有静态数据到本地,设置较大的max num,不设置过期时间,缓存数据不会被淘汰。

比如本地缓存一些静态配置,这些数据总量不大,且不会变化,则可以全部缓存到本地,永不过期,永不淘汰。这些对象会全部晋升到老年代,但是内存大小有限,不会引起问题。

实际也可以接受少量数据淘汰,这种场景内存增长很有限,不会造成内存问题。

这种场景要充分评估静态数据的内存占用大小。


大对象优化

大对象对于系统整体稳定性会造成一定影响。

从redis拉取大对象,qps一高很容易形成热点,且造成网络流量突增。

大对象超生夕灭,会加重GC负担。

大对象日志打印,将给磁盘IO带来影响。


产品设计上约束

在定义物模型时,明确说明如果超出一定限制后,在设备上报时将不再做物模型校验。

这样就不会产生大对象,从源头上限制住了。


自动降级

拉取到物模型后,程序中计算出该物模型占用的内存大小,如果大小超出阈值,则自动关闭该物模型的校验,不再缓存该大对象。


上一篇:h5 input,textarea属性placeholder样式修改失效问题解决


下一篇:eclipse JavaEE版"javax.servlet.http.HttpServlet" was not found on the Java Build Path问题的解决办法