JVM 垃圾回收原理及实践

JVM系列文章

  1. JVM 内存模型
  2. JVM 垃圾回收原理及实践

垃圾回收原理

Java 堆中存放着几乎所有的对象实例,回收之前要确定其是否存活着,有两种思路可以判断对象存活

  • 引用计数算法

如果有其他对象对其引用,则计数器加一,引用失效的时候,计数器减一。计数器为0时则可以被回收。

弊端:无法解决对象互相引用的问题,他们是内存中的孤岛,但却无法被回收 例如: A.a = B;B.b = A

  • 可达性分析算法

通过一系列称为GC Roots的对象作为起始点,然后向下搜索,走过的路径为引用链,如果一个对象到GC Roots 没有任何引用链,则说明此对象是不可达(不可用)的。

四种引用

  1. 强引用

普通的引用方式, A a = new A() , 只要强引用还存在,GC就不会回收。

  1. 软引用

描述有用但非必需的对象。在发生内存溢出异常之前,会把软引用的对象列为回收范围第二次再回收。如果第二次回收没有足够的内存,才抛出异常。 可以使用 SoftReference

  1. 弱引用

GC 总会回收掉被弱引用关联的对象。 WeakReference

  1. 虚引用(幽灵引用,影子引用)

最弱的引用。能在被GC回收时得到一个通知。 phantomReference

垃圾回收算法

  1. 标记-清除算法(Mark-Sweep)

标记阶段标记出所有要回收的对象,标记完成后统一回收。

缺点:效率不高;内存不连续,导致之后需要分配大对象时无法找到足够的连续内存而再次进行垃圾回收。

  1. 复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只用其中一块。用完一块后将上面还存活的对象复制到另外一块上。这样就没有碎片的问题了。

缺点:内存被缩小为之前的一半,代价太高。

但实际商业上新生代都是按照复制算法进行回收的,因为大多对象都是朝生夕死,所以并不是按1:1划分,而是将内存分为较大的 Eden空间和两块较小的 Survivor 空间。比例为8:1:1,这样可用空间就大大提升了。

  1. 标记-整理算法(Mark-Compact)

与标记-整理算法一样,但是不是直接对可回收对象进行清理,而是让所有存活的向一端移动,然后直接清理掉端边界以外的内存。

  1. 分代收集算法

本质上不是一种算法,而是根据对象存活周期的不同将内存划分为几块。Java 堆一般分为新生代和老年代。会根据不同代的特点选用不同的算法。

垃圾收集器

垃圾收集器是内存回收的具体实现,下面列举的是基于JDK 7的 HotSpot虚拟机。其中 CMS 和 G1 仅做简单介绍,但这两个收集器都是相对比较复杂的收集器。

  1. Serial(新生代) / Serial Old(老年代)

是一个单线程的收集器,在收集的时候必须暂停其他所有的工作线程(Stop the World),这样是为了避免一边打扫垃圾又一边制造垃圾

Serial 收集器到现在为止依然是 Client 模式下的默认新生代收集器,因为简单且高效。

  1. ParNew

多线程,其余与Serial一样。CMS(一个老年代的收集器)只能与 ParNew 或者 Serial 配合使用。

  1. Parallel Scavenge(新生代) / Parallel Old(老年代)

看起来与 ParNew 一样,但他的目标是吞吐量,即CPU用于运行用户代码的时间与CPU的总运行时间的比值。通常被称为吞吐量优先的收集器

  1. CMS

目标是以获取最短回收停顿时间为目标的收集器。

  1. G1

当今收集器技术发展的最前沿成果,主要面向服务端应用,目标是替换掉CMS收集器,特点有:

  • 并行与并发,可以缩短 Stop-The-World 时间
  • 分代收集,但是不用与其他的收集器配合,自己就可以管理整个堆
  • 空间整合
  • 可预测的停顿

内存分配与回收策略实践

这部分重要了解几个内存分配的常见情况,同时熟悉虚拟机参数的配置。

  1. 对象优先在Eden分配

Code Gist

虚拟机参数设置:-verbose:gc -Xms20M -Xms20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
Eden区属于新生代,优先在此区域分配,如果没有足够的空间会执行一次 Minor GC。Gist里的代码展示了分配3个2MB大小,一个4MB大小的对象,可以看到第一次执行了Minor GC,结果是6MB的对象移入了老年代,4MB的新对象放入了Eden区。 -XX:SurvivorRatio=8 是将 Eden与Survivor区域比值设置为8:1

可以看到经过一次GC,新生代中有6M的内容被移入了老年代。

Terminal1

  1. 大对象直接进入老年代

Code Gist

虚拟机参数设置:-verbose:gc -Xms20M -Xms20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728

通过设置 -XX:PretenureSizeThreshold=3145728 可以使超过3M的对象直接在老年代进行分配,运行完代码可以看到,4M的内容直接在老年代上了。

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

Code Gist

虚拟机给每个对象设置了一个年龄(Age)计数器,如果对象在Eden区出生且经过一次Minor GC还存活,就将其放入Survivor区,并使年龄增加1,对象在Survivor区域每熬过一次Minor GC年龄都会增加1,当增加到默认值15岁时就会晋升入老年代中。
这个默认阈值可以通过 -XX:MaxTenuringThreshold 设置

从运行结果上可以看到,尽管 alloc1 只要256KB,但在第二次GC的时候还是被移入了老年代

Terminal2

  1. 动态对象年龄判定

这个实际上是对上一条的优化,有时候并不是要求对象的年龄必须到了 MaxTenuringThreshold 才能晋升老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于SUrvivor空间的一半,则年龄大于或等于该年龄的对象直接进入老年代。

One more thing

对象的 finalize() 函数可以作为拯救自己的手段,也就是在的对象 finalize() 中使其与GC Roots从新建立关系,这样就不会被收集。但这是一个投机取巧的办法,不鼓励使用。而且finalize()方法也只会被执行一次。