JVM之垃圾回收机制
垃圾回收机制是 Java 非常重要的特性之一,也是面试题的常客。它让开发者无需关注空间的创建和释放,而是以守护进程的形式在后台自动回收垃圾。这样做不仅提高了开发效率,更改善了内存的使用状况。 下面将从
- 什么是java中的垃圾
- 堆内存的划分
- 回收垃圾的算法
- 分代回收机制
- Java中垃圾回收器的类型
- GC相关的JVM参数
- Full GC和并发垃圾回收
来简述java中的垃圾回收机制
##什么是java中的垃圾
所谓的”垃圾”就是指:当一个对象通过一系列根对象(比如:静态属性引用的常量)都不可达时就会被回收。简而言之,当一个对象的所有引用都为null(循环依赖不算做引用,Java 里没有采用这样的方案来判定对象的“存活性” )。
常见的判断是否存活有两种方法:引用计数法和可达性分析。
-
引用计数法
为每一个创建的对象分配一个引用计数器,用来存储该对象被引用的个数。当该个数为零,意味着没有人再使用这个对象,可以认为“对象死亡”。但这个有个弊端,就是无法检测“循环引用” 。
-
可达性分析
这种方案是目前主流语言里采用的对象存活性判断方案。基本思路是把所有引用的对象想象成一棵树,从树的根结点 GC Roots 出发,持续遍历找出所有连接的树枝对象,这些对象则被称为“可达”对象,或称“存活”对象。其余的对象则被视为“死亡”的“不可达”对象,或称“垃圾”。
参考下图,object5,object6 和 object7 便是不可达对象,视为“死亡状态”,应该被垃圾回收器回收。
-
GC Roots
我们可以猜测,GC Roots 本身一定是可达的,这样从它们出发遍历到的对象才能保证一定可达。那么,Java 里有哪些对象是一定可达呢?主要有以下四种:
- 虚拟机栈(帧栈中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI 引用的对象。
每次垃圾回收器会从这些根结点开始遍历寻找所有可达节点。
堆内存的划分
Java中对象都在堆上创建。为了GC,堆内存分为三个部分,也可以说三代,分别称为新生代,老年代和永久代。其中新生代又进一步分为Eden区,Survivor A区和Survivor B区。新创建的对象会分配在Eden区,在经历一次Minor GC后会被移到Survivor A区,再经历一次Minor GC后会被移到Survivor B区,直到升至老年代,需要注意的是,一些大对象(长字符串或数组)可能会直接存放到老年代。
永久代有一些特殊,它用来存储类的元信息(比如一些静态文件,这些对象的特点是不需要垃圾回收,永远存活。)。对于GC是否发生在永久代有许多不同的看法,在我看来这取决于采用的JVM。在 Java 8 里已经把 永久代 删除了,把这块内存空间给了 元空间。
总结就是:
- 新生代:存活对象少、垃圾多
- 老年代:存活对象多、垃圾少
回收垃圾的方法
根据上面介绍,我们已经知道,所有GC Roots不可达的对象都是所谓的“垃圾”,比如下图,黑色的表示垃圾,灰色表示存活对象,绿色表示空白空间。
如何处理这些垃圾?
###标记清除算法
分为标记和清除两个阶段:
首先标记出所有需要回收的对象,标记完成后再遍历一遍,把所有“垃圾”对象所占的空间直接 清空 即可 。
该算法的缺点是效率不高并且会产生不连续的内存碎片。 效果如下:
标记整理算法
标记阶段与标记清除算法一样。但后续并不是直接对可回收的对象进行清理,而是让所有存活对象都想一端移动,然后清理。优点是不会造成内存碎片。 效果如下:
复制算法
把内存空间划为两个区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。
-
优点:实现简单,运行高效。
-
缺点:会浪费一定的内存。一般新生代采用这种算法。
效果如下:
分代回收机制
Java提供多种类型的垃圾回收器。JVM中的垃圾收集一般都采用“分代收集”,不同的堆内存区域采用不同的收集算法,主要目的就是为了增加吞吐量或降低停顿时间。
新生代 - 复制算法 回收机制
对于新生代区域,由于每次 GC 都会有大量新对象死去,只有少量存活。因此采用 复制 回收算法,GC 时把少量的存活对象复制过去即可。
那么如何设计这个 复制 算法比较好呢?有以下几种方式:
思路 1. 把内存均分成 1:1 两等份
如下图拆分内存。
每次只使用一半的内存,当这一半满了后,就进行垃圾回收,把存活的对象直接复制到另一半内存,并清空当前一半的内存。
这种分法的缺陷是相当于只有一半的可用内存,对于新生代而言,新对象持续不断地被创建,如果只有一半可用内存,那显然要持续不断地进行垃圾回收工作,反而影响到了正常程序的运行,得不偿失。
思路 2. 把内存按 9:1 分
既然上面的分法导致可用内存只剩一半,那么我做些调整,把 1:1变成9:1
最开始在 9 的内存区使用,当 9 快要满时,执行复制回收,把 9 内仍然存活的对象复制到 1 区,并清空 9 区。
这样看起来是比上面的方法好了,但是它存在比较严重的问题。
当我们把 9 区存活对象复制到 1 区时,由于内存空间比例相差比较大,所以很有可能 1 区放不满,此时就不得不把对象移到 老年区 。而这就意味着,可能会有一部分 并不老 的 9 区对象由于 1 区放不下了而被放到了 老年区 ,可想而知,这破坏了 老年区 的规则。或者说,一定程度上的 老年区 并不一定全是 老年对象。
那应该如何才能把真正比较 老 的对象挪到 老年区 呢?
思路 3. 把内存按 8:1:1 分
既然 9:1 有可能把年轻对象放到 老年区 ,那就换成 8:1:1,依次取名为 Eden、Survivor A、Survivor B 区,其中 Eden 意为伊甸园,形容有很多新生对象在里面创建;Survivor区则为幸存者,即经历 GC 后仍然存活下来的对象。
工作原理如下:
- 首先,Eden区最大,对外提供堆内存。当 Eden 区快要满了,则进行 Minor GC,把存活对象放入 Survivor A 区,清空 Eden 区;
- Eden区被清空后,继续对外提供堆内存;
- 当 Eden 区再次被填满,此时对 Eden 区和 Survivor A 区同时进行 Minor GC,把存活对象放入 Survivor B 区,同时清空 Eden 区和Survivor A 区;
- Eden区继续对外提供堆内存,并重复上述过程,即在 Eden 区填满后,把 Eden 区和某个 Survivor 区的存活对象放到另一个 Survivor 区;
- 当某个 Survivor 区被填满,且仍有对象未被复制完毕时,或者某些对象在反复 Minor GC 15 次左右时,则把这部分剩余对象放到Old 区;
- 当 Old 区也被填满时,进行 Major GC,对 Old 区进行垃圾回收。
[注意:在真实的 JVM 环境里,可以通过参数 SurvivorRatio 手动配置 Eden 区和单个 Survivor 区的比例,默认为 8。]
老年代 - 标记整理算法 回收机制
根据上面我们知道,老年代一般存放的是存活时间较久的对象,所以每一次 GC 时,存活对象比较较大,也就是说每次只有少部分对象被回收。
因此,根据不同回收机制的特点,这里选择 存活对象多,垃圾少 的标记整理 回收机制,仅仅通过少量地移动对象就能清理垃圾,而且不存在内存碎片化。
总结:新生代 采用 复制算法 回收机制,老年代采用标记整理算法机制。
Java中的垃圾回收器类型
- Serial收集器:新生代收集器,使用复制算法,使用一个线程进行GC,串行,其它工作线程暂停。
- ParNew收集器:新生代收集器,使用复制算法,Serial收集器的多线程版,用多个线程进行GC,并行,其它工作线程暂停。使用-XX:+UseParNewGC开关来控制使用ParNew+Serial Old收集器组合收集内存;使用-XX:ParallelGCThreads来设置执行内存回收的线程数。
- Parallel Scavenge 收集器:吞吐量优先的垃圾回收器,作用在新生代,使用复制算法,关注CPU吞吐量,即运行用户代码的时间/总时间。使用-XX:+UseParallelGC开关控制使用Parallel Scavenge+Serial Old收集器组合回收垃圾。
- Serial Old收集器:老年代收集器,单线程收集器,串行,使用标记整理算法,使用单线程进行GC,其它工作线程暂停。
- Parallel Old收集器:吞吐量优先的垃圾回收器,作用在老年代,多线程,并行,多线程机制与Parallel Scavenge差不错,使用标记整理算法,在Parallel Old执行时,仍然需要暂停其它线程。
- CMS(Concurrent Mark Sweep)收集器:老年代收集器,致力于获取最短回收停顿时间(即缩短垃圾回收的时间),使用标记清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。使用-XX:+UseConcMarkSweepGC进行ParNew+CMS+Serial Old进行内存回收,优先使用ParNew+CMS(原因见Full GC和并发垃圾回收一节),当用户线程内存不足时,采用备用方案Serial Old收集。
可以看Java Performance一书来获取更多关于GC调优的信息。
GC相关的JVM参数
做GC调优需要大量的实践,耐心和对项目的分析。我曾经参与过高容量,低延迟的电商系统,在开发中我们需要通过分析造成Full GC的原因来提高系统性能,在这个过程中我发现做GC的调优很大程度上依赖于对系统的分析,系统拥有怎样的对象以及他们的平均生命周期。 举个例子,如果一个应用大多是短生命周期的对象,那么应该确保Eden区足够大,这样可以减少Minor GC的次数。可以通过-XX:NewRatio
来控制新生代和老年代的比例,比如-XX:NewRatio=3代表新生代和老年代的比例为1:3。需要注意的是,扩大新生代的大小会减少老年代的大小,这会导致Major GC执行的更频繁,而Major GC可能会造成用户线程的停顿从而降低系统吞吐量。JVM中可以用NewSize和MaxNewSize参数来指定新生代内存最小和最大值,如果两个参数值一样,那么就相当于固定了新生代的大小。
在做GC调优之前最好深入理解Java中GC机制,推荐阅读Sun Microsystems提供的有关GC的文档。这个链接可能会对理解GC机制提供一些帮助。下面的图列出了各个区可用的一些JVM参数。
Full GC和并发垃圾回收
并发垃圾回收器的内存回收过程是与用户线程一起并发执行的。通常情况下,并发垃圾回收器可以在用户线程运行的情况下完成大部分的回收工作,所以应用停顿时间很短。 但由于并发垃圾回收时用户线程还在运行,所以会有新的垃圾不断产生。作为担保,如果在老年代内存都被占用之前,如果并发垃圾回收器还没结束工作,那么应用会暂停,在所有用户线程停止的情况下完成回收。这种情况称作Full GC,这意味着需要调整有关并发回收的参数了。 由于Full GC很影响应用的性能,要尽量避免或减少。特别是如果对于高容量低延迟的电商系统,要尽量避免在交易时间段发生Full GC。
总结
-
为了分代垃圾回收,Java堆内存分为3代:新生代,老年代和永久代。
-
新的对象实例会优先分配在新生代,在经历几次Minor GC后(默认15次),还存活的会被移至老年代(某些大对象会直接在老年代分配)。
-
永久代是否执行GC,取决于采用的JVM。
-
Minor GC发生在新生代,当Eden区没有足够空间时,会发起一次Minor GC,将Eden区中的存活对象移至Survivor区。Major GC发生在老年代,当升到老年代的对象大于老年代剩余空间时会发生Major GC。
-
发生Major GC时用户线程会暂停,会降低系统性能和吞吐量。
-
JVM的参数-Xmx和-Xms用来设置Java堆内存的初始大小和最大值。依据个人经验这个值的比例最好是1:1或者1:1.5。比如,你可以将-Xmx和-Xms都设为1GB,或者-Xmx和-Xms设为1.2GB和1.8GB。
-
Java中不能手动触发GC,但可以用不同的引用类来辅助垃圾回收器工作(比如:弱引用或软引用)。
###参考文档
转载请注明原地址,宋德凌的博客:http://CoderOfSong.github.io 谢谢!