垃圾回收器与内存分配策略

垃圾回收器与内存分配策略

概述

Garbage Collection需要考虑三件事情:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

了解GC的目的是为了更好地排查各种内存泄露、内存溢出的问题,特别是在垃圾回收成为系统达到更高并发量的瓶颈时。回到Java,垃圾回收器主要关注的是堆内存。

对象生命

引用计数法

引用计数法是比较简单的判断对象是否存活的方法:给对象添加一个引用计数器,每当有个地方引用它时,计数器就加一;引用失效,计数数值就减一。

这个方法在ActionScript3的FlashPlayer、Python语言的一些领域有所应用。

但引用计数法不能解决对象间相互循环引用的问题,因此该方法没有被jvm采用。

可达性分析算法

在主流的商用语言如Java、C#等都是通过可达性分析来判定对象是否存活的。这个算法的思路就是从一系列被称为GC Roots的对象作为结点,从该节点向下搜索,某个对象不可达,则证明该对象不可用了,可回收。

img

在Java中,可作为GC Roots的对象包括:

  • 虚拟栈引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI即Native方法引用的对象;

再谈引用

无论哪种算法,判定对象是否存活都与"引用"有关。

在JDK1.2之火,Java对引用的概念进行了补充:

  • 强引用:类似"Object obj = new Object()",只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
  • 软引用:这是一些还有用但并非必要的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围内进行第二次回收,提供了SoftReference类来实现软引用;
  • 弱引用:无论内存是否足够,垃圾收集一定会回收掉被弱引用关联的对象;WeakReference;
  • 虚引用:最弱的引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获取一个对象实例,唯一目的就是在对象被回收时收到系统通知;PhantomReference;

生存还是死亡

即便在可达性分析算法中成为了不可达的对象,也不是非死不可的。在这个过程中,要真正回收对象,需要至少两次的标记过程。在可达性分析之后没有与GC Roots相连接的引用链,将会被第一次标记,并进行一次筛选:对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法或者该方法已经执行过,虚拟机则认为没有必要执行。

如果有必要执行,对象将会被放入F-Queue队列之中,并在稍后由一个虚拟机创立的Finalizer线程去异步执行,触发该方法。为了避免阻塞队列,该线程不承诺等待它完成,因此如果在执行finalize()时,对象重新与引用链上的任何一个对象建立联系,那么它将成功自救,移出"即将回收"的集合里。

回收方法区

虚拟机规范并有要求虚拟机在方法区实现垃圾回收,因为这些回收操作性价比有点低。方法区的回收主要是两部分内容:废弃常量和无用的类。例如常量池的字符串常量。而对于类是否无用则需要满足以下条件:

  • 该类的所有实例都已经被回收;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象并有任何引用;

满足条件仅仅是可以被回收,而不是一定回收。往往在大量使用反射、动态代理、GCLib等频繁定义ClassLoader的场景需要虚拟机具备类卸载的功能,保证方法区不会溢出。

垃圾收集算法

Mark-Sweep算法

该算法包括两个阶段:标记、清除。首先标记出所有需要回收的对象,在标记完成后统一回收。这个算法有两个缺点:

  • 效率问题:标记和回收的效率较低;
  • 空间问题:容易造成内存碎片;

Copying算法

这个算法的提出是为了解决效率问题,它将可用内存按照容量分为大小相等的两块,每次只使用其中一块,当这一块的内存用完时,就将还存活的对象复制到另外一块上面,然后再将使用过的内存空间一次性清掉。

这样内存分配就不用考虑内存碎片,分配时只需要移动堆顶指针,按顺序分配内存即可。

HotSpot也是用的这个方法,它将可用内存分成一块大的Eden空间和两块小的Survivor空间,每次使用Eden和其中一块Survivor,Eden和Survivor的比例是8:1,因此可用空间为90%。

但我们无法保证每次回收只有不多于10%的对象存活

Mark-Compact整理算法

复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率相对会降低。

因此又提出了一种新的算法:标记-整理。但与标记-清除不同的是,它不是直接对可回收对象进行整理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

Generational Collection

当前商业虚拟机采集的一种算法,即根据对象的生命周期将内存分为老生代和新生代。针对新生代,由于每次都会有大量的对象死去,所以一般会用复制算法;而对于老生代内存,则使用标记-清理或者标记-整理算法进行回收。

HotSpot的算法实现

枚举根节点

从上面提到的可达性分析中可以得知,要寻找GC Roots的结点主要是从全局性的引用与执行上下文中去找,但现在的应用往往在方法区就有数百兆,要逐个枚举太花时间了。

另外,为了分析能够在确保一致性的快照中进行,GC必须要停顿所有的Java执行线程,导致GC停顿。

目前的主流Java虚拟机使用的都是准确式GC,当系统停顿下来后,并不需要逐个去检查所有执行上下文和全局的引用位置,而是使用一组成为OopMap的数据结构来达到这个目的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是什么引用。

