☰
【从0到1学习JVM · 20】宁可浪费一半内存,为什么 JVM 新生代还要用复制算法
2026/10/2 6:28:49 网站建设 项目流程

前言

上一篇聊完标记-清除算法,大家最头疼的就是满地的内存碎片。为了解决碎片,JVM 掏出了另一套思路完全不同的方案——复制算法。本文就来聊聊它怎么用空间换时间,以及为什么新生代离不开它。


文章目录

    • 前言
    • 一、标记-清除留下的烂摊子,复制算法是怎么接盘的
      • 1.1 为什么碎片会让对象分配变得极慢
      • 1.2 复制算法的核心构想是用空间换时间
    • 二、一次遍历搞定回收,复制算法的具体运作过程
      • 2.1 顺着存活链条边找边搬
      • 2.2 清除动作为什么直接变成了重置指针
      • 2.3 搬移后栈帧与引用的地址必须重写
    • 三、看似省事的复制算法,两个致命短板摆在眼前
      • 3.1 内存空间直接打五折
      • 3.2 存活率一高就会变成性能灾难
    • 四、为什么 JVM 新生代能把复制算法用到极致
      • 4.1 弱分代假说揭示对象大多朝生夕灭
      • 4.2 Appel 式内存划分把浪费压到一成
    • 写在最后

一、标记-清除留下的烂摊子,复制算法是怎么接盘的

上一篇我们仔细聊过标记-清除算法。它的逻辑非常直白:顺着GC Roots把活着的对象标出来,然后把死掉的对象就地抹掉。

逻辑虽然简单,但跑过几次 GC 之后,堆内存就会变成一块布满孔洞的奶酪。大大小小的空闲空隙夹在一堆存活对象中间,看着总空闲内存还挺大,但要是突然来一个需要连续空间的大数组,愣是塞不进去。

复制算法的回收结果

存活对象 A (16B)

存活对象 B (32B)

存活对象 C (16B)

完全连续的大片空闲内存 (无限扩展空间)

标记-清除算法的回收结果

存活对象 A (16B)

碎片空隙 (8B)

存活对象 B (32B)

碎片空隙 (12B)

存活对象 C (16B)

总空闲 20B,但分属两处,无法分配一个 16B 连续新对象

1.1 为什么碎片会让对象分配变得极慢

有同学可能会问:有碎片就有碎片呗,用链表把这些小碎片记下来,分给小对象不就行了吗?

在上一篇中我们也提到过空闲列表(Free List)。但这事在工业级的高并发系统里代价实在太高了。

如果没有碎片,内存是完全平整连续的。JVM 只需要在空闲区边界维护一个指针。每当有新对象创建,只需要把指针往空闲方向挪动一个对象大小的距离,这就叫指针碰撞(Bump the Pointer)。两条汇编指令就能搞定一次内存分配,速度和在栈上移动栈顶指针差不多快。

但一旦有了碎片,JVM 就不能用指针碰撞了。它必须拿着空闲列表去遍历,找一块“尺寸刚巧放得下”的空闲块。如果是首次适应算法(First Fit),要从头扫链表;如果是最佳适应算法(Best Fit),更要把所有空闲块比一遍。这还没算并发分配时的加锁竞争。高并发下一秒钟创建成千上万个对象,光是找空闲块就会把 CPU 耗干。

1.2 复制算法的核心构想是用空间换时间

既然整理碎片那么费劲,能不能干脆不整理?

1969 年,计算机科学家 Fenichel 和 Yochelson 提出了半区复制算法(Semispace Copying)。它的思路非常暴力,这就是典型的“用空间换时间”。

它的基本假设很简单:
既然在一块内存里修修补补容易把地皮踩烂,那我就直接把内存一分为二。一块叫 From 空间,另一块叫 To 空间。平时写代码分配对象,全塞在 From 空间里,To 空间空着放在一边看戏。

等哪天 From 空间塞满了,垃圾回收器进场。它根本不费心思去就地清理垃圾,而是直接把 From 空间里所有还活着的对象,一个个原封不动地搬到 To 空间里去,紧挨着从头排到尾。

