Java的一大特性就是内存的分配和回收都是自动进行的。当程序规模不大时,我们完全可以不考虑内存的使用情况。但是一旦程序的规模足够大,对性能的要求足够高时,了解Java垃圾收集(GC)的内部机制并根据具体的应用特征来调整使用的垃圾收集算法就显得十分重要了。
标记阶段,目的是将不可用的对象标记出来,以便进行后阶段的回收。那么,如何判断一个对象是否可用呢?这跟指向该对象的引用有很大的关系。因此,在具体研究对象可用性判定算法之前,让我们先看一看Java中不同的引用类型。
引用类型 | GC操作 |
---|---|
强引用(Strong Reference) | 任何时候都不能被回收 |
软引用(Soft Reference) | 内存空间不足时可被回收 |
弱引用(Weak Reference) | 将在下一次GC时被回收 |
虚引用(Phantom Reference) | 不影响对象寿命,仅在被回收时收到一个系统通知 |
表中引用的强度由上至下依次减弱。可以看出,除了强引用,其他引用对于GC的执行并无太大的影响。因此,以下讨论中谈到的引用均指强引用。
该算法给每个对象添加一个引用计数器,当有引用指向对象时,计数器加1,当引用失效时,计数器减1。因此,当一个对象的引用计数变为0时,就证明该对象不可用,其所占用的内存也可以立即被释放。
但是主流的Java虚拟机中并没有使用这一简单高效的算法来管理内存,主要原因就是它无法解决循环引用(Circular)的问题。也即,当对象A和对象B相互引用,而没有任何其他对象指向A和B时,由于A和B的引用计数均为1(不等于0),引用计数算法将无法回收这两个对象。
同时,该算法对引用计数的频繁更新也会使得效率降低。
从一系列名为”GC Roots”的对象开始向下搜索,就可以形成若干条引用链。如果一个对象到”GC Roots”无任何引用链相连,该对象则被判定为可回收对象。
可以作为GC Roots的对象包括以下几类:
该算法可以很好的解决循环引用的问题。同时,对于高度变化的程序来说比引用计数法效率更高。但是,在迅速发现不可用的对象方面,则没有引用计数法那么快。
清理阶段。将Mark阶段标记出的不可用对象清除,释放其所占用的内存空间。主要有以下几种实现方式。
算法思想:遍历堆空间,将Mark阶段标记不可用的对象清除。
不足: 效率不高;空间问题,多次清除之后会产生大量的内存碎片。
适用场景:对象寿命长的内存区域。
该算法过程如下图所示:
算法思想:将内存划分为两个区域(大小比例可调整),每次只用其中一块,当此块内存用完时,就将存活对象复制到另一块内存中,并对当前块进行内存回收。
优点:解决了内存碎片问题;内存分配效率提高。每次复制后对象在堆中都是线性排列的,因此内存分配时只需移动堆顶指针即可。
不足:如果对象的存活率较高,大量的复制操作会显著的降低效率;内存空间浪费,每次都只能使用堆空间的一部分,代价高昂。
该算法过程如下图所示:
算法思想:将标记的所有可用对象向内存一端移动,然后直接清理边界以外的内存区域即可。
优点:类似于复制算法,解决了内存碎片问题,内存分配效率提高;消除了复制算法对内存空间的浪费。
不足:难以做到并行。
该算法过程如下图所示:
前面所述的Mark-Clean算法都是针对整个堆区域的,每一次GC运行都需要对堆中所有的对象进行遍历。因此,随着堆中对象数量的增多,GC的效率就会随之下降。于是,GC对程序运行做出如下假设:
基于这两个假设,GC将堆中的对象按照存活时间分为三代:Young(新生代)、Old(老年代)、Perm(永久代)。其内存划分示意图如下:
由图可见,新生代又可划分为三个区域:Eden,Survivor0,Survivor1。其中,Eden区最大,新对象的内存分配都在此区域进行。两个Survivor区域一个为From区,一个为To区,每次只使用其中的一个。
新生代的垃圾回收采用的是复制算法。第一次GC时,Eden区的存活对象会被复制到S0区。此后每次进行GC时,Eden区和From区的存活对象都会被复制到To区。如果一个对象在经历了几次垃圾回收后仍然存活,那么它就会被复制到Old Generation(老年代),此过程称为Promotion。
老年代的对象是由新生代对象经过Promotion而来,基于前面列出的假设:“如果对象已存活一段时间,那它很可能会继续存活一段时间”,该区域的对象存活率普遍较高,因此一般采用Mark-Sweep或Mark-Compact算法。
永久代并不用来存储从老年代经过Promotion而来的对象,它存储的是元数据,包括已被虚拟机加载的类信息、常量、静态变量、方法等。该区域通常不会发生垃圾回收。
在程序执行时,并非任何时候都可以停下来进行垃圾回收,只有到达某些特定的点时才能暂停,这些点称为安全点(Safepoint)。安全点的设定既不能太少以致于让GC等待时间过长,也不能太频繁导致运行时负荷增大。一般在方法调用、循环跳转、异常跳转处会产生安全点。
那么,如何在GC发生时让所有的用户线程都“跑”到最近的安全点上停下来呢,有以下两种方案:
但是,以上实现方案有一种情况无法解决,那就是用户线程不运行的时候,也即处于sleep或blocked状态的时候。由于此时线程无法轮询中断标志,也就不能保证GC开始时它一定处于安全状态。此时就需要引入安全区域(Safe Region)的概念了,它是指在一段代码片段中,对象之间的引用关系不会发生变化。安全区域可以看做是扩展了的安全点。
当用户线程执行到Safe Region时,首先会标志自己进入了安全区域。那么,就算GC要开始时该线程处于blocked状态,GC也可以放心的执行垃圾回收动作了。而当线程要离开Safe Region时,要先检查GC是否已经完成。如果完成了,线程就可以继续执行,否则需等待直到收到可以安全离开Safe Region的信号为止。
不同的虚拟机中通常有不止一种的垃圾收集器,它们实现了不同的垃圾收集算法。以下列举在Sun HotSpot虚拟机中包含的垃圾收集器。
单线程收集器,采用复制算法。GC运行时会暂停所有的用户线程(STW,Stop The World)。是虚拟机运行在Client模式下的默认新生代收集器。
Serial收集器的多线程版本,除此之外与Serial收集器几乎完全相同。是许多运行在Server模式下的虚拟机中首选的新生代收集器。原因之一是它是唯一能与CMS配合使用的新生代收集器。
可使用-XX:ParallelGCThreads参数指定垃圾收集的线程数。
与ParNew一样,是使用复制算法的多线程收集器。但是不同于ParNew对缩短垃圾收集时用户线程停顿时间的关注,Parallel Scavenge更多的是关注提高程序的吞吐量,因此常被称为“吞吐量优先”收集器。适用于在后台运算而没有太多交互的任务。
Serial收集器的老年代版本,单线程收集器,使用Mark-Compact算法。
主要用于Client模式下的虚拟机。但在Server模式下也有两大用途。
Parallel Scavenge的老年代版本,使用多线程和Mark-Compact算法,JDK1.6开始提供。
下图展示了Serial和Parallel收集器的工作模式。
Concurrent Mark Sweep,其工作过程可分为以下四个步骤:
由图可见,CMS执行过程中大部分阶段都是与用户线程并行进行的,因此用户线程暂停时间会大大减少。但是由于CMS在进行清理时,用户线程也在运行,也即此时仍然会有新的垃圾产生。这些垃圾称为“浮动垃圾”(Floating Garbage)。由于“浮动垃圾”产生于CMS标记阶段之后,它们只能等到下一次GC时才可被回收。所以,CMS并不能等到老年代几乎要满了才开始垃圾收集动作,它必须预留足够的空间给用户线程在垃圾收集过程中使用。如果预留的空间预估不准的话,就有可能出现以下两种情况:
可使用-XX:+CMSInitiatingOccupancyFraction参数来指定在老年代空间被使用多少后触发垃圾收集,默认为68%。
之所以把G1单独列出来,是因为它在内存年代划分上不同于上面介绍的所有收集器。G1把内存分为很多个大小相等的独立区域(Region),新生代和老年代不再是相互隔离的,而是都由若干个非连续的Region组成。除此之外,G1收集器还有以下几个特点:
当然,由于跨Region引用的存在,垃圾收集并不能真的以Region为单位进行。对于这种情况,G1通过为每一个Region维护一个Remember Set(RSet)来避免进行全堆扫描。RSet中记录了其他Region中的对象指向本Region对象的引用信息。
忽略RSet的维护操作,G1的执行过程主要分为以下四步,其与CMS的执行过程很相似:
在实际应用中,常常需要根据不同的应用特征调整垃圾收集器的配置方案。在调整过程中,不免需要监控各种收集器的运行过程来进行性能的比较。JDK自带了一个Visual VM工具来可视化GC的执行过程。笔者最近为了跟踪服务器上不同垃圾收集器实现的性能,分析了较多的GC日志。不同收集器生成的日志格式可能不尽相同,但都有一定的共性。下面列出的是在实际应用中使用ParNew+CMS和使用G1时产生的日志,从中可以很清楚的看到CMS和G1的执行阶段以及GC运行时用户线程暂停的时间,有兴趣的朋友可以研究一下(可以右键‘在新标签页中打开图片’查看清晰大图)。
使用的日志相关参数如下
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC