阿里云技术面试题解析:100万次登陆请求下8G内存JVM参数设置指南
点击,让程序员一起走超车之路

阿里云技术面试的时候,被问到这样一个问题:假设一个平台每天有100万个登录请求,服务节点有8G内存。如何设置JVM参数?
下面以面试题的形式给大家整理一下,做到一石二鸟:既供大家实际参考,也供大家面试参考。
大家需要学习的,除了JVM配置方案之外,就是它分析问题的方法和思考问题的视角。这些想法和观点可以帮助大家走得越来越远。接下来我们就进入正题吧。
如何针对每天100万个登录请求、8G内存设置JVM参数,大致可以分为以下8个步骤。
第一步,新系统上线时如何规划容量?
1. 例程总结
任何新的业务系统上线之前,都需要对服务器配置和JVM内存参数进行预估。这种容量和资源规划不仅仅是系统架构师的随机估计。需要根据系统的业务场景进行估计和推断。运行模型并评估 JVM 性能和 GC 频率等指标。以下是我根据大牛的经验和自己的实践总结出来的建模步骤:
2.练习套路:以登录系统为例
一些同学看到这些步骤后还是一头雾水。他们好像是在说,但是实际项目中我还是不知道怎么做。光说不练,以登录系统为例来模拟推演过程:
因此,可以粗略推断,一个每天100万请求的登录系统,按照4C8G的3实例集群配置,分配4G堆内存和2G新一代JVM即可保证系统正常负载。
基本上是对一个新系统的资源进行评估,所以每个实例需要多少容量和配置来构建一个新系统,集群中配置多少个实例等等都不是拍着头拍胸就能确定的。
第二步:如何选择垃圾收集器
吞吐量还是响应时间?
首先介绍两个概念——吞吐量和低延迟。
吞吐量 = 运行用户应用程序的 CPU 时间 /(运行用户应用程序的 CPU 时间 + CPU 垃圾收集时间)
响应时间=每次GC花费的平均时间
通常,是否优先考虑吞吐量或响应是 JVM 中的一个难题。
随着堆内存的增加,GC一次可以处理的量就越大,吞吐量就越高;但一次GC的时间会变长,导致后面排队的线程等待时间变长;相反,如果堆内存较小,则一次GC的时间会较短,排队线程的等待时间会较长。等待线程的等待时间变短,延迟减少,但同时请求的数量变少(不绝对一致)。
同时考虑两者是不可能的。优先考虑吞吐量还是响应是一个需要权衡的问题。
垃圾收集器设计注意事项
CMS 和 G1
目前主流的垃圾收集器配置是在新生代使用ParNew,在老年代使用CMS组合,或者完全使用G1收集器。从未来的趋势来看,G1是官方维护的、更受推崇的垃圾收集器。
业务系统:对于延迟敏感、大内存的业务,以及需要高吞吐量的业务,推荐CMS,使用G1回收器。
CMS 垃圾收集器的工作原理
CMS主要是老年代的收集器。老一代的标记很清楚。默认情况下,它会在Full GC算法之后执行碎片整理算法来清理内存碎片。
总之,对于延迟敏感的业务系统,推荐使用CMS。需要高吞吐量的大内存服务使用G1收集器。
第三步,如何规划各个分区的比例和大小。
总体思路是:
首先,JVM最重要、最核心的参数是评估内存和分配。第一步是指定堆内存的大小。系统上线时需要此操作。 -Xms初始堆大小,-Xmx最大堆大小,后台Java服务一般指定为系统内存的一半。如果太大,就会占用服务器的系统资源。如果太小,则无法发挥JVM的最佳性能。
其次,需要指定新生代的大小——对于常见的业务系统(比如Web应用),一般甚至可以给新生代3/4的堆内存;对于有状态服务(常见系统如IM服务、网关接入层等)新生代可以给予默认比例。 1/3 设置。服务是有状态的,这意味着内存中会有更多的本地缓存和会话状态信息常驻,因此应该在老年代中设置更多的空间来存储这些对象。
最后设置-Xss栈内存大小,设置单个线程的栈大小。默认值与JDK版本和系统有关,一般默认为512~1024kb。如果一个后台服务有数百个常驻线程,那么堆栈内存也会占用数百M大小。
对于8G内存,一般分配最大内存的一半就够了。由于机器仍然占用一定的内存,所以通常会分配4G内存给JVM。
引入性能压力测试环节,学生使用ParNew + CMS的组合回收器测试了登录界面1秒内60M的对象生成速度。
正常的JVM参数配置如下:
-Xms3072M -Xmx3072M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
由于动态对象年龄判断原理,该设置可能会导致频繁的Full GC。为什么?
在压力测试过程中,Eden区会在短时间内(例如20秒后)就满了。此时再次运行时,对象无法分配,会触发Minor GC。
假设本次GC后,S1负载100M,20秒后立即触发一次Minor GC。 S1区域中多余的100M存活对象+100M就无法再成功放入S2区域,此时就会触发JVM的动态。 Age机制将一批约100M的对象推送到老年代进行存储。如果持续运行一段时间,系统可能会在一小时内触发Full GC。
按照默认的8:1:1比例分配时,Survivor区只有1G的10%左右,也就是几十到100M。如果每次Minor GC垃圾回收后有很多Survivor对象,并且Survivor对象的大小很快就会超过Survivor的50%,那么就会触发动态年龄判定规则,允许部分对象进入老年代。
在一次GC过程中,有些Web请求可能没有被处理,几十兆的对象进入Survivor的概率非常高,甚至可能肯定会发生。
如何解决这个问题呢?为了尽可能保留新生代Eden区和Survivor区的对象,让Survivor区拥有尽可能多的内存,达到200M左右。所以我们可以更新JVM参数设置:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
阐明:
‐Xmn2048M ‐XX:SurvivorRatio=8
年轻代大小为2G,Eden与Survivor的比例为8:1:1,即1.6G:0.2G:0.2G。
达到200M,如果几十兆的对象存活下来,存活率可能不会超过50%。这样可以防止每次垃圾回收后Survivor对象过早超过50%,从而减少由于对象的动态年龄判断原理导致对象频繁进入老年代的问题。
JVM动态年龄判定规则是什么?
进入老年代的对象的动态年龄判断规则(动态晋升年龄计算阈值):Minor GC时,当Survivor中年龄为1到N的对象大小超过Survivor的50%时,年龄大于或等于N的对象会被放入老年代。
核心优化策略是尽可能保留Survivor中短期存活的对象,不进入老年代。这样这些对象就会在Minor GC时被回收,不会进入老年代而引发full gc。
我们应该如何评估新一代内存并合理分配?
特别地,这里是JVM评估内存和分配的最重要、最核心的参数。
第一步是指定堆内存的大小。系统上线时需要此操作。 -Xms 是初始堆大小,-Xmx 是最大堆大小。在后台Java服务中,一般指定为系统内存的一半。如果太大,就会占用服务器的系统。如果资源太小,则无法发挥JVM的最佳性能。
其次,需要指定新生代-Xmn的大小。这个参数非常关键,并且具有很大的灵活性。虽然Oracle官方推荐大小为3/8,但要根据业务场景来确定:
服务是有状态的,这意味着内存中会有更多的本地缓存和会话状态信息常驻,因此应该在老年代中设置更多的空间来存储这些对象。
步骤 4:合适的堆栈内存大小是多少?
xss栈内存大小,设置单个线程的栈大小。默认值与JDK版本和系统有关。一般情况下,默认值为512~1024 kb。如果一个后台服务有数百个常驻线程,那么堆栈内存也会占用数百M大小。
步骤5:对象的年龄是多少才适合将其移动到老年代?
假设一次minor gc需要二十到三十秒,大多数对象一般会在几秒内变成垃圾。
如果对象这么长时间都没有被回收,比如2分钟都没有被回收,那么可以认为这些对象会存活比较长的时间,会被移到老年代而不是继续下去占据Survivor区空间。
因此,你可以将默认的年龄15更改为更小的值,例如5。这意味着对象要经过5次Minor GC才能进入老年龄。整个时间一两分钟(5*30s = 150s)。与几秒钟相比,该物体已经存活了足够长的时间。
因此,可以适当调整JVM参数,如下:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5
步骤 6. 物体有多大?直接移到老年代比较合适。
关于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),一般可以根据自己的系统检查是否有大对象生成,并预估大对象的大小。一般来说,设置为1M就足够了。很少有大于 1M 的大对象。因此,可以适当调整JVM参数,如下:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M
步骤7.老一代垃圾收集器CMS的参数优化
JDK8默认的垃圾收集器是-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代)。如果内存较大(4G以上,只是经验值),建议使用G1。
这是4G范围内的一个以“低时延”为核心的业务系统。您可以使用以下组合:
ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)
新一代使用ParNew收集器。工作流程是经典的复制算法,分三个区域循环循环。但采用多线程并行的方式来加速MinorGC。
老一代使用CMS。然后优化老生代的参数:比如清除标记后老生代会默认排序。您还可以选择增加 GC 频率或增加 CMS 的 GC 持续时间。以下是响应优先的参数调整:
XX:CMSInitiatingOccupancyFraction=70
设置CMS在内存使用率达到70%时启动GC(因为CMS会有浮动垃圾,所以GC一般会提前启动)。
XX:+使用CMSInitiatinpOccupancyOnly
与上面配合使用,否则只生效一次:
-XX:+AlwaysPreTouch
强制操作系统实际分配内存给IVM,而不是在使用时分配。
综上所述,只要合理设置年轻代的参数,老年代CMS的参数设置基本可以使用默认值,如下所示:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC ‐XX:CMSInitiatingOccupancyFraction=70 ‐XX:+UseCMSInitiatingOccupancyOnly ‐XX:+AlwaysPreTouch
参数说明
‐Xms3072M ‐Xmx3072M 最小和最大堆设置为3G,最大和最小设置一致,防止内存抖动; ‐Xss1M线程栈1M; ‐Xmn2048M ‐XX:SurvivorRatio=8 年轻代大小为2G,Eden与Survivor的比例为8:1:1,即1.6G:0.2G:0.2G; XX:MaxTenuringThreshold=5 年龄为5,进入老年代; ‐XX:PretenureSizeThreshold=1M 大于1M的大对象直接在老年代生成; ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC 使用 ParNew+cms 垃圾收集器组合; ‐XX :CMSInitiatingOccupancyFraction=70 老年代中的对象达到此比例后触发Full。气相色谱; ‐XX:+UseCMSInitiatinpOccupancy仅当老年代中的对象达到该比例时触发Full GC; ‐XX:+AlwaysPreTouch 强制操作系统实际为 IVM 分配内存,而不是在使用时才分配。
步骤8.配置OOM时的内存转储文件和GC日志
增加了GC日志打印、OOM自动转储等额外配置内容,帮助排查问题。
-XX:+HeapDumpOnOutOfMemoryError
Out Of Memory,当JVM即将死掉时,Heap Dump被输出到指定文件。不然在开发过程中很多时候我真的不知道如何重现错误。路径只指向目录,JVM会保持文件名唯一,称为java_pid${pid}.hprof。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOGDIR}/
因为如果你指向一个特定的文件并且该文件已经存在,则无法写入。
输出4G HeapDump会导致IO性能问题。在普通硬盘上,会导致硬盘IO满20秒以上。需要注意的是,但是在容器环境中,这也会影响同一主机上的其他容器。
GC日志的输出也很重要:
-Xloggc:/dev/xxx/gc.log -XX:+PrintGCDateStamps -XX:+PrintGCDetails
GC日志实际上对系统性能影响不大,日志记录对于排查GC问题非常重要。
常见的JVM参数模板
一般来说,大公司或者架构师团队都会为项目的业务系统定制一个比较通用的JVM参数模板,但是很多小企业和团队可能会忽略这方面的设计。如果有一天你的老板突然要求你定制一个新系统的JVM参数,而你在网上搜索了大量的JVM调优文章或博客,你会发现都是零散的、不系统的JVM参数解释,你就会无法启动。 。这时候就需要一个更通用的JVM参数模板。它不能保证性能最好,但至少可以让JVM层稳定可控。
以下是为您提供的模板摘要:
基于4C8G系统的ParNew+CMS回收商模板(响应优先),新生代规模可根据业务灵活调整!
-Xms4g-Xmx4g-Xmn2g-Xss1m-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=10-XX:+UseConcMarkSweepGC-XX:CMSInitiatingOccupancyFraction=70-XX:+UseCMSInitiating OccupancyOnly-XX:+AlwaysPreTouch-XX:+HeapDumpOnOutOfMemoryError-verbose:gc-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+PrintGCTimeStamps-Xloggc:gc.log
如果优先考虑GC吞吐量,建议使用G1,基于8C16G系统的G1收集器模板:
G1收集器已经有自己的预测和调整机制,所以我们的第一选择就是信任它。
即调整-XX:MaxGCPauseMillis=N参数也符合G1的目的——让GC调优尽可能简单。
同时,不要自己显式设置新生代的大小(使用-Xmn或-XX:NewRatio参数)。
如果手动干预新生代的大小,目标时间参数将失效。
-Xms8g-Xmx8g-Xss1m-XX:+UseG1GC-XX:MaxGCPauseMillis=150-XX:InitiatingHeapOccupancyPercent=40-XX:+HeapDumpO nOutOfMemoryError-verbose:gc-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+PrintGCTimeStamps-Xloggc :gc.log
对于-XX:MaxGCPauseMillis,该参数设置有一个明显的倾向:较低↓:延迟较低,但Minor GC频繁,Mix GC回收旧区较少,增加了Full GC的风险。增加↑:一次会回收更多的对象,但系统整体响应时间也会加长。
对于InitiatingHeapOccupancyPercent,调整参数大小的效果也不同:降低↓:提前触发Mix GC,浪费CPU。增加↑:累积多代回收区域,增加Full GC的风险。
调优总结
系统上线前综合调优思路:
业务估算:根据每个任务的预期并发量和平均内存需求,评估需要多少台机器来承载,每台机器需要什么配置;容量估算:根据系统的任务处理速度,然后合理分配Eden区和Surivior区的大小,以及老年代的内存大小;回收器选择:对于响应优先的系统,建议使用ParNew + CMS回收器;对于吞吐量优先、多核大内存(堆大小≥8G)的服务,推荐使用G1 Recycler;优化思路:让短命对象在MinorGC阶段被回收(同时回收后存活的对象<Survivor区的50%,可以控制在新生代中保留)。长寿命的对象会尽早进入老年代,并且不会在新生代中来回复制。 ;尽量减少Full GC的频率,避免FGC系统的影响。
目前总结的调优过程主要是基于上线前的测试验证阶段,所以上线前我们尽量将机器的JVM参数设置为最优值。
JVM调优只是一种手段,并不是所有的问题都可以通过JVM调优来解决。大多数 Java 应用程序不需要 JVM 优化。我们可以遵循以下一些原则:
通过以上原则我们发现,其实最有效的优化方法是架构和代码层面的优化,而JVM优化则是最后的手段,也可以说是对服务器配置的最后“压榨” 。
什么是 ZGC
ZGC(Z Garbage Collector)是Oracle开发的一个以低延迟为首要目标的垃圾收集器。
它是一个基于动态Region内存布局的收集器,(暂时)没有年龄生成,并使用读屏障、染色指针和内存多重映射等技术来实现并发标记排序算法。
JDK 11中新增,仍处于实验阶段,
目前使用很少,真正普及还需要时间。
如何选择垃圾收集器
实际场景中如何选择?以下是一些建议,希望对您有所帮助:
如果你的堆大小不是很大(比如100MB),选择串行收集器通常是最有效的。参数:-XX:+UseSerialGC。如果你的应用程序运行在单核机器上,或者你的虚拟机只有单核,那么选择串行收集器仍然是合适的。此时启用一些并行收集器没有任何好处。参数:-XX:+UseSerialGC。如果您的应用程序以“吞吐量”为先,并且对较长停顿没有特殊要求。最好选择并行收集器。参数:-XX:+UseParallelGC。如果您的应用程序对响应时间要求很高并且希望减少暂停。即使暂停1秒也会导致大量请求失败,所以选择G1、ZGC或者CMS都是合理的。虽然这些收集器的GC暂停通常较短,但它需要一些额外的资源来处理工作,并且吞吐量通常较低。参数:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC等。从以上出发点来看,我们普通的Web服务器对于响应能力的要求非常高。
选择性实际上集中在CMS、G1和ZGC上。对于某些计划任务,使用并行收集器是更好的选择。
为什么Hotspot要用元空间来代替永久代呢?
什么是元空间?什么是永久代?为什么使用元空间而不是永久代?
我们先回顾一下方法区,看看虚拟机运行时的数据内存图,如下:
方法区和堆一样,是每个线程共享的内存区域。它用于存储虚拟机已加载的类信息、常量、静态变量和即时编译的代码等数据。
什么是永久代?它与方法区有什么关系?
如果在HotSpot虚拟机上开发和部署,很多程序员将方法区称为永久代。可以说,方法区就是规范,永久代就是Hotspot对规范的实现。在Java7及之前的版本中,方法区是在永久代中实现的。
什么是元空间?它与方法区有什么关系?
对于Java8,HotSpots取消了永久代,用元空间代替。也就是说,方法区还在,但是实现变了,从永久代变成了元空间。
为什么永久代被元空间取代了?
永久代的方法区与堆使用的物理内存是连续的。
永久代的大小通过以下两个参数配置:
对于永久代来说,如果动态生成的类较多,由于永久代空间配置有限,很可能会出现java.lang.OutOfMemoryError: PermGen space错误。最典型的场景是Web开发中有很多jsp页面时。
JDK8之后,方法区存在于元空间(Metaspace)中。物理内存不再与堆连续,而是直接存在于本地内存中。理论上,元空间与机器内存一样大。
元空间的大小可以通过以下参数设置:
那么,为什么要用元空间来代替永久代呢?
表面上看是为了避免OOM异常。因为PermSize和MaxPermSize通常用来设置永久代的大小,这决定了永久代的上限,但并不总是能够知道应该设置为多大。如果使用默认值,很容易遇到OOM错误。使用元空间时,可以加载多少类元数据不再由MaxPermSize控制,而是由系统实际可用空间控制。
什么是停止世界?什么是 OopMap?什么是安全点?
垃圾收集的过程涉及对象的移动。为了保证对象引用更新的正确性,必须暂停所有用户线程。虚拟机设计者将这样的暂停描述为 Stop The World。也称为 STW。
在HotSpot中,有一个称为OopMap的数据结构(映射表)。一旦类加载动作完成,HotSpot就会计算出对象中的偏移量是什么类型的数据,并将其记录到OopMap中。
在即时编译过程中,还会在特定位置生成OopMap,记录堆栈和寄存器上的哪些位置被引用。这些具体地点主要在:
循环结束(非计数循环);方法返回之前/方法调用指令之后;可能会抛出异常的地方。
这些位置称为安全点。用户程序执行时,不可能在代码指令流的任何位置暂停和启动垃圾回收,但必须执行到安全点才可以暂停。
↑
关于我
目前在SaaS创业中,如果想成为一名技术高管或者从技术转向创业,了解业务、营销、产品等是必不可少的。
阅读我原创的SaaS创业公众号分享