近亿级用户体量高并发实战:大促前压测干崩近百个服务引起的深度反思!

本文涉及的产品
性能测试 PTS,5000VUM额度
简介: 几年前,数百个服务,将堆内存从28GB升配到36GB,引发系统全面OOM的事件。

起因

最近有位小伙伴在阅读《JVM专栏》时,看到了ZGC这款源自于JDK11的性能巨兽,一瞧它那强悍的性能表现,不甘心做个理论派,没忍住亲自上手试了试,结果呢?不试还好,一试差点给内存干爆,遇到这种诡异的现象,这位小伙伴百思不得其解,最后在评论区留下了他的疑惑:

G1切换至ZGC,发现虚拟内存占用高到离谱,简单了解后发现与ZGC的实现机制有关,以8c32g服务器为例,上面部署了四个服务,均已切换至ZGC,可这么夸张的虚拟内存占用,是否会导致OOM或者其他问题?

这提问方式非常专业,三言两句不仅交代了前因后果,而且清晰的描述了问题背景和实验环境,以及附上了两张截图。我们来提取几个关键词:G1切换ZGC、8c32g、虚拟内存飙升、内存占用变高、会引起什么后果?好了,接着来看下这位小伙伴贴出的附图:

001.png

002.png

观察附图可知,目前环境为JDK21,同时虚拟内存占用来到了夸张的197.7G,同时内存占用从原有的2.7G来到3.4G,Why?其实这已经是OpenJDK努力许久的结果,如果有玩过JDK11ZGC的小伙伴,应该有印象,VIRT这个指标会直接显示成17TB~

其实类似问题在Stack Overflow上有很多相关的帖子,造成这种现象的根本原因,是由于ZGC底层的染色/颜色指针,里面用到了一种名为多重内存映射的技术,即多个虚拟地址指向同一个物理地址,这时,再使用LinuxTOP这类指令时,监测到的资源指标就会失真。当然,因为是映射出的虚拟地址,这并不会导致OOM问题出现,但会干扰正常的运维工具,比如容器检测到资源异常,导致对应的Java进程被错杀。

好了,上面这段话只是个引子,因为当时在回复这位小伙伴时,让我想起了前些年碰到的一个疑难杂症,下面进入本文的正题:几年前,数百个服务,将堆内存从28GB升配到36GB,引发系统全面OOM的事件

一、两年前的全线OOM事件

在正式开始之前,为了诸位能有更好的阅读体验,先来简单交代一下事件的背景。

项目背景是大家最熟悉不过的电商项目,只不过是近些年很热门的私域电商项目(品牌电商),定制该项目的品牌方可以算得上家喻户晓,会员数大概在7000~8000W左右,整套系统的业务服务、基层服务,加上依赖的各类中间件、资源组件等基础设施,结合集群化部署,线上的节点数量在几百个之多。

当时正逢品牌方的周年庆,所以品牌方想着搞个大促活动,活动会持续一周左右。因为独立于了淘宝、京东这类综合电商平台,少了资源位、直通车、返佣、抽佣等开支,所以活动期间的折扣力度超乎想象。

电商类的平台在大促节日前后,访问量可谓天差地别,因此,原本部署的整套系统无论是从节点规模,还是配置上来说,都无法满足大促活动所需,因此,为了平稳承接大促活动带来的海量请求,通常会选择临时对服务器升配,比如原本某个服务的节点是4c8g,在大促期间可以升至8c16g,等大促活动结束后,再降回原先的配置(得益于这些年突飞猛进的云技术,能很轻易的满足这类弹性需求)。

接近亿级的用户体量搞大促活动,这个背景看起来很唬人,但其实也没那么恐怖,毕竟这7000~8000W会员来自于各个渠道,举个简单例子,你在淘宝点进某个店铺,然后就弹出一个“免费入会领优惠券”的窗口,你点击了同意入会,就会成为对应店铺的会员,而这些会员数据就会打通到品牌方的私域平台……

正因如此,尽管有着七千多万用户体量,可正常时期的日活仅有总量的1~2%,大促活动期间,加上活动前的预热推广,巅峰日活也不过在4~5%左右。虽然这个数字远低于市面上的许多产品,可在庞大的用户基数支撑下,仍旧不容小视:7000W * 0.05 = 350W

