JVM(6)垃圾回收

185 阅读13分钟

一.垃圾回收原理

GC的基本原理:将内存中不再使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一定的资源和时间,Java在对对象的生命周期进行分析后,按照新生代、老年代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停。

  • 对新生代的对象收集称为minor GC
  • 对老年代的对象的收集称为Full GC
  • 程序中主动调用Syste.gc()强制执行的GC为Full GC 不同的对象引用类型,GC会采用不同的方法回收,JVM对象的引用分为了四种类型:
    • 强引用: 默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)
    • 软引用: 将对象用SoftReference软引用类型的对象包裹,正常情况下不会被回收,但是GC做完后发现释放不出空间存放新对象,就会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。 public static SoftReference user = new SoftReference(new User()); 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从 缓存中取出呢?这就要看具体的实现策略了。 (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建 (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。
    • 弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用。
    public static WeakReference<User> user = new WeakReference<User>(new User());
    • 虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。

二.如何标记垃圾

在没有任何引用指向一个对象时称为垃圾,通过引用计数法/根可达算法来标记垃圾。JVM内存区域包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具有确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就回收了。而Java堆区和方法区就不一样了,这部分内存的回收时动态的,正是垃圾回收器所需关注的部分。

1. 引用计数器

引用计数器是垃圾收集器中的早期策略。在这种策略中,每个对象都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其他变量被赋值为这个对象的引用时,计数加1,如(a=b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设为为一个新值时,对象实例的引用计数器减1(如可达性算法 不可达 或 生命周期指:Eden区Survival1 Survival2来回移动达到最大年龄15时(-XX:MaxTenuringThreshold) 将被回收)。任何引用计数器为0的对象实例可以被当作垃圾回收。当一个对象实例被垃圾回收时,它引用的任何对象实例的引用计数器减1。

  • 优点:引用计数器可以很快执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
  • 缺点:无法检测处循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();

        object1.object = object2;
        object2.object = object1;

        object1 = null;
        object2 = null;
    }

这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

2. 可达性分析

可达性算法是目前主流虚拟机都采用的算法,程序把所有的引用关系看作一张图,从一个节点GC Roots开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点则会被判定为可回收的对象。
在Java语言中,可作为GC Roots的对象包含下面几种:

  • 虚拟机栈中的引用的对象(栈帧中的本地变量表);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。

7e36e24fbd50451bbac120d6e75cdc08.png 可以得出对象实例1、2、4、6都具有对象可达性,也就是存活对象,不能被GC回收的对象。而随想实例3、5虽然直接相连,但并没有任何一个GC Roots与之相连,即GC Roots不可达对象,就会被GC回收的对象。

三. 垃圾回收算法

3.1 标记-清除法

标记-清除算法分为标记清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

  • 标记阶段 标记的过程其实就是前面分析的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象。
  • 清除阶段 清除的过程是对堆内存进行遍历,如果发现某个对象没被标记为可达对象(通过读取header信息),则将其回收

3a9e4283c7854ff8bf3cf6017b7d0312.png
上图是标记/清除算法的示意图,在标记阶段,从对象 GC Root 1 可以访问到 B 对象,从 B 对象又可以访问到 E 对象,因此从 GC Root 1 到 B、E 都是可达的,同理,对象 F、G、J、K 都是可达对象;到了清除阶段,所有不可达对象都会被回收。 在垃圾收集器进行 GC 时,必须停止所有 Java 执行线程(也称"Stop The World"),原因是在标记阶段进行可达性分析时,不可以出现分析过程中对象引用关系还在不断变化的情况,否则的话可达性分析结果的准确性就无法得到保证。在等待标记清除结束后,应用线程才会恢复运行。

  • 标记/清除算法缺点:
    • 效率问题 标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是特别庞大的,而且GC需要停止应用程序,这会导致非常差的用户体验。
    • 空间问题 标记清除之后会产生大量不连续的碎片(上图可看出),内存碎片太多时会导致在程序运行过程中需要分配大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

3.2 复制算法

复制算法是将可用内存按容量划分为大小相等的两块,每次使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块内存上,然后把这一块内存所有的对象一次性清理掉。

97b709d573d343f7a3a48d08c22de024.png 复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。比如在新生代中,每次收集都会有大量对象(接近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对他进行分配担保,所以我们必须选择"标记-清除"或者"标记-整理"算法进行垃圾收集。注意,"标记-清除"或"标记-整理"算法会比复制算法慢10倍以上。
复制算法缺点:
复制算法简单高效,优化了标记清除算法的效率低、内存碎片多问题,存在缺点:

  • 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;
  • 如果对象的存活率很高,极端一点的情况假设对象存活率为 100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。

3.3 标记-整理算法

标记-整理算法算法与标记/清除算法很像,事实上,标记/整理算法的标记过程任然与标记/清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。

9284d2ccf3834a01b6b90f8e7e7b9995.png 可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,jvm 只需要持有内存的起始地址即可。标记/整理算法弥补了标记/清除算法存在内存碎片的问题消除了复制算法内存减半的高额代价,可谓一举两得。
标记-整理算法缺点:
● 效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。

3.4 分代回收算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法,他的思想是按对象的存活周期不同将内存划分为几块一般是把 Java 堆分为新生代和老年代(还有一个永久代,是 HotSpot 特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法。
特点:
新生代:朝生夕灭,存活时间很短。采用复制算法来收集 老年代:经过多次 Minor GC 而存活下来,存活周期长。采用标记/清除算法或者标记/整理算法收集老年代

新生代中每次垃圾回收都发现有大量的对象死去,只有少量存活,因此采用复制算法回收新生代,只需要付出少量对象的复制成本就可以完成收集; 老年代中对象的存活率高,不适合采用复制算法,而且如果老年代采用复制算法,它是没有额外的空间进行分配担保的,因此必须使用标记/清理算法或者标记/整理算法来进行回收。

722f41405acd4a088cfe12b0a0da482f.png

新生代中的对象“朝生夕死”,每次GC时都会有大量对象死去,少量存活,使用复制算法。新生代又分为Eden区和Survivor区(Survivor from、Survivor to),大小比例默认为8:1:1。
老年代中的对象因为对象存活率高、没有额外空间进行分配担保,就使用标记-清除或标记-整理算法。
新产生的对象优先进去Eden区,当Eden区满了之后再使用Survivor from,当Survivor from 也满了之后就进行Minor GC(新生代GC),将Eden和Survivor from中存活的对象copy进入Survivor to,然后清空Eden和Survivor from,这个时候原来的Survivor from成了新的Survivor to,原来的Survivor to成了新的Survivor from。复制的时候,如果Survivor to 无法容纳全部存活的对象,则根据老年代的分配担保(类似于银行的贷款担保)将对象copy进去老年代,如果老年代也无法容纳,则进行Full GC(老年代GC)。
大对象直接进入老年代:JVM中有个参数配置
-XX:PretenureSizeThreshold,令大于这个设置值的对象直接进入老年代,目的是为了避免在Eden和Survivor区之间发生大量的内存复制。
长期存活的对象进入老年代:JVM给每个对象定义一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳,将被移入Survivor并且年龄设定为1。没熬过一次Minor GC,年龄就加1,当他的年龄到一定程度(默认为15岁,可以通过XX:MaxTenuringThreshold来设定),就会移入老年代。但是JVM并不是永远要求年龄必须达到最大年龄才会晋升老年代,如果Survivor 空间中相同年龄(如年龄为x)所有对象大小的总和大于Survivor的一半,年龄大于等于x的所有对象直接进入老年代,无需等到最大年龄要求。

四 finalize()

finalize()方法最终判定对象是否存活
即使在可达性算法中不可达对象,也并非是非死不可的,这时候它们暂时处于“缓刑“阶段,要真正宣告一个对象的死亡,至少要经历再次标记过程,标记的前提是对象在进行可达性分析后发现没有与GC Roots相连的引用链

  1. 第一次标记并进行一次筛选,当对象没有覆盖finalize()方法时,对象将直接被回收。
    
  2. 第二次标记。如果这个对象覆盖了finalize()方法,finalize()方法是对象逃脱死亡命运的最后一次机会,如果对象在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或者对象的成员变量,那在第二次标记时它将移除出”即将回收”的集合。如果对象这时候还没逃脱,那基本上就被真正回收。
    
  3. 一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize()自我救命的机会只有一次。
    

示例代码:

五如何判断一个类是无用的类