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

  |   0 评论   |   0 浏览

概述

垃圾收集的历史远远比 Java 久远,在 1960 年诞生于麻省理工学院的 Lisp 是第一门开始使用内存动态分配垃圾收集技术的语言。其作者 John McCarthy 就思考过垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候开始回收?
  • 如何回收?

学习了解垃圾收集和内存分配的意义

当需要排查各种内存溢出、内存泄漏的问题时,当垃圾收集成为系统达到更高并发量瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

垃圾回收比较侧重的内存区域

  • 程序计数器、虚拟机栈、本地方法栈 3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑如何回收的问题了
  • Java 堆方法区 这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能回不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能够知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾回收器所关注的正是这部分内存该如何管理

垃圾回收器如何判断对象"已死"

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾回收器在对堆进行回收前,第一件事就是要确定哪些还"存活"着,哪些已经"死去"(死去即不可能再被任何途径使用的对象)了。

引用计数器算法

教科书式的算法描述:

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

客观的说,引用计数算法虽然占用了一些额外的内存空间来进行技术,但它原理简单,判定效率也很高,在大多数情况下它都是一个不多的算法。也有一些比较著名的应用案例,例如微软COM (Component Object Model)技术、使用 ActionScript 3 的 FlashPlayer、 Python 语言以及在游戏脚本领域得到许多应用的Squirrel 中都使用了引用计数算法进行内存管理。

但是,在java 领域,至少主流的Java 虚拟机都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有许多例外情况需要考虑必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

举个简单的例子:对象objA 和 objB都有字段 instance,赋值令objA.instance = objB 及 objB.instance= objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问。

/**
 *  testGC() 方法执行后,objA 和 objB 会不会被 GC 呢?
 */
public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;


    private byte[] bigSize = new byte[ 2 * _1MB];

    public static void main(String[] args) {

        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.instance = objA;
        objB.instance = objB;

        objA = null;
        objB = null;

        // 假设在这行发生GC , objA 和 objB 是否能被回收?
        System.gc();


    }

}

运行结果:

image.png

从运行结果中可以清楚看到内存回收日志中包含 “4603k->210K”,意味着虚拟机并没有因为着两个对象互相吸引就放弃回收它们,这也从侧面说明了Java 虚拟机并不是通过引用计数算法来判断对象是否存活的

可达性分析算法

当前主流的商用程序语言(Java、C#等)的内存管理子系统,都是通过可达性分析算法来判断对象是否存活的。

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

image.png

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

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

引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存货都和"引用"离不开关系。

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

  • 强引用是最传统的"引用"的定义,是指在程序代码中普遍存在的引用赋值,即类似 "Object obj = new Object()" 这种引用关系。
  • 软引用是用来描述一些还有用,但非必须的对象只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
  • 弱引用也是用来描述那些非必须的对象,但是它的强度比软引用更弱一些,被软引用关联的对象只能生存到下一次垃圾集发生为止。当垃圾收集器开始工作,无论当前内存是否足够都会回收只被弱引用关联的对象
  • 虚引用也称为"幽灵引用"或者"幻影引用",它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能够在这个对象被收集器回收收到一个系统通知

不可达的对象什么时候死亡

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

如果这个对象被判定为确有必要执行finalize() 方法,那么该对象将会放置一个名为 F-Queue 的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中地其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移出"即将回收"的集合;如果对象这时候还没有逃脱,那么基本上要被回收了。

一次对象自我拯救的演示

public class FinalizeEscapGC {

    public static FinalizeEscapGC SAVE_HOOK = null;

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

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

    public static void main(String[] args) throws InterruptedException {

        SAVE_HOOK = new FinalizeEscapGC();

        // 对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :( ");
        }

        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

    }

}

SAVE_HOOK 对象的 finalize() 方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。

另外一个值得注意的地方是,,代码中有两段完全一样的代码片段,执行结果却是第一次逃脱成功,一次失败了。这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。

以上的 finalize() 方法案例,在实际使用中不要这么操作。

因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。


标题:垃圾收集器与内存分配策略
作者:zh847707713
地址:http://lovehao.cn/articles/2020/11/02/1604309630635.html