这样,GC就能直接得知这些信息了。

安全点

虽然Oop能保证HotSpot快速完成GC Roots枚举,但如果导致OopMap内容变化的指令非常多,那么为每一条指令都生成对应的OopMap是不合理的。

实际上,HotSpot只是在特定的地方记录了这些信息——Safe Point,安全点。线程只有执行到安全点才会暂停下来。安全点的选定基本是以程序“是否具有让程序长时间执行的特征”为标准而进行选定的,因此指令序列复用的地方,诸如:方法调用、循环跳转、异常跳转等才比较有可能会产生safe point。

另一方面,考虑到如何在GC发生时让所有线程跑到安全点附近停下,一般来说有两种方法:抢先式中断和主动式中断。现在主流的虚拟机都是采用的主动式中断,即当GC需要中断线程时,不是直接操作线程,而是简单地设置一个标识,各个线程执行时主动去轮询这个标识,为真时则自己主动中断挂起。轮询标识的地方和安全点是重合的。

安全区域

safepoint机制保证了程序执行时,可以在短时间内进入GC的safepoint。但如果程序不执行,没有被分配CPU时间,处于sleep或者blocked的状态中,那么它就无法响应JVM的响应。此时就需要安全区域(safe region)来解决这个问题。

安全区域指的是在一段代码中,引用关系不会发送变化,在这个区域中任意开GC都是安全的。当线程执行到safe region中的代码时,首先标识自己已经进入了safe region。那么当JVM发起GC时,就不管该线程了。而当线程要离开safe region时,它需要检查自己是否已经完成了根节点枚举,否则需要等待收到可以安全离开safe region的信号为止。

垃圾回收器

回收算法是内存回收的方法论,而垃圾回收器则是内存回收的具体实现。但是Java虚拟机规范中没有对垃圾回收器的实现有任何的规定,每个厂商都会提供自己的垃圾回收器,并且都会提供参数以供用户自定义。下面讨论的是基于JDK1.7 Update1.4之后的HotSpot虚拟机。

img

以上图为例,存在连线的收集器表示可以搭配使用。目前并不存在一个最好的收集器,我们只能根据具体的应用选择合适的收集器。

Serial收集器

Serial收集器是最基本的收集器,曾经是新生代收集的唯一选择。这是一个单线程的收集器,不单单是只使用一个CPU或者一个线程去完成收集,更重要的是它在进行回收的时候还会把其它工作线程暂停掉。

虽然这是最基本的收集器,但它依然是虚拟机运行在client模式下的默认新生代收集器。由于没有线程切换的开销,该收集器可以高效率地单线程收集。在一些桌面应用上,分配给虚拟机管理的内存不会太大,因此可以使用该类收集器。

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用多线程去回收垃圾之外,其它行为与Serial基本都是一样的。另外,它是许多运行在Server模式下的虚拟机首选的新生代收集器,原因是它与Serial收集器是目前仅有的能与CMS收集器配合工作的。

ParNew收集器在单CPU环境下不一定比Serial收集器效率更好,但当前计算机多数是多核CPU了。我们可以用-XX:+UseParNewGC选项来强制指定它。

Parallel Scavenge收集器

这是一个新生代收集器,采用的复制算法,并且也是并行的多线程收集器。但它的特别之处在于它关注的是获得一个可控制的吞吐量——>CPU用于运行用户代码的时间与CPU总消耗时间的比值。

该收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾回收停顿时间的-XX: MaxGCPauseMillis参数,和直接设置吞吐量大小的-XX: GCTimeRatio参数。其中MaxGCPauseMillis是一个毫秒数,如果设的太小,它可能会调小新生代空间,从而降低了吞吐量。

该收集器还有一个选项-XX: +UseAdaptiveSizePolicy,这是一个开关参数,打开这个参数之后,就不需要手动指定新生代的大小等细节参数了,虚拟机会收集当前运行的系统性能自动调整。

Serial Old收集器

这是Serial收集器的老年代版本,使用的是"标记-整理"算法。

Parallel Old收集器

这是Parallel Scavenge收集器的老年代版本,使用的是多线程和"标记-整理"算法。在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。在重视服务器响应的场景下用得比较多。

CMS收集器是基于"标记-清楚"算法的,它的操作过程分为四个步骤:

  • 初始标记;
  • 并发标记;
  • 重新标记;
  • 并发清除;

其中,初始标记和重新标记都需要"stop the world",初始标记仅仅是标记一下GC Roots能直接关联到的对象,而并发标记则是进行GC Roots tracing的过程,重新标记则是为了修正那些在并发标记阶段中因用户程序继续运行而产生的标记记录,这个过程会稍长。

