Java将所有运行时的分配的对象保存到堆(heap)中,虚拟机通过诸如new
,newarray
,anewarray
以及multianewarray
等指令来分配对象,但并不会在代码中显式的释放这些内存区域,而是由虚拟机中自带的内存回收器(Garbage Collection
,以下简称GC
)来负责内存分配、压缩以及回收。GC的主要任务就是找到堆中那些不再被引用的内存对象,将其回收用于分配新的对象;同时,GC还要负责压缩堆内存分片,减少内存碎片,确保新的对象有足够的连续空间可以使用。GC让开发人员不用再担心内存释放的问题,提升了开发效率;在另外一个方面,GC使开发人员不用再担心错误的释放了某个对象,确保了程序的完整性。但使用GC的一个潜在弊端是,GC在收集内存时会增加程序负担,减低程序运行效率。
接下来,就来看看Java虚拟机中常用的垃圾回收算法。
垃圾收集算法
任何垃圾收集算法都需要做如下几件事情:
- 检测需要回收的对象;
- 回收“垃圾”对象所占用的内存,将这些内存提供给程序使用;
- 对内存进行去碎片化处理;
那么,怎么确定哪些对象是需要回收的了?首先,GC需要确定一个“根对象"集合,如果某个对象可以通过“根集合”中的元素访问到,则称该对象是可达的(任何根对象都是可达的),因此就可以认为该对象是“存活的”,而对于不可达的对象则被认为是“待回收”的对象,因为他们对程序的执行不会产生影响。那么,一般根集合中的对象要如何选择了?这个跟具体的JVM实现有关系,一般有如下四种:
- 在线程中使用的本地变量;
- 正在运行的线程;
- 被类引用的静态变量
- 通过JNI调用在本地代码引用的对象
确定了根集合元素之后,JVM是怎么来区分存活对象与需要回收的对象的了?主要有两种方式,一种是引用计数(Reference counting);一种是跟踪(tracing)。引用计数回收器保存了每个对象的引用次数,如果发现该对象的引用次数为零,则认为该对象可被回收;跟踪回收器基于根集合中的节点建立对象引用图,跟踪每个对象的引用关系,跟踪过程完成后,没有标记的对象即是不可达的,因此会被回收。
引用计数回收器
引用计数是在JVM初期使用的垃圾回收策略。JVM在分配对象时,会保存一个该对象的引用计数,并且该引用计数被初始化为1,当其他变量引用该对象时,对象的引用计数会加一,而该引用不再使用或者被赋予新值时,则将该对象的引用减一。当对象引用计数为零时,就将该对象回收,一旦对象回收,所有该对象引用的对象的引用计数都需要减一。
引用计数回收器的一个优点在于,回收时间短,不会过多占用程序执行时间,这也使得这种回收方式特别适合程序执行不能中断过久的实时性环境;不足之处时,引用计数不能检测循环引用:多个对象相互引用。即使这些对象对于执行程序的根集合来讲是不可达的,它们的引用计数永远不可能为零。
正是因为这个原因,引用计数回收已经很少在实际中使用了,如今的JVM大都使用跟踪算法来实现垃圾回收。
跟踪回收器
跟踪算法从根对象开始,计算出整个对象引用图,将图中被引用的对象进行标记。跟踪回收算法一般分为三个步骤:
- 标记(mark): 从根对象开始,遍历所有可达对象,并作相应的标记;
- 清理(sweep):对于未被标记的不可达对象,回收其内存区域,确保可以被下一次内存分配使用;在JVM中,清理阶段必须包含对象的finalization。
- 压缩(compact):在删除“垃圾”对象后,通常需要将存活的对象移动到一起,清除堆内存中的碎片,从而提高新对象分配的效率。
虽然跟踪回收器解决了引用计数回收器中存在的循环引用问题,但是在清理之前,通常需要执行”stop the world(STW)”,暂停运行程序,这在一定程度上降低了程序运行的效率。因此,如何减少STW对程序运行的影响,是各个GC算法需要考虑的重点。
JVM中的垃圾回收器
当前,JVM主要提供了四种垃圾回收器:
- the serial collector
- the throughput(parallel) collector
- the concurrent(CMS) collector
- G1 collector
尽管这四种回收器在细节上有所区别,但所有的GC都将堆内存划分成不同的代(generations)
:新生代(young generation)以及老一代(old or tenured generation)。新生代又进一步划分为伊甸区(eden space)和两个幸存区(survivor space)。为什么要把堆内存划分成不同的代了?这样做有何益处?这种划分是基于经验的分析:应用中大部分对象都具有比较短的生命周期。将堆内存根据生命周期划分成更小的空间,可以减少内存回收的时间。
新生代用于新对象的分配、当新生代空间耗尽后,会进行一次垃圾回收。这种在新生代上进行的GC通常被称为Minor GC;老一代空间包含了生命周期较长以及在多次Minor GC之后存活的对象,通常老一代空间填满后,会进行一次垃圾回收,被称为Major GC(也称为full GC),与Minor GC相比,Major GC通常需要耗费更长的时间。以下是关于新生代空间的几个要点:
- 绝大多数的新创建对象都位于Eden空间;
- 当eden被填满后,进行Minor GC,所有存活下来的对象都会移动到一个survivor空间;
- Minor GC也会检查survivor空间,将其中存活的对象移动到另外一个survivor空间。因此,在任何时候,总有一个survivor空间是空的;
- 多次Minor GC后,依然存活的对象会被移动到老一代空间中去。
在JVM内存中,除了堆空间外,还有一个永生代(permanent generation),该内存空间用于存放程序中需要使用的元数据(metadata),同时还保存着Java标准库的类与方法。永生代空间的垃圾回收一般在full GC中进行。
接下来看看各个回收器算法的具体差异以及使用方式:
- seriral collector: 串行回收器是最简单的一种,其使用单个线程来处理堆内存,主要用于单核CPU以及堆空间较小的执行环境。在回收垃圾时,串行回收器会暂停所有程序,这使得它并不适用于服务器场景。JVM中可通过
-XX:+UseSerialGC
来使用串行回收器; - parallel/throughput collector: 并行回收器,对于服务器(多个CPU以及64-bit JVM)这是JVM默认使用的回收器。并行回收器使用多个线程并行的对堆内存进行扫描、标记以及去碎片,在最低程度上减少垃圾回收时程序暂停的时间。但并行回收器在执行minor/major(full) GC时,会暂停所有程序线程。因此,并行回收器比较适合于能够容忍一定时间暂停的场景。并行回收器对应的JVM参数为
-XX:+UseParallelGC/-XX:+UseParallelOldGC
(JDK 7之前的JVM使用); - CMS collector:Concurrent mark sweep旨在减少full GC引起的长时间暂停,其使用多个线程标记、清理未被引用的对象。在Minor GC时,CMS依然会执行
stop the world(STW)
,但对于full GC,CMS会使用多个线程定时的扫描老一代内存空间,丢弃那些未被使用的对象。相比并行回收器,CMS大幅减少了程序暂停的时间,但在一定程度上增加了CPU负担,而且CMS并不会对堆空间进行压缩,这会导致大量内存碎片。开启CMS对应的参数为-XX:+UseConcMarkSweepGC
。如果系统的堆内存在4G以下,并且为了避免程序暂停允许GC分配更多的系统资源,可以考虑使用CMS回收器; - G1 collector:The Garbage first Collector(G1)在JDK7中引入,是为了更好的支持堆空间在4G以上的系统。G1回收器使用多个后台进程扫描堆空间,并把堆空间分割成1MB~32MB大小的区域。G1回收器会优先扫描包含了最多待回收对象的区域。这样能够减少GC后台线程完成扫描未被引用的对象之前将对象删除的几率,从而避免了GC进入
stop the world(STW)
,降低程序运行的效率。G1清理老一代空间对象时,将对象从一个区域复制到另一个区域,这样使得G1在正常情况下也可以进行内存的去碎片化处理。因此相比CMS来说,G1具有更少的碎片。G1对于的JVM参数为-XX:+UseG1GC
;
参考资料
- Java Performance: the Definitive Guide
- https://plumbr.io/handbook/what-is-garbage-collection
- https://www.cs.cmu.edu/~fp/courses/15411-f14/lectures/21-gc.pdf
- https://betsol.com/2017/06/java-memory-management-for-java-virtual-machine-jvm/
- https://www.cs.princeton.edu/picasso/mats/HotspotOverview.pdf
- https://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All