Android工程师学习JVM(七)-面试常考之垃圾回收

951 阅读10分钟

前言

在学习JVM这个系列文章中,已经讲解了JVM规范、Class文件格式以及如何阅读字节码、ASM字节码处理、类的生命周期及自定义类加载器、内存分配、字节码执行引擎等。本篇介绍垃圾回收机制基础,理解java程序在实际运行中自动进行的堆内存回收过程

如果你对JVM、字节码、Class文件格式、ASM字节码处理、类加载及自定义类加载器、内存分配、字节码执行引擎有兴趣的话,可以看之前的文章哈,相信会收获更多哦

Android工程师学习JVM(六)-字节码执行引擎

Android工程师学习JVM(五)-内存分配基础知识

Android工程师学习JVM(四)-类加载、连接、初始化、卸载

Android工程师学习JVM(三)-字节码框架ASM使用

Android工程师学习JVM(二)-教你阅读Java字节码

Android工程师学习JVM(一)-JVM概述

1、什么是垃圾

内存中已经不再被使用到的内存空间就是垃圾

垃圾回收主要关注java堆,java堆存放的是对象,那么内存空间不再被使用也就是对象不再被使用了

怎么判断对象已经不再被使用?

方法一:引用计数法

给对象添加一个引用计数器,有访问就加1,引用失效就减1

void demo1() {
    Object ref1 = new Object();//Object引用计数为1
    Object ref2 = ref1;//Object引用计数为2
    ref1 = null;//Object一个引用失效减1,引用计数减为1
    ref2 = null;//Object最后一个引用失效,可以被回收
}

但是引用计数的方式,有一个明显的缺点-循环引用

Class Obj {
    public Object prop;
}

Class Demo {
    public static void main(String[] args) {
        Obj obj1 = new Obj(); //第一个Obj引用计数为1
        Obj obj2 = new Obj(); //第二个Obj引用计数为1
        obj1.prop = obj2; //第一个Obj引用计数为2
        obj2.prop = obj1; //第二个Obj引用计数为2
        obj1 = null; //第一个Obj引用计数减为1 
        obj2 = null; //第二个Obj引用计数减为1
        //到这的时候其实obj1和obj2都已经是null了,但因为循环引用导致引用计数永远不为0
    }
}

像上面这种情况,就需要再将obj1和obj2置为null前,先解开循环引用,手动将内存释放

obj1.prop = null;
obj2.prop = null;
obj1 = null;
obj2 = null;

这种情况在实际编程中其实是很常见的,如果需要这样防范,将非常痛苦。好在现行使用的垃圾回收机制,没有实际采用引用计数这种做法的。

那么实际使用的是什么方法呢?

方法二:可达性分析算法

可达性分析算法是从离散书序中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT凯斯,寻找对应的引用节点,找到这个引用节点后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是 GC roots 的一部分。由此得出:

Java中可做为GC ROOT的对象有

1、虚拟机栈中引用的对象(本地变量表)

2、方法区中静态属性引用的对象

3、方法区中常量引用的对象

4、本地方法栈中引用的对象(Native对象)

当一个对象不可达时,是否就一定会被回收了呢?

答案是否定的,不可达对象至少要经历两次标记过程,才会被真正宣告死亡。两次标记指的是:

1、如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。

2、当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。

3、如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

举个案例:

public class FinalizerTest {
    public static FinalizerTest object;
    public void isAlive() {
        System.out.println("I'm alive");
    }
 
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("method finalize is running");
        object = this;
    }
 
    public static void main(String[] args) throws Exception {
        object = new FinalizerTest();
        // 第一次执行,finalize方法会自救
        object = null;
        System.gc();
 
        Thread.sleep(500);
        if (object != null) {
            object.isAlive();
        } else {
            System.out.println("I'm dead");
        }
 
        // 第二次执行,finalize方法已经执行过
        object = null;
        System.gc();
 
        Thread.sleep(500);
        if (object != null) {
            object.isAlive();
        } else {
            System.out.println("I'm dead");
        }
    }
}

输出如下:

method finalize is running
I'm alive
I'm dead

如果不重写finalize(),输出如下:

I'm dead
I'm dead

从执行结果可以看出: 第一次发生 GC 时,finalize() 方法的确执行了,并且在被回收之前成功逃脱; 第二次发生 GC 时,由于 finalize() 方法只会被 JVM 调用一次,object 被回收。

