本文导火索

image-20201111225928785

当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

JVM内存分配与回收

Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能是堆内存中对象的分配与回收。

Java堆是垃圾收集器管理的主要区域,因此也被称作为GC堆(Garbage Collected Heap). 从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:

  • 新生代: Eden空间、From Survivor、To Survivor空间
  • 老年代:Old Memory

进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆空间的基本结构:

image-20201111231314990

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。

动态年龄计算的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}

经过这次GC后,Eden区和”From”区已经被清空。这个时候,”From”和”To”会交换他们的角色,也就是新的”To”就是上次GC前的“From”,新的”From”就是上次GC前的”To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,”To”区被填满之后,会将所有对象移动到老年代中。

image-20201111232843231

对象优先在Eden区分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

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

测试:

1
2
3
4
5
6
7
public class GCTest {
public static void main(String[] args){
byte[] alocation1,alloction2;
allocation1 = new byte[30900 * 1024];
//allocation2 = new byte[900*1024];
}
}

通过以下方式运行:

image-20201111234604025

添加的参数:-XX:+PrintGCDetails

image-20201111234626646

image-20201111234844467

从上图我们可以看出 eden 区内存几乎已经被分配完全(即使程序什么也不做,永久代也会使用 2000 多 k 内存)。假如我们再为 allocation2 分配内存会出现什么情况呢?

1
allocation2 = new byte[900*1024];

image-20201111235001934

简单解释一下为什么会出现这种情况: 因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC.GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。可以执行如下代码验证:

1
2
3
4
5
6
7
8
9
10
11
public class GCTest {

public static void main(String[] args) {
byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
allocation1 = new byte[32000*1024];
allocation2 = new byte[1000*1024];
allocation3 = new byte[1000*1024];
allocation4 = new byte[1000*1024];
allocation5 = new byte[1000*1024];
}
}

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)

原因:

为了避免大对象分配内存时由于分配担保机制带来的复制而降低效率。

内存分配担保机制: 当在新生代无法分配内存的时候,把新生代的对象转移到老生代,然后把新生代,然后把新对象放入腾空的新生代。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

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

动态对象年龄判定

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

修正(issue552):“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。

动态年龄计算的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
Copy to clipboardErrorCopied

额外补充说明(issue672):关于默认的晋升年龄是15,这个说法的来源大部分都是《深入理解Java虚拟机》这本书。 如果你去Oracle的官网阅读相关的虚拟机参数,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明

Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是15,这个是要区分垃圾收集器的,CMS就是6.

主要进行 gc 的区域

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

在JVM中,可以被用作GC Root的对象有:

  • 虚拟机栈中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象

对象已经死亡?

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断那些对象已经死亡(即不能再被任何途径使用的对象)。

image-20201112232337258

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的像话引用,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

1
2
3
4
5
6
7
8
9
10
11
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args){
ReferenceCountingGc objA = new ReferenceCountingGc;
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}

可达性分析算法

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

image-20201112233246468

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

再谈引用

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

1.强引用(StrongReference)

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

2.软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

3.弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动

虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

不可达的对象并非“非死不可”

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

如何判断一个常量是废弃常量?

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

  • JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代

  • JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代

  • JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾收集算法

标记-清除算法(Mark-Sweep)

标记-清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。

优点:简单

缺点:

  • 效率问题 执行时间不稳定,如果Java堆中包含大量对象,某一次回收时无用的对象非常多,这时候会花费很多时间进行内存的清理
  • 空间问题 标记清除后产生大量不连续的碎片 上图只是一个理想的删除过程,正好没有内存碎片产生,而实际上在内存中待清除的内存有可能不是连续的,导致会产生许多内存碎片,如果某个大对象无法找到一块连续的内存进行存放时,会误以为堆内存不足,提前触发Full GC

放入内存失败

所以为了解决内存碎片问题,科学家们研制出了一种新的算法:标记-复制算法

标记-复制算法(Mark-Copying)

标记-复制

由上面的动图可以看出,标记-复制算法将原本的堆内存划分了两个区域,采用了“半区复制”算法,将一半的内存省出来,当发生垃圾收集行为时,将存活的对象复制到另外一半保留区域中连续存放

标记-复制算法的优点是解决了大对象分配内存的内存碎片问题,也解决了标记-清除算法中大量垃圾对象导致的清除效率问题

缺点也非常的明显,那就是可分配的内存空间少了整整一半,而且如果某次存活的对象较多,甚至全部存活,那么复制的效率将会非常低。

标记-整理算法(Mark-Compact)标记-整理

