Java 中的垃圾回收策略
垃圾回收需要解决的问题
- 谁需要被回收
- 什么时候回收
- 怎么回收
谁需要被回收
如果一个对象再也不会被用到,就可以回收它了,所以关键在于如何知道一个对象再也不被使用了。
引用计数
当一个对象被引用时,引用计数加1,当引用失效时,计数减1。简单直观,但会出现循环引用问题。
a.tb = b
b.ta = a
即使 a
和 b
再也不会被用到了,但他们之间互相引用,导致引用计数一直不为0,无法被回收。
因为有循环引用的问题,主流的虚拟机实现都 不采用 引用计数方法判断是否需要被回收。
可达性分析
Java, C# 的主流实现都是使用可达性分析来判断一个对象是否存活的。如果从 GC Root
可以到达一个对象,那么
这个对象是可达的,还存活着,否则就不存活,可以被回收。 GC Root
包含以下几类。
- 虚拟机栈引用的对象
- 方法区静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
注意在可达性分析中,并不是只要遇到不可达的就一定回收它。标记不可达之后,还需要经过一次筛选,即对象是否有必要执行 finalize 方法,如果有必要执行,且在这个方法中,对象被引用,它将会成功复活。
如何回收
标记回收算法
最基本的垃圾回收算法,第一次扫描,标记所有可以被回收的对象,第二次扫描,回收被标记的对象。
不足之处
- 效率低
- 会产生内存碎片
复制算法
复制算法将内存分成两部分,每次只使用两部分。假如当前使用的是左半块,右半块没使用。现在要垃圾回收了, 就将左半块还存活的对象复制到右半块。然后将左半块一次性全部回收。
好处是
- 高效
- 没内存碎片
坏处是
- 空间效率低,有半块都不能用
解决方法是,调整左右两块的比例。在 HotSpot 中,内存分成一块 Eden,两块 Survivor,比例是 8:1
. 每次使用一块 Eden, 一块 Survivor A, 另一块 Survivor B 备用。
垃圾回收时,将 Eden 和 Survivor A 上存活的对象复制到 Survivor B 上,然后将 Eden 和 Survivor A 回收。然后下次使用 Eden, Survivor B,
而 Survivor A 这次备用。
这种方法的前提是每次回收时,大量对象都死掉了,只有一小部分存活着,这样,只复制一小部分就好。但是,如果大量对象存活时间比较长,就要反复来回复制,简直浪费生命。从这一点也可以看出,要根据对象存活时间的特点使用不同的回收策略。
标记整理算法
标记整理算法是对标记清除算法的改进。第一次扫描标记需要回收的对象。第二次不是将这些对象清除,而是将还存活的对象移动到内存区域的一端,全他们连在一起。然后对这块区域边界以外的地方全部回收。
另外,需要注意的一点是,垃圾回收时,对象在内存中的地址会发生改变,那么,如何保证原来的引用没有失效呢,总不能把所有变动的 引用都更新吧。参考资料3中的一个回答是这样解释的,假如一个 GC Root 保存着指向一个对象的引用,现在这个对象准备要移动了,那么 先检查一个标记位,该标记位表示是否刚刚被移动过,如果标记位没有被设置,则设置标记位,把对象移动到新位置,同时把新位置地址记录在原来地址的对象中。 如果标记位已经被设置,则返回对象中之前保存的新位置。这样,就不用更新 GC Root 中的引用,又保证了可以正确地找到对象的新地址。
分代收集算法
从上面的讨论可以看出,要根据对象存活时间使用不同回收策略。有些对象存活时间比较短,这样每次回收时,存活对象少,可以使用复制算法。而有些对象存活时间比较长,这样每次回收时,需要回收的对象少,可以使用标记清除和标记整理算法。Java 堆据此分为新生代和老年代,新生代中对象存活时间短,老年代中对象存活时间长。
分代的标准有两个,一个是存活时间,一个是对象大小,大对象会直接进入老年代。
HotSpot 垃圾回收器
HotSpot 垃圾回收器按分代,可以分为新生代回收器和老年代回收器。按垃圾回收器线程数可以分为单线程回收器和多线程回收器。按垃圾回收时是否需要停止所有工作线程可以分为并发回收器和非并发回收器。所以,看到一个回收器,需要明白它:
- 用于新生代还是老年代
- 单线程还是多线程
- 工作时是否需要停止所有工作线程
Serial 回收器
- Client 模式下默认新生代回收器,采用复制算法
- 单线程
- 需要暂停所有工作线程
ParNew 回收器
- Server 模式下首选新生代回收器,采用复制算法
- 多线程
- 不需暂停所有工作线程
- 只有它可与 CMS 回收器配合使用
Parallel Scavenge 回收器
- 新生代回收器,采用复制算法
- 多线程
- 目标在于达到一个可控的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间))
其它回收器目标在于减少用户程序因为垃圾回收而停顿的时间,而Parallel Scavenge 回收器 的目标在于可按的吞吐量,因为它又被称为吞吐量优先回收器。 这里有一个矛盾,如果每次停顿时间减少,那么用户程序可以得到更快的响应,但这同 时意味着,垃圾回收变得频繁,垃圾回收总体时间变长,吞吐量下降。可以使用 -XX:MaxGCPauseMillis
调整垃圾回收停顿时间,也可以使用
-XX:GCTimeRation
调整吞吐量。
Serial Old 回收器
- 老年代回收器,采用标记整理算法
- 单线程
- 需要暂停所有工作线程
- Client 模式下使用
- Serial 回收器老年代版本
Parallel Old 收集器
- 老年代回收器,采用标记整理算法
- 多线程
- Parallel Scavenge 老年代版本
CMS(Concurrent Mark Sweep) 回收器
- 老年代回收器,采用标记清除算法
- 多线程
- 总体上,不需要暂停所有工作线程
运行过程:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中并发标记和重新标记需要暂停所有用户线程,但这两个阶段用时相对另外两个阶段非常少。而并发标记与并发清除消耗时间长,但他们可以与用户线程一起工作。
初始标记只标记 GC Root 直接关联的对象,重新标记修正并发标记期间因用户程序运行导致标记变动的对象。 这个回收器适用于对交互响应速度敏感的应用程序。
G1(Garbage-First) 回收器
- 新生代与老年代不再物理隔离,打破原有分代概念
- 多线程
- 不需要暂停所有工作线程
- 不需要与其它回收器配合使用
- 整体上标记整理算法,局部是复制算法,不会产生内存碎片
- 可以预测停顿,这意味着用户可以指定回收操作在多长时间内完成
何时回收
空间满时
新生代中, Eden 区内存耗尽时,触发 Minor Collection, 将 Eden 区 和 Surviver-From 区 的对象复制到 Survivor-To 区,将回收新生代中的 对象。如果 Survivor-To 区没有足够空间,允许一部分对象进入老年代。
如果老年代内存耗尽,无法接受来自新生代的对象,触发 Full Collection.
无须回收
如果局部变量作用局在方法内部,可以在栈上分配对象,不在堆上,他们无须回收。
这篇文章是学习《深入理解Java虚拟机》及《HotSpot实战》的总结。
参考资料:
- 深入理解Java虚拟机
- HotSpot实战
- StackOverflow