JVM之垃圾收集器与内存分配策略

734 阅读15分钟

概述

使用Java的同学都知道在Java中垃圾自动回收,但是就算如此,我们也得知道Java是如何实现垃圾自动回收,本文我们就来学习JVM的垃圾收集和内存分配。

名词解释

在了解回收器之前,我们先来了解下几个名词

  • 吞吐量-----指CPU用于运行用户代码的时间和CPU运行时间的总值的比值,比如虚拟机总共运行了100分钟,用户代码运行了99分钟,垃圾回收时间1分钟,则吞吐量就是99%。
  • 停顿时间-----指回收器正在运行,用户程序却在暂停的时间。对于独占的回收器而言,停顿时间可能会比较长,使用并发回收器,由于垃圾回收线程和用户线程交替运行,程序的停顿时间会很短,但是由于其效率很可能不如独占垃圾回收器(由于线程上下文的切换需要耗费CPU资源),故系统的吞吐量可能会较低
  • Minor GC-----指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC通常很频繁,一般回收速度也比较快
  • Major GC-----指发生在老年代的垃圾回收动作,出现Major GC经常伴随至少一次的Minor GC。Major GC一般会比Minor GC慢10倍以上。
  • 串行-----单线程进行垃圾回收工作,但此时用户线程仍处于等待状态
  • 并发-----指用户线程和垃圾回收线程交替执行
  • 并行-----指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

如何判断对象已死?

Java堆中存放着Java世界中所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象有哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用)。

引用计数法

  • 给对象添加一个引用计数器,每有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
  • 但是引用计数法有一个致命的缺点,如果在一个对象A中引用了对象B,而对象B中又引用了对象A,这就出现了循环引用的问题,虽然两个对象都已经可以进行清除了,但是由于他们互相引用着,所以他们的引用计数器都不为0,都无法被回收

根搜索法

  • 通过一系列名为“GC Root”的对象为起始点,从这些个节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Root没有任何引用链相连时,则证明此对象是不可用的
  • 以下几种对象可以当作GC Roots:虚拟机栈中的引用对象;方法区中的类静态属性引用的对象;方法区中的常量引用的对象;本地方法栈中Native方法引用的对象(其实都是垃圾回收不会作用的区域的对象)

引用类型

  • 强引用:指在程序代码中普遍存在的,类似于“Object obj = new Object()”这类的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用:用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,只有在系统没有足够的内存时,垃圾回收才会将其回收,否则不会回收。
  • 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些,只要垃圾回收器就行工作,不管当前内存是否足够,都会回收只被弱引用关联的对象
  • 虚引用:最弱的一种引用关系,完全不会对对象的生存时间构成影响,只是为了在这个对象被回收的时候收到一个系统通知

对象的自救

  • 在根搜索算法中不可达的对象,也并非是“非死不可”的,如果该对象覆盖了finalize()方法且没执行过的话,可以在finalize()中实现自救,如果该方法已经执行过了,那都会被认为这个对象是“死”的了。
  • (方法区的垃圾回收)判断一个类是否是“无用的类”的三个决定性条件:该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;加载该类的ClassLoader已经被回收;该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

垃圾收集算法是垃圾回收的核心,关系到垃圾收集的效果和效率

标记-清除算法

标记-清除算法从根集合进行扫描,并对存活的对象进行标记。标记完成之后,再扫描整个空间中未被标记的对象进行直接回收,如下图所示:

标记-清除算法
该算法主要有两个缺点:

  • 效率问题,标记和清除过程的效率都不高
  • 空间问题,标记-清除之后会产生大量不连续的内存碎片

复制算法

复制算法将内存划分为两个区间,使用此算法时,所有动态分配的对象都只能分配在其中一个区间(活动区间),而另一个区间(空闲区间)则是空闲的。
复制算法同样从根集合扫描,将存活的对象复制到空闲区间。当扫描完毕活动区间后,会将活动区间一次性全部回收。此时原本的空闲区间变成了活动区间,下次GC的时候又会重复刚才的操作,以此循环。

