垃圾收集和内存分配

关于Java虚拟机GC垃圾收集机制,主要介绍了不同区域所使用的垃圾收集算法,以及他们的结合种类、适用场景。

3.4 HotSpot算法实现

3.4.2 安全点

​ JVM通过GC Roots节点来判断某个对象是否可达,但是如果需要逐个检查的话必然会消耗很多时间,所以需要使用一个名为OopMap的数据结构来记录引用位置的信息(如指令流的起始位置,偏移量等)。

​ 在OopMap的帮助下,JVM可以很快的完成GC Roots的枚举,但是这里会出现一个问题:并不是所有的指令都会生成对应的OopMap数据结构,假设如此需要大量的额外空间,导致GC的空间成本将会变得非常高。这里的解决办法是只在“特定位置”记录这些信息,这样的位置称之为安全点(safe point),当线程执行到safe point的时候才会停顿下来开始GC。这里的safe point选定不能太少也不能太多,太少GC等太久,太多增大运行负荷;这里的设置特征为“是否让程序长时间执行的特征”,最明显的特征就是指令序列的复用比如方法调用、循环跳转、异常跳转等位置。

​ 为了让GC发生是所有线程(不包括JNI调用的线程)都能在最近的安全点停下来,有两种方法:抢先式中断和主动式中断。抢先式:GC发生时,中断所有线程,发现不在安全点的就恢复它使之跑到安全点上(目前几乎不用,不详细表);主动式:GC发生时,不直接操作线程仅设置一个标志,各个线程在执行的时候主动去轮询这个标志,为真时自动挂起(轮询标志和安全点是重合的)。

3.4.3 安全区域

​ 安全区域的设定是为了解决程序“不执行”的时候,即未分配CPU时间,因此线程无法响应JVM的中断请求挂起,典型的例子就是Sleep和Block状态。这时需要设置一个安全区域(safe region),安全区域是安全点的一个很大的扩展,设置原则为引用关系不会发生变化的一段代码片段。在线程离开安全区域之前会检查GC是否完成了根节点的枚举,完成的话继续执行,否则等待知道收到可以安全离开安全区域的信号为止。

3.5 垃圾收集器

3.5.1 Serial收集器

​ Serial是一个最基本、发展最久的收集器,单线程,但不仅仅说明它只会使用一个CPU或者一条线程去完成垃圾收集工作,更重要的是它在收集垃圾时必须暂停其他所有线程直到它结束(Stop The World),虽然不科学但是依然是Client模式下默认新生代收集器,优点:简单高效(没有线程交互的开销,专心收垃圾)。在桌面场景中新生代内存往往不大,所以停顿时间可以控制在几十最多一百多毫秒以内。

3.5.2 ParNew收集器

​ 其实是Serial的多线程版本,只有它能与CMS收集器配合使用;目前新生代的垃圾收集只能从Serial和ParNew中选择,在单CPU的情况下ParNew不比Serial好(甚至由于线程交互的开销不能超过Serial),但是随着CPU数量的增加,它对于GC时的系统资源的有效利用还是很有好处的。默认开启的线程数与CPU数量相同,可用参数进行限制。

3.5.3 Parallel Scavenge收集器

​ Parallel Scavenge的关注点和其他收集器不同,CMS等收集器是尽可能地缩短垃圾收集时用户线程的停顿时间,而该收集器目标时达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间))。和ParNew相比他们都是使用的复制算法,又是并行的多线程~ 但是不同的是Parallel Scavenge提供了两个参数用于精确控制吞吐量,分别为控制最大垃圾收集停顿时间参数以及直接设置吞吐量大小参数;停顿时间是虚拟机通过设置吞吐量和新生代空间大小来调整的。还有一个自适应调节策略(虚拟机根据系统情况自己调节)也是和ParNew的一个重要区别。

3.5.4 Serial Old收集器

​ Serial的老年代版本,垃圾回收算法使用的是标记-整理算法。主要两大用途:jdk1.5以及之前版本中与Parallel Scavenge收集器搭配使用;作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure 时使用。

3.5.5 Parallel Old 收集器

​ Parallel Scavenge的老年代版本,使用标记-整理算法。jdk1.6之前Parallel Scavenge较尴尬:因为新生代一旦选择Parallel Scavenge,老年代就只能使用Serial Old,所以无法充分利用多CPU的处理能力。后来有了Parallel Old就可以和它组合使用,真正注重吞吐量了。

3.5.6 CMS收集器

​ CMS收集器是一种以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法实现,运作过程分为四个步骤:初始标记,并发标记,重新标记,并发清除。

​ 初始标记:仅仅标记一下GC Roots能直接关联到的对象,速度快但是需要“Stop The World”。

​ 并发标记:进行GC Roots Tracing的过程,能够与用户线程一起工作。

​ 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,此阶段停顿时间比初始标记阶段时间稍长但是远比并发标记时间短,需要“Stop The World”。具体如何实现,做了什么还不清楚暂时不表。

​ 并发清除:清理未被标记的对象(不可达的),与用户线程一起并发执行。

​ 主要特点:并发收集、低停顿。

