上一篇理清楚了怎么判断一个对象是不是垃圾。但判断出来之后呢怎么把这些垃圾对象占的内存腾出来腾出来的过程中会不会有副作用读完几种垃圾回收算法之后我发现没有一种算法是完美的各有各的短板。JVM 的解法不是选一个最好的而是用分代的思路把三种算法组合起来让各自在合适的场景下发挥优势。这篇就写写我理解的过程。标记-清除最直观的思路但留下了碎片先想最简单的方式标记出哪些对象是垃圾然后一个一个清掉。这就是标记-清除Mark-Sweep算法的基本思路。它分两步走第一步标记。从上篇说的 GC Roots 出发做一遍可达性分析把所有还活着的对象打个标。没被打标的就是垃圾。第二步清除。把所有没打标的对象占用的内存空间标记为空闲放回空闲列表里。听起来很简单对吧但它有两个明显的缺点。缺点一效率不高标记和清除两个阶段都要遍历整个堆。标记阶段要从 GC Roots 出发遍历所有存活对象。清除阶段要遍历整个堆找到所有死亡对象。堆越大这两个遍历就越慢。而且标记和清除两个阶段之间不能穿插其他工作程序得暂停着等它干完。缺点二内存碎片这是更致命的问题。清除之后的堆空间不是连续的。对象 A 没了留下一块缝对象 B 没了留下另一块缝。这些缝分布在堆的各处大小不一。当你需要分配一个大对象比如一个很大的数组时虽然总的空闲内存够用但没有一块连续的空隙能装下它。JVM 只能提前触发下一次 GC。如果 GC 之后还是凑不出连续空间那就 OutOfMemoryError 了。我刚开始理解碎片问题的时候觉得不就是一些碎片嘛多一点少一点有什么关系后来看到一个具体的例子才明白假设堆里有 10 MB 空闲空间但最大的一块只有 1 MB。这时候要分配一个 2 MB 的对象即使总的空闲空间远大于 2 MB也分配不了。碎片问题的本质不是总量不够是连续空间不够。复制算法消灭了碎片但浪费了一半内存既然标记-清除有碎片问题那就换个思路不原地清除而是把活着的对象搬到一个新地方然后整块扔掉旧地方。这就是复制Copying算法的思路。它的做法是把堆内存分成两块一样大小的区域叫 From 和 To。平时只用 From 区分配对象。当 From 区满了触发一次 GC把 From 区里所有还存活的对象一个一个复制到 To 区复制完成后把 From 区整个清空交换 From 和 To 的身份这个做法有一个很明显的好处完全没有碎片。因为所有存活对象都被紧凑地排列在了 To 区的一端空闲空间是连续的整块。但它也有一个同样明显的坏处你只能用到一半的内存。另一半 To 区虽然存在但在下一次 GC 之前完全是空的。如果堆总共有 100 MB你实际能用的只有 50 MB。我第一次读到这个的时候觉得这也太浪费了吧为了消除碎片牺牲一半内存后来才理解这个算法其实不是在全部内存上用的而是在一个特定的场景下用的新生代。新生代里的对象有一个特点大部分都活不过第一次 GC“朝生夕死”。这意味着每次复制的时候需要搬的对象很少复制成本很低。用一半空间的代价换来无碎片的快速回收这笔账是划算的。标记-整理存活率高的场景下复制就不行了复制算法有一个前提假设每次 GC 时死亡对象多、存活对象少。但如果反过来呢比如老年代里面的对象已经在多次 GC 中存活下来了存活率很高。这时用复制算法要把大部分对象都搬一遍复制成本很高。这就引出了第三种算法标记-整理Mark-Compact。它的标记阶段跟标记-清除一样从 GC Roots 出发做可达性分析标记存活对象。但清除阶段不一样它不直接清除垃圾而是先把所有存活对象移动到堆的一端然后清理掉边界之外的所有内存。这样做的好处是既消除了碎片存活对象被紧凑排列在了一端又不会浪费一半内存不需要预留一块空闲区域。代价是移动对象的开销。如果存活对象很多移动它们需要时间。而且移动过程中对象引用地址会变JVM 需要把所有引用这些对象的地方都更新一遍。这个更新引用的过程也需要遍历整个堆。标记-清除、复制、标记-整理三种算法的核心取舍可以总结成一个三角关系无碎片 ──────── 复制算法 │ │ │ │ │ 标记-整理 │ │ │ 不浪费空间 ──── 标记-清除标记-清除不浪费空间不需要预留一半但有碎片复制算法无碎片但浪费一半空间标记-整理无碎片 不浪费空间但要移动对象有性能开销没有一个是完美的。那 JVM 怎么选分代回收不选一个而是用组合JVM 的答案是不选一个算法而是根据对象的年龄分区域每个区域用最合适的算法。这个思路基于一个被大量实践验证的观察大多数对象活不久。比如你在一个方法里new一个临时对象方法执行完这个对象就没人引用了。只有少数对象会存活很久比如全局的配置对象、缓存对象、单例对象。基于这个观察JVM 把堆分成了两个主要区域新生代Young Generation放刚创建的对象。这里的对象特点是存活率低大部分活不过第一轮 GC。新生代用复制算法。因为大部分对象都会在 GC 时死掉需要复制的存活对象很少复制成本很低。用一半空间碎片换复制效率在这里是划算的。新生代内部也不是简单的一分为二而是分成了三块一个较大的Eden 区和两个较小的Survivor 区from 和 to。比例一般是 8:1:1。新对象先在 Eden 区分配Eden 区满了触发 Minor GC把 Eden Survivor from 里存活的对象复制到 Survivor to清空 Eden 和 Survivor from交换 from 和 to 的角色这样算下来每次 GC 实际浪费的空间只有 10%一个 Survivor 区而不是复制算法原生的一半。这个优化我第一次看到的时候觉得挺妙的用两个小区域代替一个大区域把空间浪费从 50% 降到了 10%。每次熬过一轮 GC对象的年龄就 1。当年龄超过一个阈值默认 15可以通过-XX:MaxTenuringThreshold设置对象就从新生代升入老年代。老年代Old Generation / Tenured放多次 GC 后仍然存活的对象。这里的对象特点是存活率高生命周期长。老年代用标记-清除或标记-整理算法。因为这里对象存活率高用复制算法的话每次都要复制大量对象成本太高。标记-清除和标记-整理虽然慢一些但不需要移动大量存活对象标记-整理需要移动但次数少得多。为什么这个组合能行分代回收的精髓是用算法的特点去匹配区域的特点区域对象特点选用算法原因新生代存活率低、变化快复制算法复制成本低碎片少老年代存活率高、变化慢标记-清除/整理避免大量复制空间利用率高每种算法都有短板但把短板放对场景短板就不再是问题了。复制算法浪费空间但用在新生代大部分对象都死了浪费一个 Survivor 完全能接受。标记-清除有碎片但用在老年代这里对象变化慢碎片问题不那么频繁。标记-整理有移动开销但用在老年代GC 次数少每次 GC 时移动一次的开销可以接受。我读到这里的时候感觉这个设计思路挺值得记下来的不是找一个银弹解决所有问题而是把不同的问题分拆开用不同的工具去处理。分治思想在系统设计里随处可见。把流程串一遍结合上一篇讲的判断垃圾的方法GC 的整体流程是这样的分配对象。新对象优先在新生代 Eden 区分配。如果 Eden 区空间不够触发一次 Minor GC。Minor GC。从 GC Roots 出发做可达性分析标记 Eden 和 Survivor from 里的存活对象。用复制算法把存活对象复制到 Survivor to。清空 Eden 和 Survivor from。存活对象的年龄 1。如果年龄超过阈值升入老年代。进入老年代。熬过多轮 GC 的对象进入老年代。大对象比如长字符串、大数组也可能直接在老年代分配因为复制大对象在新生代成本太高。Major GC / Full GC。老年代空间不足时触发。老年代用标记-清除或标记-整理算法回收。如果老年代回收后空间还不够抛出 OutOfMemoryError。Minor GC 和 Full GC 的差别不仅仅在频率上。Minor GC 很快因为要处理的对象不多而且用的是复制算法。Full GC 很慢因为老年代对象多、算法复杂。所以要尽量避免 Full GC。常见的优化方向就是调整新生代和老年代的大小比例、调整对象升入老年代的年龄阈值让大部分对象在新生代就处理掉。理完之后我的几点感受1. 没有完美的算法只有合适的场景。三种垃圾回收算法各有各的短板。JVM 的解法不是找最优解而是用分治思路把问题拆开让每个算法在适合它的场景下干活。2. 分代回收的洞察来自于对对象生命周期的观察。说大多数对象朝生夕死这句话只需要一秒钟但这个观察是 JVM 团队在实际运行大量应用之后得出的统计结论。分代回收有效前提是这个观察是正确的。3. 8:1:1 的 Survivor 比例是一个优化过的权衡。如果用复制算法的原生实现1:1会浪费 50% 的空间。JVM 通过两个小 Survivor 区把浪费降到 10%同时保证了复制效率。这个比例不是拍脑袋定的是实际测试调出来的。4. 了解这些算法的意义不只是面试。之前遇到过一个 OutOfMemoryError 的问题dmesg 看日志发现触发了 Full GC 但回收不掉多少空间。如果当时知道老年代用的什么算法、为什么会回收不掉排查方向会清晰很多。如果你也在学这些概念建议用一个具体的例子在脑子里跑一遍一个对象从 new 出来到进入老年代经历过几次 GC、每次经历了什么算法、对象的内存地址怎么变化。把这个流程走通了后面的 GC 调优和收集器原理上手就会快很多。一个后端小白的 JVM 学习笔记接上篇《刚学 JVM GC 的时候我在这些概念上卡了好久》垃圾回收算法定义与边界讲 JVM 垃圾回收所使用的具体算法包括标记-清除、复制算法、标记-整理、分代回收。涵盖各算法原理、优缺点、适用场景。不包括 GC 定义、对象存活判定、具体收集器实现。内容归属标记-清除算法标记清除算法的缺点是什么主要缺点有两个• 一个是效率问题标记和清除过程的效率都不高• 另外一个是空间问题标记清除之后会产生大量不连续的内存碎片空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。各算法详解垃圾回收算法有哪些• 标记-清除算法标记-清除算法分为标记和清除两个阶段首先通过可达性分析标记出所有需要回收的对象然后统一回收所有被标记的对象。标记-清除算法有两个缺陷一个是效率问题标记和清除的过程效率都不高另外一个就是清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。• 复制算法为了解决碎片空间的问题出现了复制算法。复制算法的原理是将内存分成两块每次申请内存时都使用其中的一块当内存不够时将这一块内存中所有存活的对象复制到另一块上然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时都只能使用一半的内存空间。内存利用率严重不足。• 标记-整理算法复制算法在 GC 之后存活对象较少的情况下效率比较高但如果存活对象比较多时会执行较多的复制操作效率就会下降。而老年代的对象在 GC 之后的存活率就比较高所以就有人提出了标记-整理算法。标记-整理算法的标记过程与标记-清除算法的标记过程一致但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。• 分代回收算法分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期或者说经历过的 GC 次数。对象创建时一般在新生代申请内存当经历一次 GC 之后如果对还存活那么对象的年龄 1。当年龄超过一定值(默认是 15可以通过参数 -XX:MaxTenuringThreshold 来设定)后如果对象还存活那么该对象会进入老年代。算法在 GC 机制中的应用以下内容摘自java的gc 机制你知道哪些然后是具体的回收算法。最基础的有标记 - 清除先标记所有可回收对象然后直接清理它们的内存。但这样会产生很多内存碎片后续分配大对象时可能找不到连续空间。为了解决碎片问题有标记 - 整理标记后不是直接清除而是把存活对象往一端移动然后清理边界外的内存这样内存更整齐但移动对象会有性能开销。还有复制算法把内存分成两块每次只用其中一块回收时把存活对象复制到另一块然后清空当前块。这种方式没有碎片但会浪费一半内存适合存活对象少的场景比如新生代。实际中JVM 会分代使用这些算法。因为对象的生命周期不同新生代刚创建的对象存活率低老年代存活久的对象存活率高。新生代一般用复制算法分成 Eden 区和两个 Survivor 区比如 8:1:1新对象先放 Eden满了就触发 Minor GC把存活对象复制到一个 Survivor多次存活后移到老年代。老年代用标记 - 清除或标记 - 整理因为对象存活率高复制成本太高。