同时,所有电商大促都有个显著的特点:开盘即巅峰,系统流量会从活动开始的半小时后开始急剧下降,活动开始后的半小时,可以视为整个大促期间的峰值流量,只要能平稳承接前半小时的峰值流量,就代表能保障整个活动期间系统稳定性。

PS:国内各大电商平台,在618、双11期间都面临这个问题,可它们的用户基数更加庞大,活动期间的日活、并发也高的离谱,就算它们有能力接住峰值流量,但峰值过后带来的资源闲置成本太高。所以,会发现这些平台都已经从业务上解决“开盘即巅峰”的峰值流量,如允许提前加购,又或延长活动时间,将流量分散到不同的交易日。当然,就算这样也无法避免开盘即巅峰的场景,所以又会发现几大电商巨头,近两年又开始将活动期分为不同专场(数码专场、美妆专场、百货专场……),从而实现将购物需求不同的群体,分散到不同时间段。

不过业务限制流量峰值的手段,并不适用于做垂直类目的私域电商,很难拆出不同专场来分担压力。为此,活动开始后的半小时,预计当日的60%活跃用户会访问系统,更关键的是前十分钟,其中半数以上的用户,会在该时间段结束交易。当然,活动预热期间,会提前放券并引导会员加购,可即便如此,用户完整的交易链路也会发出40+请求,就算用户不走完链路放弃交易,从开屏到提交订单也大概20+请求,简略估算出前十分钟内,每个在线用户的平均请求数为30

PS:当时评估的平均请求数,是根据用户行为轨迹分析+业务监控指标得出,业务指标主要参考促销期活跃用户与提交订单的比率(大概在50%),以及提交订单的转化率(80%左右),本篇不对这方面做展开,毕竟不同系统的指标不同,大家感兴趣后续可能会出“高并发”相关的文章讲述。

下面粗略估算下系统的峰值请求数:

  • 大促开始当天日活为总量的5%,当日活跃数为7000W * 0.05 = 350W
  • 活动前半小时的在线用户数为60%,前半小时在线用户数为350W * 0.6 = 210W
  • 前十分钟的在线用户数为第二项的半数,前十分钟在线用户数为210W * 0.5 = 105W
  • 前十分钟每个在线用户平均请求数为30,前十分钟总请求量为105W * 30 = 3150W
  • 用总量除以总时间,每秒的平均请求数为3150W / 600s = 52500个;

上面得出每秒的平均请求数后还不够,因为是提前预热的活动,大促开始的一瞬间,会是系统流量的最高峰,为此,系统的峰值并发大概在10w+/s。预估出大促期间的峰值流量后,我们将以该峰值作为基准对系统进行多轮压测,旨在达成三个关键目标:一是确定并优化最适合此压力水平的硬件配置;二是识别并预防在峰值压力下系统可能出现的性能瓶颈;三是揭示并修正峰值压力下可能隐藏的代码隐患

PS:有人或许会好奇,上面提到的各类指标是如何得知的?很简单,在系统核心链路的各个关键点上做埋点,而后上报到业务监控系统,就能分析出所需的各类指标,如用户点击与加购的比率、加购与成交的比率、提交订单与付款的比率、多维度的峰值订单数、付款后跟踪订单的比率、访问与转化的比率、各类营销活动与成交的比率……。数据埋点除开能得到前面的业务指标外,还能得到偏技术的指标,如交易链路的平均请求数、催生一个有效订单需要多少次请求、活动期间各时间段的请求数/并发用户数/在线用户数趋势……

1.1、OOM事件的来龙去脉

好了,前面简单交代事件背景后,下面再来看看OOM事件的场景。

以预估的峰值作为压测基准,毫不犹豫,我们启动了压测集群对大促核心接口进行全链路压测,可过程却并非如同我们想象中的那般顺利,在压测持续一分钟左右,系统中某些核心服务,和涉及到在线计算的服务,就出现了内存资源告警。当时预设的内存利用率告警值为90%,这些服务分配的内存为28G在这么短的时间内出现告警,只能是因为业务请求量过大,对象分配速率过高,从而引起内存利用率飙升

从趋势上来看,目前分配的内存不足以支撑此次业务需求,为了避免OOM问题出现,我们对这些内存告警的服务进行了提额。当初服务的内存升配标准以4GB作为提额单位。因此,一部分出现告警、且经过分析后存在OOM隐患的服务,将内存升配两个单位,即从原有28G提升至36G

