垃圾收集器与内存分配策略

654 阅读11分钟

如何判断对象是否应该被回收?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象哪些应该被回收。

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

存在的问题:很难解决对象之间循环引用的问题。

主流的Java虚拟机都没有选择该方式来管理内存。

可达性分析算法

基本思路:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如下图

可达性分析算法
可达性分析算法

图片来源:《深入理解Java虚拟机

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

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 在本地方法栈中Native方法引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

Java四种引用类型

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软 引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强 度依次逐渐减弱。

Java引用类型
Java引用类型

finalize()方法

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

对象可以在finalize()方法中通过将自己与引用链上的对象重新关联,来逃脱垃圾回收。

PS:至今没写过这个方法。不推荐使用。

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 *
 * 代码来源:《深入理解Java虚拟机》
 * @author : fuyuaaa
 * @date : 2020-06-03 15:58
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }


        // 下面这段代码与上面的完全相同,但是这次自救却失败了,因为finalize只会被执行一次
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

result:
finalize method executed!
yes, i am still alive :)
no, i am dead :(

方法区的垃圾收集

方法区的垃圾收集主要回收两部分内容:

废弃的常量:没有地方引用这个常量

不再使用的类型:(同时满足三个条件)

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

垃圾收集算法

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

缺点

  • 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间的碎片化问题,可能会导致大对象无法分配内存而不得不提前gc。

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

缺点

  • 将会产生大量的内存间复制的开销
  • 浪费一半空间

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。

标记-整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

缺点

  • 如果有大量对象存活,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

经典垃圾收集器

HotSpot虚拟机的垃圾收集器
HotSpot虚拟机的垃圾收集器

PS:

  • 上图为HotSpot虚拟机的。有连线代表可以搭配使用。

  • 由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、 ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP 214)。

  • 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

  • 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Serial 收集器

  • 最基础、历史最悠久
  • 新生代收集器
  • 单线程
  • 标记-复制算法
  • 会造成“Stop The World”
Serial:Serial Old收集器运行示意图
Serial:Serial Old收集器运行示意图

ParNew 收集器

  • Serial垃圾收集器的多线程并行版本
  • 新生代收集器
  • 多线程并行
  • 标记-复制算法
  • 会造成“Stop The World”
ParNew:SerialOld收集器运行示意图
ParNew:SerialOld收集器运行示意图

Parallel Scavenge 收集器

  • 新生代收集器
  • 多线程并行
  • 标记-复制算法
  • 会造成“Stop The World”
  • 收集器目标是达到一个可控制的吞吐量
    • 控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis
    • 直接设置吞吐量大小 -XX:GCTimeRatio
    • 自适应的调节策略 -XX:+UseAdaptiveSizePolicy

Serial Old 垃圾收集器

  • Serial收集器的老年代版本
  • 单线程
  • 标记-整理算法
  • 会造成“Stop The World”
Serial:SerialOld收集器运行示意图2
Serial:SerialOld收集器运行示意图2

Paraller Old 垃圾收集器

  • Parallel Scavenge收集器的老年代版本
  • 多线程并发
  • 标记-整理算法
  • 始于JDK6
ConcurrentMarkSweep收集器运行示意图
ConcurrentMarkSweep收集器运行示意图

CMS 收集器

  • 标记-清除算法
  • 以获取最短回收停顿时间为目标
ConcurrentMarkSweep收集器运行示意图
ConcurrentMarkSweep收集器运行示意图

具体步骤:

CMS收集器步骤
  • 优点:
    • 并发收集、低停顿
  • 缺点:
    • 对处理器资源敏感,在并发阶段会占用线程导致总吞吐量变低。
    • 无法处理“浮动垃圾”(并发阶段产生的新的垃圾)。
    • gc时需要预留内存空间给用户线程,如果这个内存不够,会导致“并发失败”,会临时使用Serial Old收集器进行老年代收集(会“Stop The World”)。
    • 标记-清除,无法处理空间碎片(CMS默认开启了内存碎片的合并整理过程)。

Garbage First 收集器

G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。

可预测的停顿时间模型:将Region作为单次回收的最小单元,让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

G1收集器运行示意图
G1收集器运行示意图

具体步骤:

G1收集器步骤

PS

  • TAMS:在收集线程与用户线程同时运行的时候,肯定会有新对象的分配。G1为每一个Region设 计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
  • STAB:原始快照算法,用于处理并发时可能产生的“对象”消失问题。可以瞅瞅这个:链接
  • 与CMS类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC(多线程)而产生长时间“Stop The World”。

内存分配与回收策略

  • 对象优先在Eden区分配,当Eden区没有足够的空间分配时,进行一次Minor GC

    • 在Minor GC时,如果对象无法放入Survivor,则直接进入老年代(分配担保机制)
  • 大对象直接进入老年代,避免大量的内存复制操作。

    • HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配。默认值为0,即任何对象(如果大于Eden大小,那就直接老年代)都现在Eden分配。
  • 长期存活的对象进入老年代,每个对象都有一个年龄计数器,对象在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能放入Survivor区,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15,通过-XX:MaxTenuringThreshold设置),就会被晋升到老年代中。

  • 动态对象年龄判定,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

  • 空间分配担保,在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于,那这一次Minor GC可以确保是安全的。如果小于,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC;如果担保了,还是失败了(对象突增,大于之前平均大小),则还是进行FullGC。

    PS:-XX:HandlePromotionFailure在JDK6 Update 24之后就无效了。

    空间分配担保

参考

深入理解Java虚拟机

图片来源《深入理解Java虚拟机》以及自己画的

本文使用 mdnice 排版