垃圾回收机制


Java 垃圾回收 GC

堆的GC

1. 方法区的垃圾回收

方法区的垃圾回收主要针对:废弃的常量不再使用的类型信息

  • 废弃的常量,比如常量池中的字面量,如字符串池中的某个字符串的值已经不与任何字符串对象相同。
  • 回收类型信息又被称为类型卸载。回收条件比回收常量更苛刻。
    • 一般,在大量使用反射、动态代理、CGLIB 等字节码框架,动态生成 JSP 以及 OSGi 一类频繁自定义类加载器的场景中,都需要 JVM 具有类型卸载能力,来保证方法区的剩余空间。

3. 新生代 与 老年代

3.1 年轻代

堆是 GC 的主要场所,年轻代按照容量分为 Eden : S_0 : S_1 = 8 : 1 : 1

  • Eden 区:对象新建时,是在 Eden 区分配内存。当 Eden 满时,则触发 Minor GC。
  • Survivor 区:存放在 GC 中生存下来的对象,分为两块 大小相同 的空间:S_0S_1 ,又被称为 from 区to 区

S_0 和 S_1 总有一个是空的,一个是非空的。当 Eden 区容量满的时候,会把 Eden 和那个非空的 Survivor 区(假设是 S_0 )进行 Minor GC ,有引用指向的对象(使用可达性分析算法)会被保留下来并复制到另一个空的 S 区(假设是 S_1 ),并且分代年龄(分代年龄存在于对象头中)加一,然后 Eden 和 S_0 中所有垃圾对象都会被回收。如此一来,S_0 和 S_1轮流作为非空的 Survivor 区。

3.2 老年代

当年轻代中的对象经过多次 GC 后被保留下来的对象,如果其 分代年龄 到达老年代的要求,则会被放入老年代。

  • 新生代中的对象也可以通过一些机制来直接进入老年代。

老年代满的时候会发生 Full gc,回收 老年代 + 年轻代 的所有垃圾对象,当没有可以被回收的对象时,则发生 OOM (OutOfMemory) 异常。

4. 对象何时进入老年代

4.1 大对象直接进入老年代

大对象指的是需要大量连续内存的对象,比如字符串数组

可以设置大对象的大小阈值,超过阈值就会直接进入老年代。

  • JVM 中设置该阈值:-XX:PretenureSizeThreshold

该机制的目的是为了避免大对象在 Eden 区与 Survivor 区之间来回复制,从而产生大量的内存复制操作。

4.2 长期存活的对象可以直接进入老年代

虚拟机通过分代收集的思想来管理内存,给每个对象分配一个对象年龄计数器 Age,储存在对象头中。

  • 当对象在 Eden 出生,经过了第一次 Minor GC,存活下来后若能被 Survivor 容纳,则移动到 Survivor 区,并将对象年龄设置为 1。
  • 对象每次在 Minor GC 中存活下来,年龄就增长 1。
  • 当年龄增长到阈值后(默认为 15,因为对象头中标识GC分代年龄的字段占4bit)被放入老年代。可以通过 -XX:MaxTenuringThreshold 来改变这个阈值。

对象的组成

4.3 对象动态年龄判断机制

在那块非空的 Survivor 区,若某批对象的总大小超过了该区内存的 50%,则此时 Survivor 区中,大于等于该批对象中最大年龄的对象,可以直接进入老年代。

  • 该机制是为了让那些可能会长期存活的对象提前进入老年代。
  • 该机制的触发一般是在 Minor GC 之后。
  • 内存阈值可以调整:-XX:TargetSurvivorRatio

4.4 空间分配的担保机制

担保机制是指当发生 Minor GC 时,另一块 Survivor 区不足以存放所有的新生代存活下来的对象,因此新生代中的对象会提前进入老年代,称为担保机制

在年轻代发生 Minor GC 之前,JVM 会检查老年代中的最大可用的连续空间是否大于新生代中所有对象的总空间,因为最坏的情况下所有新生代对象都会存活下来:

  • 如果大于,则 JVM 认为该次 Minor GC 是安全的。可以正常进行 Minor GC。
  • 如果小于,则判断 -XX:HandlePromotionFailure 参数是否允许担保失败:
    • 如果不允许担保失败,则直接进行 Full GC,回收新生代与老年代中的垃圾对象。
    • 如果允许担保失败,则检查老年代中的最大可用的连续空间是否大于历史晋升到老年代中的对象的平均大小:
      • 如果大于,则承担此次担保失败的风险,并进行 Minor GC;
      • 如果小于,则认为一定会失败,直接进行 Full GC。