为了提升内存的利用率,科学家提出了标记-整理算法,该算法的起始过程和标记-清除算法相同,先标记处待回收对象的内存区域,但是在清除时不是对所有可回收对象清除,而是让所有存活对象往内存空间的一边移动,把存活对象边界外的内存直接清空掉。

标记-整理算法提高了内存的利用率、解决了大对象分配时的内存碎片问题,看似完美的垃圾收集算法,也有它的弊端。在移动存活对象的过程中,需要全程暂停用户程序的执行,被设计者称为“Stop The World”。

分代收集算法(Generational Collection)

新生代垃圾收集及内存分配

分代收集

分代收集算法本质上标记-复制算法,它把堆内存中较大的一块区域作为新生代区域,新生代区域中分为一个Eden区域和两个Survivor区域,Eden和Survivor的比例默认是8:1,因为在Eden区域,绝大数对象都熬不过第一轮GC(98%),所以每个Survivor区域只需要10%的空间就足矣了,每一次触发Minor GC时,就会将Eden区和Survivor区存活的对象复制到另外一个Survivor区域中,然后清除掉被回收的对象,每次都依据这样的步骤进行垃圾收集。

不知道你有没有注意到每个对象有一个数字的标记,这个标记是对象的年龄,当对象到了15岁以后(默认情况)就会被晋升为老年代

晋升老年代

晋升老年代

如图所示,当对象在Survivor区存活了15次以后,就会晋升为老年代对象。

还有以下情况会晋升为老年代对象:

大对象。当对象所占连续内存非常大时,不会分配在Eden区,如果分配在Eden区,那么对象存活时产生的复制操作将导致效率大大降低。

如果在Survivor区,相同年龄的对象总大小大于Survivor区空间的一半时,也会将这些年龄相同的对象直接晋升到老年代,原因也是防止对象的复制操作导致的效率问题。

空间分配担保

在对象无法分配到Eden区时,会触发一次Minor GC,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,如果大于,那么这次Minor GC是安全的,如果不大于的话,JVM就需要判断HandlePromotionFailure是否允许空间分配担保。

如果允许担保,则证明老年代的连续可用内存空间大于历次晋升到老年代对象的平均大小,此时触发一次Minor GC,如果小于,那么证明老年代并没有把握放得下Survivor区有可能晋升的对象,此时发生一次Full GC

Stop The World

发生GC(MinorGC或者FullGC)时,都会将用户线程停顿并进行垃圾收集,在Minor GC中,STW的时间较短,只涉及Edensurvivor区域的对象清除和复制操作,而Full GC则是对整个堆内存进行垃圾收集,对象的扫描、标记和清除操作工作量大大提高,所以Full GC会导致用户线程停顿较长时间,如果频繁地发生Full GC,那么用户线程将无法正常执行。

或者通俗的理解:

你给你妈妈打扫房间时,你是希望她坐在一旁静静等你扫完地再继续活动,还是想你一边扫地,她一边丢垃圾呢?

Safe Points

既然要用户线程停顿下来,那么要在什么地方停顿呢?JVM采用主动式中断方式告诉Java线程需要停顿了,JVM在特定的位置设置了这些安全点(Safe point),让线程可以在这些安全点主动挂起。

方法调用、循环跳转、异常跳转

这些安全点的特征是令程序有可能进行某一段长时间执行的特征

在这些安全点上存有对象引用信息的OopMap数据结构,这种数据结构你可以理解为HashMap这种数据结构,它内部存储了什么位置上存储了对象引用信息,这些信息在类加载完成时就确定下来了。所以JVM在垃圾收集时不需要从一个个方法的GC Roots去扫描,从OopMap中可以快速准确地定位到这些GC Roots

如果用户线程本身处于停顿状态,例如阻塞(Blocked)、睡觉(Sleep),那么此时触发GC时,用户线程无法响应JVM的中断(我听不见你喊我,我睡着了~),用户线程无法主动地跑去安全点中断挂起,此时该怎么办呢?

对于这种情况,必须引入Safe Region来解决。

Safe Region

安全区域是指,用户线程进入某一段代码区域中时,引用关系不会发生变化,那么在这片代码区域的任何地方开始GC都不会受到影响。实现的方式是,用户线程进入安全区域时会标识自己已经进入安全区域,在JVM发起GC时不必理会那些已经标识为进入安全区域的线程,当用户线程需要离开安全区域时,会主动检查JVM是否已经完成了需要停顿线程的工作,如果已完成则可以离开,如果未完成则必须一直等待,直到JVM发送可以离开安全区域的信号为止。

垃圾收集器

