[牛感悟系列]JAVA(1)理解JAVA垃圾回收
理解JAVA垃圾回收的好处是什么?满足求知欲是一方面,编写更好的JAVA应用是另外一方面。
如果一个人对垃圾回收过程感兴趣,那表明他在应用程序开发领域有相当程度的经验。如果一个人在思考如何选择正确的垃圾回收算法,那意味着他对应用程序的特性完全了解。当然,不能一概而论。不过,相信很少人会不认为理解垃圾回收是做一个好的JAVA开发的前提。
本文的目的是对垃圾回收进行简明介绍。
在学习垃圾回收之前,有一个概念需要知道。这个概念是:停止世界(stop-the-world)。任何一个垃圾回收算法都会引发停止世界。停止世界指的是JVM在运行垃圾回收的时候会暂停程序的运行。当停止世界发生的时候,除了垃圾回收意外的进程都会暂停。当垃圾回收完成的时候,这些暂停的任务会恢复运行。
垃圾回收的分代
在JAVA中并不用明确指明某段内存,并在程序代码中将其清空。有的人通过将对象设置为空,或者用System.gc()方法来清空内存。将对象设置为空没有多大影响。然而调用System.gc()方法将会明显地影响系统的性能。因此不应调用这个方法。(幸运的是,还没有NHN的开发者被发现调用这个方法。)
在JAVA中,开发者并不用在程序代码中指定清空某段内存。垃圾回收器会找到不再需要的对象,也就是垃圾,并清除他们。垃圾回收器有以下两个假设:(也许说是预设或者前提会更好。)
- 大部分的对象都很快就会不可访问。
- 老对象对新对象的引用是不常见的。
这些假设被称为弱分代假设。那么为了保障这个假设的优势,在HotSpot VM中一般都在物理上将对象分为两代:新生代,和老年代
新生代:大部分新创建的对象都位于这个地方。由于大部分对象都很快不可访问,在新生代中创建的很多对象都将消失。当对象从这个区域消失的时候,我们就说发生了一次次要垃圾回收。
老年代:在新生代中没有变得不可访问并继续生存的对象将会被复制到这个区域。这个区域一般比新生代要更大。由于它更大,这里的垃圾回收相对于新生代就要不那么频繁。当对象从老年代中消失的时候,我们就说发生了一次主要垃圾回收(或者说完全垃圾回收)。
让我们在图上来看这个过程:
图1:垃圾回收区域和数据流
上图中的永生代被称为方法区域。它存储类或被扣留的字符串。这个区域并不存储老年代中存活的对象。这个区域有可能发生垃圾回收。这个区域发生的垃圾回收也是主要垃圾回收。
有的人也许会问:
如果老年代中的对象需要引用新生代中的对象,会发生什么情况?
为了处理这种情况,老年代中有一个大小为512字节的块,作为卡片表(card table)。当老年代中的对象需要引用新生代中的对象时,在卡片表中加入相应记录。当需要在新生代中进行垃圾回收时,就会扫描这个表判定是否可以回收对象。这样就避免扫描整个老年代中的对象。卡片表由写栅栏(write barrier)管理。写栅栏可以加快次要垃圾回收的速度。尽管它本身需要消耗一些资源,不过整体的垃圾回收时间减少了。
图2:卡片表结构
新生代的组件
理解新生代是理解垃圾回收的基础。新生代中的对象都是第一次创建。新生代分为3个区域。
- 1个伊甸空间
- 2个生存者空间
总共有3个空间,其中2个是生存者空间。每个空间的执行过程是:
- 大部分新创建的对象都在伊甸空间。
- 在伊甸空间的一次垃圾回收之后,存活的对象将被移动到两个生存者空间的其中一个。
- 在伊甸空间的再一次垃圾回收之后,存活的对象将被继续在生存者空间中堆积。
- 当其中一个生存者空间满载之后,存活的对象将被移动到另外一个生存者空间。满载的生存者空间将被标记为没有数据的状态。
- 经过上面几步若干次循环之后的存活的对象将会被移动到老年代中。
在上述过程中,总有一个生存者空间必须为空。如果两个生存者空间都有数据,或两个生存者空间都没有数据。那么就意味着系统出问题了。
下图说明了在次要垃圾收集中,数据被堆积到老年代的过程:
图3:垃圾回收之前和垃圾回收之后
注意在HotSpot VM中,有两个快速内存分配的技术。一个叫做bump-the-pointer,另一个叫做TLABs(线程本地分配缓存)。
Bump-the-pointer技术追踪分配到伊甸空间的最后一个对象。这个对象将被分配到伊甸空间的顶部。如果在这之后又有一个对象被创建,它只要检查对象的大小是否适合于伊甸空间。如果适合,对象将被放到伊甸空间中,并分配到顶部。因此,当新的对象被创建的时候,只有最后加入的对象需要被检查。这就使内存分配更加高效。不过,在多线程环境中就是另外一回事了。如果要用线程安全的方式在伊甸空间中保存多线程使用的对象,就会因为要用锁导致性能大幅下降。TLABs是解决HotSpot VM中这个问题的方案。这允许每个线程都单独拥有伊甸空间的一小部分。每个线程都只能访问他们自己的TLAB,这样Bump-the-pointer技术既可以不用使用锁了。
这是对新生代中垃圾回收的快速概览。你没有必要去记住这两个技术。不知道他们也不用去坐牢。但是请记住,对象第一次在伊甸空间中创建以后,长期存活的对象将会经由生存着空间移动到老年代。
老年代垃圾回收
老年代一般会在数据满载的时候执行一次垃圾回收。执行过程会随着垃圾回收的类型而变化。那么了解垃圾回收的各种类型将对理解执行过程有所帮助。
根据JDK7,有5种垃圾回收类型:
- 序列垃圾回收
- 并行垃圾回收
- 并行老垃圾回收(并行压缩垃圾回收)
- 同步标记清空垃圾回收(CMS)
- 垃圾优先(G1)垃圾回收
在这之中,序列垃圾回收不应当在服务器上使用。这种垃圾回收类型仅适用于单核工作站。使用这种序列垃圾回收将会急剧降低应用的性能。
现在来介绍各种垃圾回收类型:
序列垃圾回收(-XX:+UseSerialGC)
在上面一节已经介绍了新生代中的垃圾回收。老年代垃圾回收中使用的算法叫做“标记-清空-压缩”。
- 算法第一步是标记老年代中的存活对象。
- 然后,遍历堆,只保留存活对象,清空其它。
- 最后一步,它从头开始填充堆,保证对象连续片列。并将堆分成两个部分:一部分有对象,一部分没有。
序列垃圾回收适合于内存小,CPU核少的机器。
并行垃圾回收 (-XX:+UseParallelGC)
图4:序列垃圾回收和并行垃圾回收的差异
通过图4,可以很容易看出序列垃圾回收和并行垃圾回收的差异。当序列垃圾回收只使用一个线程处理一次垃圾回收。并行垃圾回收使用多个线程处理一次垃圾回收。并行垃圾回收适用于大内存多核CPU的机器。因此它也被叫做吞吐垃圾回收。
并行老垃圾回收(-XX:+UseParallelOldGC)
JDK5开始支持并行老垃圾回收。和并行垃圾回收相比,唯一的不同是,这是老年代垃圾回收的算法。它有三步:标记-概括-压缩。概括阶段仅标记垃圾回收曾经处理过的区域中的存活对象。这是和并行垃圾回收的差异。它将由更为复杂的步骤。
同步标记清空(CMS)垃圾回收 (-XX:+UseConcMarkSweepGC)
图5:序列垃圾回收和CMS垃圾回收
如图5所示,CMS垃圾回收比前述各种垃圾回收类型都要复杂。它的初始标记阶段很简单。最靠近classloader的对象中的存活对象将会被搜索。这样,暂停时间就会非常短站。在同步标记阶段,存活对象引用的对象将会被追踪和检查。这一步的不同点在于它和其它线程同时执行。在重标记阶段,在同步标记阶段新增加的对象或不再被引用的对象将会被检查。最后,在同步清空阶段,开始垃圾回收过程。垃圾回收和其它线程同步执行。由于这个垃圾回收类型用这种方式执行,它的暂停时间就非常短。CMG垃圾回收也被叫做低延迟回收压机回收。它适合反应时间要求非常高的环境。
这种垃圾回收类型在低停止世界时间上非常有优势。它也同样有以下弱势:
- 它比其它的垃圾回收类型消耗更多的内存和CPU时间。
- 压缩阶段并不被默认提供。
使用这个类型需要经过非常小心的评估。如果因为过多的内存碎片需要执行压缩任务,停止世界时间将会比其它垃圾回收类型更长。需要检查压缩任务的执行时间和执行频率。
垃圾优先(G1)垃圾回收
Finally, let's learn about the garbage first (G1) GC.
最后,介绍垃圾优先(G1)垃圾回收。
图6:G1垃圾回收的布局
如果需要理解G1垃圾回收,首先要忘了新生代和老年代。如图6所示,一个对象分配一个网格,然后执行垃圾回收。然后,在一个区域满载以后,对象将被分配到另外一个区域,然后执行垃圾回收。数据从新生代的三个空间移动到老年代的过程不存在于G1垃圾回收类型。这个类型曾用于替代CMS垃圾回收。在很长一段时间内,这导致了很多问题和抱怨。
G1垃圾回收的最大的优势是性能。它比前述各个垃圾回收类型都要快。但是在JDK6中,这不是正式特性。JDK7种正式提供这个特性。JDK6中因为应用了G1而导致JVM崩溃的案例也有所听闻。请等到它更加稳定。
在这个文章中,仅仅是对JAVA垃圾回收的概览。请期待下篇文章,介绍如何检测JAVA垃圾回收状态,垃圾回收调优。