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

深入理解Java虚拟机第三章总结

Posted by 袁平 on September 28, 2018

前言

本文为«深入理解Java虚拟机»第三章以及部分博客内容总结, 权作个人笔记~

: 文中部分图片来自: http://hllvm.group.iteye.com/group/wiki/?show_full=true


正文


一. 对象是否死亡

进行垃圾收集首先需要的就是判断对象是否死亡, 也就是是否可以清除收集, 下面将讲解判断对象是否死亡的方法

1.1 引用计数算法

1.1.1 原理

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

1.1.2 优缺点

优点: 实现简单, 效率高

缺点: 无法解决循环引用的问题, 如下;

主流Java虚拟机中没有使用引用计数来管理内存的

public class ReferenceCountingGC {

    public Object instance = null;
    private final int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB]; // 占用内存, 以便GC日志中看清楚是否被回收过

    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        System.gc();
    }
}

1.2 可达性分析算法

1.2.1 原理

通过一系列成为GC Root的对象作为起始点, 从这些节点开始往下搜索, 搜索走过的路径称为引用链, 当一个对象到GC Root没有任何引用链相连时, 则证明此对象是不可引用的; 如下图(图片来自)

GC Root

可作为GC Root的对象:

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

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

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

  4. 本地方法栈中JNI(Native方法)引用的变量

使用可达性分析算法时, 至少需要经过两次标记过程才能宣告一个对象真正死亡: 如果对象在进行可达性分析后发现没有与GC Root相连接的引用链, 那它会被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法, 当对象没有覆盖finalize()方法时, 或者finalize()方法已经被虚拟机调用过, 虚拟机将这两种情况都视为没有必要执行(也就是说finalize()方法最多执行一次, 这点需要注意~); 如果这个对象被判定为有必要执行finalize()方法, 那么这个对象将会放置在一个叫做F-Queue的队列中, 并在稍后由一个由虚拟机自动建立, 低优先级的Finalizer线程去执行它, 但并不承诺会等待finalize()方法运行结束(这里也需要注意, 这是为了防止finalize()中出现耗时操作甚至死循环); 在finalize()中对象可以使自己再次加入引用链中, 逃脱GC; 但是不建议使用该方法来逃脱GC

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC finalizeEscapeGC = null;

    int count = 0;

    public void isAlive() {
        System.out.println("Yes, I am alive :) ");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed");
        FinalizeEscapeGC.finalizeEscapeGC = this; // 再次进入引用链
        // 经过验证, 虚拟机不会等待finalize()执行完毕
//        while (true) {
//            System.out.println(count++);
//        }
    }

    public static void main(String[] args) throws InterruptedException {
        finalizeEscapeGC = new FinalizeEscapeGC();
        finalizeEscapeGC = null;
        System.gc();
        Thread.sleep(500);
        if (finalizeEscapeGC == null) {
            System.out.println("No, I am dead :(");
        } else {
            finalizeEscapeGC.isAlive();
        }

        finalizeEscapeGC = null;
        System.gc();
        Thread.sleep(500);
        if (finalizeEscapeGC == null) {
            System.out.println("No, I am dead :(");
        } else {
            finalizeEscapeGC.isAlive();
        }
    }
}

上述程序运行结果为: 可以看出, finalize()确实被调用了, 而且只被调用了一次