PS:为什么不选择往集群加入更多的低配节点,从而提升系统整体的吞吐量,而是使用更少高配节点来应对高并发场景呢?相信有过实际高并发经验的小伙伴有所体会,本文不做具体展开,后续有机会展开讲述。

重新分配堆空间后,重启对应的所有服务集群,可是谁都没料到,这次升配带来了预料之外的灾难性后果!

等待服务重启完毕后,再次开启压测集群对系统进行压测,可结果令人更加出乎意料,这回在压测持续十多秒后,升配后的服务再次告警!一开始还未发现问题的严重性,以为只是“节点升配重启后,未对服务进行预热”导致的问题,所以立马停止压测,并将压测从“并发模式”改为了“摸高模式”,留给程序一定的预热时间:

  • 并发:如压测用例配了2000条并发线程,在压测集群启动的一瞬间,就同时发出请求;
  • 摸高:同样的压测配置,在压测集群启动后,先使用小部分线程发出请求,后续逐步增加并发线程数。

相较于并发模式,摸高模式会逐步增加线程数,直至一定时间后才会增加到配置好的线程数,这种模式能让系统进行充分的预热,如数据库连接、热点数据缓存、懒加载的资源、容器与线程池线程的初始化、Java方法的JIT编译……。改为摸高模式后,再次自信满满的开启了又一轮压测。

可是,除去摸高前期的预热时间外,压测持续时间比上次多出几秒钟,升配后的服务再次触发告警,并且在一定时间后,系统中大部分服务陷入OOM或宕机状态,Why?将内存从28G升至36G后,触发告警的时间更短,意味着出现系统在更短的时间里旧遇到了性能瓶颈,故障节点数更多,代表灾难进一步扩大!这到底是为什么?

1.2、OOM原因排查过程

带着上面两个疑惑,整个团队进入了排查、讨论的过程,由于团队全是经验丰富的老手,第二个问题的原因很快被定位,为什么发生故障的节点数量更多了?因为各服务间有着藕断丝连般的关系,而此前升配的某些服务属于系统基建设施,大量服务都对其存在依赖调用关系,因为它们触发OOM的速度更快了,所以导致依赖它的上游服务出现大量请求堆积,而之前的《网络请求篇》提到过一个概念:

任何一个请求,在Tomcat中都会对应一条线程处理,这个关系可以简化为:一个请求 ≈ 一条线程。

那么,上游服务堆积的大量请求,就会在堆空间产生大量对象,而这些对象都与线程存在强引用关系,内存不足触发GC时,GC线程也无法回收对应内存,最终抛出OOM错误。当然,还有一部分宕机的服务,是由于下游已经故障,压测期间时刻都有大量并发请求,最后服务被瞬时流量打到宕机。

PS:尽管系统有熔断机制也无济于事,因为服务熔断需要反应时间,毕竟熔断机制依赖于响应时间过长、错误率过高等指标来触发。当下游服务故障后,假设系统内RPC的超时时间为三秒,这意味着上游服务感知到故障并触发熔断的窗口就至少需要三秒以上,在这个窗口期间内,亦大促峰值作为基准的并发请求,足以将任何一个低配节点“击穿”。

这个问题也被称为服务雪崩,其原因很轻易就被定位到了,但为什么在相同压力下,升配后会导致服务更快的遇到瓶颈,最终引发OOM呢?这是引发服务雪崩的根源问题,于是,我们开始逐步排查问题。

  • ①检查OOM类型:排除栈溢出、元空间溢出,OOM是堆空间溢出;
  • ②检查JVM启动参数:堆空间大小正常、各分代比例正常、各优化参数无误……
  • ③分析堆快照文件:没有内存泄漏、没有不可控的大对象、请求与堆增长比例正常……
  • ④走查压测链路代码:没有无法退出的死循环、没有不可控的大批量读取、没有未关闭的资源……
  • ⑤……

虽然团队里个个是老鸟,可是以往解决OOM问题的经验貌似全都不管用了,这让我们百思不得其解,场面一再陷入僵局。没办法,最后只能用万能的对比排除法:问题发生在28G升配至36G的背景下,现象为升配后堆空间分布均匀但增长更快,对内存的消耗更高。这种异常现象在升配前并不存在,因此,启了两台不同配置的机器,再抽样观察两个节点的堆空间变化。

