垃圾回收算法(四种)

  • 垃圾回收需要找到内存中存活的对象
  • 还需要释放不再存活的对象, 使程序可以再次利用这部分空间
  1. 标记-清除算法
  2. 复制算法
  3. 标记-整理算法
  4. 分代GC
  • 垃圾回收会有单独的GC线程来完成, 但是不管哪一种GC算法, 都会有部分阶段需要停止所有用户线程, 称之为Stop The World, 简称STW, 如果STW的时间过长, 则会影响用户使用
  • 为什么一定需要STW?
    • 分析工作必须在能够确保一致性的快照中进行
    • 一致性指整个分析期间系统被冻结在某个时间点上
    • 如果分析过程中对象的引用关系还在不断地变化, 那么分析的准确性就没有办法保证
    • 如果不暂停业务线程的话, 在垃圾回收期间新创建的对象会被错误的回收, 这是因为业务线程和垃圾回收线程都是并行执行的
  • 评价标准:
    1. 吞吐量: CPU用于执行用户代码的时间与CPU总执行时间的比值, 吞吐量=执行用户代码的时间/(执行用户代码的时间+GC时间), 吞吐量越高表示垃圾回收效率越高
    2. 最大暂停时间: 垃圾回收过程中STW时间的最大值, 越小越好
    3. 堆使用效率: 不同垃圾回收算法, 堆内存使用效率不同. 比如标记清除算法, 可以完整的使用堆内存, 复制算法会将堆内存一分为二, 每次只能使用一半内存. 从堆使用效率上来说, 标记清除算法要优于复制算法
  • 三者不可兼得, 堆内存越大, 最大暂停时间就越大, 减少最大暂停时间, 就会降低吞吐量

标记-清除算法

两个阶段

  1. 标记阶段: 将所有存活对象标记, 使用可达性算法, 从GC Root开始通过引用链遍历出所有的存活对象
  2. 清除阶段: 从内存中删除没有被标记, 也就是非存活对象
  • 优点: 实现简单, 只需要在第一阶段维护每个对象的标志位, 第二阶段删除即可
  • 缺点:
    1. 碎片化问题: 内存是连续的, 所以对象被删除之后, 内存中可能会出现很多细小的可用内存单元. 如果我们需要一个比较大的空间, 这些内存单元会无法进行分配
    2. 分配速度比较慢:因为内存碎片存在, 需要维护一个空闲链表, 有可能发生每次遍历链表的最后才能获得合适的内存空间

复制算法

核心思想

  1. 准备两块空间FromTo空间, 每次在对象分配阶段, 只能使用其中一块空间(From空间)
  2. GC阶段将From中存活的对象复制到To空间中
  3. 将两块空间的名字互换, 也就是FromTo互换名字, 因为只有From空间存储对象
  • 完整复制算法
    1. 将堆内存分隔成两块From空间和To空间, 对象分配阶段, 创建对象
    2. GC阶段开始, 将GC Root搬运到To空间
    3. GC Root关联的对象, 搬运到To空间, From空间剩下的是没有被GC Root关联的对象了
    4. 清理From空间, 并把名称互换
  • 优点:
    1. 吞吐量高, 只需要遍历一次存活对象并复制到To空间即可, 比标记-整理算法少了一次遍历过程. 但是性能比标记-清除算法低, 因为标记-清除算法不需要进行对象的移动
    2. 不会出现碎片化的问题, 复制算法复制后会有序地排列对象, 所以不会出现内存碎片
  • 缺点: 每次只能用一半的堆内存, 另一半的空间来创建对象使用

标记-整理算法(标记-压缩算法)

对标记-清理算法中容易产生内存碎片的问题的一种解决方案
两个阶段

  1. 标记阶段: 将所有的存活对象标记, 使用可达性分析算法, 从GC Root开始通过引用链遍历出所有存活对象
  2. 整理阶段: 将存活对象移动到堆的一端, 清理掉非存活对象的内存空间
  • 优点:
    1. 内存使用率高, 整个堆内存都可以使用, 复制算法只能用半个堆内存
    2. 不会出现碎片化的问题, 整理阶段可以将对象往内存的一侧进行移动, 剩下的空间都是可以分配对象的有效空间
  • 缺点: 整理阶段的效率不高, 整理算法有很多, 比如Lisp2整理算法需要对整个堆中的对象搜索三次, 整体性能不佳. 可以通过Two-Finger, 表格算法, ImmixGC等高效的整理算法优化这个阶段的性能

分代GC(Generational GC)

分代垃圾回收将整个内存区域划分为年轻代(Young区, 新生代)和老年代(Old区)

年轻代存放存活时间比较短的对象, 老年代存放存活时间比较长的对象

