前言
上一篇聊完标记-清除算法,大家最头疼的就是满地的内存碎片。为了解决碎片,JVM 掏出了另一套思路完全不同的方案——复制算法。本文就来聊聊它怎么用空间换时间,以及为什么新生代离不开它。
文章目录
- 前言
- 一、标记-清除留下的烂摊子,复制算法是怎么接盘的
- 1.1 为什么碎片会让对象分配变得极慢
- 1.2 复制算法的核心构想是用空间换时间
- 二、一次遍历搞定回收,复制算法的具体运作过程
- 2.1 顺着存活链条边找边搬
- 2.2 清除动作为什么直接变成了重置指针
- 2.3 搬移后栈帧与引用的地址必须重写
- 三、看似省事的复制算法,两个致命短板摆在眼前
- 3.1 内存空间直接打五折
- 3.2 存活率一高就会变成性能灾难
- 四、为什么 JVM 新生代能把复制算法用到极致
- 4.1 弱分代假说揭示对象大多朝生夕灭
- 4.2 Appel 式内存划分把浪费压到一成
- 写在最后
一、标记-清除留下的烂摊子,复制算法是怎么接盘的
上一篇我们仔细聊过标记-清除算法。它的逻辑非常直白:顺着GC Roots把活着的对象标出来,然后把死掉的对象就地抹掉。
逻辑虽然简单,但跑过几次 GC 之后,堆内存就会变成一块布满孔洞的奶酪。大大小小的空闲空隙夹在一堆存活对象中间,看着总空闲内存还挺大,但要是突然来一个需要连续空间的大数组,愣是塞不进去。
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,继续对外分配对象。
内存平整了,碎片消失了,后续新对象的分配重新恢复成极速的指针碰撞。听起来是不是特别解气?
二、一次遍历搞定回收,复制算法的具体运作过程
要真正理解复制算法的高效,必须看它在回收时到底省掉了哪些步骤。
对比标记-清除算法,复制算法最明显的特征是:它把标记和转移合二为一,回收时对整个存活对象集只进行一次遍历。
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。
如果这个对象被很多其他对象引用,或者当前运行的多个线程栈帧里都有变量指向它,JVM 就必须去遍历这些引用点并完成指针重写。这也是为什么在垃圾回收期间必须强力执行STW:如果在地址还没修正完的时候让业务线程跑,业务线程顺着旧地址读写,就会直接读出错误数据甚至引发内存访问段错误。
三、看似省事的复制算法,两个致命短板摆在眼前
虽然消除了碎片,但半区复制算法在很长一段时间里被很多人诟病。原因就在于它的两个先天缺陷极其刺眼。
3.1 内存空间直接打五折
最直观的痛点就是浪费。
假设你给 JVM 分配了 4GB 堆内存,采用经典的一对一复制算法:
- 2GB 作为 From 空间放对象;
- 剩下的 2GB 作为 To 空间只能常年空着。
这就相当于你花 10000 块钱买了一台电脑,里面有 32GB 内存条,但操作系统告诉你平时只能用 16GB,另一半必须留着做保洁。对于资源寸土寸金的服务器来说,直接打五折的有效利用率任谁看了都心疼。
3.2 存活率一高就会变成性能灾难
另一个致命问题在于复制的边际成本。
复制算法的高效,建立在一个严苛的前提之下:每次垃圾回收时,活着的对象必须非常少。
设想一下极端场景:如果某块内存里的对象存活率高达 95% 甚至 100%(比如老年代里常驻的大量缓存、Spring 单例 Bean、线程池核心对象):
- GC 线程必须把这 95% 的对象原封不动地在内存里拷贝一遍;
- 每一个搬迁的对象都要重新计算新地址,并在栈帧和外部引用里挨个修正指针;
- 原本期望的“快速搬迁”变成了大规模的内存 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 把新生代切成了三块:
- 一块容量巨大的Eden 区(伊甸园区,占 80% 空间);
- 两块面积较小且等大的Survivor 区(幸存者区,分别叫 From Survivor / S0,以及 To Survivor / S1,各占 10% 空间)。
平时应用程序在干什么?
所有新new出来的对象全部往 Eden 区塞;S0 区域里放着前几轮 Minor GC 幸存下来的对象;而 S1 区域安安静静地空着。
这时新生代总共可用的内存空间是多少?
是Eden(80%) + S0(10%) = 90%!
只有 S1(10%)作为闲置的搬迁目的地。原本令人肉痛的 50% 内存浪费,被硬生生压缩到了区区 10%。
当 Eden 和 S0 塞满时,GC 启动:
- GC 线程把 Eden 和 S0 中全部存活的对象,一股脑复制进空的 S1 区;
- 清空 Eden 和 S0,S1 里存活对象的年龄全部加 1;
- S0 与 S1 角色互换,原本的 S1 变成下一次分配备用的 S0。
万一哪天倒霉,遇到极端情况,存活对象超过了 S1 能承受的 10% 上限怎么办?
JVM 早就留好了退路:分配担保机制(Handle Promotion)。一旦 S1 区装不下这轮存活下来的对象,多出来的对象直接越级保送到老年代去,绝不会因为空间不够而发生崩溃。
通过这一套组合拳,JVM 既享受了复制算法“零碎片、分配快、只扫存活者”的极致性能,又把内存浪费控制在极低水平。
写在最后
从标记-清除的碎片妥协,到复制算法的空间换时间,垃圾回收算法的演进从来都不是非黑即白的单选题。
复制算法在理论上有着浪费空间、在长寿命对象面前力不从心的硬伤,但只要把它安插在“朝生夕灭”的新生代,再配上 Eden 与 Survivor 的空间微调,缺陷就转化成了最锋利的武器。技术架构的精妙,往往不在于追求单点的完美,而在于为它找到最合适的着陆场。
如果你在准备面试或者梳理 JVM 体系,记得顺手点个关注。下一篇,我们来聊聊老年代究竟靠什么算法扛起大梁。