JDK 1.6 之后,默认允许担保失败,即 -XX:HandlePromotionFailure 参数失效,直接判断老年代中的最大可用的连续空间是否大于新生代中所有对象的总空间或者历史晋升到老年代中的对象的平均大小,如果可行就尝试进行 Minor GC,否则直接进行 Full GC。


5. 如何判断对象已死

判断一个对象是否还存活可以采用:

  1. 引用计数法:每引用一次计数器 +1,否则 -1。
  2. 可达性分析算法

JVM 中使用的是可达性分析算法。因为对于引用计数法,当存在两个对象相互引用时,无法判断它们是死对象。

5.1 可达性分析算法

将 GC Roots 对象为根,向下搜索引用的对象,找到的对象标记为非垃圾对象,存在于堆中但未标记的对象都作为本次垃圾回收的垃圾对象

可以固定作为 GC Roots 的对象包括:

  • 虚拟机栈中引用的对象,如方法参数、局部变量等;
  • 本地方法栈中引用的对象;
  • 静态属性的引用;
  • 常量引用,如字符串池中的引用;
  • Java 虚拟机内部的引用,如基本类型对应的 Class 对象,常驻的异常对象,系统类加载器等;
  • 被同步锁持有的对象

除了这些固定的 GC Roots 外,还可以有其他对象“临时”加入。

可达性分析算法

6. 垃圾收集算法

JVM 中的垃圾回收基于分代收集理论:

  1. 弱分代假说:大多数对象不会长时间存活,所以分为年轻代和老年代。
  2. 强分代假说:若一个对象熬过了多次 GC,则很有可能继续生存下去,即进入老年代。
  3. 跨代引用假说:老年代中的对象引用年轻代中的对象被称为跨代引用,认为跨代引用的对象只占少数。
    • 因此在新生代中划分一小块区域,叫做记忆集,用来指明老年代的哪一块内存会存在跨代引用,这样在扫描 GC Roots 的时候只需要额外扫描这一小块区域即可,不用扫描整个老年代。
卡表 Card Table:记忆集的具体实现

把 Old 区分为多个 card,每个 card 中有多个对象,如果该card中存在引用指向 young区,则标记为 dirty card。这样进行 minor Gc 时只需扫描 dirty card 中的对象即可。

  • card table 底层实现类似于 bitmap,每个bit对应一个card区

6.1 部分收集与整堆收集

垃圾收集 GC 按照被收集的堆内区域,分为 :

  • 部分收集 Partial GC:分为新生代收集和老年代收集。
    • 新生代收集 Minor GC:发生在 Eden 区满的时候,回收范围为 年轻代
    • 老年代收集 Major GC:只有 CMS 收集器会单独收集老年代。
  • 整堆收集 Full GC: 回收范围是整个 Java 堆和方法区

Full GC 触发条件:

  1. 老年代满了
  2. 方法区满了
  3. minor gc 时担保失败了

6.1.1 Stop the World 机制

发生 GC 时会产生 STW (Stop the World)机制,全程暂停 用户应用线程 的运行,安心进行 GC。

  • Full GC 会导致 STW 时间更长,因此 JVM 调优就要减少 full GC 的次数。
Stop the World 的原因
  1. 为了判断垃圾对象:在利用可达性分析算法来判断对象是否存活时,为了防止对象的引用关系发生变化,从而避免堆中对象的 GC 标记(是否为垃圾对象)发生变化。
  2. 为了整理内存碎片:垃圾回收后,存活的对象需要被复制或移动到其他内存,并更新引用地址,这是一个负重的操作。

6.2 垃圾回收算法

主要有:

  1. 标记-清除算法
  2. 标记-复制算法
  3. 标记-整理算法

6.2.1 标记-清除算法

先标记出所有需要回收的对象,然后统一清除。

缺点是:

  1. 效率低
  2. 会产生大量的内存碎片

