转载

阿里面试官问我JVM垃圾回收算法,还好我看了这篇

程序计数器、虚拟机栈、本地方法栈都是线程私有的,会随着线程而生,随线程而灭;
栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈操作.
每个栈帧中的本地变量表都是在类被加载的时候就确定的,每一个栈帧中分配多少内存基本上是在类结构确定时就已知了,因此这几块区域内存分配和回收都具备确定性,就不需要过多考虑回收问题了.

然而,Java堆和方法区中的内存清理工作就没那么容易了.
堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时.因此它们没法随着线程的创建而创建、线程的结束而释放.

Java堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定.
方法区中存放类信息、静态成员变量、常量.类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类.因此,JVM究竟要加载多少个类也需要在程序运行期间确定.
因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一点心思.

1 Java堆内存的回收

1.1 如何判定哪些对象需要回收?

在对堆进行对象回收之前,首先要判断哪些是无效对象.我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收.一般有两种判别方式:

  • 引用计数法 (Reference Counting)
    每个对象都有一个整型的计数器,当这个对象被一个变量或对象引用时,该计数器加一;当该引用失效时,计数器值减一.当计数器为0时,就认为该对象是无效对象.
  • 可达性分析法 (Reachability Analysis)
    所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象.
    这里写图片描述
    GC Roots对象包括:
    -虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI(即所谓的Native方法)引用的对象

GC Roots并不包括堆中对象所引用的对象!这样就不会出现循环引用.

  • 两者对比
    引用计数法虽然简单,但存在无法解决对象之间相互循环引用的严重问题,且伴随加减法操作的性能影响.
    因此,目前主流语言均使用可达性分析方法来判断对象是否有效.

2 回收无效对象的过程

当经可达性算法筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会,具体过程如下:

  • 判断该对象是否覆盖了finalize()方法
  • 若已覆盖该方法,且该对象的finalize()方法还未被执行过,那么就会将finalize()扔到F-Queue队列中;
  • 若未覆盖该方法或已调用过finalize()方法,则直接释放对象内存
  • 执行F-Queue队列中的finalize()方法
    虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束.如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除
  • 对象重生或死亡
    如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了.如果没有,那么就会被垃圾收集器清除.

注意:强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally或者其他方式都能做得更好.
因为finalize()不确定性大,开销大,无法保证各个对象的调用顺序.

以下代码示例看到:一个对象的finalize被执行,但依然可以存活
/** * 演示两点: * 1.对象可以在被GC时自救 * 2.这种自救机会只有一次,因为一个对象的finalize()方法最多只能被系统自动调用一次,因此第二次自救失败 * @author sss * @since 17-9-17 下午12:02 * */
public class FinalizeEscapeGC {

    private static FinalizeEscapeGC SAVE_HOOK = null;

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

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


    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        // 对象第一次成功自救
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5s以等待它
        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 :(");
        }
    }
}

运行结果

finalize methodd executed!
yes,I am still alive :)
no,I am dead :(

3 方法区的内存回收

如果使用复制算法实现堆的内存回收,堆就会被分为新生代和老年代,新生代中的对象"朝生夕死",每次垃圾回收都会清除掉大量的对象;而老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除掉.

由于方法区中存放生命周期较长的类信息、常量、静态变量,因此方法区就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉.

方法区中主要清除两种垃圾:

  1. 废弃常量
  2. 废弃的类

3.1 回收废弃常量

回收废弃常量和回收对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除.

3.2 回收无用的类

判定无用类的条件则较为苛刻:

  1. 该类所有对象实例都已被回收,即Java堆不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类的java.lang.Class对象没有被任何对象或变量引用 ,无法通过反射访问该类的方法
    只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class.这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除.

4 垃圾收集算法

现在我们知道了判定一个对象是无效对象、判定一个类是无用类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据.

4.1 标记-清除算法(Mark-Sweep)

最基础的收集算法,因为后续的算法也都是基于此思路并对其不足进行改进而得.

首先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据.

不足:
这种算法标记和清除过程效率都不高,且标记清除之后存在大量不连续的内存碎片,导致日后因为存储大对象时无法找到足够连续内存而提前触发另一次GC,降低了空间利用率.
这里写图片描述

4.2 复制算法(Copying)

  • 将内存分成大小相等两份,只将数据存储在其中一块上.当需要回收时,也是首先标记出废弃的数据,然后将有用的数据复制到另一块内存上,最后将第一块内存全部清除.
    这里写图片描述

  • 分析
    这种算法避免了空间碎片,但内存缩小了一半.
    而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高.

  • 解决空间利用率问题
    在新生代中,由于大量的对象都是"朝生夕死",也就是一次垃圾收集后只有少量对象存活,因此我们可以将内存划分成三块:Eden、Survior1、Survior2,内存大小分别是8:1:1.分配内存时,只使用Eden和一块Survior1.当发现Eden+Survior1的内存即将满时,JVM会发起一次MinorGC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块Survior2中.接下来就使用Survior2+Eden进行内存分配.
    通过这种方式,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题.

  • 什么是分配担保?
    当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发MinorGC,对该区域的废弃对象进行回收.但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区.这个过程就是"分配担保".

4.3 标记-整理算法(Mark-Compact)

效率偏低。

在回收垃圾前,标记过程仍与"标记-清除"算法一样,但后续不是直接清理可回收对象,而是让所有存活的对象移到一端,然后直接清掉端边界之外的内存.

  • 分析:
    它是一种老年代的垃圾收集算法.老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用"复制"算法,每次需要較多的復制操作,效率很低.
    而且,在新生代中使用"复制"算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行"分配担保";
    而如果在老年代使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象时,没有其他区域给他作分配担保.
    因此,老年代中一般使用"标记-整理"算法.

4.4 分代收集算法(Generational Collection)

当前商业虚拟机都采用此算法.根据对象存活周期的不同将Java堆划分为老年代和新生代,根据各个年代的特点使用最佳的收集算法.

  • 老年代中对象存活率高,无额外空间对其分配担保,必须使用"标记-清理"或"标记-整理"算法;
  • 新生代中存放"朝生夕死"的对象那就用复制算法,只需要付出少量存活对象的复制成本就可以完成收集.

5 Java中引用的种类

Java中根据生命周期的长短,将引用分为4类

  • 强引用
    我们平时所使用的引用就是强引用.
    类似A a = new A();
    也就是通过关键字new创建的对象所关联的引用就是强引用.
    只要强引用还存在,该对象永远不会被回收.

  • 软引用
    一些还有用但并非必需的对象
    只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象.
    软引用通过SoftReference类实现.
    软引用的生命周期比强引用短一些.

  • 弱引用
    也是描述非必需对象,比软引用更弱
    所关联的对象只能存活到下一次GC发生前.
    只要垃圾收集器工作,无论内存是否足够,弱引用所关联的对象都会被回收.
    弱引用通过WeakReference类实现.

  • 虚引用
    也叫幽灵(幻影)引用,最弱的引用关系.
    它和没有引用没有区别,无法通过虚引用取得对象实例.
    设置虚引用唯一的作用就是在该对象被回收之前收到一条系统通知.
    虚引用通过PhantomReference类来实现.

正文到此结束
本文目录