垃圾收集器与内存分配策略——深入理解Java虚拟机 笔记二

  在本篇中,作者大量篇幅介绍了当时较为流行的垃圾回收器,但现在Java 14都发布了,垃圾收集器也是有了很大的进步和发展,因此在此就不再对垃圾收集器进行详细的研究。但其基本的算法思想还是值得我们参考学习的。

概述

   第一篇笔记Java内存区域与内存溢出异常中讲到了,Java的内存划分可以分为由所有线程共享的Java堆和方法区,以及每个线程之间相互独立的程序计数器、本地方法栈、虚拟机栈。其中每个线程相互独立的部分,并不会给垃圾回收造成困难。因为随着方法、线程的结束,内存自然就释放了。因此我们垃圾收集关注的,都是Java堆和方法区这部分内存,因为这部分内存的回收和分配都是动态的。

对象存亡的判别

   在对垃圾回收之前,我们应该先判断哪些对象没有用应该被回收、哪些对象应该被保留。

引用计数算法

   引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用此对象,计数器加1,当引用失效时,计数器减1,任何时候计数器为0的对象就是不可能再被使用的。
   这种判别方法简单易行,但是Java语言并没有使用这样的方法来判别对象,主要的原因是这种算法无法解决对象之间相互调用的情况。
   比如有这样的代码段:对象a和对象b之间相互引用,然后把他们全赋值为null,此时他们不可能再被访问,但是因为他们相互调用,引用计数器不为0,则无法释放对象。

public class TestGC {
    public Object obj = null;
    public static void main(String[] args) {
        TestGC a = new TestGC();
        TestGC b = new TestGC();
        a.obj = b;
        b.obj = a;
        a = null;
        b = null;
    }
}

根搜索算法

   为了解决上面提到的相互引用的问题,Java采用根搜索算法来判别对象是否存活。
   根搜索算法的基本思路是:通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到"GC Roots"没有任何引用链相连的时候,则证明该对象是不可用的。
   在Java中,可以作为"GC Roots"的对象包括:
   1、虚拟机栈(栈帧中的本地变量表)中的引用的对象。
   2、方法区中的类静态属性的引用对象。
   3、方法区中的常量引用的对象。
   4、本地方法栈中JNI(native方法)的引用对象。
   对于如下的图,即使Object4和object5有链接关系,但是因为没有跟"GC Roots"相连,所以仍然认为是不可用的对象。

方法区中的判别

   相对于堆中垃圾回收相比,方法区中的垃圾回收的性价比比较低,在堆中,尤其是在新生代中,常规垃圾回收一次可以回收70%~95%的空间。而永久代的垃圾收集效率远低于此。
   永久代的垃圾回收主要回收两个部分:废弃常量和无用的类。
   回收废弃常量很简单,只要判别没有对象引用此常量即可。而收集无用的类就要复杂许多。一般来说,类要满足如下三个条件才能看做无用的类:
   1、该类的所有实例都已经被回收,也就是Java堆中不存在任何该类的实例。
   2、加载该类的ClassLoader已经被回收。
   3、该类对应的java.lang.class对象没有在任何地方被引用。无法在任何地方通过反射访问该类的方法。

   在大量使用反射、动态代理的框架中,以及动态生成JSP的频繁自定义ClassLoader的场景,都需要虚拟机具有类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

  只是对于算法思路的讲解,而忽略具体的实现细节。

标记-清除算法

   标记-清除算法是最基础的算法,它首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
   这个算法的好处是,实现简单,但是它有两个严重的问题:1、效率低下,标记和回收的效率都不高。2、空间问题,因为标记回收会造成大量的不连续的内存碎片,当一个较大的对象需要分配时,往往会因为找不到对应连续的存储空间而多次执行垃圾收集工作。
   虽然标记-清除算法有这样的缺点,但其他的算法大多是在此算法的思路上进行优化,改进其存在的问题。
   标记-清除算法的示意图如下:

复制算法

   为了解决效率问题,复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后把已使用过的内存空间全部清理。这样做的好处是不用考虑内存碎片问题,只要按顺序分配内存即可,而且每次只要对一块内存进行清理。但缺点就是使实际可用的内存空间减小。
   复制算法的示意图如下:

   但是,实际根据研究表明,新生代中98%的对象都是创建后存在很少一段时间就不可用了,因此不需要按照1:1的情况进行划分。因此这个算法的实际思路其实就是:进行一遍标记后,让还存活的保留下来,然后顺序安置到另一个区域,再重新使用该空间进行接下来变量的存储。
   在作者写作时,表示商业虚拟机都采用此复制算法进行回收新生代。而内存划分为1个eden和2个survivor,每次使用eden和一个survivor来存储,另一个survivor来存储保存下来的对象。通常eden和survivor的大小比例是8:1.
   但如果有超过10%的对象存活,就需要依赖其他内存(如老年代)来进行分配担保。

标记-整理算法

   该算法与标记-清除算法相比,不同就是此算法会将仍然存在的对象往前移动,从而解决了大量内存碎片的问题。
   其示意图为:

分代收集算法

   该算法没有新的思想,只是按照对象存活周期将内存划分为几块,然后不同的内存划分对应于不同时期的对象,采用不同的垃圾收集算法。

内存分配与回收策略

   内存分配有几条普遍的规则:

对象优先在Eden区分配。

   大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配的时候,虚拟机将发起一次Minor GC.

大对象直接进入老年代。

   大对象就是需要大量连续内存空间的Java对象。像很长的字符串、数组之类。

长期存活的对象将进入老年代

   虚拟机给每个对象定义了一个对象年龄计数器,对象每次在Minor GC下存活一次则加1.当达到阈值时,可以晋升为老年代。

动态对象年龄判定

   并不是所有情况下,都必须要达到阈值才能晋升为老年代,如果Survivor中对象过多,也可以提前进入老年代。

空间分配担保

   新生代使用复制算法时,并不能保证每次存活的对象都小于Survivor空间大小,如果超出了的话,就需要借用老年代的空间进行对象的存储。

posted @ 2020-05-15 12:00  小新而已  阅读(164)  评论(0编辑  收藏  举报