也就是说,finalize可以用来拯救对象,但这是不提倡的,gc是不可控的,不确定性大。

2、引用分类

第一小节中讲述了引用计数法和根搜索算法判断一个对象是否有引用。那么java中进行垃圾回收所看的引用只分为有引用和无引用吗?

答案是否定的,如果只看有引用和无引用,功能也太单薄了。实际上java中的引用共有四种:

1、强引用就是指在程序代码之中普遍存在的,类似"Object obj=new Object()"这类的引用,垃圾收集器永远不会回收存活的强引用对象。

2、软引用:还有用但并非必需的对象。在系统 将要发生内存溢出异常之前 ,将会把这些对象列进回收范围之中进行第二次回收。

3、弱引用也是用来描述非必需对象的,被弱引用关联的对象 只能生存到下一次垃圾收集发生之前 。当垃圾收集器工作时,无论内存是否足够,都会回收掉只被弱引用关联的对象。

4、虚引用是最弱的一种引用关系。 无法通过虚引用来取得一个对象实例 。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

看完了怎么判断对象是垃圾,下面来看如何收集垃圾

3、垃圾收集算法

3.1、标记清除法

标记清除法算法分成标记和清除两个阶段,先标记处要回收的对象,然后统一回收这些对象

它的主要缺点有两个:

1、效率问题,标记和清除两个过程效率都不高

2、空间问题,标记和清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序在运行过程中需要分配较大对象时,无法找到足够大的连续内存而不得不提前触发垃圾回收

3.2、复制算法

复制算法:把内存分成两块相同的区域,每次使用其中一块,当一块使用完了,就把这块上还存活的对象拷贝到另外一块,然后把这块清除掉。

优点:实现简单,运行高效,不用考虑内存碎片问题

缺点:内存浪费严重

现行的商业虚拟机都采用这种算法来回收新生代,实际上大多数对象的存活期都不长,所以没必要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor

当回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor空间上,再清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden:Survivor=8:1,也就是新生代可用内存空间占新生代容量的90%,只有10%会因为复制算法而浪费

如果Survivor空间不够,就要依赖老年代进行分配担保,将放不下的对象直接进入老年代

分配担保:当新生代进行垃圾回收后,新生代的存活区放不下存活的对象,就需要把这些对象放置到老年代,也就是老年代为新生代的GC做空间分配担保。

分配担保并不一定就能成功,还可能存在老年代最大连续空间也不够存放的情况。这里有一套规则如下:

1、在发生MinorGC前,JVM会检查老年代最大连续空间,是否大于新生代所有对象总空间,如果大于,可以确保MinorGC是安全的。(即使MinorGC没有回收任何对象,存活的对象依然能够放到老年代)

2、如果小于,那么JVM会检查是否设置了允许担保失败,如果允许,则继续检查老年代最大可用连续空间,是否大于历次晋升到老年代对象的平均大小

3、如果大于,则尝试进行一次MinorGC

4、如果小于,则进行FullGC

3.3、标记整理法

标记整理法:由于复制算法在对象比较多的时候,效率较低,且有空间浪费,因此老年代一般不会选用复制算法,老年代多选用标记整理法

标记整理法,标记过程和标记清除一样,但后续不是直接清除可回收对象,而是让所有存活对象向一端移动,然后直接清除边界以外的内存。

3.4、分代收集算法

当前商业虚拟机的垃圾回收都采用”分代收集”算法,根据对象存活周期将内存分为几块并根据不同特点采用不同垃圾收集算法。

一般是把Java堆分为新生代和老年代,这样可以根据各个年代的特点采取合适的算法。在新生代中,每次垃圾收集都发现有大量对象死去,少量对象存活,那就选用复制算法。只需要付出少量存活对象的复制成本就可以完成收集。而老年代中对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收

4、小结

1、垃圾回收主要针对堆内存进行

2、垃圾回收首先要知道什么是垃圾,即不再被使用的内存,引用计数法和可达性算法用于判断内存是否是垃圾

3、JVM中不止分为引用和无引用,引用被扩展为强、软、弱、虚四种类型

4、常见标记回收垃圾的算法有标记清除、复制算法、标记整理算法。当前商业虚拟机垃圾回收采用分代收集算法