一、碎片及整理(Fragmenting and Compacting)
JVM在清除不可达对象之后,还得确保它所在的空间是可以进行复用的。对象删除会导致碎片的出现,这有点类似于磁盘碎片,这会带来两个问题:
写操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。
JVM在创建新对象的,会在连续的区块中分配内存。因此如果碎片已经严重到没有一个空闲块能足够容纳新创建的对象时,内存分配便会报错。
为了避免此类情形,JVM需要确保碎片化在可控范围内。因此,在垃圾回收的过程中,除了进行标记和删除外,还有一个“内存去碎片化”的过程。在这个过程当中,会给可达对象重新分配空间,让它们互相紧挨着对方,这样便可以去除碎片。
二、分代假设
如上所述,垃圾回收需要完全中止应用运行。显然,对象越多,回收的时间也越长。那么我们能不能在更小的内存区域上进行回收呢?通过可行性调查,一组研究人员发现应用中绝大多数的内存分配会分为两大类:
1、绝大部分的对象很快会变为不可用状态。
2、还有一些,它们的存活时间通常也不会很长。
这些结论最终构成了弱分代假设(Weak Generational Hypothesis)。基于这一假设,虚拟机内的内存被分为两类,新生代(Young Generation)及老生代(Old Generation)。后者又被称为年老代(Tenured Generation)。
有了各自独立的可清除区域后,这才出现了众多不同的回收算法,正是它们一直以来在持续提升着GC的性能。
这并不说明这样的方式是没有问题的。比如说,不同分代中的对象可能彼此间有引用,在进行分代回收时,它们便为视为是“事实上”的GC根对象(GC roots)。
而更为重要的是,分代假设对于某些应用来说并不成立。由于GC算法主要是为那些“快速消失”或者“永久存活”的对象而进行的优化,因此对于那些生命周期“适中的对象,JVM就显得无能为力了。
三、内存池
在堆里面进行内存池的划分对大家来说应该是非常熟悉的了。不过大家可能不太清楚的是在不同的内存池中,垃圾回收是如何履行它的职责的。值得注意的是,虽然不同的GC算法细节实现上有所不同,但概念却是大同小异的。
四、伊甸区(Eden)
新对象被创建时,通常便会被分配到伊甸区。由于通常都会有多个线程在同时分配大量的对象,因为伊甸区又被进一步划分成一个或多个线程本地分配缓冲(Thread Local Allocation Buffer,简称TLAB)。有了这些缓冲区使得JVM中大多数对象的分配都可以在各个线程自己对应的TLAB中完成,从而避免了线程间昂贵的同步开销。
如果在TLAB中无法完成分配(通常是由于没有足够的空间),便会到伊甸区的共享空间中进行分配。如果这里还是没有足够的空间,则会触发一次新生代垃圾回收的过程来释放空间。如果垃圾回收后伊甸区还是没有足够的空间,那么这个对象便会到老生代中去分配。
当进行伊甸区的回收时,垃圾回收器会从根对象开始遍历所有的可达对象,并将它们标记为存活状态。
前面我们已经提到,对象间可能会存在跨代引用,因此最直观的做法便是扫描其它分区到伊甸区的所有引用。但不幸的是这么做会做成分代的做法变得毫无意义。JVM对此有它自己的妙招:卡片式标记(card-marking)。基本的做法是,JVM将伊甸区中可能存在老生代引用的对象标记为"脏”对象。
标记完成后,所有存活对象会被复制到其中的一个存活区。于是整个伊甸区便可认为是清空了,又可以重新用来分配对象了。这一过程便被称为”标记复制“:存活对象先被标记,随后被复制到存活区中。
五、存活区(Survivor)
紧挨着伊甸区的是两个存活区,分别是from区和to区。值得一提的是其中的一个存活区始终都是空的。
空的存活区会在下一次新生代GC的时候迎来它的居民。整个新生代中的所有存活对象(包含伊甸区以及那个非空的名为from的存活区)都会被复制到to区中。一旦完成之后,对象便都跑到to区中而from区则被清空了。这时两者的角色便会发生调转。
存活对象会不断地在两个存活区之间来回地复制,直到其中的一些对象被认为是已经成熟,“足够老”了。请记住这点,基于分代假设,已经存活了一段时间的对象,在相当长的一段时间内仍可能继续存活。
这些“年老”的对象会被提升至老年代空间。出现对象提升的时候,这些对象则不会再被复制到另一个存活区,而是直接复制到老年代中,它们会一直待到不再被引用为止。
垃圾回收器会跟踪每个对象历经的回收次数,来判断它们是否已经“足够年老”,可以传播至老年代中。在一轮GC完成之后,每个分区中存活下来的对象的计数便会加一。当一个对象的年龄超过了一个特定的年老阈值之后,它便会被提升到老年代中。
JVM会动态地调整实际的年龄阈值,不过通过指定-XX:+MaxTenuringThreshold参数可以给该值设置一个上限。将-XX:+MaxTenuringThreshold设置为0则立即触发对象提升,而不会复制到存活区中。在现代的JVM中,这个值默认会被设置为15个GC周期。在HotSpot虚拟机中这也是该值的上限。
如果存活区的大小不足以存放所有的新生代存活对象,则会出现过早提升。
六、老生代
老生代的内存空间的实现则更为复杂。老生代的空间通常都会非常大,里面存放的对象都是不太可能会被回收的。
老生代的GC比新生代的GC发生的频率要少得多。由于老生代中的多数对象都被认为是存活的,也就不会存在标记-复制操作了。在GC中,这些对象会被挪到一起以减少碎片。老生代的回收算法通常都是根据不同的理论来构建的。不过大体上都会分成如下几步:
1、标记可达对象,设置GC根对象可达的所有对象后的标记位
2、删除不可达对象
3、整理老生代空间的对象,将存活对象复制到老生代开始的连续空间内。
七、持久代
在Java 8以前还有一个特殊的空间叫做持久代(Permanent Generation)。这是元数据比如类相关数据存放的地方。除此之外,像驻留的字符串(internalized string)也会被存放在持久代中。这的确给Java开发人员带来了不少麻烦事,因为很难评估这究竟会使用到多少空间。评估不到位偏会抛出java.lang.OutOfMemoryError: Permgen space的异常。只要不是真的因为内存泄漏而引起的OutOfMemoryError异常,可以通过增加持久代空间的大小来解决这一问题,
八、元空间
由于元数据空间大小的预测是件繁琐且低效的工作,于是Java 8中干脆就去掉了持久代,转而推出了元空间。从此以后,那些个杂七杂八的东西便都存储到正常的Java堆了。
但是,类定义如今则是存储到了元空间里。它存储在本地内存中,不会与堆内存相混杂。默认情况下,元空间的大小只受限于Java进程的可用本地内存的大小。这大大解放了开发人员,他们不会再因为多增加了一个类而引发java.lang.OutOfMemoryError: Permgen space异常了。值得注意的是,虽然看似元空间大小毫无限制了,但这一些并非是没有代价的——如果任由元空间无节制地增长,你可能会面临的是频繁的内存交换(swapping)或者是本地内存分配失败。
九、新生代GC(Minor GC) vs 老生代GC(Major GC)vs Full GC
清除堆内存不同区域的垃圾回收事件又被称为新生代GC,老生代GC,以及Full GC事件。我们将介绍一下不同事件的区别在哪里。
重要的是我们希望知道应用是否到达它的服务能力上限了,而这又只能去监控应用的处理延时或者吞吐量。只有在这个时间GC事件才能派上用场。这些事件的关键之处在于它们是否停止了应用的运行,以及停了多久。
不过由于新生代GC,老生代GC,Full GC这几个术语被广泛使用却又没有一个清晰的定义,我们还是先来详细地介绍一下它们的区别。
9.1 新生代GC
新生代垃圾的回收被称作Minor GC。这个定义非常清晰,理解起来也不会有什么歧义。不过当处理新生代GC事件时,还是有一些有意思的东西值得注意的:
只要JVM无法为新创建的对象分配空间,就肯定会触发新生代GC,比方说Eden区满了。因此对象创建得越频繁,新生代GC肯定也更频繁。
一旦内存池满了,它的所有内容就会被拷贝走,指针又将重新归零。因此和经典的标记(Mark),清除(Sweep),整理(Compact)的过程不同的是,Eden区和Survivor区的清理只涉及到标记和拷贝。在它们中是不会出现碎片的。写指针始终在当前使用区的顶部。
在一次新生代GC事件中,通常不涉及到年老代。年老代到年轻代的引用被认为是GC的根对象。而在标记阶段中,从年轻代到年老代的引用则会被忽略掉。
和通常所理解的不一样的是,所有的新生代GC都会触发“stop-the-world”暂停,这会中断应用程序的线程。对绝大多数应用而言,暂停的时间是可以忽略不计的。如果Eden区中的大多数对象都是垃圾对象并且永远不会被拷贝到Survivor区/年老代中的话,这么做是合理的。如果恰好相反的话,那么绝大多数的新生对象都不应该被回收,新生代GC的暂停时间就会变得相对较长了。
现在来看新生代GC还是很清晰的——每一次新生代GC都会对年轻代进行垃圾清除。
9.2 老年代GC与Full GC
关于这两种GC其实并没有明确的定。不过根据新生代GC(Minor GC)清理的是新生代空间的认识来看,不难得出以下推论:
1、Major GC清理的是老年代的空间。
2、Full GC清理的是整个堆——包括新生代与老年代空间