年轻代中有Eden区, Survivor区(S0, S1)

JDK 8中, 添加-XX:+UserSerialGC参数使用分代回收的垃圾回收器, 运行程序, 使用Arthas中的memory命令可以查看三个区域的内存情况, 分别是eden_space, survivio_space, tenured_gen, 就是伊甸园区, 幸存者区, 老年代

  • -Xmn: 设置新生代的大小, 也就是伊甸园区和幸存者区的大小
  • -XX:SurvivorRatio: 伊甸园区和幸存区的比例, 默认为8. 如果新生代1G内存, 伊甸园区就是800MB, S0S1100MB
  • -XX:+PrintGCDetails or verbose:gc打印GC日志, 前者打印信息详细一些,后者简单一些。
  • 老年代大小就是堆大小与新生代大小的差
  • 使用分代回收时, 创建出来的对象首先放入Eden伊甸园区, 如果对象在Eden区越来越多, 直到Eden区满, 新创建的对象无法放入, 就会触发年轻代GC, 称为Minor GC或者Young GC. Minor GC会把需要Eden中和From需要回收的对象回收, 把没有回收的对象放在To区域中
    • 所以Minor GC就是一个复制算法, 初始S0From, S1To, 接下来S0变成了To, S1变成了From
    • Eden再次满时, 此时会回收Eden区和S1 From中的对象, 并把Eden, From中的剩余的对象放在S0
  • 每次Minor GC后都会为对象记录他的年龄, 初始值为0, 每次GC完都要加1
  • 年龄到达15以后(15是最大值), 默认值和垃圾回收器有关, 对象就会晋升到老年代
  • 老年代空间不足, 无法放入新的对象时, 先会尝试Minor GC(为了尽量避免对象放入老年代, 比如新生代中年龄都是2或者3, 只是被占用满了, 需要将对象放在老年代中, 如果新生代空间没有满, 也可以不用放入老年代, 所以先进行Minor GC, 尽量避免对象放入老年代). 如果还是不足, 就会触发Full GC, 对整个堆进行垃圾回收
    • 如果Full GC没有释放足够的老年代的空间, 就会产生OutOfMemory错误
  • 为什么分代GC要把堆分成年轻代和老年代?

系统中的大部分对象, 都是创建出来以后不再使用可以被回收, 比如用户获取订单数据, 订单数据返回给用户之后就可以释放了

老年代中会存放长期存活的对象, 比如Spring中的大部分bean对象, 在程序启动之后就不会被回收了

在虚拟机的默认设置中, 新生代的大小要远小于老年代的大小

  1. 可以通过调整新生代和老年代的比例来适应不同类型的应用程序, 提高内存的利用率和性能.
    • 比如一个用户很多的网站, 有很多人需要访问订单数据, 那如果新生代较小, 则会有很多年龄较小的对象进入老年代, 导致GC时间延长, 此时可以将新生代的内存区调大一些, 直接可以通过Minor GC回收
  2. 新生代和老年代可以使用不同的垃圾回收算法, 新生代一般使用复制算法, 减少内存碎片; 老年代可以使用标记-清除或者标记-整理算法, 由程序员自己调整(因为老年代空间比较大, 使用了复制算法就只能使用一半空间了, 就不合理了), 标记-清除算法效率较高, 但是会有内存碎片, 而标记-整理算法没有内存碎片, 但是STW较长
  3. 分代设计中允许只回收新生代Minor GC, 如果能满足对象分配的要求就不需要堆整个堆进行回收了, STW时间减少(最核心), 尽可能做Minor GC, 减少Full GC

垃圾回收器

  • 垃圾回收器是垃圾回收算法的具体实现, 由于垃圾回收器分为年轻代和老年代, 所以除了G1之外的其他垃圾回收器必须成对使用
    GC

年轻代的Serial以及老年代的Serial Old

年轻代的ParNew以及老年代的CMS

年轻代的Parallel Scavenge以及老年代的Parallel Old

G1可以同时控制年轻代和老年代(JDK 9之后主流推荐)

JDK 9废弃了年轻代的Serial以及老年代的CMS; 年轻代的ParNew以及老年代的Serial Old

JDK 14中废弃了Parallel Scavenge以及老年代的Serial Old

Serial垃圾回收器-年轻代

单线程串行回收年轻代的垃圾回收器

Serial

  • 回收年轻代, 使用复制算法
  • 优点: 单CPU下吞吐量出色
  • 缺点: 多CPU下吞吐量不如其他垃圾回收器, 堆偏大会让用户线程处于长时间等待(因为回收的时候只有单线程进行GC)
  • 适合Java编写的客户端程序, 或者硬件配置有限的场景

