写点什么

GuavaCache 与物模型大对象引起的内存暴涨分析——设备管理运维类

作者:阿里云AIoT
  • 2023-03-17
    浙江
  • 本文字数:5179 字

    阅读完需:约 17 分钟

背景介绍

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


总结一下

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


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


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

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

事件 - 电视温度过高事件

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


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


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


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

以一台单机进行分析


image


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

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

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


问题分析

Dump 分析


image


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

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


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

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


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

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

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

实际 Java 对象到底有多大?


image


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

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

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

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

为什么会这么大?有 1.5 M

展开来看


image


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

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

实际重点关注 valueReference

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

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

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

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


GC 日志分析


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


image


分水岭


image


可以看到

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 区。


image


图一:初始状态

图二:在新生代创建对象

图三: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 次存活进入老年代。


image


这种 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 要求的年龄。


image


这种 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 无法容纳的对象将进入老年代。


image


image


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

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


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

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


image


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


image


从日志看,确实发生了 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 带来影响。


产品设计上约束

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

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


自动降级

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


物联网平台产品介绍详情:https://www.aliyun.com/product/iot/iot_instc_public_cn


阿里云物联网平台客户交流群

用户头像

阿里云AIoT

关注

物联网内容搬运者 2022-04-22 加入

还未添加个人简介

评论

发布
暂无评论
GuavaCache与物模型大对象引起的内存暴涨分析——设备管理运维类_缓存_阿里云AIoT_InfoQ写作社区