CMS是一个优秀的收集器,但它有以下的缺点:

  • 对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/4,随着CPU资源的增加,回收线程的利用率反而下降。另外,如果CPU附在比较大,还需要分出一半的的运算能力去执行收集器线程,那么用户程序的执行速度就更慢了;
  • CPU收集器无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生。这是因为CMS并发清理阶段,用户线程还在运行,有可能产生新的垃圾,只能等待下一次GC去清理;
  • 最后一个缺点则是因为"标记-清除"算法可能出现大量的空间碎片。CMS提供了一个参数-XX:CMSFullGCsBeforeCompaction,这个参数用来设置执行多少次Full GC后,跟着带来一次压缩整理;

G1收集器

G1(Garbage-First)收集器是当前收集器的最前沿成果之一,这是一款面向服务端应用的垃圾收集器,具备以下特点:

  • 并行与并发:G1能充分利用多CPU的优势来缩短Stop-The-World的停顿时间;
  • 分代收集:虽然G1收集器可以独立管理整个GC堆,但它仍保留分代概念,以获取更好的收集效果;
  • 空间整合:没有采用CMS的"标记-清理"算法;
  • 可预测的停顿:建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;

G1收集器中,Java堆的内存布局是被划分为多个大小相等的独立区域,虽然保留了新生代和老年代的概念,但关键是它们不再是物理隔离的,而是一部分region的集合。

G1收集器之所以可以建立时间预测模型,是因为它根据各个region的垃圾堆积价值(回收所获得的空间大小以及回收所需要时间的经验值),维护一个优先队列,每次根据允许的回收时间,优先回收价值最大的Region。

在G1收集器中,Region之间的对象引用以及其它收集器中的新生代与老生代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个region都有一个对应的Remembered Set,虚拟机在发现程序在对引用类型进行写操作时,就会产生一个write barrier来中断写操作,检查该对象是否处于不同的region中。如果是,就会把相关引用记录到对象所属Region的Remembered Set中。这样进行回收的时候,只需要把GC Roots的枚举范围加入Remembered Set即可。

G1的操作步骤为:

  • 初始标记:与CMS一样;
  • 并发标记:进行可达性分析,找出存活对象;
  • 最终标记:主要是修正标记记录;
  • 筛选回收:根据刚刚提到的优先队列进行筛选回收;

如果应用追求地停顿,G1已经可以作为一个选择;如果追求吞吐量,则G1并不会有特别的优势。

理解GC日志

GC日志只是一些认为确定的规则,我们来解读一下:

1
2
33.125:[GC [DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
100.667:[Full GC [Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
  • 最前面的“33.125:”和“100.667:" 代表了GC发生时间(从java虚拟机启动以来经过的秒数);
  • 日志开头“[GC ”和“[Full GC”说明了这次垃圾收集的停顿类型(并不是区分新老生代的)。有"Full"说明这次GC是发生了Stop-The-World的。一般因为出现了分配担保失败之类的问题才会导致STW。如果调用System.gc()方法所触发的收集,那么这里将显示“[Full GC(System)”;
  • “ [DefNew”、“[Tenured”、“[Perm”表示GC发生区域,这里显示区域名称与使用的GC收集器密切相关;
  • 后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”;
  • “0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒;

内存分配与回收策略

JVM的自动内存管理系统主要是解决了两个问题:给对象分配内存和回收分配给对象的内存。

对象优先在Eden分配

Minor GC(新生代GC):指发生在新生代的垃圾收集操作,因为Java对象大多都具备朝生夕灭的特定,所以GC特别频繁,回收速度也比较快;

Major GC(老年代GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC。一般会比Minor GC慢十倍以上

大多数情况下,对象直接在新生代的Eden区中分配,但Eden区没有足够的空间进行分配时,虚拟机将会进行一次Minor GC。

大对象直接进入老年代

所谓的大对象指的是需要大量连续内存空间的Java对象,比如哪些很长的字符串或者数组,经常出现大对象的一个直接后果就是导致内存还有不少空间的时候就会提前触发垃圾回收。

虚拟机提供了一个-XX: PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。

长期存活的对象将进入老年代

内存回收需要识别哪些对象应该放在新生代、哪些放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经历了第一次Minor GC后仍然能存活,并且为Survivor容纳的话,将会被移动到Survivor空间中,并且年龄设为1。之后每次经历Minor GC,则年龄增加1岁。当它的年龄增加到默认值15,则会被晋升到老年代中。关于这个阈值,可以通过参数-XX: MaxTenuringThreshold来设置。

动态对象年龄判定

虚拟机并不是严格要求必须达到MaxTenuringThreshold的设置才能晋升老年代。如果在Survivor空间中相同年龄的所有对象的大小之和大于Survivor空间的一半,则年龄大于或者等于该年龄的对象则可以直接进入老年代。

空间分配担保

新生代使用复制收集算法,为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此如果在Minor GC之后大量对象仍然存活,则需要老年代进行担保。

在发送Minor GC之前,虚拟机会先检查老年代的最大可用连续空间是否大于新生代所有对象的总空间,如果成立则进行Minor GC。如果不成立,则继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于,则进行一次Minor GC,否则则不进行冒险。