Serial Old垃圾回收器-老年代

单线程串行回收, 是Serial的老年代版本

  • -XX:+UseSerialGC新生代、老年代都使用串行回收器
  • 回收老年代, 使用标记-整理算法
  • 优缺点跟Serial算法相同
  • 一般配合Serial使用, 或者在特殊情况下同CMS使用, 实际使用不多, 一般在CPU资源比较匮乏的时候使用

ParNew垃圾回收器-年轻代

本质上就是对Serial在多CPU下的优化, 使用多线程进行垃圾回收

ParNew

-XX:+UseParNewGC新生代使用ParNew回收器,老年代使用串行回收器

  • 回收年轻代, 使用复制算法
  • 优点: 多CPU下停顿时间较短
  • 缺点: 吞吐量和停顿时间不如G1, 所以在JDK 9之后不建议使用
  • 适合JDK 8以及之前的版本中, 与CMS老年代垃圾回收器配合使用

CMS(Concurrent Mark Sweep)垃圾回收器-老年代

关注的是系统的暂停时间, 允许用户线程和垃圾回收线程在某些步骤中同时执行, 减少用户线程的等待

-XX:+UseConcMarkSweepGC

CMS

  • 使用标记-清除算法回收老年代
  • 优点: 系统由于垃圾回收停顿时间较短, 所以用户体验好
  • 缺点: 内存碎片问题, 退化问题(会退化为Serial Old单线程), 浮动垃圾问题
  • 适合大型互联网系统中, 用户请求数据量大, 频率高的场景, 比如订单接口, 商品接口等

CMS执行步骤

  1. 初始标记: 用极短的时间标记出GC Root能够直接关联到的对象
  2. 并发标记: 标记所有的对象, 用户线程此时不需要暂停
  3. 重新标记: 由于并发标记阶段没有STW, 因此有些对象会发生变化, 存在错标和漏标的情况, 需要重新标记(这也是STW的原因)
  4. 并发清理: 清理死亡对象, 用户线程不需要暂停(因此会有浮动垃圾, 不能完全垃圾回收)

只有初始标记和重新标记两个阶段用户线程需要暂停, 但是这两个线程执行时间较短, 是并发执行的, 所以STW较短

  • CMS缺点:
    1. 使用标记-清除算法, 会有大量内存碎片, 在Full GC时进行碎片整理, 导致用户线程暂停, 可以使用-XX:CMSFullGCsBeforeCompation=N(默认为0)调整NFull GC之后再整理
    2. 无法处理在并发清理过程中产生的浮动垃圾, 不能做到完全的垃圾回收, 也就是在并发清理阶段, 如果用户产生了对象, 并且很快就失效了, 则不能在并发清理阶段被回收
    3. 如果老年代内存不足无法分配对象, CMS就会退化为Serial Old单线程回收老年代

Parallel Scavenge垃圾回收器-年轻代

  • JDK 8默认的垃圾回收器, 多线程并行回收, 关注系统的吞吐量, 具备自动调整堆内存大小的特点
  • 年轻代的复制算法
  • 优点: 吞吐量高, 手动可控, 为了提高吞吐量, 虚拟机会动态调整堆的参数(不需要关注最大堆内存, 年轻代的大小了)
  • 缺点: 不能保证单次的停顿时间(可以设置最大单次暂停时间)
  • 适合后台任务, 不需要与用户进行交互, 并且容易产生大量的对象的任务, 比如大数据的处理, 大文件的导出

允许手动设置最大暂停时间和吞吐量, 官方建议不要设置堆内存的最大值, 垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小

最大暂停时间-XX:MaxGCPauseMillis=n设置每次垃圾回收时的最大停顿毫秒数

吞吐量-XX:GCTimeRatio=n设置吞吐量为n, 也就是用户线程执行时间=n/(n+1)

自动调整内存大小-XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿毫秒数自动调整内存大小

实际上, 当我们把最大暂停时间减小的时候, 垃圾回收器会主动减少堆内存, 从而减少最大暂停时间

PS

Parallel Old垃圾回收器-老年代

  • PS收集器的老年代版本, 利用多线程并发收集
  • -XX:+UseParallelGC或者-XX:+UseParallelOldGC可以使用PS + PO这种组合
  • 回收老年代的标记-整理算法, (Arthas上面显示MarkSweep也就是标记清除, 这是因为老年代垃圾回收器不会单独使用标记清除算法, 官方没有将整理放上来, 所以显示MarkSweep, 包括CMS用的也不是单纯的标记清除算法)
  • 优点: 并发收集, 在多CPU下效率较高
  • 缺点: 暂停时间较长
  • 适合与PS一起使用

