JVM 垃圾回收

JVM 垃圾回收

老闫师傅老闫师傅
在寒假时,看完了周志明老师的《深入理解JAVA虚拟机》,书中有专门的一章来讲解垃圾回收,又在一次面试时被问到,感觉垃圾回收是JVM的一个很重要的机制,所以打算专门写一篇博客。

要想搞明白垃圾回收,就是要搞明白四个问题

在JVM的哪块内存中发生垃圾回收?

哪些对象需要被回收?

什么时候回收?

怎样回收这些对象?

首先是第一个问题:在JVM的哪块内存中发生垃圾回收?

这里,我们要先说一下JVM的内存分区。

1.程序计数器:当前线程所执行的字节码的行号指示器,控制着程序的分支、循环、跳转、异常处理。是线程私有的。

2.虚拟机栈:为每个方法在执行时创建一个栈帧,用于存储局部变量表、操作数栈栈等。其中局部变量表存储基本数据类型和对象引用。是线程私有的。

3.JAVA堆:所有对象实例都在堆上分配。所有线程共享此块内存。

4.方法区:所有线程共享的内存区域,用于存储虚拟机加载的类信息、常量、静态变量等。

看完上面对于JVM内存的各个区域介绍,应该已经知道了垃圾回收发生在哪块区域了。由于垃圾回收是回收和对象有关的东西,所以主要发生在虚拟机栈和堆中;少部分发生在方法区。

下图更详细的说明了垃圾回收发生的位置:

本地方法栈与虚拟机栈相似,经常在一些虚拟机实现中被合并起来。


下来是第二个问题,哪些对象需要被回收?

其实这个问题很好回答,当然是不用的对象需要被回收啦!但是JVM不知道哪些对象是你不用的。所以就有了另一种判断方法:不能用的对象就是你不需要用的,因为你就算想用也用不到,所以必然是不用的对象。

所以就有了引用计数法来判断一个对象是否可以使用。

引用计数法非常简单——给一个对象添加一个引用计数器,初始值为零,有一个地方引用它时,计数器加一,当一个引用失效时,计数器减一。计数器为零时此对象不可用。

下面就是对一个引用计数的举例:

引用计数法有一个致命的缺陷,如下图,就是当两个对象相互引用时,这两个对象实际是不可获得的,但是由于引用计数不为零,所以均不会被回收。

为了解决这个问题,就有了可达性分析法:

算法的思路是通过一系列称为GC Roots的对象作为起始点,从这些节点向下搜索,当一个对象到GC Roots时没有任何引用链相连,证明此对象是不可用的。如下图,从GC Root不能到达ObjD ObjF ObjE,所以这三个对象是不可用的。

可做GC Roots的对象有 虚拟机栈中引用的对象(本地变量表)、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象(Native对象)。

接下来要说说JVM中的四种引用:

1.强引用:程序中普遍存在的一种引用 Object o = new Object();垃圾收集器永远不会回收被强引用的对象。

2.软引用:如下所示,当我们存在有用但不是必需的对象时,例如缓存,就可以使用软引用。

只要内存空间足够,软引用对象就不会被回收,将要发生内存溢出异常时,会将软引用的对象回收。

SoftReference<String> sr = new SoftReference<String>(new String("hello"));

3.弱引用:弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。

WeakReference<String> sr = new WeakReference<String>(new String("hello"));

4.虚引用:虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。

  要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);

下面是第三个问题:什么时候发生垃圾回收?(此处我说的不够准确,可以看一下大神的回答 :Major GC和Full GC的区别是什么?触发条件呢?)


首先需要知道,GC又分为minor GC 和 Full Gc(也称为Major GC)。Java 堆内存分为新生代和老年代,新生代中又分为1个Eden区域 和两个 Survivor区域。

那么对于 Minor GC 的触发条件: 大多数情况下,直接在 Eden 区中进行分配 。如果 Eden区域没有足够的空间,那么就会发起一次 Minor GC;对于 Full GC(Major GC)的触发条件:也是如果老年代没有足够空间的话,那么就会进行一次 Full GC。

实际上,需要考虑一个空间分配担保的问题

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于则进行Minor GC,如果小于则看HandlePromotionFailure设置是否允许担保失败(不允许则直接Full GC)。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则尝试Minor GC(如果尝试失败也会触发Full GC),如果小于则进行Full GC。

接下来是最后一个问题:怎样进行垃圾回收?

这一部分主要说说垃圾回收的几种算法思路。

标记-清除算法:先标记出需要被回收的对象,然后全部清除,如下图所示:

这种算法有两个严重的问题,一是标记清楚的效率不高,二是产生内存碎片

为了解决效率低的问题,有了复制算法:将内存划分为相等的两块区域A和B,一次只用其中一块A,当需要垃圾回收时,将A中所有存活的对象复制到B,然后清楚A,使用B。就这样周而复始。但是也有一个明显的问题:可使用的内存大小只有一半。

为了解决内存碎片问题,又有了标记整理算法:先标记,然后让所有存活对象向另一端移动,然后直接清理端边界以外的内存。如下图:

我们将以上两种方法结合起来,就可以得到一种较为两全的方法——分代收集法:这里再说一遍上面提到过的老年代和新生代。

新生代:

所有新对象创建发生在Eden区,Eden区满后触发新生代上的minor GC,将Eden区和非空闲Survivor区存活对象复制到另一个空闲的Survivor区中。

永远保证一个Survivor是空的,新生代minor GC就是在两个Survivor区之间相互复制存活对象,直到Survivor区满为止。

由于新生代大多数对象是“朝生夕死”的所以,对于新生代采用有两个很小的Survivor区、一个大的Eden区,使用复制算法的原理进行回收:一次使用一个Survivor区1和Eden区,生还的对象移入另一个保留区2,然后清空所有,周而复始。




老年代:

Eden区满后触发minor GC将存活对象复制到Survivor区,Survivor区满后触发minor GC将存活对象复制到老年代。

经过新生代的两个Survivor之间多次复制,仍然存活下来的对象就是年龄相对比较老的,就可以放入到老年代了,随着时间推移,如果老年代也满了,将触发Full GC,针对整个堆(包括新生代、老年代)进行垃圾回收。

老年代大多数对象会长期存活,不适合复制算法,所以使用标记-整理算法。

到这,上面所说的四个问题就全部回答完了。

下面还有两个小问题:

1.为什么C/C++没有垃圾回收机制?

因为C/C++可以直接操控内存地址,以至于不能判断出哪个对象是可用或不可用,因为每个对象都是可以通过内存直接获得的。

2.避免FullCG。

将转移到老年代的对象数量降到最少。

可以通过增加新生代空间的大小来减少进入老年代的对象数量。

减少Full GC的执行时间。

如果你试图通过消减老年代空间来减少Full GC的执行时间,可能会导致OutOfMemoryError 或者 Full GC执行的次数会增加。与之相反,如果你试图通过增加老年代空间来减少Full GC执行次数,执行时间会增加。

所以需要将老年代设定为一个合适的值。

这些就是我对JVM中垃圾回收的理解,不足之处希望指出。

28 条评论