代码改变世界

Java的垃圾回收

2012-12-11 16:31  ggzwtj  阅读(1489)  评论(2编辑  收藏  举报

GC也是一种内存管理吧,也许是因为第一次在Java中听说这个名词,所以涉及到Java的很多时候会被人问起。

首先,什么是垃圾?

  直观上看垃圾就是没有用的东西,事实也是这样,前面分配过的内存如果在以后都不会被用到了那么就可以认为这类的内存是垃圾。从这个角度上看,任何在分配内存的地方都可能产生垃圾。大部分分配内存的行为都是为了对象,那么大部分的垃圾也就产生在堆上了。但是类的信息保存也需要内存啊(况且也会对应一个Class对象),所以方法区上也应该会有垃圾回收。

然后,如何识别垃圾?

  很多的内存管理都是基于标记计数的(比如内核里面),在引用的时候+1,在释放的时候-1,在发现计数值为0的时候这块内存就不再需要了。但是为什么很多地方都不会使用引用计数?最致命的问题引用计数只是一个充分非必要条件,也就是说:引用计数为0的时候内存不会再被使用,而不会再被使用的内存引用计数不一定为0。最简单的例子就是循环引用了:如果A引用了B,同时B又引用了A,那么这两对象的计数永远也都不会是0了(那么这两个对象占用的空间也当然用于不会释放)。那么什么是充分必要条件?很简单,根据垃圾的定义去判断就可以了:对象的访问肯定是通过引用来实现的,如果访问不到了那就说明是垃圾,那么在判断的时候我们只需要顺着引用去遍历也次就可以找到所有正在使用的内存(剩下的就是垃圾了)。

在Java中可以作为根的对象包括以下几种:

  1. 虚拟机栈中引用的对象;
  2. 方法区中的类静态属性引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中JNI的引用的对象;

那么,如何回收?

  其实对象并非只有“有用”和“没用”这两种状态,因为在GC之前还会有机会调用finalize方法,在改方法中对象可能复活(但是只能复活一次)。那么,指向对象的引用可能有以下四种:

  1. 强引用;
  2. 软引用;
  3. 弱引用;
  4. 虚引用;

最简单的回收算法是“标记-清除算法”,其实就是标记出来垃圾然后将其释放:

从上图中也非常容易看到这种算法的缺点:容易产生内存碎片,另一个不太明显的缺点就是效率低。为什么效率低呢?因为堆上面大部分的对象是垃圾(至于为什么就不多说了),而在该算法中删除的过程重要处理的就是这部分,所以会慢一点。

复制算法”正好可以解决这个问题:

实现的时候将内存分成两部分,每次只是用其中的一部分,在进行垃圾回收的时候将正在使用的对象拷贝到另一半的内存中。但是,这样很明显会浪费很多的内存,那么怎么优化呢?还是从垃圾比较多的角度来想。每次回收之后的对象占用的空间其实很小的,也就是只要很少的内存,也就是:

较大的一块内存区域是Eden,还有两个较小的Survivor,具体的过程如上(两个Surivior交替使用)。当然Survivor的大小可能不够用,这个时候就需要“内存担保”了。那么现在再来看下垃圾回收的过程中还有没有做一些无用功?堆上的一些对象可能有很长的生命周期(在Web应用中更是如此),那么这些对象就会被不停地在两个Survivor之间拷来拷去。这样也就有了“分代收集算法”:

  其实分代收集并没有什么特别的地方,如果一个对象的生命周期比较长,那么就把他放到老年代中。那怎么去预测一个对象的生命长短?一个很简单的方法就是如果活了足够久的时间,那么我们就认为这个对象会继续活很久(当然这个阀值可以去动态计算),当然也有可能刚进入老年代就变成垃圾了,不过这个概率应该很小。老年代是没有内存担保的(还有一个原因就是老年代中的存活率很高),所以适合用“标记-清理”的方式来收集。

上面的这些算法都有一个共同的问题:垃圾回收都是全量的。如果全量收集消耗了很多的时间,那么用户程序的体验就会下降很多(尤其是搜索引擎)。那么每次不是全量地,而是只回收一部分的垃圾,这样就能减少一次回收的时间(也就减少了用户程序的间隔),那么就有了“火车算法”:

 

首先将内存分成不同的块,回收也是针对块来操作的,所以检查的是有没有外部的引用指向块内的对象,主要的操作如下:

  1. 新的对象要不被打包成车厢挂到现有的火车尾部,或者当成一辆新的火车进站;
  2. 任何被其他车厢引用的对象都移出去;
    1. 如果存在来自非成熟对象空间的引用,将其移到其他的火车上去;
    2. 如果被其他火车引用,移动到对应的火车上去;
    3. 1、2完成之后,将被本列火车引用的对象移动到最后一节车厢去;
  3. 当然,可能编号最小的车厢整个就被回收了;

上面讨论的都是回收算法,当然,算法都是去适应不同的场景,很少见一个算法能很好的处理各种情况。那么,如果利用这些算法来完成垃圾回收的任务则是垃圾回收器要干的事:

Serial Old收集器如下:

 

使用的算法是“标记-清理”,关键是单线程的。Parallel Old则仅仅是将Serial Old改成了多线程形式。

CMS做了很多让垃圾回收和用户进程并行的努力:

其中:

  • 初始标记:只标记被GC Roots直接引用的对象,非常快;
  • 并发标记:也就是GC Roots Tracing;
  • 重新标记:修正并发标记期间由用户程序改变的对象的标记记录;
  • 并发清理:清除垃圾对象;
  • 重置线程:重置CMS的数据结构,等待下一次的垃圾回收;

CMS的优点是能使得最费时的标记和清理阶段和用户线程并行,使得Stop The World(很酷的名字吧)的时间尽量变短,但这也是一把双刃剑,由于占用了CPU资源用户程序执行的速度当然是会变慢。另一个缺点是,CMS采用“标记-清理”的算法,这样就容易产生内存碎片。