经过一段时间观察,这时发现了一个惊人现象:对象数量大致相同的情况下,36G堆明显比28G堆使用的内存更多!这意味着什么?这意味着在36G的堆空间里,每个对象“更胖”!可Java对象又不是萝卜白菜,总不会因为土地更肥沃、更大,所以长得更好吧?但这种情况就是出现了,继续沿着这个方向排查。

堆空间超过28G、同一个Java对象占用的空间更多,这是新的线索,顺着这个方向,一路从百度到谷歌,功夫不负有心人,最终定位到了导致本次事件的真凶:指针压缩技术

1.3、事件真凶:指针压缩技术

在之前《JVM对象篇》中,我们曾提到过指针压缩的概念,程序运行时产生的各种数据都存储在内存中,指针则是连接程序与数据之间的桥梁,而不同位数的操作系统下,指针大小有所差异,32位操作系统对应的指针大小为32Bit,64位系统则为64Bit,指针大小决定了CPU在内存中的寻址能力/范围。

Java亦是同理,进程运行期间依赖指针工作,堆中每个对象的引用,其实就对应着操作系统的指针,而在64Bit虚拟机上,每根指针大小同样为64Bit/8Byte。可是内存作为计算机最珍贵的硬件之一,JVM从32位过渡到64位时,会额外消耗大量内存来存储指针,JVM官方为了屏蔽两者的差异性,设计了一种名为“指针压缩”的机制,能在64位虚拟机中,将指针从原本的八字节,有效减少至四字节!这种技术怎么实现的?

二、深入理解指针压缩机制

想要搞明白指针压缩技术,这一切还得从计算机硬件的内存谈起,如果较早接触计算机的小伙伴应该知道,安装32位操作系统的计算机,最多只能支持4GB内存寻址,也就是说,即使你在32位的电脑上,插一根8GB的内存条,系统也只能识别出4GB的可用内存

可32位系统最大支持寻址4GB,这个数字究竟怎么来的?首先来说下寻址的概念,CPU在运算时需要数据,这时就需要将内存里的数据Load出来,但提取数据之前需要知道数据在哪里,接着才能去内存中挨个找,而计算机中表示数据地址的东西就叫做指针,根据指针找数据,这个动作叫做寻址过程

大家都知道,计算机的最小操作单位是Bit(位),每个位只能表示01。而在32位系统中,指针的大小为32Bit,这意味着每个指针包含32个二进制位,每个位的值可以填01。因此,总共可以表示232次方(即4294967296)个不同的地址。如果以计算机最小的Bit作为单位,32Bit指针的寻址范围就是0~512MB;但CPU在内存寻址以Byte作为单位,1Byte=8Bit,说明32Bit指针最大支持512MB*8=4GB寻址范围

搞明白操作系统的32Bit指针后,再来看看64位的操作系统,按照上面的推理过程,64Bit指针的最大寻址范围则为2^64=16777216TB,但是X86_64架构的硬件只存在48条有效的地址总线,为此,现实中64Bit指针允许的最大寻址范围为2^48=256TB

下面来看看Java的指针压缩技术,JVM是怎么做到将64Bit指针压缩到32Bit,同时还能支持在64位操作系统上寻址的?

2.1、Java指针压缩技术详谈

想要弄明白指针压缩技术,得先来回顾下Java对象在内存中的布局,如下:

003.png

一个对象由对象头、实例数据、对齐填充三部分组成,对齐填充只有在对象大小不满足8Byte的整数倍时存在,其中对象头可细分为MarkWord、KlassWord、ArrayLength,最后的数组长度,只有数组类型的对象才会存在,对这块不清楚的伙伴可以回看之前的《JVM对象篇》

这里主要讨论一下“对齐填充”,当一个Java对象的大小不满足8的整数倍,就会出现对齐填充数据,将对象Size补齐为8的整数倍,如一个对象为26Byte,就会出现6Byte的对齐填充数据,将对象补齐为32Byte。关于这点,相信大家在学JVM时都有所接触,可是许多人不理解为什么要这样做,究其根本,就是为了实现指针压缩机制。