G1垃圾回收器

JDK 9之后默认使用G1垃圾回收器, PS关注吞吐量, 允许用户设置最大暂停时间, 但是会减少年轻代的可用空间大小

CMS关注暂停时间, 但是吞吐量方面会有下降

G1设计目标就是将上述两种垃圾回收器的优点融合:

  1. 支持巨大的堆空间回收, 具有较高的吞吐量
  2. 支持多CPU并行垃圾回收
  3. 允许用户设置最大暂停时间
    所以强烈建议使用G1垃圾回收器

内存结构

  • G1之前的垃圾回收器, 一般内存结构是连续的
    beforeg1
  • G1将整个堆划分为多个大小相等的区域, 称为区Region, 区域不要求连续, 分为eden, Survivor, Old区.
    • Region的大小通过堆空间大小/2048计算得到, 也可以通过-XX:G1HeapRegionSize=32m指定(region大小为32M), Region size必须是2的指数幂,取值范围从1M32M

垃圾回收方式

  1. 年轻代回收(Young GC)
  2. 混合回收(Mixed GC)(回收年轻代加上老年代)

年轻代回收(Young GC)

回收Eden区和Survivor区中不再使用的对象, 会导致STW, G1会尽可能的保证暂停时间, 可以通过-XX:MaxGCPauseMillis=n(默认为200)设置最大暂停时间的毫秒数

  1. 新创建的对象放在Eden区, 如果Eden + Survivor超过年轻代区的60%, 就会判断年轻代空间不足, 无法分配对象的时候会执行Young GC
  2. 标记EdenSurvivor区域中的存活对象
  3. 根据最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区域, 并且年龄+1, 并清空这些区域(因为使用了复制方法, 不会产生内存碎片)

进行Young GC的时候会记录每次垃圾回收的Eden区域和Survivor区域的平均耗时, 从而作为下次回收时的参考依据, 这样就可以根据配置的最大暂停时间计算出本次回收最多能回收多少个Region区域了

比如-XX:MaxGCPauseMillis=n(默认为200),每个Region回收耗时40ms, 所以这次最多只能回收4个Region

  1. 后面如果再发生Young GC, 步骤相同, 只是Survivor会搬运到另一个Survivor

如果一个对象年龄达到阈值(默认是15), 就会被放入老年代

  1. 如果部分对象大小超过Region的一半, 那么就会直接放到老年代中, 称为Humongous区(巨大的)

比如堆内存4G, 每个Region``2M, 只要一个对象超过1M, 就会被放入Humongous区中, 如果对象过大, 就会横跨多个Region

  1. 多次回收之后, 会有很多老年代区, 如果达到阈值(-XX:InitiatingHeapOccupancyPercent)占用总堆空间的默认的45%, 就会触发Mixed GC, 回收所有年轻代和部分老年代对象以及大对象区, 采用复制算法完成

混合回收(Mixed GC)

分为初始标记Initial Mark, 并发标记Concurrent Mark, 最终标记Remark或者Finalize Marking, 并发清理(Cleanup)

G1对老年代的清理会选择存活度最低的区域来进行回收, 可以保证回收效率最高, 也就是G1的由来(判断哪个区域存活对象最少, 就优先清除哪个区域)

G1old

  • 初始标记: 标记GC Root引用的对象为存活, 并行执行, 暂停用户线程, 速度较快
  • 并发标记: 将第一步中标记对象的引用对象标记为存活, 和用户线程一起执行(可能会出现错标漏标)
  • 最终标记: 标记一些引用改变漏标的对象, 不管新创建、不再关联的对象(但是上一步结束后可能有些对象不再使用了, G1不再处理)
  • 并发复制清理: 使用复制算法, 将存活对象复制到别的Region中, 此操作不会产生内存碎片
  • 如果清理过程中没有足够的空Region存放转移的对象, 就会出现Full GC, 单线程执行标记-整理算法, 此时会导致用户线程暂停. 所以尽量保证堆中有一定的空间

-XX:+UseG1GC, 打开G1的开关, JDK 9之后默认打开

-XX:MaxGCPauseMillis=n设置最大暂停时间

  • 使用复制算法回收年轻代+老年代
  • 优点:
    1. 对较大的堆如果超过6G对回收时, 延迟也可控
    2. 不会产生内存碎片
    3. 并发标记的SATB算法效率高
  • 缺点: JDK 8之前还不够成熟

总结

  1. JDK 8以及之前: ParNew + CMS(关注暂停时间); PS + PO(关注吞吐量); G1(JDK 8之前不建议使用, 较大堆并且关注暂停时间)
  2. JDK 9之后, 使用G1