
前言上一篇聊完标记-清除算法大家最头疼的就是满地的内存碎片。为了解决碎片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 暂停业务线程恢复执行72.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.memoryPoolnewbyte[totalBytes];this.semiSpaceCapacitytotalBytes/2;this.fromStart0;this.toStartsemiSpaceCapacity;this.allocatePointerfromStart;}/** * 极速的指针碰撞分配 */publicsynchronizedintallocate(intobjectSize){if(allocatePointerobjectSizefromStartsemiSpaceCapacity){// 空间不足触发 GC 搬迁performCopyingGc();}intallocatedAddressallocatePointer;allocatePointerobjectSize;returnallocatedAddress;}/** * 模拟复制回收清空原 From 空间只需要重置指针 */privatevoidperformCopyingGc(){// 1. 将所有存活对象拷贝至 toStart 指向的区域此处省略模拟拷贝细节intcopiedBytes1024;// 假设存活对象仅占 1024 字节// 2. 交换 From 与 To 的角色inttempfromStart;fromStarttoStart;toStarttemp;// 3. 关键点原本被占满的旧区域无需清理内部数据重置指针即可完成逻辑释放this.allocatePointerfromStartcopiedBytes;}}没有循环遍历垃圾没有内存碎片维护清理动作退化成了一个常量时间复杂度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、线程池核心对象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 临时对象StringBuildersbnewStringBuilder(REQ_);sb.append(orderId).append(_).append(UUID.randomUUID());// 临时的校验器与上下文 DTOValidationContextctxnewValidationContext(sb.toString());booleanpassedctx.validate();// 方法返回后方法栈帧弹出上述所有局部变量指向的堆对象立即变成垃圾returnpassed?SUCCESS:FAILED;}privatestaticclassValidationContext{privatefinalStringtoken;publicValidationContext(Stringtoken){this.tokentoken;}publicbooleanvalidate(){returntoken!nulltoken.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% 空间。渲染错误: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 区域安安静静地空着。这时新生代总共可用的内存空间是多少是Eden80% S010% 90%只有 S110%作为闲置的搬迁目的地。原本令人肉痛的 50% 内存浪费被硬生生压缩到了区区 10%。当 Eden 和 S0 塞满时GC 启动GC 线程把 Eden 和 S0 中全部存活的对象一股脑复制进空的 S1 区清空 Eden 和 S0S1 里存活对象的年龄全部加 1S0 与 S1 角色互换原本的 S1 变成下一次分配备用的 S0。万一哪天倒霉遇到极端情况存活对象超过了 S1 能承受的 10% 上限怎么办JVM 早就留好了退路分配担保机制Handle Promotion。一旦 S1 区装不下这轮存活下来的对象多出来的对象直接越级保送到老年代去绝不会因为空间不够而发生崩溃。通过这一套组合拳JVM 既享受了复制算法“零碎片、分配快、只扫存活者”的极致性能又把内存浪费控制在极低水平。写在最后从标记-清除的碎片妥协到复制算法的空间换时间垃圾回收算法的演进从来都不是非黑即白的单选题。复制算法在理论上有着浪费空间、在长寿命对象面前力不从心的硬伤但只要把它安插在“朝生夕灭”的新生代再配上 Eden 与 Survivor 的空间微调缺陷就转化成了最锋利的武器。技术架构的精妙往往不在于追求单点的完美而在于为它找到最合适的着陆场。如果你在准备面试或者梳理 JVM 体系记得顺手点个关注。下一篇我们来聊聊老年代究竟靠什么算法扛起大梁。