前面说过,如果用计算机最小的操作单位Bit来作为寻址单位,32Bit指针支持的最大寻址范围仅为512MB,可是32位操作系统为什么最大支持4GB呢?因为内存中的数据按Byte对齐,就算只写入1Bit数据,在内存中也会占用1Byte空间,所以CPU可以用1Byte(8Bit)作为寻址单位JVM官方在设计64位虚拟机时,也巧妙利用了这一点!

既然数据对齐的边界值越大,就能带来更大的寻址范围,恰恰Java很少存在仅有1Byte的微型对象。为此,如果继续按CPU1Byte作为对齐边界,就会导致指针上很多地址根本无法完全利用!所以,JVM干脆一不做二不休,直接以8Byte作为对齐边界,如下:

004.png

由于对齐边界是8字节,这意味着在3、7、9、111Byte……这种位置,绝不可能成为一个对象的起始地址,所以JVM在寻址时,就只需要找8的整数倍位置即可,只有这些位置上,才可能是一个对象的起始!对象按8Byte对齐后,JVM的引用指针只需要记录每个边界地址就行。这也是为何JVM64Bit指针压缩到32Bit,却依旧可以支持0~32GB(4GB*8)范围内的对象寻址的原因

当然,具体的指针压缩与对象寻址实现细节如下:

005.png

因为对象以八字节对齐,代表所有对象的Size都是8的整数倍,所以不管是32Bit指针,还是64Bit指针,末尾的三个二进制位始终为0。OK,已知所有对象的指针后三位都是0,那这最后三位就没必要存储,从而又能多出三个二进制位来表示更多的地址。

可是如果不存储后三位0,会导致指针记录的地址出现偏差,为了保证JVM正常工作,堆中存储引用指针时,右移三位自动将后三个0抹除;相反,寻址时只需要左移三位补齐后三个0,就能得到正确的引用地址,这时就能得出了前面指针压缩的最终结论:通过位移这种轻量级的CPU操作,将32Bit指针变为了35Bit,因此可寻址的最大范围就变成了2 ^ 35 / 1024 / 1024 = 32GB

PS:如果对位运算、操作系统指针较为陌生的小伙伴,看后面这段原理不太明白没关系,看懂前面的即可。

2.2、指针压缩相关的JVM参数

好了,到这里相信大家也明白了,为什么Java对象要按照8Byte的整数倍填充对齐数据,就是为了压缩后的指针寻址时能正确找到Java对象。当然,8Byte这个对齐值并不是固定,你可以通过JVM参数调整:

  • -XX:ObjectAlignmentInBytes:设置对齐的边界值,值必须为2的次幂,通常范围为8~256

不过一般不会调整该参数,8Byte是最佳对齐边界,如果将其调整为更高,虽然能带来更大的寻址范围,可是会造成堆中出现大量无效的对齐数据。比如设置成32Byte,能支持的最大堆则为128GB,但这时假设一个对象实际大小为40Byte,为了保持边界值的整数倍,就会补齐64-40=24Byte填充数据。

同时,我们可以通过下述两个参数来控制指针压缩机制(+代表启用,-代表禁用):

  • -XX:+UseCompressedOops:开启普通指针的压缩机制;
  • -XX:+UseCompressedClassPointers:开启类型指针的压缩机制。

当然,这两个参数在JDK1.7之后是默认开启的,大家可以在启动Java程序时,加入-XX:+PrintCommandLineFlags参数,来查看JDK默认开启的参数。上述两个参数开启后,运行期间所有引用指针都会被压缩吗?答案是不会,只会压缩下述指针:

  • ①对象头里的类型指针:对象头里的KlassWord原大小为8Byte,压缩后为4Byte
  • ②全局与静态变量指针:属于Class的类成员(引用类型)、全局的引用指针;
  • ③普通对象的引用指针:栈帧内非基本数据类型的封装对象,都会从八字节压缩成四字节。

为啥栈帧里八大基本类型的封装对象指针,并不会被压缩呢?答案是基本数据类型有特殊处理,跟Java的自动拆/装箱机制有关。最后,我们再来唠唠指针压缩的好处:

  • ①更高效的寻址能力:无需挨个字节查找对象的起始地址,只需要按八字节进行跳跃寻址;
  • ②节省更多的堆空间:压缩指针大小,降低对象Size,相同内存能容纳更多对象,减缓GC频率。

