18.1、背景引入
我们通常会通过案例分析,来指导大家如何在不同的场景下,预测系统的内存使用模型。我们需要合理地调整新生代、老年代、Eden和Survivor各个区域的内存大小,然后尽可能地优化参数,以减少新生代对象进入老年代的情况,让这些对象尽可能地在新生代中被回收。
在我们的讨论背景中,我们以电商系统为例。电商系统通常被拆分为多个独立的子系统进行部署,例如商品系统、订单系统、促销系统、库存系统、仓储系统和会员系统等。
我们的案例背景是一个每日处理上亿请求量的电商系统。我们可以推算,这样的系统每天会有多少活跃用户?如果我们假设每个用户平均访问20次,那么上亿的请求量大约需要500万的日活跃用户。
接下来,我们继续推算,这500万的日活跃用户中,有多少人会下订单?如果我们按照10%的付费转化率来计算,那么每天大约有50万人会下订单,也就是每天大约有50万订单。
如果这50万订单集中在每天4小时的高峰期内,那么平均每秒大约只有几十个订单。这可能会让人感觉压力并不大,因为在几十个订单的压力下,我们并不需要过多关注JVM的性能。在这种情况下,新生代的内存每秒只会被占用一部分,新生代会在很长一段时间后才会满,然后通过一次Minor GC,垃圾对象被清理掉,内存空间就被释放出来,几乎没有任何压力。
18.2、特殊的电商大促场景
如果你考虑到特殊的电商大促场景,你的想法可能会有所改变。在平常情况下,许多中小型电商平台的系统压力并不是特别大,高并发情况也相对较少,每秒几千的并发压力可能就已经算是高峰压力了。然而,一旦遇到大促场景,比如双11等,情况就会发生显著变化。
设想一下,在类似双11的节日里,零点一到,许多人都在等待大促的开始,准备疯狂购物。在这个时候,可能在大促开始的短短10分钟内,瞬间就会产生50万订单。那么,在这个时间段内,每秒可能会有接近1000的下单请求。因此,我们需要针对这种大促场景,对订单系统的内存使用模型进行深入分析。
18.3、了解大促期间您需要的机器军团规模
为了应对大促期间的瞬时下单压力,订单系统需要部署几台机器呢?
基本上可以按3台来算,即每台机器每秒需要承受300个下单请求。这个配置是非常合理的,而且需要假设订单系统部署的就是最普通的标配4核8G机器。
从机器本身的CPU资源和内存资源角度来看,抗住每秒300个下单请求是没问题的。但是问题就在于需要对JVM有限的内存资源进行合理的分配和优化,包括对垃圾回收进行合理的优化,让JVM的GC次数尽可能最少,而且尽量避免Full GC,这样可以尽可能减少JVM的GC对高峰期的系统性能的影响。
18.4、如何精确预测大促期间订单系统的内存需求?
根据背景信息,我们需要对订单系统的内存使用进行模型预估。我们假设系统每秒处理300个下单请求,这个数值与实际生产环境相近。
每个订单的处理是相对耗时的,涉及多个接口调用,因此每秒处理100~300个订单请求是合理的。我们以每个订单1KB的大小进行估算,那么300个订单将产生约300KB的内存消耗。
考虑到每个订单还会关联其他业务对象,如订单条目、库存、促销和优惠券等,通常单个订单的开销需要放大10倍至20倍。此外,除了下单操作外,订单系统还包含其他与订单相关的操作,如订单查询等,所以整体开销可以再扩大10倍。
综上所述,每秒钟的内存开销大约为:300KB × 20 × 10 = 60MB。然而,一旦这300个订单处理完毕,这些相关对象就会失去引用,进入可回收状态,因此在一秒钟后,这60MB的内存可以被视为垃圾进行回收。
大家看下图:
18.5、如何巧妙分配内存来提升性能?
假设我们有一台4核8G的计算机,通常我们会将JVM内存设置为4G,剩余的内存则留给操作系统等其他程序使用。在分配JVM内存时,我们可以将堆内存设置为3G,其中新生代和老年代各占1.5G。每个Java线程的虚拟机栈大小为1M,因此如果有几百个线程,大约需要几百M的内存。此外,还需要为永久代分配256M的内存。这样,基本上就分配了4G的内存。
在使用JVM时,还需要设置一些必要的参数,例如开启“-XX:HandlePromotionFailure”选项。不过,这个参数在JDK 1.6之后已经被废弃,因此在生产环境中一般不会设置这个参数。在JDK 1.6之后,只要满足以下两个条件之一,就可以直接进行Minor GC,而不需要提前触发Full GC:1. “老年代可用空间”> “新生代对象总和”;2. “老年代可用空间”> “历次Minor GC升入老年代对象的平均大小”。
因此,如果我们使用的是JDK 1.7或JDK 1.8,那么JVM参数可以保持如下设置,后面也不再加入“-XX:HandlePromotionFailure”参数:
“-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M”,此时JVM内存入下图所示。
接着就很明确了,订单系统的系统程序在大促期间不停的运行,每秒处理300个订单,都会占据新生代60MB的内存空间
但是1秒过后这60MB对象都会变成垃圾,那么新生代1.5G的内存空间大概需要25秒就会占满,如下图。
在25秒后,系统将执行Minor GC。由于设置了"-XX:HandlePromotionFailure"选项,因此需要进行的检查主要是比较“老年代可用空间大小”和“历次Minor GC后进入老年代对象的平均大小”。在初始阶段,这个检查通常是可以通过的。
因此,Minor GC会直接运行,可以回收掉99%的新生代对象。这是因为除了最近一秒钟的订单请求仍在处理中,大部分订单已经完成处理,所以此时可能存活的对象大约为100MB。
然而,这里出现了一个问题。如果"-XX:SurvivorRatio"参数的默认值为8,那么此时新生代中的Eden区大约占用了1.2GB内存,每个Survivor区占用了150MB内存。如下图。
所以Eden区1.2GB满了就要进行Minor GC了,因此大概只需要20秒,就会把Eden区塞满,就要进行Minor GC了。
然后GC后存活对象在100MB左右,会放入S1区域内。如下图。
当Eden区的1.2GB空间被填满时,就会触发一次Minor GC(垃圾回收)。这个过程大约需要20秒。在Minor GC执行之后,那些仍然存活的对象,其大小约在100MB左右,会被移动到S1区域,如下图。
此时JVM参数如下:
“-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8”
18.6、如何优雅地解决Survivor空间不足?
在对JVM进行优化时,首先需要关注的一个问题是,通过估算判断新生代的Survivor区是否足够。
根据上述逻辑,如果每次新生代垃圾回收大约占用100MB,甚至可能超过150MB,那么在Minor GC之后,对象无法放入Survivor区的情况可能会经常发生,这会导致对象频繁地进入老年代。
此外,即使在Minor GC后的对象少于150MB,但如果是100MB的对象进入Survivor区,由于这是一批同龄的对象,它们会直接超过Survivor区空间的50%,这也可能导致对象进入老年代。
因此,按照我们这个模型来看,Survivor区域显然是不足的。
在这里,建议调整新生代和老年代的大小。对于这种普通业务系统,显然大部分对象都是短生命周期的,不应该频繁进入老年代,也没有必要为老年代分配过大的内存空间。首先应该尽量让对象留在新生代中。
因此,可以考虑将新生代调整为2GB,老年代调整为1GB。这样,Eden区将为1.6GB,每个Survivor区将为200MB。如下图。
在这个阶段,我们可以通过增大Survivor区域的大小,有效地降低新生代垃圾回收(GC)后存活对象无法放入Survivor区域的问题,或者解决同龄对象超过Survivor区域50%的问题。这样做可以显著降低新生代对象被晋升到老年代的几率。
在这种情况下,Java虚拟机(JVM)的参数设置如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
对于任何系统,首先需要做的是进行内存使用模型的预估和合理分配内存,尽量确保每次Minor GC后的对象都能留在Survivor区域,避免它们被晋升到老年代。这是优化的首要步骤。
18.7、新生代如何巧妙躲过垃圾回收进入老年代?
众所周知,当对象在Minor GC后无法放入Survivor区时,它们会被送入老年代。此外,如果某些对象连续躲过15次垃圾回收,它们也会自动晋升至老年代。
根据上述内存运行模型,通常情况下,每20多秒会触发一次Minor GC。按照默认参数“-XX:MaxTenuringThreshold”的值15,如果一个对象连续躲过15次GC,意味着它在新生代中已经停留了几分钟。在这种情况下,将其晋升至老年代是合理的。
有些博客建议提高这个参数,例如将其增加到20或30。然而,这种观点并不正确。在考虑调整该参数时,必须结合系统的运行模型。如果一个对象在几次GC后仍然无法被回收,说明它可能是系统中长期存活的核心业务逻辑组件,如使用@Service、@Controller等注解标注的组件。这类对象通常很少,一个系统中最多只有几十MB。
因此,提高“-XX:MaxTenuringThreshold”参数的值并没有太大意义。让这些对象在新生代中多停留几分钟又能如何呢?
实际上,你甚至可以降低这个参数的值,例如将其降低到5,这意味着如果一个对象躲过5次Minor GC,在新生代中停留超过1分钟,就尽快将其晋升至老年代,避免占用过多新生代内存。
总之,对于这个参数,务必结合你的系统具体运行模型来进行调整。
请记住,JVM没有通用的最佳参数设置,但有一套通用的分析和优化方法。当前的JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5
18.8、究竟什么决定了对象直达老年代?
在计算机编程中,有一个逻辑概念是大对象可以直接进入老年代。这是因为大对象通常表示它们需要长期存活和使用。例如,在Java虚拟机(JVM)中,可能会缓存一些数据,这通常可以根据系统中是否创建了大对象来决定。
然而,一般来说,将大对象的阈值设置为1MB就足够了。因为超过1MB的大对象非常罕见。如果存在这样的大对象,可能是因为你提前分配了一个大数组、大List等用于存放缓存数据的数据结构。此时JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
18.9、正确配置JVM垃圾回收器
同时,请大家不要忘记设置垃圾回收器。对于新生代,我们使用ParNew,而对于老年代,我们使用CMS。以下是相应的JVM参数:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
ParNew垃圾回收器的核心参数主要是新生代的内存大小,以及Eden和Survivor的比例。只要这些参数设置得当,就可以避免在Minor GC后,对象无法放入Survivor而进入老年代,或者在动态年龄判定后进入老年代。这样,只要给新生代的Survivor留出足够的空间,Minor GC通常就不会有问题。
然后,根据你的系统运行模型,合理设置-XX:MaxTenuringThreshold
,使得长期存活的对象能够尽快进入老年代,而不是一直在新生代中停留。
这样,我们就得到了一个初步优化的JVM参数,它已经结合了你的业务需求。明天,我们将继续通过案例来分析老年代的垃圾回收和参数优化方法。