6.2.2 标记-复制算法

JVM 用该算法对新生代进行回收。

标记-复制算法被称为 半区复制。把内存平分为 2 块,每次只用一块,先标记出所有存活的对象,然后复制到另一块区域。

根据弱分代假说,大部分对象都会马上死亡,使用Appel 式回收进行优化:

把新生代分为 Eden 区 + 2 块 Survivor 区。内存大小比例 8 :1:1

每次内存分配时占用 Eden 区 + 其中一块 Survivor 区,在 GC 时,把这两块中还存活的对象复制到另一块 Survivor 区,然后直接清空之前的两块区域。

  • 如果另一块 Survivor 区不足以存放之前被回收的两块区域,则利用担保机制使对象进入老年代。

6.2.3 标记-整理算法

JVM 利用该算法对老年代进行回收。

先标记出所有存活的对象,然后移动到内存空间的同一端,避免产生内存碎片。然后清除边界外的内存。

7. 垃圾收集器

7.1 Serial 收集器

特点是单线程

  1. 只使用一个处理器或收集线程去完成垃圾收集工作;
  2. 在垃圾收集时必须暂停其他所有工作线程 Stop The World,直到收集结束。

7.1.1 Serial Old 收集器

Serial 收集器的老年代版本,单线程。

  • 使用 标记-整理 算法。

Serial Old 收集器的两种用途:

  1. JDK 5 之前,与 Parallel Scavenge 收集器配合使用。
  2. 作为 CMS 收集器发生失败后的后备预案,在并发收集发生 Concurrent mode Failure 时使用。

7.2 ParNew 收集器

并行版本的 Serial 收集器,首次实现了让垃圾收集线程与用户线程同时工作。

  • ParNew 配合 CMS 收集器。ParNew 用来收集新生代,CMS 来回收老年代。

7.3 Parallel Scavenge 收集器

是一个新生代收集器。目标是使吞吐量可控,被称为吞吐量优先收集器。

  • 吞吐量:处理器用于运行用户代码的时间与处理器总消耗时间的比值。
  • 可以通过参数来控制最大停顿时间和吞吐量大小。
    • 停顿时间的缩短需要牺牲吞吐量和新生代空间。

7.3.1 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。

  • 使用 标记-整理 算法。

注重吞吐量或处理器稀缺时,可以使用收集器组合:Parallel Scavenge + Parallel Old

7.4 CMS 收集器

Concurrent Mark Sweep,基于 标记-清除 算法,针对老年代的垃圾收集器。追求最短的回收停顿时间。

  • 优点:并发收集、低停顿。
  • 缺点:
    1. 对处理器资源敏感,适用于多核处理器;
    2. 并发标记与并发清理时,用户线程不被暂停,此时会产生“浮动垃圾”。
    3. 标记-清除算法会产生大量空间碎片,给大对象分配带来困难,可以通过JVM参数来设置 CMS 完成后进行一次碎片整理。

运作过程

  1. 初始标记:单线程标记 GC Roots 所直接关联的对象,速度很快。需要 Stop the World;
  2. 并发标记:从 GC Roots 的直接关联对象开始,并发遍历整个对象图,耗时长但不用暂停用户线程;
  3. 重新标记:修正并发标记期间,因为用户线程继续运作所导致标记变动的部分,需要 Stop the World
  4. 并发清除:并发删除死亡的对象。此时清除线程与用户线程会同时并发进行。
CMS 收集器运行过程

CMS 不会等到内存饱和才去收集,因为GC并发标记阶段允许用户进程的并行进行,所以 CMS 收集器必须预留一部分空闲空间来保证用户进程的正常运作。一般当内存使用率阈值达到 68% 时,会执行 CMS 回收。

若内存增长过快,则可能发生并发回收失败 Concurrent Mode Failure,此时需要使用 Serial Old 收集器来进行单线程的垃圾回收,全程 Stop the World

7.5 G1 收集器

G1Garbage First 收集器。

  • 面向全堆,且不需要其他收集器的配合。
  • 可以面向堆内存的任何部分来组成回收集 Collection Set。
  • 不会产生内存碎片。
Region 区域