垃圾收集器分为新生代收集器与老年代收集器,各种不同的收集器之间如果符合标准则可以相互搭配使用。

新生代收集器

Serial收集器

Serial/Serial Old收集器

Serial收集器是一款单线程的垃圾收集器,“单线程”的意义不仅仅是指它只能用一条线程或占用一个处理器去完成垃圾收集操作,更重要的是它进行垃圾收集时,需要暂停其它所有线程,直到垃圾收集结束。它身为最古老的一款垃圾收集器,在当今依旧广泛受用,它有以下优点:

  • 对于内存受限的环境,它是所有收集器里额外内存消耗最小的

  • 没有线程交互的开销,Serial收集器可以很好地专注于收集垃圾,把用户线程都停掉

在用户桌面的应用场景和近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般不会特别大,收集几十兆、一两百兆的新生代(桌面应用的新生代甚至少于这个容量),垃圾收集完全可以控制在十几、几十毫秒,最多一百毫秒,这点停顿时间对用户来说是十分友好的。

ParNew收集器

parNew收集器

ParNew是一款并行新生代收集器,parNew收集器除了支持多线程并行收集以外,其余的行为与Serial收集器完全一致,包括收集算法、STW(Stop The World)、对象分配规则、回收策略等等。

parNew是不少运行在服务器端模式下的HotSpot虚拟机中首选的新生代收集器,其中一个与性能、功能无关但很重要的原因是:除了Serial收集器,只有ParNew能够与CMS收集器配合工作。

CMS收集器与Parallel Scavenge收集器不能配合工作的一个原因是:Parallel Scavenge收集器内部并没有按照分代收集的框架进行设计垃圾回收,在之后的G1收集器也同样没有按照分代回收的框架设计。

Parallel Scavenge收集器

image-20201113001642889

Parallel Scavenge收集器同样是基于标记-复制算法实现的收集器,也是能够并行收集的一款新生代收集器,那它与ParNew收集器的差别在哪里呢?

Parallel Scavenge收集器的特别之处在于它与其它收集器的关注点不一样,其它垃圾收集器关注如何最大限度地减少STW的时间,而Parrel Scavenge关注的是如何达到一个可控制的吞吐量(Throughput),由于与吞吐量关系密切,所以也被称作“吞吐量优先收集器”。

Parallel Scavenge收集器可以实现自适应策略,这是另外一个与ParNew收集器的差别,可以通过指定-XX:UseAdaptiveSizePolicy参数,虚拟机就会根据系统当前的运行情况收集监控信息,并且自动调整系统的相关JVM参数以提供最高的吞吐量和最合适的停顿时间

老年代收集器

Serial Old收集器

Serial/Serial Old收集器

使用标记-整理算法,是一个单线程收集器,它有另外两个用途:

它作为CMS收集器发生失败后的后备预案,在CMS收集器并发收集发生Concurrent Mode Failure使用

作为Parallel Scavenge的老年代收集器

这个时候就有疑惑了,Parallel Scavenge收集器不是没有按分代收集框架实现吗,为什么能够搭配Serial Old收集器使用

《深入理解Java虚拟机》:Parallel Scavenge收集器架构中含有PS MarkSweep收集器进行老年代收集,并非直接调用Serial Old收集器,但是PS MarkSweepSerial Old的实现几乎是一样的,所以官方很多地方用Serial Old代替它进行讲解。

Parallel Old收集器

Parallel Scavenge/Parallel Old

Parallel OldParallel Scavenge的老年代版本,支持多线程并发收集,基于标记-整理算法设计,自从JDK6以后,Parallel OldParallel Scavenge成为了最好的搭档,在注重吞吐量或者处理器资源比较紧缺的情况下,都可以采用这个组合。

CMS收集器

CMS收集器是基于获取最短回收停顿时间为目标的收集器,CMS收集器适合追求服务的响应速度的应用,例如基于浏览器的B/S系统的服务端上。

CMS是基于标记-清除算法设计的,它支持用户线程与GC线程并发执行,如下图所示

CMS收集器

运作过程分为4个阶段:

初始标记、并发标记、重新标记、并发清除

初始标记的过程就是扫描GC Roots;

并发标记是扫描GC Roots链上所有的对象,此时会出现一些对象标记的变动,因为用户线程仍然在执行;

重新标记的过程是修正并发标记期间产生引用变动的那一部分对象的标记记录

并发清除是删除掉标记阶段判断已经死亡的对象,由于不用移动存活对象,此时也是可以并发执行的。