综上所述,大部分小伙伴应该也明白了一开始的问题,为什么将内存从28G升配至36G后,反而更容易令程序触发瓶颈了,就是因为当堆大小超出32G后,超出32Bit指针最大寻址范围,默认启动的指针压缩会失效,从而引发堆内所有指针从压缩后的32Bit,膨胀回压缩前的64Bit

在有些人看来,指针压缩省下来的32Bit/4Byte很不起眼,潜意识下,感觉指针膨胀回64Bit也没什么大不了,可是诸位要记住一点!Java运行期间,数量最多的不是字符、不是对象、不是基本类型的数据,而是这最容易让人忽略的引用指针!一根或许看不出来变化,当千千万万根指针一同膨胀时,将会给程序带来灾难性的后果。

三、指针压缩失效场景复现

经过前面一番啰嗦,咱们已经将最开始抛出来的问题讲明白了,可是上面仅停留在理论阶段,一切皆是纸上谈兵,那么有没有办法验证下这些理论呢?答案是当然有,这不得不再次请出我们的老朋友:JOL

3.1、对象内存分析工具

先来导个包:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

这个库由OpenJDK官方提供,其中提供了不少API,但较为常用的就三个:

// 查看指定对象的内部信息(如对象头、实例数据、对齐填充等)
ClassLayout.parseInstance(zhuZi).toPrintable();

// 查看指定对象外部信息,如引用的对象
GraphLayout.parseInstance(zhuZi).toPrintable();

// 查看指定对象所占空间的总大小
GraphLayout.parseInstance(zhuZi).totalSize();

好了,接着来上个例子感受一下这三个API

public class ZhuZi {
   
   
    int id;
    Object obj;

    public static void main(String[] args) {
   
   
        ZhuZi zhuZi = new ZhuZi();
        zhuZi.id = 1;
        zhuZi.obj = new Object();

        System.out.println(GraphLayout.parseInstance(zhuZi).totalSize());
        System.out.println("===============================性感分割线==================================");
        System.out.println(ClassLayout.parseInstance(zhuZi).toPrintable());
        System.out.println("===============================性感分割线==================================");
        System.out.println(GraphLayout.parseInstance(zhuZi).toPrintable());
    }
}

其中定义了一个ZhuZi类,其中有两个属性,id为基本数据类型,obj为引用类型,执行结果如下:

006.png

从上图结果来看,zhuZi对象的总大小为40Byte(包含对象自身+关联对象的总大小)。接着来看这个对象的内部信息,图中已经用橙色框+红色分割线指明,zhuZi对象总共由三部分组成:12Byte的对象头,8Byte的实例数据,4Byte的对齐填充,合计24Byte。最后注意看对象外部信息,包含zhuZi对象为24Byte,内部引用的obj对象为16Byte,两者相加就得到了最开始的总大小(实际堆中分开存储)。

3.2、指针压缩失效场景复现

好了,既然我们可以通过JOL观察对象的内存布局,那么也一定能通过它来复现堆空间不小于32GB导致的指针压缩现象,我们可以加上这些JVM启动参数:

-XX:+UseCompressedOops -XX:+UseCompressedClassPointers -Xms32g -Xmx32g

上述参数表示手动开启指针压缩机制,而后将Java堆空间分配成了32GB,当然,如果你的机器没有32G以上的空闲内存,可以将上述的-Xms参数移除掉,只要保证最大-Xmx存在即可,大家点击运行,就会发现如下结果:

007.png

这是HotSpot虚拟机给出的警告,大致含义就是分配的最大堆空间超出了压缩后的指针寻址上限,因此就算手动开启了指针压缩,也会自动失效。不过也值得一提的是,如果目前对象的对齐边界为8字节,那么指针压缩机制会在小于32GB的堆中生效(等于32G也会失效)

上面证明的确可以使指针压缩机制失效,下面来丰富一下前面的ZhuZi对象,如下:

@Data
public class ZhuZi {
   
   
    Integer x;
    Integer y;
    Object obj1;
    Object obj2;
    String s1;
    String s2;
    String s3;
    String s4;
    BigDecimal bigDecimal;
    Date date1;
    Date date2;
    List<String> strList;
    List<Object> objs;

    public static void main(String[] args) {
   
   
        ZhuZi zhuZi = new ZhuZi();
        System.out.println(GraphLayout.parseInstance(zhuZi).totalSize());
        System.out.println("===============================性感分割线==================================");
        System.out.println(ClassLayout.parseInstance(zhuZi).toPrintable());
    }
}