搬迁完毕之后,原来的 From 空间彻底作废,直接整块清零;接着 From 和 To 互换身份,原来的 To 变成新的 From,继续对外分配对象。

内存平整了,碎片消失了,后续新对象的分配重新恢复成极速的指针碰撞。听起来是不是特别解气?

二、一次遍历搞定回收,复制算法的具体运作过程

要真正理解复制算法的高效,必须看它在回收时到底省掉了哪些步骤。

对比标记-清除算法,复制算法最明显的特征是:它把标记和转移合二为一,回收时对整个存活对象集只进行一次遍历。

To 内存区 (完全空闲)From 内存区 (正在使用)GC 垃圾收集线程JVM 控制器业务线程To 内存区 (完全空闲)From 内存区 (正在使用)GC 垃圾收集线程JVM 控制器业务线程发现对象 A 可达:立即深拷贝至 To 区,指针连续递增发现对象 B 可达:紧接着对象 A 之后写入 To 区遇到不可达对象(垃圾):直接跳过,不做任何处理持续创建新对象,From 区触发容量阈值1发起 STW (Stop The World),所有业务线程停在安全点2启动复制算法回收3顺着 GC Roots 遍历可达对象4全部存活对象搬迁完毕,From 区指针直接重置为 05互换 From 与 To 逻辑角色6解除 STW 暂停,业务线程恢复执行7

2.1 顺着存活链条边找边搬

在触发STW之后,GC 线程从GC Roots出发。

在传统的标记-清除算法里,第一遍遍历只是去改对象头的Mark Word标记位;第二遍再从堆底线性扫到堆顶,把没标记的垃圾清掉。

而在复制算法里,GC 线程只要顺着引用链摸到一个存活对象,顺手就在 To 空间里划出一块对应大小的连续内存,把这个对象的数据按字节原样拷贝过去。

因为只关心活着的对象,所有已经死掉的对象在遍历时直接被忽略。这省去了大量的判断与扫描开销。堆里要是有 100 万个对象,其中 99 万个都是垃圾,GC 线程根本不需要把这 99 万个对象逐个摸一遍,它只需要把那 1 万个幸存者找出来运走就行。

2.2 清除动作为什么直接变成了重置指针

这是复制算法最爽快的地方。

当所有存活对象都被复制到 To 空间之后,原来的 From 空间里留下的全都是垃圾对象。这时候 JVM 需要像标记-清除算法那样,写个循环去把每个死对象的内存逐字节擦拭或者放进空闲链表吗?

完全不需要。

在操作系统和底层内存管理看来,数据根本不需要物理抹除。JVM 只需要把指向 From 空间的内存起始分配指针直接“啪”地一下拉回到开头(或者重置分区元数据)。下一次这个区域再启用时,写入的新数据直接覆盖旧数据。

用代码来模拟,这中间的开销仅仅是一次指针赋值:

