Java垃圾回收
2024-06-17 19:46:49

概述

Java虚拟机中,程序计数器虚拟机栈本地方法栈三个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作。每一个栈帧分配多少内存基本在编译期可以确定,因此这几个区域的内存分配和回收都具备确定性,不需要过多的考虑如何回收的问题,线程结束时内存自然跟着回收了。

Java堆方法区则不同:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期才能知道程序会创建多少对象,这部分的内存的分配与回收是动态的。所以垃圾收集器关注的就是这部分内存该如何管理。


对象的消亡

判断对象是否存活有两种算法,一是引用计数算法,另一个是可达性分析算法

  • 引用计数算法:

    简单来说,在对象中添加一个引用计数器,当有一个指向对象的引用时+1,当引用失效时-1,任何时刻计数器为零的对象就是不可能再被使用的。

    但主流的Java虚拟机中并没有选用该算法,因为该算法需要大量额外处理才能保证正确地工作。举例来讲它很难解决对象之间相互引用的问题。

  • 可达性分析算法:

    该算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索路径称为“引用链”。如果一个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。

    在Java技术体系中,固定可作为GC Roots的对象包括以下几种:

    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如当前正在运行的方法中用到的参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
    • 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用
    • 在本地方法栈中Native方法引用的对象
    • 所有被同步锁持有的对象

Java中引用的概念

  • JDK 1.2之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
  • JDK 1.2之后,Java 对引用概念进行了扩充:将引用分为强引用软引用弱引用虚引用
    • 强引用
    • 软引用
    • 弱引用
    • 虚引用

如何判断一个常量是废弃常量?

当常量池中存在某一常量,而系统有没有任何一个对象引用这个常量,若在这时需要内存回收,而垃圾收集器判断确有必要的话,这个常量就会被清理出常量池。

如何判断一个类是无用的类?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。


分代收集理论

当今商业虚拟机的垃圾收集器,大多都遵循了“分代收集”的理论进行设计,它建立在两个分代假说上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象越难以消亡

据此,垃圾收集器通常将Java堆划分出不同的区域,将回收对象按照年龄分配到不同的区域存储。

在划分出不同的区域之后,垃圾收集器才可以针对某一区域或某些部分区域进行回收。

由此,划分出了不同的回收类型:

  • 新生代收集(Minor GC/Young GC):目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):只有CMS收集器会有单独收集老年代的行为
  • 混合收集(Mixed GC):目标是收集整个新生代及部分老年代,目前只有G1收集器有这个行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

Java堆结构如图:

Java堆结构


垃圾收集算法

Java虚拟机中不同的垃圾回收器有不同的垃圾收集算法。可简单分为三种

  • 标记——清除:该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。这种垃圾收集算法会带来两个明显的问题:
    1. 效率问题
    2. 空间问题(标记清除后会产生大量不连续的碎片)
  • 标记——复制:为了解决效率问题,“标记-复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
  • 标记——整理:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

HotSpot 为什么要分为新生代和老年代?

  • 在新生代每次收集都有大量对象消亡,所以可以选择“标记——复制算法,提高效率
  • 老年代对象存活几率较高,所以可以采用”标记——清除“和”标记——整理”算法进行收集

垃圾收集器

  1. Serial(串行)收集器

    单线程收集器,它只会使用一条垃圾收集线程去完成垃圾收集工作,并且在进行垃圾收集时需要暂停其他工作线程。

    新生代采用标记——复制算法,老年代采用标记——整理算法

    简单高效(与其他收集器的单线程相比)

  2. ParNew收集器

    ParNew收集器是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和Serial收集器一样

    新生代采用标记——复制算法,老年代采用标记——整理算法

  3. Parallel Scavenge收集器

    Parallel Scavenge收集器也是多线程收集器,和ParNew收集器的区别在于Parallel Scavenge收集器更多关注的是吞吐量,而CMS等垃圾收集器更多关注的是用户线程的停顿时间。这也是Jdk1.8默认垃圾收集器

    新生代采用标记——复制算法,老年代采用标记——整理算法

  4. Serial Old收集器

    Serial收集器的老年代版本,一种用途是在JDK1.5以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案

  5. Parallel Old收集器

    Parallel Scavenge收集器的老年代版本

  6. CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器实现了让垃圾回收线程与用户线程同时工作

    CMS收集器采用的是“标记——清除”算法,运作过程分四个步骤:

    1. 初始标记
    2. 并发标记
    3. 重新标记
    4. 并发清除
  7. G1收集器

    G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

  8. ZGC收集器

CMS收集器

CMS 前的垃圾收集器在 GC 线程工作时都会 **STW(Stop The World)**,即停止用户线程。而 CMS 的 GC 线程在部分场景下可以与用户线程并行。

  • 初始标记

    初始标记阶段会标记 GCRoots 直接关联的对象以及 年轻代指向老年代的对象,会发生短暂的 STW ,但由于没有向下追溯(只标记一层),所以速度很快

  • 并发标记

    并发标记阶段主要从 GCRoots 向下追溯,标记所有可达对象,该阶段由于与用户线程并行,所以可能有对象发生变化

  • 重新标记

    该阶段会发生 STW 暂停用户线程,扫描 老年代(dirty card)年轻代,找出存活的老年代对象

  • 并发清除

    与用户线程并行,回收所有不可达对象,这个过程中可能产生浮动垃圾

缺点:

  • 使用标记清除,产生内存碎片,空间利用率减小
  • 并发过程需要预留空间,如果预留空间不足,会报 Concurrent Mode Failure,然后采用 Serial Old 进行老年代回收
  • 要处理内存碎片,需要 STW

G1收集器

CMS 垃圾收集器会出现停顿时间不可预知的情况,G1 收集器会让用户提前设定一个可以接受的 STW 时间,根据这个时间尽可能满足。

G1 收集器将堆空间以逻辑形式划分为一个个 Region

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收