上面将ZhuZi类的属性值增加到了13个,这个数量远小于平时项目中定义的Entity、VO、BO、DTO、Qurey……各种类,下面先来看看指针压缩未失效的对象大小:

-XX:+UseCompressedOops -XX:+UseCompressedClassPointers -Xmx31g

008.png

此时注意观察,由13个字段组成的zhuZi对象,本身只会占用64Byte空间,接着用会造成指针压缩失效的参数启动看看:

-XX:+UseCompressedOops -XX:+UseCompressedClassPointers -Xmx32g

009.png

当指针压缩时,同样的zhuZi对象没有任何变更,体积就从64Byte膨胀到120Byte!咱们来换算一下,假设目前堆空间为30GB,总共可存储的为:30 * 1024(MB) * 1024(KB) * 1024(B) / 64 = 503316480(五亿多个)。

再通过上述得到的数字反推,如果在指针压缩失效的情况下,存储这五亿多对象的所需内存为:503316480 * 120 / 1024(B) / 1024(KB) / 1024(MB) = 56.25GB

我们借着ZhuZi对象,通过推导指针压缩失效前后的所需空间大小,就会发现Java中的神奇现象:能用30GB存下的数据,你用50GB都存不下!两者的区别仅在于指针压缩,这也是指针压缩带来的灾难性后果!正因如此,大家在分配堆空间时要切记这一点,在非必要情况下,一定不要为Java堆空间分配32GB及以上的内存

四、OOM事件总结

讲到这里,本文的核心话题接近落幕,回到最开始的引子,为什么那位小伙伴从G1切换到ZGC,会让我回想起这个事故呢?或许有人会觉得两者八竿子打不着,实则不然,当传统的垃圾收集器切换到ZGC时,亦会出现指针压缩失效的问题!

Why?道理很简单,如果有深入研究过ZGC的小伙伴应该知道,ZGC这款收集器的核心是《染色指针技术》,而在JDK21之前,ZGC的分代模型还未完善。因此,当你在JDK21之前的环境中,手动切换到ZGCnew出来的每个Java对象,对象头里的信息都换成了染色指针(JDK21好像也是,还没去深入研究)!

同时,如果你深入研究过染色指针技术,就会发现染色指针需要的空间至少为64Bit,示意图如下:

010.png

那么染色指针能否压缩呢?答案是ZGC里不可以,也没必要实现,毕竟推出ZGC的原因很简单,之前包括G1在内的垃圾收集器,应对大堆场景尤为吃力,ZGC主要就是应对几百GB、TB级的大堆场景。因为堆空间大的离谱,所以之前的收集器,每次GC造成的停顿短则数十秒,长则几十分钟。反观ZGC,号称在TB级的大堆中,甚至都能亚秒级的延迟!

也就是因为这样,能用上ZGC的服务堆空间必然不小,而超过32GB空间的堆,指针压缩基本失效,就算能够通过加大对齐边界值来提升可寻址范围,也因为大量对齐填充导致内存浪费,所以,ZGC压根就没必要去实现指针压缩机制。

综上所述,ZGC和前面的事件,都有着殊途同归的特点,就是每根指针都会占用64Bit空间,这也我为什么会从G1切到ZGC,导致内存占用变高的问题,联想到文中事件的原因。

最后,作为Java这类高级别语言的从业者,学习、工作中很少有接触指针的概念,因此许多时候会下意识忽略掉指针带来的内存开销,从便捷性来说,这或许是一件好事;可从知识性角度来看,无疑会拉远对技术理解的透彻性。可是不管怎样,通过本文希望能让大家对Java指针建立起更深刻的理解~