packagecom.crayontech.gc;/** * 简易模拟半区复制算法的指针重置机制 */publicclassCopyingAllocator{privatefinalbyte[]memoryPool;privatefinalintsemiSpaceCapacity;// from 区与 to 区的边界起点privateintfromStart;privateinttoStart;// 当前在 from 区分配对象的指针偏移量privateintallocatePointer;publicCopyingAllocator(inttotalBytes){this.memoryPool=newbyte[totalBytes];this.semiSpaceCapacity=totalBytes/2;this.fromStart=0;this.toStart=semiSpaceCapacity;this.allocatePointer=fromStart;}/** * 极速的指针碰撞分配 */publicsynchronizedintallocate(intobjectSize){if(allocatePointer+objectSize>fromStart+semiSpaceCapacity){// 空间不足,触发 GC 搬迁performCopyingGc();}intallocatedAddress=allocatePointer;allocatePointer+=objectSize;returnallocatedAddress;}/** * 模拟复制回收:清空原 From 空间只需要重置指针 */privatevoidperformCopyingGc(){// 1. 将所有存活对象拷贝至 toStart 指向的区域(此处省略模拟拷贝细节)intcopiedBytes=1024;// 假设存活对象仅占 1024 字节// 2. 交换 From 与 To 的角色inttemp=fromStart;fromStart=toStart;toStart=temp;// 3. 关键点:原本被占满的旧区域无需清理内部数据,重置指针即可完成逻辑释放this.allocatePointer=fromStart+copiedBytes;}}

没有循环遍历垃圾,没有内存碎片维护,清理动作退化成了一个常量时间复杂度O ( 1 ) O(1)O(1)的指针重置。

2.3 搬移后栈帧与引用的地址必须重写

天下没有免费的午餐。既然对象挪了窝,物理内存地址必然发生改变。

在 Java 里,对象之间的相互引用、线程栈帧中局部变量表里的对象引用,都是通过直接内存地址(或者压缩指针)挂接的。

当对象从 From 空间的0x1000搬移到了 To 空间的0x2000时,所有原本指向0x1000的指针必须全部同步修改为0x2000。

搬迁后

引用指针必须更新修正

堆 To 区: User 对象 (0x2000)

线程栈局部变量表 Slot 1

搬迁前

地址: 0x1000

线程栈局部变量表 Slot 1

堆 From 区: User 对象 (0x1000)

如果这个对象被很多其他对象引用,或者当前运行的多个线程栈帧里都有变量指向它,JVM 就必须去遍历这些引用点并完成指针重写。这也是为什么在垃圾回收期间必须强力执行STW:如果在地址还没修正完的时候让业务线程跑,业务线程顺着旧地址读写,就会直接读出错误数据甚至引发内存访问段错误。

三、看似省事的复制算法,两个致命短板摆在眼前

虽然消除了碎片,但半区复制算法在很长一段时间里被很多人诟病。原因就在于它的两个先天缺陷极其刺眼。

3.1 内存空间直接打五折

最直观的痛点就是浪费。

假设你给 JVM 分配了 4GB 堆内存,采用经典的一对一复制算法:

  • 2GB 作为 From 空间放对象;
  • 剩下的 2GB 作为 To 空间只能常年空着。

这就相当于你花 10000 块钱买了一台电脑,里面有 32GB 内存条,但操作系统告诉你平时只能用 16GB,另一半必须留着做保洁。对于资源寸土寸金的服务器来说,直接打五折的有效利用率任谁看了都心疼。

3.2 存活率一高就会变成性能灾难

另一个致命问题在于复制的边际成本。

复制算法的高效,建立在一个严苛的前提之下:每次垃圾回收时,活着的对象必须非常少。

设想一下极端场景:如果某块内存里的对象存活率高达 95% 甚至 100%(比如老年代里常驻的大量缓存、Spring 单例 Bean、线程池核心对象):

  1. GC 线程必须把这 95% 的对象原封不动地在内存里拷贝一遍;
  2. 每一个搬迁的对象都要重新计算新地址,并在栈帧和外部引用里挨个修正指针;
  3. 原本期望的“快速搬迁”变成了大规模的内存 memcpy 搬砖劳动。

在这种情况下,复制算法的执行时间会随着存活对象的增加而线性暴增,效率甚至远不如带着碎片的标记-清除或者后面会讲的标记-整理算法。

四、为什么 JVM 新生代能把复制算法用到极致

既然缺点这么明显,为什么几乎所有现代 JVM(包括 HotSpot 的 Serial、ParNew、Parallel Scavenge、甚至 G1 内部的 Young 区域)在新生代垃圾回收上,都不约而同地选择了复制算法?

答案是:JVM 设计者巧妙地利用了对象的生死规律,把复制算法的短板彻底规避了。

4.1 弱分代假说揭示对象大多朝生夕灭

IBM 曾做过一项著名的专门研究:在绝大多数企业级商业应用里,新生代里创建出来的对象,超过 98% 都是朝生夕灭的。

这就是著名的弱分代假说(Weak Generational Hypothesis)。

在业务系统里,大量的代码都是类似下面这种形态:

packagecom.crayontech.gc;importjava.util.UUID;publicclassOrderQueryService{/** * 典型的接口处理方法:内部创建大量短命对象 */publicStringprocessOrderRequest(StringorderId){// StringBuilder 临时对象StringBuildersb=newStringBuilder("REQ_");sb.append(orderId).append("_").append(UUID.randomUUID());// 临时的校验器与上下文 DTOValidationContextctx=newValidationContext(sb.toString());booleanpassed=ctx.validate();// 方法返回后,方法栈帧弹出,上述所有局部变量指向的堆对象立即变成垃圾returnpassed?"SUCCESS":"FAILED";}privatestaticclassValidationContext{privatefinalStringtoken;publicValidationContext(Stringtoken){this.token=token;}publicbooleanvalidate(){returntoken!=null&&token.length()>5;}}}

每来一次 HTTP 请求,Spring 控制器、Service 层会产生几十上百个临时 DTO、StringBuilder、局部变量包装对象。这些对象在方法执行结束、栈帧退出的那一瞬间,就彻底与GC Roots断开了联系。

当新生代空间被撑满触发 GC 时,实际存活下来的对象往往连 2% 都不到。

既然只有 2% 的幸存者,复制算法“只搬活对象、不理死对象”的特性简直就是为新生代量身定制的武器!搬迁这 2% 的对象耗时极短,随后整块区域直接清零,速度快到飞起。

4.2 Appel 式内存划分把浪费压到一成

解决了“存活率高不高”的问题,剩下的就是“内存打五折”的痛点了。

HotSpot 虚拟机的设计者并没有死板地按照 1:1 去划分 From 和 To。他们借鉴了计算机科学家 Andrew Appel 的设计思想,提出了一种更加精妙的分区方案:Appel 式回收(Appel’s Collector)。

HotSpot 把新生代切成了三块:

  1. 一块容量巨大的Eden 区(伊甸园区,占 80% 空间);
  2. 两块面积较小且等大的Survivor 区(幸存者区,分别叫 From Survivor / S0,以及 To Survivor / S1,各占 10% 空间)。
渲染错误:Mermaid 渲染失败: Parse error on line 2: ... subgraph 新生代物理空间划分 (90% 可用率) Ed -----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'GRAPH', 'DIR', 'subgraph', 'SQS', 'end', 'AMP', 'COLON', 'START_LINK', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'UP', 'NUM', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'PS'

平时应用程序在干什么?
所有新new出来的对象全部往 Eden 区塞;S0 区域里放着前几轮 Minor GC 幸存下来的对象;而 S1 区域安安静静地空着。

这时新生代总共可用的内存空间是多少?
是Eden(80%) + S0(10%) = 90%!
只有 S1(10%)作为闲置的搬迁目的地。原本令人肉痛的 50% 内存浪费,被硬生生压缩到了区区 10%。

当 Eden 和 S0 塞满时,GC 启动:

  1. GC 线程把 Eden 和 S0 中全部存活的对象,一股脑复制进空的 S1 区;
  2. 清空 Eden 和 S0,S1 里存活对象的年龄全部加 1;
  3. S0 与 S1 角色互换,原本的 S1 变成下一次分配备用的 S0。

万一哪天倒霉,遇到极端情况,存活对象超过了 S1 能承受的 10% 上限怎么办?

JVM 早就留好了退路:分配担保机制(Handle Promotion)。一旦 S1 区装不下这轮存活下来的对象,多出来的对象直接越级保送到老年代去,绝不会因为空间不够而发生崩溃。

通过这一套组合拳,JVM 既享受了复制算法“零碎片、分配快、只扫存活者”的极致性能,又把内存浪费控制在极低水平。

写在最后

从标记-清除的碎片妥协,到复制算法的空间换时间,垃圾回收算法的演进从来都不是非黑即白的单选题。

复制算法在理论上有着浪费空间、在长寿命对象面前力不从心的硬伤,但只要把它安插在“朝生夕灭”的新生代,再配上 Eden 与 Survivor 的空间微调,缺陷就转化成了最锋利的武器。技术架构的精妙,往往不在于追求单点的完美,而在于为它找到最合适的着陆场。

如果你在准备面试或者梳理 JVM 体系,记得顺手点个关注。下一篇,我们来聊聊老年代究竟靠什么算法扛起大梁。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询