CMS收集器有三个缺点:

  1. 对处理器资源特别敏感,由于是并发执行,所以CMS收集器工作时会占用一部分CPU资源而导致用户程序变慢,降低总吞吐量,建议具有四核处理器以上的服务器使用CMS收集器
  2. CMS无法清除浮动垃圾,有可能出现Concurrent Mode Failure失败而导致另一次STWFull GC产生。由于并发清理过程中用户线程与GC线程并发执行,就一定会产生新的垃圾对象,但是无法在本次GC中处理这些垃圾对象,不得不推迟到下一次GC中处理,这些垃圾对象就称为“浮动垃圾”,到JDK6的时候,CMS收集器启动阈值达到92%,也就是老年代占了92%的空间后会触发GC,但是如果剩余的内存8%不足以分配新对象时,就会发生“并发失败”,进而冻结用户线程,使用Serial Old收集器进行一次Full GC,所以触发CMS收集器的阈值还是根据实际场景来设置,参数为-XX:CMSInitiatingOccu-pancyFraction
  3. 基于标记-清除算法会导致内存碎片不断增多,在分配大对象时有可能会提前触发一次Full GC。所以CMS提供两个参数可供开发者指定在每次Full GC时进行碎片整理,由于碎片整理需要移动对象,所以是无法并发收集的,-XX:+UseCMSCompactAtFullCollection(JDK9开始废弃),-XX:CMSFullGCsBeforeCompaction(JDK9开始废弃,默认值是0,每次Full GC都进行碎片整理)。

Garbage First收集器

这是一个在垃圾收集器技术发展历史上的里程碑式的成果,它取代了Parallel Scavenge + Parallel Old的组合,并取代了CMS,作为它们的继承者和替代者,G1到底有什么魔力呢?

G1是一种“停顿时间模型”收集器,也就是说可以指定在时间片段为M毫秒时,垃圾收集所占用的时间不会超过N毫秒。

G1颠覆了之前的所有垃圾收集器的垃圾收集行为:要么新生代收集(Minor GC)、要么老年代收集(Major GC)、要么整堆收集(Full GC),而G1可以面向堆内存任何部分组成回收集(Collection Set , CSet),衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾数量较多,这就是G1所特有的Mixed GC模式。

G1堆内存布局

可以看到上图中每一个方块就是一个Region,每个Region可以存放1~32MB大小的对象,使用参数-XX:G1HeapRegionSize指定,Region中可以存放Eden/Survivor/Humongous/Old,G1中新生代和老年代并不是连续存放的,而是一个动态的集合。

注意在G1中专门用Region存放一个Humongous大对象,当对象容量大于Region的一半时就认为它是大对象,按照“大对象优先在老年代中分配”,Humongous也是老年代的一部分对象。

G1收集器将Region单元看出是最小的内存回收单元,每次发生GC时,G1收集器都会评估各个Region价值大小,根据用户所指定的收集停顿时间来优先处理那些回收价值最大的Region,这也是Garbage First的由来。

G1收集器垃圾收集

G1收集器的运作过程可以分为4个步骤:

初始标记:仅记录GC Roots对象,需要停顿用户线程,但时间很短,借助Minor GC同步完成。

并发标记:从GC Roots开始遍历扫描所有的对象进行可达性分析,找出要回收的对象,由于是并发标记,有可能在扫描过程中出现引用变动。

最终标记:将并发标记过程中出现变动的对象引用给纠正过来。

筛选回收:对各个Region的回收价值和成本进行排序,根据用户所希望的停顿时间来制定回收计划,选取任意多个Region区域进行回收,把回收的Region区域中的存活对象复制到空的Region区域中,然后清空掉原来的Region区域,涉及对象的移动,所以需要暂停用户线程,由多条GC线程并行完成。

如何设置G1的停顿时间?-XX:MaxGCPauseMillis

G1的停顿时间不能过短,如果停顿时间过短,那么每次GC收集都只会回收占用Region内存区域很小的一部分,而随着内存不断分配,堆上的垃圾越来越多,GC的速度低于分配的速度,就会触发Full GC,所以,只要我们把停顿时间设置后的效果为垃圾回收的速度与内存分配的速度大致相同,那么在理论上来说就永远不会发生Full GC这也是G1被称为很牛逼的一个地方。

G1和CMS的比较

G1从整体上看是“标记-整理”算法,从局部(两个Region之间)上看是“标记-复制”算法,不会产生内存碎片,而CMS基于“标记-清除”算法会产生内存碎片。

G1在垃圾收集时产生的内存占用和程勋运行时的额外负载都比CMS高

G1支持动态指定停顿时间,而CMS无法指定

两者都利用了并发标记这个技术