复制算法
复制算法的特点:

  • 优点是实现简单,运行高效,存活对象较少的时候,极为高效
  • 缺点是空间利用率低

标记-整理算法

标记-整理算法采用标记-清除算法一样的方法进行对象的标记,但在回收不存活的对象占用空间后,会将所有的存活的对象往一端空闲空间移动,并更新对应的指针。

标记-整理算法
标记-整理算法的特点:

  • 因为有一个存活对象压缩的操作,解决了内存碎片的问题
JVM为了优化内存的回收,使用了分代回收的方式,对于新生代内存的回收(Minor GC)主要采用复制算法。
而对于老年代内存的回收(Major GC),大多采用标记-整理算法。

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法:

  • 将Java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法
  • 新生代大多数都是朝生夕死的对象,适合使用复制算法,高效,因为每一次垃圾回收的时候,对象存活率很低
  • 老年代一般都是采用标记-清除或者标记-整理算法

垃圾收集器

在目前的主流JVM中,具体由Serial、ParNew、ParallelScavenge、Serial old、Parallel Old、CMS、G1等七种垃圾回收器,下图中,表示出了不同的垃圾回收器适用于不同的内存区域以及各个垃圾回收器之间的配合使用关系。

垃圾收集器
图中的七种垃圾回收器,分别用于不同的分代的垃圾回收:

  • 新生代:Serial、ParNew、Parallel Scavenge
  • 老年代:Serial old、Parallel old、CMS
  • 全堆:G1

Serial收集器 (JVM参数:-XX:UseSerialGC)

  • Serial收集器是最基本、历史最悠久的收集器
  • 工作在新生代,所以采用的是复制的垃圾回收算法
  • 是一个单线程收集器,“单线程”的意义不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,而是说在它进行垃圾收集时,必须暂停其他所有的工作线程("Stop The World")
  • Stop The World意为着在进行垃圾回收的时刻只能进行垃圾回收,用户的工作线程会被暂停,直到垃圾回收过程完成,这对于服务器端的JVM来说是不可以忍受的
  • Serial由于没有线程的切换去耗费CPU资源和时间,所以它是效率非常高的,在一般的Client端是可以使用这个Serial收集器的
    serial收集器
  • 由上图可见,单线程采用复制并且暂停所有其他线程

ParNew收集器 (-XX:UseParNewGC)

  • Serial收集器的多线程版本
  • 作用于新生代,采用复制回收算法,也得Stop The World
  • 根据CPU核数,开启不同的线程数
    ParNew

Parallel Scavenge收集器 (XX:+UseParallelGC)

  • 新生代收集器,使用复制回收算法
  • 多线程回收器,与ParNew不同是更关注程序运行的吞吐量(用户代码运行时间占总运行时间的百分比)

Serial Old收集器 (-XX:+UseSerialGC)

  • Serial的老年代版本,同样还是单线程收集器
  • 采用标记-整理算法,主要也是在Client模式下的虚拟机使用

Parallel Old收集器 (-XX:+UseParallelOldGC)

  • Parallel Scavenge收集器的老年代版本,使用多线程标记-整理算法
  • 同样考虑吞吐量优先指标,非常适合注重吞吐量CPU资源敏感的场合

CMS收集器

  • 最短回收停顿时间为前提的回收器,属于多线程回收器,采用标记-清除算法
  • 相比之前的回收器,CMS回收器的运作过程比较复杂:
  • 初始标记-----仅仅是标记GC Root能直接关联的对象,这个阶段很快,但仍需Stop The World
  • 并发标记-----进行的是GC Tracing,从GC Root开始对堆进行可达性分析,找出存活对象
  • 重新标记-----为了修正并发期间由于用户程序继续运作导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记的时间短,也需要Stop The World
  • 并发清除-----开始并发清除前面标记的可以回收的对象,垃圾回收的线程与用户线程并发执行,所以能达到停顿时间短这个目第 当然,CMS回收器可定也会有相应的缺点:
  • 采用的是标记-清除算法,会产生内存碎片
  • 在并发清除的阶段,用户线程也在继续运作,这个时候所产生的垃圾(浮动垃圾)无法在这次的回收过程中回收,必须得等到下一次的垃圾回收
  • 对CPU资源非常依赖,过分依赖于多线程环境,默认情况下,开启的垃圾回收的线程数为(CPU的数量 + 3)/ 4,当CPU数量少于4个时,CMS对用户查询的影响很大