相关实践学习
通过性能测试PTS对云服务器ECS进行规格选择与性能压测
本文为您介绍如何利用性能测试PTS对云服务器ECS进行规格选择与性能压测。
相关文章
|
3月前
|
缓存 NoSQL Java
Java高并发实战:利用线程池和Redis实现高效数据入库
Java高并发实战:利用线程池和Redis实现高效数据入库
306 0
|
6天前
|
缓存 测试技术 Apache
告别卡顿!Python性能测试实战教程,JMeter&Locust带你秒懂性能优化💡
【9月更文挑战第5天】性能测试是确保应用在高负载下稳定运行的关键。本文介绍Apache JMeter和Locust两款常用性能测试工具,帮助识别并解决性能瓶颈。JMeter适用于测试静态和动态资源,而Locust则通过Python脚本模拟HTTP请求。文章详细讲解了安装、配置及使用方法,并提供了实战案例,帮助你掌握性能测试技巧,提升应用性能。通过分析测试结果、模拟并发、检查资源使用情况及代码优化,确保应用在高并发环境下表现优异。
28 5
|
6天前
|
测试技术 Apache 数据库
从慢如蜗牛到飞一般的感觉!Python性能测试实战,JMeter&Locust助你加速🏃‍♂️
【9月更文挑战第6天】你的Python应用是否曾因响应缓慢而让用户望而却步?借助JMeter与Locust,这一切将迎刃而解。JMeter作为Apache基金会的明星项目,以其强大的跨平台和多协议支持能力,成为性能测试领域的魔法师;而Locust则以Python的简洁与高效,让性能测试更加灵活。通过实战演练,你可以利用这两款工具轻松识别并解决性能瓶颈,优化数据库查询、网络配置等,最终使应用变得敏捷高效,轻松应对高并发挑战。
9 1
|
1月前
|
存储 缓存 运维
优化高并发环境下的数据库查询性能:实战经验与技巧
在高并发环境下,数据库性能往往成为系统瓶颈。本文将深入探讨在高并发场景下优化数据库查询性能的策略与实践,包括索引优化、查询优化、数据库架构设计以及缓存机制的应用。通过对具体案例的分析,读者将能够掌握提升数据库性能的关键技术,从而在面对大规模用户请求时提高系统的响应速度和稳定性。
|
22天前
|
存储 监控 固态存储
【性能突破】揭秘!如何让您的数据库在高并发风暴中稳如磐石——一场关于WAL写入性能优化的实战之旅,不容错过的技术盛宴!
【8月更文挑战第21天】在高并发环境下,数据库面临极大挑战,特别是采用Write-Ahead Logging (WAL)的日志机制。本文通过一个在线交易系统的案例,分析了WAL写入性能瓶颈,并提出优化方案:理解WAL流程;分析磁盘I/O瓶颈、缓冲区设置与同步策略;通过增大WAL缓冲区、使用SSD及调整同步策略来优化;最后通过测试验证改进效果,总结出一套综合优化方法。
38 0
|
1月前
|
监控 测试技术 数据库
从慢如蜗牛到飞一般的感觉!Python性能测试实战,JMeter&Locust助你加速🏃‍♂️
【8月更文挑战第4天】曾几何时,Python应用响应缓慢,用户体验大打折扣。但有了JMeter与Locust,一切迎刃而解!JMeter,跨平台的性能魔法师,助你轻松模拟高并发场景,揪出性能瓶颈。Locust,则是Python世界的性能小能手,以简洁的Python代码实现高效测试。两者联手,让你的应用摆脱蜗牛速度,迎接流畅体验的新篇章!
19 0
|
2月前
|
负载均衡 微服务
微服务06----Eureka注册中心,微服务的两大服务,订单服务和用户服务,订单服务需要远程调用我们的用,户服务,消费者,如果环境改变,硬编码问题就会随之产生,为了应对高并发,我们可能会部署成一个集
微服务06----Eureka注册中心,微服务的两大服务,订单服务和用户服务,订单服务需要远程调用我们的用,户服务,消费者,如果环境改变,硬编码问题就会随之产生,为了应对高并发,我们可能会部署成一个集
|
2月前
|
测试技术 Linux
linux 服务器运行jmeter 进行服务性能压测
linux 服务器运行jmeter 进行服务性能压测
46 0
|
2月前
|
Java 测试技术
用代码模拟调用接口方式压测现网服务器的服务性能
用代码模拟调用接口方式压测现网服务器的服务性能
27 0
|
2月前
|
存储 安全 Java
Java面试题:假设你正在开发一个Java后端服务,该服务需要处理高并发的用户请求,并且对内存使用效率有严格的要求,在多线程环境下,如何确保共享资源的线程安全?
Java面试题:假设你正在开发一个Java后端服务,该服务需要处理高并发的用户请求,并且对内存使用效率有严格的要求,在多线程环境下,如何确保共享资源的线程安全?
48 0