起因
最近有位小伙伴在阅读《JVM专栏》时,看到了ZGC
这款源自于JDK11的性能巨兽,一瞧它那强悍的性能表现,不甘心做个理论派,没忍住亲自上手试了试,结果呢?不试还好,一试差点给内存干爆,遇到这种诡异的现象,这位小伙伴百思不得其解,最后在评论区留下了他的疑惑:
从
G1
切换至ZGC
,发现虚拟内存占用高到离谱,简单了解后发现与ZGC
的实现机制有关,以8c32g
服务器为例,上面部署了四个服务,均已切换至ZGC
,可这么夸张的虚拟内存占用,是否会导致OOM
或者其他问题?
这提问方式非常专业,三言两句不仅交代了前因后果,而且清晰的描述了问题背景和实验环境,以及附上了两张截图。我们来提取几个关键词:G1
切换ZGC、8c32g
、虚拟内存飙升、内存占用变高、会引起什么后果?好了,接着来看下这位小伙伴贴出的附图:
观察附图可知,目前环境为JDK21
,同时虚拟内存占用来到了夸张的197.7G
,同时内存占用从原有的2.7G
来到3.4G
,Why?其实这已经是OpenJDK
努力许久的结果,如果有玩过JDK11
中ZGC
的小伙伴,应该有印象,VIRT
这个指标会直接显示成17TB
~
其实类似问题在
Stack Overflow
上有很多相关的帖子,造成这种现象的根本原因,是由于ZGC
底层的染色/颜色指针,里面用到了一种名为多重内存映射的技术,即多个虚拟地址指向同一个物理地址,这时,再使用Linux
中TOP
这类指令时,监测到的资源指标就会失真。当然,因为是映射出的虚拟地址,这并不会导致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
(位),每个位只能表示0
或1
。而在32位系统中,指针的大小为32Bit
,这意味着每个指针包含32
个二进制位,每个位的值可以填0
或1
。因此,总共可以表示2
的32
次方(即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
对象在内存中的布局,如下:
一个对象由对象头、实例数据、对齐填充三部分组成,对齐填充只有在对象大小不满足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
的微型对象。为此,如果继续按CPU
的1Byte
作为对齐边界,就会导致指针上很多地址根本无法完全利用!所以,JVM
干脆一不做二不休,直接以8Byte
作为对齐边界,如下:
由于对齐边界是8
字节,这意味着在3、7、9、111Byte……
这种位置,绝不可能成为一个对象的起始地址,所以JVM
在寻址时,就只需要找8
的整数倍位置即可,只有这些位置上,才可能是一个对象的起始!对象按8Byte
对齐后,JVM
的引用指针只需要记录每个边界地址就行。这也是为何JVM
将64Bit
指针压缩到32Bit
,却依旧可以支持0~32GB(4GB*8)
范围内的对象寻址的原因。
当然,具体的指针压缩与对象寻址实现细节如下:
因为对象以八字节对齐,代表所有对象的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
为引用类型,执行结果如下:
从上图结果来看,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
存在即可,大家点击运行,就会发现如下结果:
这是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
此时注意观察,由13
个字段组成的zhuZi
对象,本身只会占用64Byte
空间,接着用会造成指针压缩失效的参数启动看看:
-XX:+UseCompressedOops -XX:+UseCompressedClassPointers -Xmx32g
当指针压缩时,同样的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
之前的环境中,手动切换到ZGC
,new
出来的每个Java
对象,对象头里的信息都换成了染色指针(JDK21
好像也是,还没去深入研究)!
同时,如果你深入研究过染色指针技术,就会发现染色指针需要的空间至少为64Bit
,示意图如下:
那么染色指针能否压缩呢?答案是ZGC
里不可以,也没必要实现,毕竟推出ZGC
的原因很简单,之前包括G1
在内的垃圾收集器,应对大堆场景尤为吃力,ZGC
主要就是应对几百GB、TB级的大堆场景。因为堆空间大的离谱,所以之前的收集器,每次GC
造成的停顿短则数十秒,长则几十分钟。反观ZGC
,号称在TB
级的大堆中,甚至都能亚秒级的延迟!
也就是因为这样,能用上ZGC
的服务堆空间必然不小,而超过32GB
空间的堆,指针压缩基本失效,就算能够通过加大对齐边界值来提升可寻址范围,也因为大量对齐填充导致内存浪费,所以,ZGC
压根就没必要去实现指针压缩机制。
综上所述,ZGC
和前面的事件,都有着殊途同归的特点,就是每根指针都会占用64Bit
空间,这也我为什么会从G1
切到ZGC
,导致内存占用变高的问题,联想到文中事件的原因。
最后,作为Java
这类高级别语言的从业者,学习、工作中很少有接触指针的概念,因此许多时候会下意识忽略掉指针带来的内存开销,从便捷性来说,这或许是一件好事;可从知识性角度来看,无疑会拉远对技术理解的透彻性。可是不管怎样,通过本文希望能让大家对Java
指针建立起更深刻的理解~