G1收集器

G1是JDK1.7中正式投入使用的用于取代CMS的压缩回收器。它虽然没有在物理上隔断新生代与老生代,但是仍然属于分代垃圾回收器。G1仍然会区分年轻代与老年代,年轻代依然有Eden区和Survivor区。
G1首先将堆分为分为大小相等的Region,避免全区域的垃圾回收。然后追踪每个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的回收时间优先回收价值最大的Region。同时G1采用Remembered Set来存放Region之间的对象引用,从而避免全堆扫描。G1的分区示例如下图所示:

这种使用Region划分内存空间以及有优先级的区域回收方式,保证G1回收器在有限的时间内可以获得尽可能高的回收率
G1和CMS运作过程有很多相似之处,整个过程也分为4个步骤:

  • 初始标记-----仅仅是标记GC Root能直接关联的对象,这个阶段很快,但仍需Stop The World
  • 并发标记-----进行的是GC Tracing,从GC Root开始对堆进行可达性分析,找出存活对象
  • 重新标记-----为了修正并发期间由于用户程序继续运作导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记的时间短,也需要Stop The World
  • 筛选回收-----首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段可以与用户线程一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高回收效率。
    与其他的GC回收器相比,G1具备如下4个特点:
  • 并行与并发-----使用多个CPU(并行)来缩短Stop The World的停顿时间,部分其他回收器需要停顿用户线程来进行GC动作,而G1回收器仍可以通过并发的方式让用户线程继续执行
  • 分代回收-----与其他回收器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他回收器配合就能独立管理整个GC堆,但它能够采用不同的策略去处理新创建的对象和已经存活一段时间、熬过多次GC的旧对象,以获取更好的回收效果。新生代和老年代不再是 物理隔离,是多个大小相等的独立Region。
  • 空间整合-----与CMS的标记-清理算法不同,G1从整体来看是基于标记-整理算法实现的回收器。从局部上来看是基于复制算法实现的;但无论如何,两种算法都意味着G1运行期间不会产生内存碎片
  • 可预测的停顿-----这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同关注点。G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾回收上的时间不得超过M毫秒

垃圾收集器总结

从前面的介绍我们可以直到,在运行使用JVM时,我们可以根据不同使用环境下选择不同的垃圾回收器(根据设置JVM参数),当然我们需要直到的是,随着时间的推移,越往后的垃圾回收器肯定会越智能,越好,所以我们平时使用的一般都是G1垃圾回收器,因为它是一个非常强大的垃圾回收器。

内存配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化的解决了两个问题:

  • 给对象分配内存
  • 回收分配对象的内存
    前面我们花了很大的篇幅去学习虚拟机中的垃圾收集器体系及其运作原理,现在我们来看看如何给对象分配内存

对象优先在Eden区分配

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

大对象直接进入老年代

所谓的大对象就是指,需要大量连续内存空间的java对象,最典型的就是那种很长的字符串及数组,在给大对象分配内存时应直接将其放至老年代

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

如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1.对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,可以通过参数-XX:MaxTenuringThreshold来设置)时,就会被晋升到老年代中

动态对象年龄判定

为了能够更好地适应不同程序地内存状况,虚拟机并不总是要求对象地年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄

空间分配担保

在发生Minor GC时,虚拟机会监测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HanlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则也要进行一次Full GC

参考资料

周志明--《深入理解Java虚拟机++JVM高级特性与最佳实践》和JVM系列(六)-JVM垃圾回收器