finalize method executed
Yes, I am alive :)
No, I am dead :(

1.3 再谈引用

JDK1.2以前, Java引用很传统: 如果reference类型的数据中存储的数值代表的是另一块内存的起始地址, 就称这块内存代表着一个引用

但是实际我们希望的是: 当内存空间还足够时, 能够保留在内存中; 当内存空间在进行垃圾收集后还是非常紧张, 则可以抛弃某些对象

JDK1.2之后, 扩充了引用概念:

: 以下引用强度递减

  1. 强引用: 类似Object obj = new Object()的引用; 这类引用永远不会被垃圾收集器回收

  2. 软引用: 有用但非必需的对象; 在系统将要发生内存溢出之前, 将回收这部分对象, 如果回收之后内存仍然不够, 才会内存溢出

  3. 弱引用: 无论当前内存是否足够都会回收掉只被弱引用关联的对象

  4. 虚引用: 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过一个虚引用来取得对象实例; 为一个对象设置虚引用关联的唯一目的是能在这个对象被收集器回收之前收到一个系统通知

1.4 方法区回收

GC的主要区域是堆, 但是方法区也会进行GC; 方法区的GC主要是废弃常量和无用的类

废弃常量好理解, 和堆中对象回收一样, 没有引用即回收

无用类需要满足条件:

  1. 该类的所有实例都已经被回收, 也就是Java堆中不存在该类的任何实例

  2. 加载该类的ClassLoader已经被回收

  3. 该类对应的java.lang.Class对象没有在任何地方被引用, 无法在任何地方通过反射访问该类的方法


二. 垃圾收集算法

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

2.1.1 原理

先标记, 后回收咯

如下图:

标记清除算法

2.1.2 优缺点

  1. 标记和清除的效率都不高

  2. 产生大量不连续的内存碎片

2.2 复制算法

多用于新生代

2.2.1 原理

将内存分成相等的两块, 每次只使用其中一块, 当这一块用完了, 就将还存活的对象复制到另一块, 然后把使用过的内存空间一次清掉

如下图:

复制算法

2.2.2 优缺点

  1. 效率高, 无碎片

  2. 每次可使用内存减半

2.3 标记-整理算法

多用于老年代

2.3.1 原理

让所有存活对象都向一端移动, 然后清理掉端边界以外的内存

如下图:

标记整理算法

2.4 分代收集算法

2.4.1 分代

分代的依据是: 不同对象的生命周期不同; 因此不同对象可以采用不同的回收方式, 以便提高回收效率; 因为如果每次都对整个堆进行回收遍历的话, 对于生命周期较长的对象而言, 实际上有很多次回收都是不必要和没用的

分代:

分代

分为年轻代(Young Generation), 老年代(Old Generation)和永久代(Permanent Generation); 永久代主要存放的是Java类的类信息, 与垃圾收集要收集的Java对象关系不大; 年轻代和老年代的划分是对垃圾收集影响比较大的

2.4.2 原理

新生代采用复制算法, 有老年代作空间担保; 老年代中因为对象存活率高, 没有额外空间对它进行分配担保, 就必须使用标记-清理或者标记-整理算法

关于分代收集算法更详细信息, 可以参见博客: http://hllvm.group.iteye.com/group/wiki/2863-JVM


三. 内存分配与回收策略

  1. 对象优先在Eden分配, 当Eden没有足够空间时, 将发起一次Minor GC(Minor GC指发生在新生代的GC, 较为频繁, 但是速度也快; Major GC指的是发生在老年代的GC; Full GC指的是发生在整个堆空间的GC, 包括老年代和新生代)

  2. 大对象直接进入老年代, 这是为了防止在新生代的Eden和两个Survivor区之间发生大量的内存复制

  3. 长期存活的对象直接进入老年代; 虚拟机给每个对象定义一个年龄计数器, 如果对象在Eden区出生并经过第一Minor GC后仍然存活, 并且能被Survivor区容纳, 将被移到Survivor区中, 并且对象年龄设置为1, 对象在Survivor区中没度过一次Minor GC, 年龄就增1, 当其年龄达到一定程度(默认15岁), 则移入老年代

  4. 动态对象年龄判定, 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 而不必等到年龄达到阈值

  5. 空间分配担保: 因为新生代采用复制算法, 可能会出现复制空间不足的情况, 老年代可以为新生代作内存空间担保

四. Others

这里提一些其他的要点~

  1. GC时会发生Stop the World, 由此可以联想, 也有可能由于GC造成的Android白屏~

  2. 安全点和安全区: 引用关系不变; GC只有在程序执行达到安全点或者安全区的时候才能进行; 如何让所有线程都跑到最近的安全点? 有两种方法: 抢先式中断(GC时, 首先把所有线程全部中断, 如果发现有线程不在安全点上, 则恢复该线程, 让它跑到安全点)和主动式中断(GC时, 不直接对线程中断, 仅仅简单地设置一个标志, 各个线程执行时主动去轮询该标志, 发现该标志为真时就自己中断挂起)(多采用主动式中断)

五. 参考链接

  1. http://hllvm.group.iteye.com/group/wiki/?show_full=true