jvm垃圾收集器按不同的角度似乎有几种分法。例如,按收集的区域有收集新生代和老生代的分别,按收集时是否多线程有串行和并行的分别,按是否会停止jvm中用户线程的运行有并发与非并发的分别。可见一个垃圾收集器具有多种属性。
从官方的一些文档看,一般将Hotspot JVM的垃圾收集器分为三类
- Serial GC 串行收集器
- Parallel GC 并行收集器
- the mostly Concurrent GC 基本并发收集器
对于上面提到的串行与并行,具体在gc的领域里,其含义如下:
串行 Serial vs. 并行 Parallel
- 串行:只使用一个线程执行垃圾收集
- 并行:使用多个线程执行垃圾收集
并发与非并发则具体是:
Stop the World vs. 并发 Concurrent
- Stop the World 指在执行垃圾收集时,jvm会停止应用的用户线程,导致应用出现停顿
- 并发Concurrent则与STW相反,垃圾收集的过程可以和用户线程同时执行。
除此之外,不同的gc实现还有incremental和monolithic的区别,具体来说就是
Incremental vs. Monolithic
- Incremental 指gc以一系列的分段的步骤执行垃圾回收过程,使得用户线程可以在各步骤之间运行。通常也和STW联合使用。
- Monolithic 则和Incremental相反,gc的过程是整体执行的,它总是引起STW、停止用户线程的执行,直到垃圾收集过程结束
最后,gc中还有Precise和Conservative的区分
Precise vs. Conservative
- Precise 指gc在收集的过程中,可以完全识别、处理所有对象的引用reference。一个垃圾收集器如果会移动对象在内存中的位置,那么它必须是Precise的,以免遗漏处理对象导致内存被破坏。可以说,所有商用的服务端JVM都使用Precise的收集器,并在它们的某些过程中会移动对象。
- Conservative 相对的则对于某些对象引用是不知晓的。某些语言和系统使用了这种机制,比如C++
分代收集
JVM垃圾收集器的一大特点就是使用分代收集。在运行期,java对象处于JVM堆内存中,而JVM将这部分内存分成了不同的区域,即常说得新生代和老生代,而对于像G1这样的新一代收集器,它进一步将其所收集的内存区域分成更小的Region,这一点后面再说。
无论是分成新生代老生代,还是更小的Region,之所以使用分代的方式进行垃圾收集,其原因是基于一个实践中观察到的结果,即对于大多数应用程序,运行期创建的对象大多只存活很短的一段时间,相应的,只有少量的对象会在内存中持续很长时间,这个结果被称作“the weak generational hypotheis”。
大致上,分代收集会按下面的算法进行对象在内存中的处理。新创建的对象都分配在新生代上,这些对象每熬过一次gc,其“年龄”就会增加,当对象的年龄增加到一定程度后,虚拟机将把这些对象转移到老生代中。
正是由于对象存活时间存在不同,采用分代管理后,JVM可以针对不同区域里对象的特点采用不同的算法进行有效的收集。例如,对于新生代,其中大量的对象在收集时都会死去,就适合用后面提到的“复制算法”,而老生代中的对象存活时间长,就适合后面提到的“标记 - 清除”、“标记 - 整理”算法。
采用分代收集后,对于新生代收集的停顿时间会大大短于老生代收集的停顿时间,这使得可以降低老生代的停顿频率(但停顿的持续时间不一定会减少)
永久代
在Java 8之前,除了新生代、老生代之外,java管理的堆中还有一部分叫“永生代”的区域。实际上,永生代的说法是针对HotSpot虚拟机而言的。这部分区域,用于存放虚拟机加载的类信息、常量、静态变量、运行时常量池等。严格的讲,从JVM规范的角度看,这个区域应该叫做“方法区”,而HotSpot虚拟机只是刚好把这个方法区实现为gc分代中的一种而已。
对于HotSpot虚拟机而言,永久代是一个带来很多麻烦的地方。其当初设计时的一些假设在实际证明是不正确的,尤其是在目前有许多应用使用自定义的ClassLoader的情况下。
因此,HotSpot虚拟机逐步的对永生代进行改进。在Java 7中,将字符串String从永久代中的存放改为了存放在老生代中。进一步的,到了Java 8,永生代直接被废除。这里的废除并不是说不需要存储这部分数据了,而是将其实现脱离分代管理的机制,转为用本地内存native memory来存储,并且改名为元空间Metaspace。由于永生代的大小需要在jvm启动前就通过参数显式指定其大小(默认的是64MB,对于64 bit scaled pointer则是85MB),并且在运行中不能改变其大小,同时由于很难准确估计永生代到底需要多少内存空间,因此常常出现OOM内存溢出的异常。而元空间由于脱离了堆实现的限制后,使用native memory,因此其实际内存大小就是整个内存空间的可用大小,因而它不会再像永生代那样容易发生OOM了。
同时,由于永生代是和老生代有捆绑关系的,即当老生代满了的时候,进行full gc,永生代也会一并触发回收。改用元空间后,解绑了这层关系,使得full gc的处理可以进行简化。
Remembered Set
分代的目的是对不同存活时间的对象分开处理,以提高垃圾收集的效率,减少停顿时间。但这样实现后,会带来一些额外的问题。例如,收集新生代中的对象时,JVM需要判断该对象是不是被老生代中的对象引用了,这可能导致收集新生代需要扫描老生代。
为了减少上面对老生代的扫描时间,JVM引入了一个叫Remembered Set的集合,用于记录所有从老生代引用新生代对象的信息。这样,收集新生代时,只需要检查RS,而不用扫描整个老生代了。这时,RS也可以被看作是新生代gc的roots之一。
RS在大部分收集器中,使用一个叫CardTable的表进行跟踪。CardTable可能会使用byte或bit来表示某些老生代是否有新生代的引用,这种实现使得检查起来很快。但是需要注意的是,即使CardTable内是松散填充的(意味着有较少的老生代引用了新生代),在检查时,仍然需要检查整个CardTable。这也意味着老生代的堆大小增长,会影响CardTable的检查时间,进而影响新生代收集的时间。
收集机制
Precise的垃圾收集器,使用跟踪算法进行收集,跟踪算法相比引用计数算法,可以更有效回收对象,避免遗漏,尤其是对具有循环引用的对象组,可做到安全、精准的收集。
跟踪算法的收集器可能使用三种技术
mark / sweep / compact
即常说的 “标记 - 清除”算法copying
即常说的“复制”算法mark / compact
即常说的“标记 - 整理”算法
从上面可以看出,这三种技术其实是一些更具体的手段的组合。下面分别说一下这些具体的方法
1. Mark
Mark也叫Trace,即所说的跟踪,它是一种可以找到堆中所有存活对象的方法。当进行垃圾收集时,收集器从gc的roots开始查找所有活着(可达)的对象,对其进行标记,以备后续阶段的进一步操作。
这里的gc roots,通常包括:
- static variables
- registers
- thread stacks上的内容
- remembered set
标记阶段的耗时随着存活对象集的大小线性增长,也就是说与堆本身的大小没有关系。
2. Sweep
Sweep是对象的清除阶段,它不一定是真正的将对象抹去,有可能是将被回收对象的内存记在空闲列表中表示这块区域可以被重新使用,也可能是对其做某种处理以便被后面的compact阶段使用。
其复杂度随堆的大小影响,因为Sweep过程需要覆盖整个堆。
3. Compact / Relocate
压缩是为了避免内存出现碎片,因此JVM总会使用压缩。压缩可分为两种形式
- in-place
在压缩的内存区域内进行对象的移动,将所有对象移动到堆的一侧,以便清理出连续的空闲空间。 - evacuating
把一个区域Region的对象移动到另一个外部空区域中,然后清空原区域。实际上,这个和后面说到的copy很像,目前资料看来,evacuating是专门针对G1收集器而言的。
4. Copy
Copy复制算法一般针对新生代的垃圾收集使用,它将新生代的堆分成 from 和 to 两个区域。每次垃圾收集时,to区域总是空的,gc对 from 区域进行对象的trace,确定存活的对象,然后将存活的对象copy到 to 区域,并反转当前 to 和 from 的角色,使得原来的 from 区域变成 to,即为新的空区域。
复制算法通常是Monolithic的,因为它要保证整个过程的一致、完整性,不允许存在中间状态。这样,通常要求from 和 to两个区域的大小是一致的,否则可能出现to区域容纳不下from区域内存活对象的情况。
但是,这样又导致总有一半的新生代内存区域是空闲的,使得内存使用率不高。
针对这种情况,复制算法的垃圾收集器可以有优化的地方。而优化的基础,就是分代收集。据IBM的研究,98%的对象都是朝生夕死的,因此并不需要1:1的比例分成from和to区域。对于HotSpot虚拟机,从一开始就使用了优化版的复制算法,它将内存区域分成3部分,一个较大的Eden区,两个较小的Survivor区域。每个新创建的对象都分配到Eden区,当垃圾回收时,总是回收Eden区和一个Survivor区,并将这两个区域中存活的对象都复制到剩下的那个Survivor区域里。收集完成后,之前的Survivor区和Eden区被清空,之前的Survivor区就变为下次gc时对象要拷贝到的区域了。
Eden和2个Survivor的比例在HotSpot中默认是8:1:1。因此只有10%的内存会被空闲出来。另外前面说到的98%的对象可以被回收并不是绝对的,因此可能出现回收后Survivor区容纳不下存活对象的情况,这时分代收集的好处就体现出来了。由于有老生代的存在,多出来的存活对象可以被提前提升到老生代中,这个机制被称作内存担保。