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

简介: 物模型是对设备在云端的功能描述,包括设备的属性、服务和事件。本文记录线上环境,大量设备上报数据,进行物模型校验引起的一次内存告警分析。

背景介绍

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

名词

描述

产品

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

设备

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

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

物模型

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

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


总结一下

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


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


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

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

事件 - 电视温度过高事件

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


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


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


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

以一台单机进行分析


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

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

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


问题分析

Dump分析


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

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


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

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


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

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

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

实际Java对象到底有多大?



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

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

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

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

为什么会这么大?有1.5 M

展开来看



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

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

实际重点关注valueReference

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

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

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

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


GC日志分析


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


分水岭



可以看到

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



图一:初始状态

图二:在新生代创建对象

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



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



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




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

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


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

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



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

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


产品设计上约束

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

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


自动降级

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


相关实践学习
钉钉群中如何接收IoT温控器数据告警通知
本实验主要介绍如何将温控器设备以MQTT协议接入IoT物联网平台,通过云产品流转到函数计算FC,调用钉钉群机器人API,实时推送温湿度消息到钉钉群。
阿里云AIoT物联网开发实战
本课程将由物联网专家带你熟悉阿里云AIoT物联网领域全套云产品,7天轻松搭建基于Arduino的端到端物联网场景应用。 开始学习前,请先开通下方两个云产品,让学习更流畅: IoT物联网平台:https://iot.console.aliyun.com/ LinkWAN物联网络管理平台:https://linkwan.console.aliyun.com/service-open
相关文章
|
23天前
|
编译器 C语言
动态内存分配与管理详解(附加笔试题分析)(上)
动态内存分配与管理详解(附加笔试题分析)
43 1
|
20天前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
44 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
4天前
|
Web App开发 JavaScript 前端开发
使用 Chrome 浏览器的内存分析工具来检测 JavaScript 中的内存泄漏
【10月更文挑战第25天】利用 Chrome 浏览器的内存分析工具,可以较为准确地检测 JavaScript 中的内存泄漏问题,并帮助我们找出潜在的泄漏点,以便采取相应的解决措施。
51 9
|
9天前
|
机器学习/深度学习 算法 物联网
大模型进阶微调篇(一):以定制化3B模型为例,各种微调方法对比-选LoRA还是PPO,所需显存内存资源为多少?
本文介绍了两种大模型微调方法——LoRA(低秩适应)和PPO(近端策略优化)。LoRA通过引入低秩矩阵微调部分权重,适合资源受限环境,具有资源节省和训练速度快的优势,适用于监督学习和简单交互场景。PPO基于策略优化,适合需要用户交互反馈的场景,能够适应复杂反馈并动态调整策略,适用于强化学习和复杂用户交互。文章还对比了两者的资源消耗和适用数据规模,帮助读者根据具体需求选择最合适的微调策略。
|
8天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。
|
20天前
|
Java 测试技术 Android开发
让星星⭐月亮告诉你,强软弱虚引用类型对象在内存足够和内存不足的情况下,面对System.gc()时,被回收情况如何?
本文介绍了Java中四种引用类型(强引用、软引用、弱引用、虚引用)的特点及行为,并通过示例代码展示了在内存充足和不足情况下这些引用类型的不同表现。文中提供了详细的测试方法和步骤,帮助理解不同引用类型在垃圾回收机制中的作用。测试环境为Eclipse + JDK1.8,需配置JVM运行参数以限制内存使用。
26 2
|
21天前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
42 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
23天前
|
程序员 编译器 C语言
动态内存分配与管理详解(附加笔试题分析)(下)
动态内存分配与管理详解(附加笔试题分析)(下)
41 2
|
29天前
|
存储 Java
深入理解java对象的内存布局
这篇文章深入探讨了Java对象在HotSpot虚拟机中的内存布局,包括对象头、实例数据和对齐填充三个部分,以及对象头中包含的运行时数据和类型指针等详细信息。
28 0
深入理解java对象的内存布局
|
1月前
|
存储 编译器 C++
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作(二)
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作