G1 收集器把堆内存划分为多个大小相等的独立区域 Region,每个 Region 都能独立扮演新生代的 EdenSurvivor 或者老年代。RegionG1 中的最小回收单元。

在 G1 中,物理分代不再存在,但逻辑分代依然存在。

Region 大小可以存放 1~32MB 大小的对象。使用参数 -XX:G1HeapRegionSize 来设置。

Region Humongous 区域

超过一个 Region 大小一半的对象被判定为大对象,被专门储存在 Region 中的 Humongous 区域。对于超大对象,则会占用连续的 N 个 Region Humongous 区域。并把该区域看成老年代来处理。

RememberSet 记忆集

每个 Region 中都有一个 RS,存放其他 Region 到本 Region 中的引用信息,通过扫描 RS 即可找到跨区引用。

TAMS 指针

每个 Region 中有两个 TAMS Top at Mark Start 指针,划分出当前 Region 中的一块区间,用于垃圾回收过程中用户线程的新对象分配。

并发回收过程中的新分配的对象都要分配在这两个指针位置之上

如果垃圾收集速度赶不上新对象分配的速度,则可能导致内存不足,带来 Full GC。

7.5.1 G1 的运作过程

  1. 初始标记:单线程标记 GC Roots 直接关联的对象,并修改 TAMS 指针。该过程需要短时间暂停用户线程。初始标记阶段一般是借用 Minor GC 的过程来同步完成的。
  2. 并发标记:从 GC Roots 开始,对堆中对象进行可达性分析,来递归扫描堆中的对象图来找出需要回收的对象。此时允许用户线程执行。
    • 扫描完对象图之后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  3. 最终标记:暂时暂停用户线程,处理并发标记阶段结束后仍遗留的少量的 SATB 记录。
  4. 筛选回收:对各个 region 的回收价值和回收成本进行排序,然后根据用户期望的停顿时间来执行回收计划,可以自由选择任意多个 Region 来构成回收集,然后把要回收区域中的存活对象复制到空的 Region 中,再清理掉整个旧 Region 中的全部空间。此过程需要暂停用户线程。
G1 运作过程

三色标记法:对于CMS 和 G1

是一种垃圾标记法,让JVM不发生停顿或短暂停顿来标记垃圾对象。用于 CMS 和 G1。

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。

  • 黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)
  • 灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
  • 白色:该对象没有被标记过。(对象垃圾)

流程:

  1. 在初始和并发标记阶段,从GC roots开始按照三色标记规则去标记存活对象。
  2. 在扫描结束后,暂停用户线程,然后扫描灰色对象并完成标记
  3. 最后清除所有的白色对象
CMS 增量更新来应对漏标问题

若一个白色对象被重新引用,则引用它的对象,如果之前是黑色的,则要变成灰色的,下次标记时继续让GC线程完成标记。

SATB snapshot at beginning 原始快照 在G1中解决漏标问题
  1. 在开始标记时生成一个存活对象的快照
  2. 标记过程中,如果一个引用断开了,则推入GC堆栈,保证白色垃圾对象能被GC扫描到
  3. 然后根据Rset,判断白色对象有没有其他Region引用它,没有的话就回收

CMS 与 G1

空闲列表与指针碰撞

Java堆是被所有线程共享的一块内存区域,主要用于存放对象实例,为对象分配内存就是把一块大小确定的内存从堆内存中划分出来,通常有指针碰撞和空闲列表两种实现方式。

1.指针碰撞法

假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。

2.空闲列表法

事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。


文章作者: Yu Yang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Yu Yang !
 上一篇
JVM 内存结构 JVM 内存结构
JVM内存区域划分JVM运行时数据区分为:堆、方法区、栈(虚拟机栈、本地方法栈)、程序计数器。 1. 程序计数器 Program Counter 线程私有,是当前线程的字节码行号指示器。 如果线程执行 Java 方法,指示字节码指令的地址
2020-09-26
下一篇 
Java 类加载机制 Java 类加载机制
Java 类加载机制总结类加载是把字节码 .class 文件加载到内存里,从而生成对应类的 Class 对象,同一个类只有一个 Class 对象。当该类需要被实例化的时候,即用 new 关键字来创建对象时,JVM 会去获取该 Class 对
2020-09-26
  目录