缺点:

  1. 对CPU资源非常敏感,虽然不会导致用户线程停顿但是会明显占用CPU资源而导致应用程序变慢,总吞吐量降低。CMS默认启动的回收线程数是(CPU数+3)/ 4 。这样导致用户程序执行速度忽然降低难以接受,所以虚拟机提供了“增量式并发收集器”,模拟操作系统使用抢占式来模拟多任务,使GC线程、用户线程交替运行,这样会使整个垃圾收集时间变长。实践证明这种处理效果一般,不提倡使用。

  2. 无法处理浮动垃圾,浮动垃圾:由于并发清理阶段用户线程的运行产生新的垃圾,这部分垃圾未被标记导致当前周期无法被处理,需等下一周期。所以CMS不能老年代满了再收集,需要设置一部分空间给应用程序,jdk1.6中将启动阈值提高到了92%。但是用户可以通过参数进行设置,如果预留内存无法满足需要(Concurrent Mode Failure),虚拟机会启动后备预案:临时启动Serial Old来重新进行老年区的垃圾收集,这样停顿时间就会很久。如果启动阈值设置过高,会导致大量的“Concurrent Mode Failure”失败,性能大大降低。

  3. 这个缺点就是“标记-清除”算法普遍存在的了,收集结束会有大量空间碎片产生,当有大对象时无法找到足够大的连续空间来分配,因此提前触发了一次Full GC。为此CMS收集器提供-XX:+UseCMSCompactAtFullCollection开关参数(默认开),用于在进行Full GC时开启碎片合并整理过程,这样停顿时间变长。虚拟机设计者还提供-XX:CMSFullGCsBeforeCompaction,设置多少次不压缩的Full GC以后来一次带压缩的(默认0)。

    这里有个疑问:既然使用“标记-清除”会导致大量空间碎片产生,那为什么不使用“标记-整理”算法呢?遂一顿搜索发现:原来CMS为了获取最短回收停顿时间而将耗时长的设为并发(并发标记和并发清理),如果在并发时通过整理移动了对象的内存,那么线程就会找不到应用对象在哪里。

3.5.7 G1收集器

​ G1(Garbage-First)收集器是新出的,面向服务端应用的收集器,有如下特点:1.并行与并发,能充分利用多CPU、多核环境缩短STW的时间;2.分代收集,能够采用不同方式处理不同类型的旧对象;3.空间整合,从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的(没理解);4.可预测停顿,G1除了追求低停顿外还建立可预测的停顿时间模型,制定M时间片段内收集垃圾时间不能超过N,其原因在于可以有计划地避免在整个堆中进行全区域的垃圾收集,G1跟踪各个Region中垃圾收集的价值(根据获取空间大小和所需时间)维护一个表,每次优先回收价值高的Region。

​ Region之间不可能是孤立的,不然检测Region之间的对象引用的时候还得扫描整个Java堆导致效率降低。所以每个Region需维护一个Remembered Set记录该Region被引用对象的相关引用信息,在内存回收时加入Remembered Set的扫描即可。

G1收集器运作步骤:

  1. 初始标记:和CMS一样,该阶段仅标记一下GC Roots能直接关联的对象并修改TAMS。
  2. 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,该过程耗时久但是可并发操作。
  3. 最终标记:修正并发标记过程中因用户程序运作而导致产生变化的标记记录,虚拟机将其记录在线程Remembered Set Logs中,并在此阶段将其合并到Remembered Set中,可并行。
  4. 筛选标记:首先对各个Region回收价值和成本进行排序,根据用户期望的停顿时间来制定回收计划,可并行,但是停顿用户线程将大幅提高收集效率。
3.5.8 GC日志

3.6 内存分配与回收策略

3.6.1 对象优先在Eden分配

​ Eden区和Survivor区都属于新生代,而Eden区存放的对象一般都是生命周期不长的对象(因此使用复制算法进行回收)。

3.6.2大对象直接进入老年代

​ 所谓大对象指大量连续内存空间的Java对象,最典型的就是很长的字符串以及数组(更坏的消息是遇到一群“朝生夕灭”的“短命大对象”),经常导致内存还有不少就不得不提前触发垃圾收集来获取足够的连续空间来存放他们。可以通过设置-XX:PretenureSizeThreshold参数来设置这个上限值。

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

​ 通过对象年龄计数器来记录对象,当熬过第一次Minor GC进入Survivor区并将年龄设置为1,接着每熬过一次Minor GC就将年龄加1,当年龄加到一定程度就被晋升入老年代中(默认为15),这个年龄阈值可以通过-XX:MaxTenuringThreshold参数设置。

3.6.4 动态对象年龄判断

​ 虚拟机并不是永远要求对象年龄达到阈值才晋升老年代的,如果在Survivor中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就直接进入老年代无需等到要求的阈值年龄了。

3.6.5 空间分配担保

​ 在发生Minor GC前,虚拟机会检查老年代最大连续可用空间是否大于新生代所有对象总空间,如成立则此次Minor GC是安全的。如不成立,虚拟机检查是否允许担保失败(HandlePromotionFailure参数),若允许则检查老年代最大可用连续空间是否大于历次晋升到老年代对象平均大小,若大于则进行一次有风险的Minor GC,若小于或者设置为不允许担保失败,则进行一次Full GC。

​ JDK 6 update 24以后规则发生了更改,即不管HandlePromotionFailure参数如何设置,代码中都不会使用它,规则变为只要老年代连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

0%