刚接触JVM调优或者准备面试的时候,很多人都会被jstat -gcutil输出里的S0、S1两列弄懵——明明名字差不多,使用率却经常一个高一个低,过一会儿还角色互换。这个现象背后其实是年轻代垃圾回收最核心的复制算法,也是JVM内存模型里最容易讲不清、但面试又最爱问的点。这篇文章我就从S0和S1到底是什么开始,一路聊到它们怎么工作、参数怎么调、线上怎么看,尽量把这块掰开揉碎讲明白。
S0和S1全名是Survivor Space 0和Survivor Space 1,也就是幸存者区,是JVM堆内年轻代(Young Generation)的一部分。它们不是摆设,而是Minor GC(新生代回收)时用来暂存存活对象的“周转站”。理解了这一对空间,JVM内存模型、垃圾回收机制、对象晋升规则基本就能串起来了。
1. 先搞清楚S0和S1在JVM内存模型中的位置
1.1 堆内存的三大块划分
HotSpot JVM的Java堆内存通常被划分为年轻代、老年代和元空间(Metaspace,从JDK 8起移出堆外),其中年轻代内部又细分出三小块:Eden区、S0区、S1区。
年轻代的任务是“快速分配、快速回收”。绝大部分新对象都用new直接分配在Eden区,因为Eden区内存连续、分配效率高。而Eden区边上挂着的S0和S1,则是为Minor GC准备的“避难所”。
这里有个容易混淆的点:S0和S1不是Eden的两个副本,它们是两个大小相等、功能对等的Survivor空间,JVM在任何时刻只会使用其中一个来存放存活对象,另一个保持空闲等待下一次GC使用。你可以把Eden当成“出生室”,S0/S1当成“临时病房”——对象从Eden出来,如果还活着,就被挪进病房待一阵子,直到满一定“年龄”才转去老年代。
默认情况下,Eden区与单个Survivor区的比例是8:1,由-XX:SurvivorRatio控制。也就是说,如果年轻代整体是10MB,那么Eden约8MB,S0和S1各1MB。很多刚接触的人会问,为什么不把Survivor区设大一点?原因后面讲复制算法时会说,Survivor区并不是越大越好,1:1:8是个经过权衡的经典比例。
1.2 S0和S1为什么是两个,而不是一个
这是理解JVM内存模型的关键。设想一下:如果只有一个Survivor区,Minor GC时存活对象从Eden拷贝过来,下次GC时Eden又满了,此时Survivor区里既有上一次的存活对象,又有新来的对象,整理起来会非常麻烦,而且很容易产生内存碎片。
两个Survivor区的设计,本质是给复制算法腾出“交换空间”。Minor GC开始时,S0和S1中必有一个是空的(称为To区),另一个存有上次GC后存活下来的对象(称为From区)。GC过程中,Eden区和From区里的存活对象会被复制到To区,然后Eden和From被整体清空。GC结束后,原本空的To区变成了有数据的新From区,原来的From区则变成空的To区,两个区角色互换。
这就是你看到S0、S1使用率周期性“一升一降”的原因——不是某个区出故障了,而是GC后它们交换了身份。这句话我建议所有学JVM的人记住,它是面试里高频考察点,也是排查Survivor相关问题时最容易豁然开朗的一步。
2. 复制算法与对象晋升机制:S0/S1的工作原理
2.1 复制算法的核心逻辑
S0和S1的工作离不开复制算法(Copying Algorithm)。这个算法的思路非常直白:把内存按容量划分为两块,每次只使用其中一块。当这一块快满时,把仍然存活的对象复制到另一块上,然后把已使用的那块一次性清空。这样每次只需要处理“存活对象”,不需要像标记-清除那样单独做一次整理,也就天然避免了内存碎片。
放在年轻代里具体流程是:
- 新对象分配在Eden区。
- Eden区空间不足时触发Minor GC。
- 标记出Eden区和From Survivor区中仍然可达的存活对象。
- 将这些存活对象按顺序复制到To Survivor区。
- 清空Eden区和From Survivor区。
- 交换From和To的角色,等待下一次GC。
这里有一个重要前提:Survivor区通常不会全部被占满。复制算法假设绝大多数对象都是“朝生夕灭”的,真正能活过几次Young GC的对象很少,所以Survivor区不需要设置得很大。如果存活对象过多,To区放不下,多余的对象就会通过“分配担保”机制直接进入老年代。后面讲调优时会细说。
2.2 对象年龄与晋升阈值
每个对象在Survivor区每熬过一次Minor GC,年龄(Age)就加1。当年龄达到-XX:MaxTenuringThreshold设定的阈值时,对象就会被晋升到老年代。HotSpot默认值是15,但注意这个值不是一成不变的,在开启某些GC组合或者使用自动调整策略时可能会有变化。
晋升不是只有年龄满了一个条件。还有两个常见路径:
- 大对象直接进入老年代:通过
-XX:PretenureSizeThreshold指定大小阈值,超过阈值的对象在分配时直接放到老年代,避免在Eden和Survivor之间反复拷贝。 - 动态年龄判断:JVM并不死板地等到15岁才晋升。如果在Survivor空间中,同龄对象的大小总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象可以直接晋升老年代。这个机制也叫动态年龄判定,它的目的是防止Survivor区被长期存活对象占满,影响GC效率。
我实际排查过一个问题:某服务SurvivorRatio没改,MaxTenuringThreshold默认15,但老年代增长特别快。用jstat观察发现S0/S1长期处于接近100%的状态,显然是存活对象远超Survivor容量,触发了动态年龄晋升或分配担保逻辑。这种场景下,单纯调大MaxTenuringThreshold反而没用,得把Survivor区本身调大或者优化对象存活率。
2.3 S0/S1与老年代之间的“担保”机制
Minor GC开始前,JVM会检查老年代最大可用连续空间是否大于年轻代所有对象总大小。如果大于,说明Minor GC即使把全部对象都晋升,老年代也接得住,这时可以安全地执行Minor GC。如果小于,JVM会检查是否允许“分配担保失败”(HandlePromotionFailure)。在JDK 6 Update 24之后,这个判断逻辑有所简化,简单说就是老年代连续空间是否足够放入年轻代存活对象的平均值,如果不够,就可能提前触发一次Full GC。
这也是为什么你会在有些案例里看到:明明只是Eden满了触发的Minor GC,结果却发生了Full GC。根因往往出在Survivor空间太小,或者对象存活率异常,导致大量对象被迫晋升,而老年代空间又紧张。理解S0/S1在这里面的作用,比死记硬背“担保机制”四个字有用得多。
3. S0/S1相关关键参数与调优思路
3.1 核心参数速查
想调优S0/S1,先得认识这几个参数。下面这张表是我自己常用的速查表,建议收藏:
| 参数 | 作用 | 默认值 | 备注 |
|---|---|---|---|
-Xmn | 设置年轻代大小 | 由JVM根据堆大小自动决定 | 年轻代过大影响老年代空间,过小导致频繁Minor GC |
-XX:SurvivorRatio | Eden与单个Survivor区大小比例 | 8 | 设置为8表示Eden:S0:S1 = 8:1:1 |
-XX:MaxTenuringThreshold | 对象晋升老年代的最大年龄 | 15(CMS下可能不同) | 过小导致对象过早进入老年代,过大会让Survivor区堆积无用对象 |
-XX:InitialSurvivorRatio | 初始Survivor区占用比例 | 8 | 主要在自适应调整时有用 |
-XX:TargetSurvivorRatio | GC后Survivor区的期望使用率 | 50% | 与动态年龄判断联动,过高会频繁触发晋升 |
-XX:PretenureSizeThreshold | 超过该大小的对象直接进入老年代 | 0(表示不启用) | 只对Serial和ParNew收集器生效 |
参数归参数,实际调优不能直接套默认值。举个例子,如果业务对象普遍生命周期较长,默认的SurvivorRatio=8可能就会导致Survivor区频繁放不下,触发动态年龄晋升,让对象过早进入老年代,老年代很快满了之后又触发Full GC,整个恶性循环就是这么来的。
3.2 怎么根据业务设置Survivor区大小
判断Survivor区是否合适,最直观的方法是看GC日志或jstat里的S0/S1使用率。一个比较健康的特征是:Minor GC结束后,S0/S1的使用率在30%到50%左右。如果长期接近0%,说明Survivor区可能偏大,资源有浪费但影响不算大;如果长期超过80%,说明对象存活量接近Survivor上限,很容易触发Promotion Failure或者动态晋升。
实际调大Survivor区的做法,通常不是单独调-XX:SurvivorRatio,而是配合-Xmn一起调整。比如某个服务年轻代分配了2GB,默认SurvivorRatio=8,那么S0和S1各约200MB。如果发现S0/S1使用率老是90%以上,可以尝试-Xmn3g -XX:SurvivorRatio=6,这样Eden约2.25GB,S0/S1各约375MB,既扩大了年轻代整体容量,又提高了Survivor的冗余度。
但要记住一个原则:Survivor区不是越大越好。它占的是年轻代的空间,Survivor越大,Eden就越小,新对象可分配的连续空间就越少,Minor GC频率反而可能上升。调优的本质是找平衡点,不是单维度拉满。
3.3 MaxTenuringThreshold设多少才合理
很多面试攻略喜欢背“默认15”,但在实际工作中,15这个值并不是最优解。如果你的服务中对象存活时间大部分很短,比如缓存类、状态类对象,它们可能活过一两次Minor GC就会变成垃圾,那么把它们留在Survivor区反复拷贝反而浪费CPU,不如早点晋升到老年代,减少Survivor区压力。这种情况下可以把-XX:MaxTenuringThreshold调低,比如5或6。
反过来,如果老年代空间紧张,Full GC代价很高,那就应该尽量把对象留在Survivor区,让它们在年轻代被回收掉,这时可以把阈值调高,同时确保Survivor区足够大。
这里有个隐藏点:在并发收集器(如CMS、G1)下,默认的MaxTenuringThreshold可能会被JVM自动调整,不一定就是15。我自己用jinfo -flag MaxTenuringThreshold查看时,经常发现实际值跟文档不一致。所以在排查问题时,先确认当前JVM实际生效的参数,再下结论。
4. 线上如何监控S0/S1,以及常见异常解读
4.1 jstat:最基础也最够用的查看方式
线上排查JVM问题,我第一个用的工具基本就是jstat。要看S0/S1,最直接的命令是:
jstat -gcutil <pid> 1000输出内容里:
- S0、S1表示两个Survivor区的使用率百分比。
- E表示Eden区使用率。
- O表示老年代使用率。
- M表示Metaspace使用率。
- YGC、YGCT表示Young GC次数和耗时。
- FGC、FGCT表示Full GC次数和耗时。
用这个命令连续观察几次GC周期,很快就能看出规律。比如下面这种输出:
S0 S1 E O M CCS YGC YGCT FGC FGCT 36.00 0.00 72.00 45.00 92.00 88.00 128 0.640 3 0.180 0.00 38.00 80.00 46.00 92.00 88.00 130 0.650 3 0.180注意S0和S1的数值在两次采样间发生了角色互换,第一次是S0为36%、S1为0,第二次变为S0为0、S1为38%。这说明期间发生了一次Minor GC,存活对象被复制到了另一个Survivor区。这是完全正常的现象,千万不要以为是数据异常。
4.2 jmap与VisualVM、Arthas的补充视角
jstat看的是动态使用率和GC统计,jmap -heap <pid>则能一次性输出堆的详细配置,包括年轻代分配大小、SurvivorRatio参数、Eden/S0/S1的容量等,对确认“JVM实际生效参数”很有帮助。
Arthas是线上排查的神器,它的dashboard命令会实时展示堆内存各个区域的使用情况,包括S0、S1、Eden、Old等,且不需要在启动时加额外参数,适合在容器环境里临时排查。我个人在Docker部署的Java服务里用jstat经常受限,Arthas的attach方式反而更顺滑。
VisualVM则适合本地开发环境和压测阶段,图形界面看S0/S1的使用率曲线非常直观,尤其适合观察GC后Survivor区的回落情况。
4.3 典型异常场景解读
场景一:S0/S1使用率长期为0,但Eden区回收正常。如果Survivor区几乎永远空着,说明存活对象太少,或者对象直接进了老年代。这时要看老年代增长情况,如果老年代涨得很快,可能是PretenureSizeThreshold设置得过低,或者MaxTenuringThreshold被调得很低导致过早晋升。
场景二:S0/S1交替上涨,但没有清零迹象。有可能Survivor区内有较大对象在反复拷贝。拷贝是有成本的,尤其是大对象在Survivor区之间来回搬移,会明显抬高YGCT。此时要考虑提升晋升效率,比如放宽PretenureSizeThreshold,让大对象直接走老年代。
场景三:jstat显示S0/S1极高,然后马上触发Full GC。这种往往是存活对象总量接近Survivor容量,JVM被迫走分配担保或动态晋升,大量对象涌入老年代,导致老年代空间不足。解决思路不是简单加老年代,而是先分析为什么Survivor区撑不住,是对象存活率本来就高,还是年轻代太小。
4.4 关于G1和ZGC的补充说明
说清楚一个容易踩坑的点:S0/S1这两个名字主要适用于传统的分代收集器,比如Serial、ParNew配合Parallel Old或者CMS的组合。但当你使用G1时,jstat -gcutil的输出里S0和S1通常是0,因为G1的堆组织方式完全不一样,它没有固定的连续年轻代Eden/S0/S1布局,而是用Region来动态划分Eden区、Survivor区和老年代。
G1里也有Survivor Region的概念,但数量是动态的,不再是两个固定区域。所以如果在G1环境下看到S0/S1恒为0,别慌,这不是JVM坏了,而是收集器的内存布局变了。排查G1时应该多关注E、O和G1自身日志里的survivor regions信息。ZGC更是没有传统意义上的年轻代和老年代之分,S0/S1就更无意义了。搞清楚这一点,能避免很多无谓的排查弯路。
5. 常见问题与排查技巧实录
5.1 面试最常问的S0/S1相关问题
既然这个标题对应的高频场景是JVM面试题,我就从面试官视角整理几个常见问题,顺便给出回答思路。
问题一:年轻代为什么需要两个Survivor区?
回答要点:避免内存碎片、支撑复制算法。如果只有一个Survivor区,存活对象混合后难以整理;两个区可以保证在任意时刻有一个空区作为To区,让GC后的对象按顺序紧凑复制,杜绝碎片化。还可以提一句,这是“牺牲空间换时间”的典型做法。
问题二:对象什么时候从Survivor区进入老年代?
回答要点:一是年龄达到MaxTenuringThreshold;二是动态年龄判断(同龄对象总和超过Survivor一半);三是大对象直接进老年代;四是To区空间不足时通过分配担保进入老年代。这四个点能覆盖大部分追问。
问题三:S0和S1为什么使用率会来回换?
回答要点:Minor GC结束后From和To角色互换,原To区变成下一次的From区,所以数值会从一个区跑到另一个区。理解复制算法就理解了这个问题。
问题四:Survivor区设置多大合适?
回答要点:没有固定答案,需要结合实际对象存活率和GC频率。一般要求Minor GC后Survivor使用率在30%-50%,太高则关注晋升和Full GC,太低则考虑适当缩小Survivor扩大Eden。
5.2 线上案例:一次由SurvivorRatio引发的频繁Full GC
之前排查过一个订单服务的案例。现象是接口偶发卡顿,Full GC一天几十次。用jstat看,老年代峰值也不高,但每次Full GC前都伴随S0/S1接近100%。进一步看GC日志,发现大量对象在Survivor区停留没几轮就被动态晋升到老年代,老年代空间很快被这些“不该来”的对象塞满。
当时服务启动参数里没有显式设置-XX:SurvivorRatio,默认8,年轻代总共1.5GB,算下来S0/S1各约150MB。业务高峰期瞬时并发高,大量短生命周期对象和少量中生命周期对象混在一起,Survivor区容量明显不够。调整方案是把-Xmn提到2GB,SurvivorRatio改成5,让S0/S1各约333MB,同时把MaxTenuringThreshold从15降低到8(给动态晋升留更多余地)。上线后Full GC频率降到了每天几次,接口毛刺明显改善。
这个案例想说明的是:S0/S1的问题很少单独出现,它一定和Eden大小、晋升阈值、老年代空间连在一起。排查时要按“年轻代整体容量 → Survivor容量 → 晋升阈值 → 老年代容量”这条链路走,不要只看单一指标。
5.3 排查时容易忽略的细节
第一,注意观察YGCT和对象拷贝量。jstat -gcutil看不出来,但jstat -gc会输出C gc相关的容量字段,结合GC日志里的[PSYoungGen: xxxK->yyyK可以算出每次GC后存活对象大小。如果yyyK经常接近Survivor容量,说明阈值随时可能触发。
第二,关注TargetSurvivorRatio的影响。默认值是50%,意味着JVM希望GC后Survivor区使用率不超过50%。如果对象多到超过这个比例,会加速动态晋升。有些调优文章喜欢把这个值调高到70%-80%,我个人的经验是不要开太高,因为Survivor区本来就是给“侥幸存活”的对象用的,不是给长期对象准备的,调太高反而打乱晋升节奏。
第三,官方文档和实际行为可能有差异。不同JDK版本、不同垃圾回收器组合对默认值的处理不一样,建议上线前用jinfo -flag逐个确认关键参数,别赌记忆。
6. 最后分享点个人经验
做JVM调优这些年,我越来越觉得S0和S1是理解年轻代GC的一把钥匙。很多表面上复杂的问题,比如Minor GC频繁、Full GC过早、对象晋升异常,归根到底都是Survivor区的容量和晋升策略没匹配上业务的对象存活特征。
我在实际工作中会先通过jstat -gcutil连续采样一段时间,把GC间隔、S0/S1使用率、Eden峰值画成时间线,和业务压测的QPS曲线放到一起看。这样定位起来往往比盯着单一参数改配置效率高得多。另一个小技巧是,每次上线前都打印GC日志(-Xlog:gc*在JDK 11+,或-XX:+PrintGCDetails -XX:+PrintGCDateStamps在JDK 8),线上出问题时能回看当时每个区域的实时状态,尤其是S0/S1的复制量,这对确认是不是Survivor区瓶颈非常有用。
S0和S1本身不复杂,复杂的是它和Eden、老年代、晋升策略之间的联动关系。把这层联动搞清楚,JVM内存模型这座山,你基本就算翻过去一大半了。