JVM与垃圾收集器
垃圾收集(GC)
垃圾检测
在实际回收垃圾对象前,我们必须标识出哪些对象该被回收,即垃圾检测。
对象引用类型
- 强引用(StrongReference)
Object obj = new Object()
的 obj 就是一个强引用。
当内存不足,JVM 宁愿抛出OutOfMemoryError
错误,使程序异常终止,也不会回收强引用对象来释放内存,除非已经没有引用关联这些对象了。
除了强引用之外,其他三种引用都在java.lang.ref
包中。 - 软引用(SoftReference)
GC 发现了只具有软引用的对象并不会立即进行回收,而是让它活的尽可能久一些,在内存不足前再进行回收。
在使用缓存的场景的时候会经常采用此种引用方式,来增加系统可用性的弹性空间。Spring 和 cache 里面大量采用了此种引用方式。 - 弱引用(WeakReference)
GC 一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。如果有场景,发现创建完对象很少可能会用到,就采用这种方式,不过实际工作确实很少见到有人用到3,4两个引用。 - 虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期;如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
虚引用主要用来跟踪对象被 GC 回收的活动,虚引用必须和引用队列(ReferenceQueue)配合使用。
Reference
Reference 抽象类是除强引用外的所有引用类型的父类,有以下几种子类
- SoftReference 类:软引用
1
2MyObject obj = new MyObject();
SoftReference<MyObject> ref = new SoftReference<MyObject>(obj); - WeakReference 类:弱引用
- PhantomReference 类:虚引用
- ReferenceQueue 类:引用队列
垃圾检测算法 - 引用计数
堆中的每一个对象的对象域包含一个引用计数器。该计数器的维护规则如下:
- 当一个对象被创建,并把指向该对象的引用赋值给一个变量时,引用计数置为1
- 当再把这个引用赋值给其他变量时,引用计数加1
- 当一个对象的引用超过了生命周期或者被设置为新值时,对象的引用计数减 1,任何引用计数为 0 的对象都可以被当成垃圾回收。
- 当一个对象被回收时,它所引用的任何对象计数减1,这样,可能会导致其他对象也被当垃圾回收。
但是一般垃圾回收器并不会采用这种算法,主要是因为引用计数算法存在循环引用的问题(注意不是栈帧里的引用,而是堆中实例的互相引用)
1 | public class ReferenceCountingGC { |
如上边代码所示,执行objA = null
和objB = null
后,它们二者的 instance 域仍然互相是对方的引用。
垃圾检测算法 - 可达性分析
若一个对象没有引用链与任一个 GC Roots 相连时,此对象可回收
包括虚拟机栈中引用的对象、方法区中类的静态成员变量引用的对象、方法区中的常量引用的对象、本地方法栈中 Native 方法引用的对象
根部(Roots):表示引用链的头部
引用链(Reference Chain):多个引用形成的一条链
引用:是 reference 类型的对象,其中存储的数据代表的是另外一块内存的起始位置,有强引用(Strong)、软引用(Soft)、弱引用(Weak)、虚引用(Phantom)四种。
此算法的基本思想就是选取一系列 GC Roots 对象作为起点,开始向下遍历搜索其他相关的对象,搜索所走过的路径成为引用链,遍历完成后,如果一个对象到 GCRoots 对象没有任何引用链,则证明此对象是不可用的,可以被当做垃圾进行回收。
那么问题又来了,如何选取 GCRoots 对象呢?在 Java 语言中,可以作为 GCRoots 的对象包括下面几种:
- 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
- 方法区中的类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(Native 方法)引用的对象。
如上图所示,Obj8、Obj9、Obj10 都没有到 GC Root 的引用链,因此它们会被标记为垃圾,即便 Obj9 和 Obj10 之间有引用关系。
引用与垃圾检测算法
对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。
- 如果对象在进行可达性分析后发现没有与 GCRoots 相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法,若对象没有覆盖 finalize 方法或者该 finalize 方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的 finalize 方法,即该对象将会被回收。反之,若对象覆盖了 finalize 方法并且该 finalize 方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。
- 对 F-Queue 中对象进行第二次标记,如果对象在 finalize 方法中拯救了自己,即关联上了 GCRoot 引用链,如把 this 关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那就会被回收。如下代码演示了一个对象如何在 finalize 方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。具体代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32/*
* 此代码演示了两点:
* 1.对象可以再被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* */
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println(this + ": finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
System.out.println(SAVE_HOOK);
// 对象第一次拯救自己
SAVE_HOOK = null;
System.out.println(SAVE_HOOK);
System.gc();
// 因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
System.out.println(SAVE_HOOK);
// 下面这段代码与上面的完全相同,但是这一次自救却失败了
// 一个对象的finalize方法只会被调用一次
SAVE_HOOK = null;
System.gc();
// 因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
System.out.println(SAVE_HOOK);
}
}
垃圾收集算法
标记清除(Mark-Sweep)
先标记所有需要清除的对象,再统一回收。是最基础的垃圾回收算法,后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
问题
- 效率低,标记和清除都需要一次线性扫描;
- 产生大量内存碎片,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
首先标记出所有需要回收的对象,使用可达性分析算法判断一个对象是否为可回收,在标记完成后统一回收所有被标记的对象。下图是算法具体的一次执行过程后的结果对比。
复制算法(Copying)
将可用内存划分为大小相等的两半,对每一块使用指针碰撞(从已分配内存向空闲内存空间移动对象大小的空间)的方法为对象分配空间,如果这一块内存用完,就将还存活的对象复制到另一半块上,将原来的这一半一次清理掉。
HotSpot 中使用的是 Eden-Survivor 方法,大体上每次使用一个 Eden 和一个 Survivor 来分配对象空间,当回收时,将这两块中还存活的对象一次性复制到另一块 Survivor 中,Eden 和 Survivor 的比例为8:1
。如果 Survivor 的空间不够了,就会使用老年代进行分配担保(Handle Promotion)。
- 将现有的内存空间分为两快,每次只使用其中一块;
- 当其中一块时候完的时候,就将还存活的对象复制到另外一块上去;
- 再把已使用过的内存空间一次清理掉。
优点:
- 由于是每次都对整个半区进行内存回收,内存分配时不必考虑内存碎片问题;
- 只要移动堆顶指针,按顺序分配内存即可,可以利用Bump-the-pointer(指针碰撞)实现,实现简单,运行高效;
像标记-清除算法清理后的内存空间并不规整,可能会有很多碎片,因此只能使用空闲列表(Free List)的方式分配内存。
缺点:
- 内存减少为原来的一半,太浪费了(用空间换时间);
- 对象存活率较高的时候就要执行较多的复制操作,效率变低;
- 如果不使用50%的对分策略,老年代需要考虑空间担保策略,复杂度变高。
将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。图是算法具体的一次执行过程后的结果对比。
标记-整理算法(Mark-Compact)
标记过程和Mark-Sweep一样,但是不直接清除,而是让存活的对象向前移,再清理端边界外的内存。
标记过程还是和标记-清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,标记 - 整理算法示意图如下
标记-整理算法往往与标记-清除同时使用,优先执行标记-清除,当内存空间碎片过多时,才运行标记-整理压缩内存空间。
分代收集算法(Generational Collection)
将 Java 堆分为新生代和老生代,根据各个年代的特点采取最适当的收集算法。在新生代中死得快,就选用复制算法(要复制的少),老生代中对象存活率高,就使用标记整理或标记清除算法。
Java垃圾回收的基本概念
GC文章有些常用的概念:
- Mutator:生产垃圾的对象;
- TLAB(Thread Local Allocation Buffer):线程可以优先将对象分配在Eden区的一块线程独享内存,因为是线程独享的,没有锁竞争,所以分配速度更快。
- Card Table:Java中的垃圾收集器以内存页作为分配单位,使用Card Table标记被写入过的卡页为dirty,dirty页面中的对象可达性可能发生变化,因此在像CMS这样的垃圾回收器的重标记阶段会被重新扫描一次。
- 分代回收
JVM中采用的分代回收算法将堆内存划分为年轻代、老年代、元空间和常量池(字符串、常量),以及栈空间、堆外内存。
垃圾回收主要处理的是年轻代和老年代的对象。 - 对象分配
JVM通过Unsafe调用C的allocate和free方法分配、释放对象,分配方法有空闲链表(free list)和碰撞指针(bump pointer)两种。 - GC
垃圾收集需要先识别垃圾,然后再使用垃圾回收算法回收空间。
垃圾识别算法主要有引用计数、可达性分析;
GC算法常见的主要是Mark-Sweep、Mark-Compact、Copying。 - 垃圾收集器
不同的收集器会有不同的内存负责范围,不同的算法,比如CMS采用标记清除算法清理老年代空间,使用CMS时需要和ParNew搭配回收年轻代。
HotSpot GC 触发时机
GC 目标内存区域
对于虚拟机中线程私有的区域,如程序计数器、虚拟机栈、本地方法栈都不需要进行垃圾回收,因为它们是自动进行的,随着线程的消亡而消亡,不需要我们去回收,比如栈的栈帧结构,当进入一个方法时,就会产生一个栈帧,栈帧大小也可以借助类信息确定,然后栈帧入栈,执行方法体,退出方法时,栈帧出栈,于是其所占据的内存空间也就被自动回收了。
而对于虚拟机中线程共享的区域,则需要进行垃圾回收,如堆和方法区,线程都会在这两个区域产生自身的数据,占据一定的内存大小,并且这些数据又可能会存在相互关联的关系,所以,这部分的区域不像线程私有的区域那样可以简单自动的进行垃圾回收,此部分区域的垃圾回收非常复杂,而垃圾回收也主要是针对这部分区域。
可达性分析
对于可达性分析而言,我们知道,首先需要选取 GCRoots 结点,而 GCRoots 结点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的局部变量表)中。方法区可以很大,这对于寻找 GCRoots 结点来说会非常耗时。当选取了 GCRoots 结点之后,进行可达性分析时必须要保证一致性,即在进行分析的过程中整个执行系统看起来就好像被冻结在某个时间点上,不可以在分析的时候,对象的关系还在动态变化,这样的话分析的准确性就得不到保证,所以可达性分析是时间非常敏感的。
为了保证分析结果的准确性,就会导致GC 进行时必须停顿所有 Java 执行线程(Stop the world),为了尽可能的减少 Stop the world 的时间,Java 虚拟机使用了一组称为OopMap的数据结构,该数据结构用于存放对象引用的地址,这样,进行可达性分析的时候就可以直接访问 OopMap 就可以获得对象的引用,从而加快分析过程,减少 Stop the world 时间。
OopMap 数据结构有利于进行 GC,是不是虚拟机无论何时想要进行 GC 都可以进行 GC,即无论虚拟机在执行什么指令都可以进行 GC?答案是否定的,因为要想让虚拟机无论在执行什么指令的时候都可以进行 GC 的话,需要为每条指令都生成 OopMap,显然,这样太浪费空间了。为了节约宝贵的空间,虚拟机只在”特定的位置“存放了 OopMap 数据结构,这个特定的位置我们称之为安全点。程序执行时并非在所有地方都能够停顿下来开始 GC(可达性分析),只有到达安全点的时候才能暂停。安全点可以由方法调用、循环跳转、异常跳转等指令产生,因为这些指令会让程序长时间执行。
现在我们已经知道了安全点的概念,即进行 GC 必须要到达安全点,那么在发生 GC 时如何让所有线程到达安全点再暂停呢?有两种方法:
- 抢先式中断,在发生 GC 时,首先把所有线程全部中断,如果发现线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。
- 主动式中断,在发生 GC 时,不中断线程,而是设置一个标志,所有线程执行时主动轮询这个标志,发生标志位真就自己中断挂起,轮询标志的地方和安全点是重合的,也有可能是创建对象需要分配内存的地方。
现在问题又来了,当程序不执行的时候,如何让所有线程达到安全点呢?典型的就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程是无法跑到安全点再中断自己的,虚拟机也肯定不可能等待该线程被唤醒并重新分配 CPU 时间后,跑到安全点再暂停。为了解决这个问题,引入安全区域的概念。安全区域是对安全点的扩展,可以看成由很多安全点组成,安全区域是指一段代码片段之中,引用关系不会发生变化。在这个区域的任何地方开始 GC 都是安全的。当线程执行到安全区域的代码时,首先标示自己已经进入了安全区域,那么,在这段时间里 JVM 发起 GC 时,就不用管标示自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者整个 GC 过程),若完成,线程继续执行;否则,它必须等待直到收到可以安全离开安全区域的信号。
分代回收 GC 类型及对象晋升(新生代 -> 老年代)
根据作用区域的不同,GC 主要分为 3 种:
- Minor GC:对象通常在新生代的 Eden 区进行分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC,非常频繁,速度较快;
- Major GC:指发生在老年代的 GC,出现 Major GC,经常会伴随一次 Minor GC,同时 Minor GC 也会引起 Major GC,一般在 GC 日志中统称为 GC,不频繁。
- Full GC:指发生在老年代和新生代的GC,速度很慢,需要Stop The World。可以用System.gc() 强制执行 Full GC,但这在生产环境中是需要被禁止的。
对象的晋升机制:
- 对象优先在新生代区中分配,若没有足够空间,则触发 Minor GC,经过 Minor GC 仍存活的对象年龄 +1,若年龄超过一定限制(默认为 15),则被晋升到老年态;
- 大对象(需要大量连续内存空间)直接进入老年态;
- 长期存活的对象进入老年态。
GC Cause
定义GC Cause的代码位置:src/share/vm/gc/shared/gcCause.hpp
和 src/share/vm/gc/shared/gcCause.cpp
:
1 | const char* GCCause::to_string(GCCause::Cause cause) { |
JVM会因这些Cause触发回收:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
列举一些经典的GC Cause及参考的解决方案:
- 扩容时发生的GC
如果-Xms
和-Xmx
的值设置得不一样,刚开始只会分配-Xms
大小的堆空间,每次不够时再向操作系统申请,这时必须进行一次GC。
因此,需要尽量将-Xms
和-Xmx
、-XX:-MaxNewSize
和-XX:NewSize
、-XX:MetaSpaceSize
和-XX:MaxMetaSpaceSize
这样的值设置成一样的。 - System.gc()
如果扩容缩容、Old区达到回收阈值、Metaspace空间不足、Young区晋升失败、大对象担保失败等几种情况都没有发生,却触发了GC,那有可能是因为代码中显式调用了System.gc()
。System.gc()
一般用于清理DiectBuffer对象,因为DirectBuffer会申请堆外空间。
因此System.gc()
的去留需要根据即使情况来判断。 - Metaspace OOM
1.8之后,Java将类、字符串常量等数据保存到了元空间,而元空间又位于堆中,因此GC时会将元空间的数据也一并回收掉。
但是元空间大小会受-XX:MaxMetaSpaceSize
这个属性限制,如果空间不够且无法继续扩容,则将触发OOM。
一般Metaspace OOM是由动态加载类数据造成的,可以dump内存快照观察类数据的Histogram(直方图),或者直接通过命令定位,jcmd打几次Histogram的图,看一下具体是哪个包下的Class增加较多即可定位。 - 过早晋升
如果发生了以下情况,可能是发生了过早晋升:
分配速率接近于晋升速率,对象晋升年龄较小。
GC 日 志 中 出 现“Desired survivor size 107347968 bytes, new threshold 1(max 6)”等信息,说明此时经历过一次 GC 就会放到 Old 区。
Full GC 比较频繁,且经历过一次 GC 之后 Old 区的变化比例非常大。
发生过早晋升的根本原因可能是:Young/Eden区过小;分配速率过大。
晋升年龄受一个阈值MaxTenuringThreshold
控制,如果设置得过大,会导致该晋升的对象一直停留在年轻代,每次YoungGC都需要复制大量对象,失去了老年代的作用;如果设置得过小,大量对象被晋升到Old区,失去了年轻代的作用。不同情况下JVM内存成分不同,对象的生命周期分布也不同,因此晋升年龄是动态调整的。/src/hotspot/share/gc/shared/ageTable.cpp#compute_tenuring_threshold
可以看到 Hotspot 遍历所有对象时,从所有年龄为 0 的对象占用的空间开始累加,如果加上年龄等于 n 的所有对象的空间之后,使用 Survivor 区的条件值(Target-SurvivorRatio / 100,TargetSurvivorRatio 默认值为 50)进行判断,若大于这个值则结束循环,将 n 和 MaxTenuringThreshold 比较,若 n 小,则阈值为 n,若 n 大,则只能去设置最大阈值为 MaxTenuringThreshold。动态年龄触发后导致更多的对象进入了 Old 区,造成资源浪费。
如果是Young/Eden过小,可以调整比例,一般可以在Heap 内存不变的情况下适当增大 Young 区,一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。
如果是分配速率过大,可以分析一下代码是不是哪些地方动态加载类过快了;或者直接扩大元空间,适应这种速度。
CMS FullGC频繁
CMS的原理是一次Young GC后,负责处理CMS的一个后台线程concurrentMarkSweep会不断地轮询,使用shouldConcurrentCollect()
检测是否达到回收条件。如果达到条件则调用collect_in_background()
启动一次Background模式GC。
判断是否进行回收的代码:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
比较常见的有:-XX:+UseCMSInitiatingOccupancyFraction
触发、上次Young GC失败触发。单次CMS GC(老年代GC)耗时过长
CMS回收主要耗时阶段是Init Mark和Final Remark,因为这两个阶段都需要STW,
见Old区垃圾回收细节:CMSCollector::collect_in_background
、CMSCollector::collect
不同算法触发的时机
- Minor GC(年轻代 GC)
触发时机:在 Enden 满了之后将被触发
GC 在优先级最低的线程中运行,一般在应用程序空闲即没有应用线程在运行时被调用。
当发生 Minor GC 后空间仍不够,触发 Major GC - Full GC / Major GC(老年代GC)
触发时机:- 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行。(可通过通过
-XX:+ DisableExplicitGC
来禁止 RMI 调用 System.gc。) - 方法区空间不足,如果没有动态加载,一般是发生在启动的时候的,但是JDK1.8之后元空间替换了方法区,因此不会有这种情况了。
- 老年代空间不足,引起FullGC,这种情况比较复杂,有以下几种情况:
3.1、通过对象的正常晋升机制触发对象向老年代移动时,老年代空间不足,由-XX:MaxTenureThreshold
参数定义;
3.2、大对象直接进入老年代,此时老年代空间不足,由-XX:PretenureSizeThreshold
参数定义;
3.3、动态年龄判定机制会将对象提前转移至老年代。年龄从小到大累加,当加入某个年龄段后,这个年龄对象占用空间大小总和超过survivor区域 *-XX:TargetSurvivorRatio
的时候,从这个年龄段往上年龄的对象进入老年代;
3.4、由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
- 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行。(可通过通过
在进行MinorGC之前,JVM的空间分配担保机制可能会触发3.2、3.3、3.4的发生,也就是触发一次FullGC。
所谓的空间分配担保机制,就是在MinorGC之前,虚拟机会检查老年代最大可用连续内存空间是否大于新生代所有对象的总空间。
- 如果大于,则此次Minor是安全的;
- 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次MinorGC,但这次MinorGC依然是有风险的,失败后会重新发起一次FullGC,如果小于或者HandlePromotionFailure=false,则改为直接进行一次FullGC。
最后,当发生 FullGC 之后空间还是不够,将抛出 OutOfMemoryError。
对象分配和回收策略
对象的内存分配,绝大部分都是在堆上分配,少数经过JIT编译后被拆散为标量类型并间接在栈上分配。
在堆上的分配又可以有如下分配,主要在新生代的 Eden 区分配,如果启动了本地线程分配缓冲,将按照线程优先在TLAB上分配,少数直接在 Tenured 区分配,虚拟机也提供了一些参数供我们来控制对象内存空间的分配。
总而言之,对象分配具有以下几种策略:
对象优先在 Eden 区分配
1 | -Xms20M -Xmx20M -Xmn10M |
新生代可用的空间:9M = 8M(Eden 空间容量) + 1M(一个 Survivor 空间的容量)
老年代可用的空间:10M
分配完 alloc1、alloc2、alloc3 之后,无法再分配 alloc4,会发生分配失败,则需要进行一次 Minor GC,survivor to 区域的容量为 1M,无法容纳总量为 6M 的三个对象,则会通过担保机制将 alloc1、allo2 转移到老年代,然后再将 alloc4 分配在 Eden 区。
大对象直接进入 Tenured 区
大对象需要大块连续内存空间,大对象的出现容易提前触发 GC 以获取更大的连续空间来供分配大对象,可以设置-XX:PretenureSizeThreshold
的值来控制多大的对象直接分配到 Tenured 区,默认是 0,即所有对象不管多大都先在 Eden 区中分配空间。
1 | /** |
因为设置了-XX:PretenureSizeThreshold=3145728
控制大小超过 3M 的对象直接进入 Tenured 区,可以看到 5M 的对象直接被分配到了 Tenured 区。
长期存活的对象进入 Tenured 区
每个对象有一个对象年龄计数器,与前面的对象的存储布局中的 GC 分代年龄对应。对象出生在 Eden 区、经过一次 Minor GC 后仍然存活,并能够被 Survivor 容纳,则设置年龄为 1,对象在 Survivor 区每次经过一次 Minor GC,年龄就加 1,当年龄达到阈值(默认 15),就晋升到老年代,虚拟机提供了-XX:MaxTenuringThreshold
来进行设置。
1 | /** |
如 GC 日志中所示,总共发生了两次 Minor GC:
- 第一次是在给 alloc3 分配的时候,此时 Survivor 区不能容纳 alloc2,但是可以容纳 alloc1,所以 alloc1 进入了 Survivor 区并且年龄变成 1、达到了阈值,将在下一次 GC 时晋升到老年代,而 alloc2 则通过担保机制进入了老年代;
- 第二次 GC 是在第二次给 alloc3 分配空间时,这时 alloc1 年龄+1,晋升到老年代,此时 GC 也可以清理出原来 alloc3 占据的 4MB 空间,将 alloc3 分配在 Eden 区。
因此,最后的结果是 alloc1、alloc2 在老年代,alloc3 在 Eden 区。
动态对象年龄判断
除了对象年龄自然达到-XX:MaxTenuringThreshold
而被转移到 Tenured 区外,如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半,则年龄大于等于该年龄的对象也可以直接转移到 Tenured 区、而无需等年龄达到-XX:MaxTenuringThreshold
。
1 | /** |
发生了两次 Minor GC:
- 第一次发生在给 alloc4 分配内存时,此时 alloc1、alloc2 将会进入 Survivor 区,而 alloc3 通过担保机制将会进入老年代;
- 第二次发生在给 alloc4 分配内存时,此时,Survivor 区的 alloc1、alloc2 达到了 Survivor 区容量的一半,将会进入老年代,此时 GC 可以清理出 alloc4 原来的 4MB 空间,并将 alloc4 分配在 Eden 区。
最终,alloc1、alloc2、alloc3 在老年代,alloc4 在 Eden 区。
空间分配担保
老年代连续空间大于新生代对象总大小、或者历次晋升的平均大小,就会执行 Minor GC,否则将进行 Full GC。GC 期间,如果 Survivor 区空闲空间小于存活对象,则需要老年代进行分配担保,把 Survivor 区无法容纳的对象直接转移到老年代。
例子在上一节中已经给出,这里不再赘述。
HotSpot GC 实现方式
计算所需空间大小
ConcurrentMarkSweepGeneration::compute_new_size()
1 | void ConcurrentMarkSweepGeneration::compute_new_size() { |
对垃圾回收算法的改进
复制算法
两个区域 A 和 B,初始对象在 A,继续存活的对象被转移到 B。
这两个区域并不需要根据 1:1 划分内存空间,而是将内存划分为一块较大的 Eden Space 和两块较小的 Survivor Space,在 HotSpot 中默认大小比例为 8:1。
当执行年轻代回收时会将 Eden 区存活的对象复制到一个空闲的 Survivor,下一次 GC 时将 Eden 区和这个 Survivor 区存活的对象复制到另一个 Survivor 区,因此总是会有一块 Survivor 区是空闲的。
当 Survivor 空间不够用的时候,需要依赖于老年代的空间担保。
标记-清除算法
一块区域,标记可达对象(可达性分析),然后回收不可达对象,这会引入碎片,因此在空间碎片过多导致无法继续分配时往往会执行一次整理来压缩空间。
标记-整理算法
相对标记清理算法来说多了碎片整理的过程,可以整理出更大的内存放更大的对象。
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存(有点 copy 的意思,但是比 copy 省空间。比清理好的一点是没有碎片)。
分代回收
新生代:初始对象,生命周期短的
永久代:长时间存在的对象
整个 java 的垃圾回收是新生代和年老代的协作,这种叫做分代回收。
在大的分代回收的思想下面,不同的代区可以选择不同的收集器,而不同的收集器在不同的代区又会用到不同的算法。
方法区回收策略
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
方法区的垃圾回收主要回收两部分内容:
- 从常量池回收废弃常量。
如何判断废弃常量呢?以字面量回收为例,如果一个字符串“abc”已经进入常量池,但是当前系统没有任何一个 String 对象引用了叫做“abc”的字面量,那么,如果发生垃圾回收并且有必要时,“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。 - 卸载无用的类。既然进行垃圾回收,就需要判断哪些是废弃常量,哪些是无用的类。
如何判断无用的类呢?需要满足以下三个条件- 该类的所有实例都已经被回收,即 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足以上三个条件的类可以进行垃圾回收,但是并不是无用就被回收,虚拟机额外提供了一些参数供我们配置。
直接内存(堆外内存)
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
NIO 类可以直接通过 Native 函数分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
使用堆外内存时需要注意:
- 由于垃圾收集器不涉及堆外内存,因此堆外内存何时分配何时回收都需要用户自己来定义;
- 直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果我们发现 OOM 之后 Dump 文件很小,而程序中有直接或间接使用了 NIO ,那就可以考虑检查一下是不是这方面的原因。
JVM 垃圾收集器的演进
垃圾收集器是内存回收算法的具体实现,随着 JDK 的升级我们已经有很多种垃圾收集器可供选择:
- JDK1.4 && JDK1.5 很少用了,基本上是 Serial(Serial Old)。
- JDK1.6 是ParNew或者Parallel(Parallel Old)。
- JDK1.7 Parallel、Parallel Old。
- JDK1.8 Parallel Scavenge(新生代)、Parallel Old(老年代) 配合 CMS。
- JDK1.9+ G1出现,且为默认收集器
在Java中如何配置垃圾收集器
如何知道 JVM 进程当前使用的是哪种垃圾收集器?
- java -XX:+PrintCommandLineFlags
打印启动时参数,根据启动时参数可以推断 JVM 进程使用的是什么垃圾收集器,但是这并不准确。 - jmap
1
jmap -heap <PID>
垃圾统计配置
- -XX:+PrintGC
- -XX:+PrintGCDetails
- -XX:+PrintGCTimeStamps:可与上面参数一起使用
- -XX:+PrintGCApplicationConcurrentTime:打印每次垃圾回收前,程序未中断的执行时间,可与上面参数一起使用
- -XX:+PrintGCApplicationStoppedTime:打印垃圾回收期间程序暂停的时间,可与上面参数一起使用
- -XX:PrintHeapAtGC:打印 GC 前后的详细堆栈信息
- -Xloggc:filename:与上面几个配合使用,把日志信息记录到文件来分析
使用什么垃圾回收器
- -XX:+UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收
- -XX:+UseConcMarkSweepGC 设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收
- -XX:+UseParallelOldGC 手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记-压缩)收集器执行内存回收
- -XX:+UseSerialGC 手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记-清理-压缩)收集器执行内存回收
- -XX:+UseParNewGC 手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记-清理-压缩)收集器执行内存回收
- -XX:+UseParallelGC 手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记-清理-压缩)收集器执行内存回收
Serial / Serial Old 收集器
Serial(串行)收集器是最基本、发展历史最悠久的串行收集器,JDK 1.5 之前默认都是此收集器,因为那时候 CPU 都是单核的。
使用
- -XX:+UseSerialGC
这个配置指定年轻代为 Serial,同时会指定老年代采用 Serial Old。
实现原理
- 单线程阻塞队列。
- 年轻代采用复制算法,老年代采用标记整理算法,作用于老年代时称作 Serial Old 收集器。
优点
简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。
缺点
- 它是一个单线程收集器,只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,无法有效利用多核 CPU;
- 它在进行垃圾收集时,必须暂停其他所有的工作线程,直至 Serial 收集器收集结束为止(Stop The World)。
应用场景
- HotSpot 虚拟机运行在 Client 模式下的默认的新生代收集器。
- 单 CPU 虚拟机里面。
- JDK 1.3.1 之前,是虚拟机新生代收集的唯一选择。JDK 1.5.0 之前老年代的唯一选择。
- 内存比较小的情况下,效率还是很高的。
ParNew 收集器
使用
- -XX:+UseParNewGC
如果使用此配置默认年轻代,老年代采用 Serial Old。 - -XX:ParallerGCThreads=3
ParNew 默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的情况下可使用 -XX:ParallerGCThreads 参数设置。
实现原理
ParNew 收集器就是 Serial 收集器的多线程版本(即并发模式),除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码。
优点
- 多 CPU 环境下 GC 时更有效利用系统资源,是 Server 模式下虚拟机的首选新生收集器。
- 可以与 CMS 搭配使用。
缺点
- 只能用于新生代。
- ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越。
Parallel Scavenge 并行收集器
使用
- -XX:+UseParallelGC
- -XX:+UseParallelOldGC
- -XX:+UseAdaptiveSizePolicy
这是一个动态调整各个代区的内存大小的开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应调节策略(GC Ergonomics)。 - -XX:ParallelGCThreads=n
并行 GC 线程数。 - -XX:MaxGCpauseMillis=5
默认 GC 最大停留时间。 - -xx:GCTimeRatio
GC 占用总时间的最大比率。
实现原理
- 并行
- 可控的吞吐量
吞吐量(Throughput),即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即“吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)”。
假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。 - 自适应调节策略
优点
- 可以调整吞吐量,减少停顿时间,从而提升用户体验
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
缺点
Parallel Scavenge 收集器无法与 CMS 收集器配合使用。
并发标记清理(Concurrent Mark-Sweep,CMS)收集器
使用
- -XX:+UseConcMarkSweepGC,使用 CMS 收集器;
- -XX:CMSInitiatingOccupancyFraction=80
当老年代的使用率达到80%时,就会触发一次 CMS GC - -XX:+UseCMSCompactAtFullCollection
Full GC 后,进行一次碎片整理,整理过程是独占的,会引起停顿时间变长。 - -XX:+CMSFullGCsBeforeCompaction
设置进行几次 Full GC 后,进行一次碎片整理。 - -XX:ParallelCMSThreads,设定 CMS 的线程数量(一般情况约等于可用 CPU 数量)。
实现原理
CMS 收集器运行过程中各步骤所涉及的并发和所需的停顿时间如下图所示:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
顾名思义,CMS 采用标记清除算法,它的工作流程分为以下 6 个步骤:
- 初始标记(CMS initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要Stop The World(stw)。
- 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程,在整个过程中耗时最长。
根据上个阶段找到的 GC Roots 遍历查找,并不是上一阶段存活的对象都会被标记,因为在标记期间用户的程序可能会改变一些引用,如上图所示。 - 并发预清理(CMS Concurrent Preclean):并发过程,标记并发执行过程中的脏区域(Card)。
如上图所示,在并发运行过程中(包括上一阶段),一些对象的引用可能会发生变化,预清理过程将包含这个对象的区域(Card)标记为 Dirty,这也就是Card Marking。
然后,由这些脏可达的对象也会被重新标记: - 可中断预清理(CMS Concurrent Abortable Preclean):这也是一个并发阶段,这个阶段的主要目的是尽量承担最终标记阶段的工作。
因为重新标记阶段阶段需要全堆扫描,此时如果先进行了MinorGC则可以大大较少需要扫描的对象数量,因此Abortable Preclean阶段的目的就是等一段时间,看看能不能在重新标记前执行一次MinorGC。
为什么重新标记阶段需要做全堆扫描?因为判断对象是否可达需要使用根搜索算法,而只有MinorGC时才会使用根搜索算法,否则CMS也不知道之前的并发阶段是否产生了新的不可达对象。 - 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要Stop The World。
通常 Remark 阶段会在年轻代尽可能干净的时候运行,目的是为了减少连续 STW 发生的可能性。 - 并发清除(CMS concurrent sweep):清除不再使用的对象。
下面以一个真实环境中的FullGC日志为例:
1 | 2020-08-20T04:37:36.159+0800: 638682.623: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1930043K(2097152K)] 2000027K(4793536K), 0.2664430 secs] [Times: user=0.11 sys=0.02, real=0.26 secs] |
上面的GC日志中:
- 第 1 行、初始标记阶段,会发生STW,标记GC Root直接引用的对象,GC Root直接引用的对象不多,因此很快。
1930043K
:当前老年代使用的容量;2097152K
:老年代可用的最大容量;2000027K
:整个堆目前使用的容量;4793536K
:整个堆的可用容量;0.2664430 secs
:这个阶段的持续时间;[Times: user=0.11 sys=0.02, real=0.26 secs]
:对应 user、system 和 real 的时间统计。 - 第 2~3 行、并发标记阶段,由第一阶段标记过的对象出发所有可达的对象都在本阶段标记。
6.513/6.529 secs
:这个阶段的持续时间与时钟时间;[Times: user=2.11 sys=0.40, real=6.53 secs]
:时间统计,但是因为是并发执行的,并不仅仅包含 GC 线程的工作。 - 第 4~5 行、并发预清理阶段,查找前一阶段执行过程中,从新生代晋升或新分配或被更新的对象,通过并发地重新扫描这些对象,可以减少下一个 STW 重新标记阶段的工作量。
0.024/0.026 secs
:持续时间与时钟时间;Times: user=0.03 sys=0.01, real=0.03 secs
:时间统计。 - 第 6~7 行、并发可终止的预清理阶段,这个阶段其实跟上一个阶段做的东西一样,也是为了减少下一个 STW 重新标记阶段的工作量。增加这一阶段是为了让我们可以控制这个阶段的结束时机,比如扫描多长时间(默认 5 秒)或者 Eden 区使用占比达到期望比例(默认 50%)就结束本阶段。
- 第 8 行、
Final Remark
重新标记阶段,会发生STW,暂停所有用户线程,从 GC Root 开始重新扫描整个堆,标记存活的对象。这一阶段是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。需要注意的是,虽然 CMS 只回收老年代的垃圾对象,但是这个阶段依然需要扫描新生代,因为很多 GC Root 都在新生代,而这些 GC Root 指向的对象又在老年代,这称为跨代引用。YG occupancy: 571811 K (2696384 K)
:年轻代当前占用量及容量;Rescan (parallel) , 0.0743374 secs
:Rescan 是当应用暂停的情况下完成对所有存活对象的标记,这个阶段是并行处理的;weak refs processing, 0.0004330 secs
:第 1 个子阶段,处理弱引用;class unloading, 3.9423498 secs
:第 2 个子阶段,卸载不再使用的 class;scrub symbol table, 0.5589452 secs ... scrub string table, 0.0015701 secs
:最后一个子阶段,清理符号表和字符表。1 CMS-remark: 1930043K(2097152K)
:这一阶段之后老年代的使用量与总量;2501855K(4793536K)
:这一阶段后堆的使用量与总量(包括年轻代);4.5824373 secs
:这一阶段的持续时间,也就是 STW 的时间。[Times: user=0.47 sys=0.04, real=4.58 secs]
:这一阶段统计的持续时间。
经过这5个阶段之后,老年代所有存活的对象就都被标记过了,之后可以通过清除算法去清理老年代不再使用的对象。 - 第 9~10 行、并发清除;
- 第 11~12 行、重置,重新初始化 CMS 内部数据结构,以备下一轮 GC 使用。
普通串行标记清除算法与并行标记清除算法(CMS)的比较如下图所示:
如上图可知,并发标记清除算法与串行标记清除算法之间的区别主要在于,前者将标记过程分成了 3 个部分,其中占用时间最长的Concurrent Mark
不需要stw
。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
优点
并发收集、低停顿,因此 CMS 收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。
缺点
- 对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量+3)/4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25%的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个时(比如 2 个),CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。
- 标记-清除算法导致的内存碎片。
CMS 是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生,可能会提前触发一次 FullGC。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。
可能会引起Promotion Failed(空间分配担保失败),即进行Minor GC时,发现Survivor Space放不下,对象只能放到老年代,而老年代也放不下。 - 无法处理浮动垃圾(Floating Garbage),可能出现Concurrent Mode Failure失败而导致另一次 Full GC 的产生。
由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
应用场景
- CMS 以最短回收停顿时间为目标,非常符合那些集中在互联网站或者 B/S 系统的服务端上的 Java 应用,这些应用都非常重视服务的响应速度,不能有明显的暂停时间。
- 当你的应用程序需要有较短的应用程序暂停,而可以接受垃圾收集器与应用程序共享应用程序时,则可以选择 CMS 垃圾收集器。
- 典型情况下,有很多长时间保持 live 状态的数据对象(一个较大的老年代)的应用程序,和运行在多处理上的应用程序,更适合使用 CMS 垃圾收集器。例如 Web 服务器。
G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一。它是一款面向服务端应用的垃圾收集器。
使用
G1 可以用于年轻代和老年代,且算法分 3 个步骤,所以配置种类比较多。
只作用于年轻代的配置:
- -XX:G1NewSizePercent
年轻代最小值,默认值 5%。 - -XX:G1MaxNewSizePercent
年轻代最大值,默认值 60%。
作用于老年代的配置:
- -XX:InitiatingHeapOccupancyPercent
当老年代大小占整个堆大小百分比达到该阈值时,会触发一次 Mixed GC。 - -XX:+UseCMSInitiatingOccupancyOnly
其他配置:
- -XX:MaxGCPauseMillis
设置 G1 收集过程目标时间,默认值 200ms。 - -XX:G1ReservePercent
默认值 10%,预留的空闲空间的百分比 - -XX:G1HeapRegionSize
配置 Region 块的大小,范围 1MB 到 32MB,设置后会根据最小堆 Java 堆内存划分出 2048 个 Region 块
实现原理 - 内存结构与GC算法
在 G1 算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块,称为Region,每个 Region 是逻辑连续的一段内存,结构如下:
由上图可见:
- 新生代与老年代并不是连续的,而是一些 Region 的集合;
- 为了避免全堆扫描,对其他 Region 对象的引用会被记录到一个Remembered Set中,每个 Region 都对应一个 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会插入一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否位于其他 Region 中,如果是则将其引用信息记录到该 Region 对应的 Remembered Set 中,当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证即使不对全堆扫描也不会产生遗漏。
- 一些Regine被标明了H,代表Humongous,这表示这些Region存储的是巨大对象(Humongous object,H-obj),即大小大于等于Region一半的对象,对这些大对象有一些特殊的规则。
堆内存中一个 Region 的大小可以通过 -XX:G1HeapRegionSize
参数指定,大小区间只能是 1M、2M、4M、8M、16M 和 32M,总之是 2 的幂次方,如果 G1HeapRegionSize
为默认值,则在堆初始化时计算 Region 的实践大小。
G1 可以独立管理整个堆空间,但是能够采用不同方式来处理新创建对象和已经存活了一段时间、经历过多次 GC 的老对象,以获取更好的收集效果。G1 中提供了三种模式垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。
Young GC
发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 Eden Region 中分配内存,当所有 Eden Region 被耗尽无法申请内存时,就会触发一次 Young GC,这种触发机制和之前的 Young GC 差不多,执行完一次 Young GC,活跃对象会被拷贝到 Survivor Region 或者晋升到 Old Region 中,空闲的 Region 会被放入空闲列表中,等待下次被使用。
Mixed GC
当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 old gc,除了回收整个 Young Region,还会回收一部分的 Old Region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。
Mixed GC 的执行过程有点类似 CMS,主要分为以下几个步骤:
- initial mark: 初始标记过程,整个过程需要 STW,但耗时比较短,标记了从 GC Root 可达的对象,它们能被 GC Root 直接关联到;
- concurrent marking: 并发标记过程,整个过程 gc collector 线程与应用线程可以并行执行,标记出 GC Root 可达对象衍生出去的存活对象,并收集各个 Region 的存活对象信息;
- remark: 最终标记过程,整个过程需要 STW,GC 线程与用户线程并行执行,耗时较短,标记出那些在并发标记过程中遗漏的、或者由于用户线程继续运行导致的标记变动,变动记录将被记录在 Remembered Set Logs 中,此阶段会把其整合到 Remembered Set 中;
- clean up: 垃圾清除过程,与用户线程并发执行,时间用户可控,对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 时间进行回收,如果发现一个 Region 中没有存活对象,则把该 Region 加入到空闲列表中。
Full GC
如果对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满,就会触发一次 Full GC,G1 的 Full GC 算法就是单线程执行的 Serial Old GC,使用标记-整理算法,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 Full GC。
实现原理 - 并行和并发
G1 使用多个 CPU 来缩短 Stop The World 停顿时间,与用户线程并发执行。
实现原理 - 可预测的停顿
G1 建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
优点
缺点
应用场景
各垃圾收集器之间的比较
- CMS 与 Serial Old 是可以相互配合的
- G1 既可以用于年轻代又可以用于老年代
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单 CPU 环境下的 Client 模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单 CPU 环境下的 Client 模式、CMS 的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境时在 Server 模式下与 CMS 配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或 B/S 系统服务端上的 Java 应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换 CMS |
如何排查GC问题
GC问题可能会有很多表象,比如:GC耗时增大、线程Block增多、慢查询增多、CPU负载高等。
为了排查根因,有几种比较有效的判断方法:
- 先发生的事件是根因的概率更大,监控各个指标发生异常的时间点,比如如果先观察到CPU负载高,那么整个问题的影响链就有可能是:CPU负载高->慢查询增多->GC耗时增大->线程Block增多->RT上涨。
- 结合历史情况,比如之前慢查问题比较多,那么问题影响链就可能是:慢查询增多->GC耗时增大->CPU负载高->线程Block增多->RT上涨。
- 实验,比如只触发线程Block就会发生问题,那么问题很有可能就是线程Block引起的。
- 反证,比如发现其他节点CPU和慢查都正常,但是还是出现了问题,那么问题很有可能和CPU和慢查无关。
QA
哪些对象的引用会被当作 GC Root 呢
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
下面的变量a即为一个GC Root。1
2
3int main() {
int a = 1;
} - 方法区中类静态属性(类变量)引用的对象
下面的b即一个GC Root。1
2
3class A {
int b = 1;
} - 方法区中常量引用的对象
下面的字符串”123”会被加载到方法区中的字符串常量表,也是一个GC Root。1
2
3class A {
static final String c = "123";
} - 本地方法栈中 JNI(native 方法)引用的对象
实现JNI方法时,在方法体内创建的局部变量。
弱引用和软引用有什么区别?
强引用比较简单,虚引用很少见,容易混淆的是弱引用和软引用:
- 弱引用
只要垃圾回收时弱引用对象没有任何其他强引用,则对象会被回收。 - 软引用
在系统将要发生溢出异常之前,将会把这些对象列进回收范围进行第二次回收,如果这次回收没有足够内存,才会抛出内存溢出异常。JVM 在分配空间时,若果 Heap 空间不足,就会进行相应的 GC,但是这次 GC 并不会收集软引用关联的对象,但是在 JVM 发现就算进行了一次回收后还是不足(Allocation Failure),JVM 会尝试第二次 GC,回收软引用关联的对象。
为什么新生代采取复制算法而老年代采取标记-整理算法
这个问题等价于为什么在不同的代中使用不同的垃圾收集器。
主要原因来自新生代和老年代的区别,新生代新陈代谢快,采用复制算法,Survivor 区可以相对较小,不会有太大的空间浪费,并且保证了较高的效率;老年代反之。
为什么不用标记清除算法
效率低,标记和清除都需要一次线性扫描,相当于比别的算法慢一倍,而且产生大量内存碎片,内存碎片的问题也出现在 C 语言的 malloc/free 中。
垃圾收集器中的并发和并行分别代表什么?
并行指各垃圾收集器线程可以同时运行,此时用户线程仍然处于等待状态。
并发指用户线程可以和垃圾收集器同时(可能是交替)运行,它们不在同一个CPU上执行。
为什么 CMS 要 3 次标记
- 第 1 次标记(Initial Mark):标记 GCRoot 可直达的对象,耗时短。
- 第 2 次标记(Concurrent Mark):从上一部分标记对象出发标记引用链。
为什么这个阶段可以并发标记?如果新创建了一个 GC Root 引用的对象或者引用链变更了怎么办?实际上这个步骤已经能将绝大多数需要标记的对象标记上了,如果有遗漏都是在下一阶段弥补的。 - 第 3 次标记(Remark):重新标记阶段将上一阶段执行过程中用户线程新创建的对象和引用链中新引用的对象都标记上,这个过程相对较短,因此 STW 也可以接受。
从 3 次标记过程的特征可以看出,CMS 将耗时长的部分并行化了,从而保证整个 gc 过程的高性能。