JVM垃圾回收详解
# JVM垃圾回收
# 对象死亡
# 判断对象死亡的方法
- 引用计数法:给对象添加一个计数器,每当有一个地方引用它就加 1,当引用失效就减 1。任何时刻计数器为零的对象就不再能被使用了。该方法简单、效率高。但主流的虚拟机都没有使用它,因为它有一个致命的缺点,很难解决对象循环引用的问题。
- 可达性分析算法:就是通过一系列的”GC Roots“作为对象引用的起点,不断的向下搜索,这些节点组成的路径成为引用链,当一个对象不在任何一条引用链上时,就说明这个对象是不可用的,需要被回收。GC Roots 主要包含:
- 虚拟机栈(栈中的局部变量表)引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁(synchronaized 关键字)持有的对象
# 对象可以被回收,就代表一定被回收吗?
即使可达性分析法中不可达的对象,也不会被立刻回收,真正要回收还要经历两次标记过程。第一次标记时会进行筛选,筛选的条件是此对象是否要执行 finalize 方法,当对象没有覆盖 finalize 方法或 finalize 方法已经被调用过时,虚拟机将不执行筛选。
被判定为需要执行的对象会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何对象建立关联,否则就会被真的回收。JDK9 中 finalize 方法逐渐废弃。
# 如何判断一个常量是废弃常量?
假如字符串常量池中存储字符串”abc",当没有任何String对象引用该字符串常量的话,就说明常量“abc"就是废弃常量,如果这个时候发生内存回收的话且有必要的话,”abc"就会被系统清理出常量池。
运行时常量池在方法区内,方法区在元空间内;
JDK1.6:字符串常量池在方法区中 JDK1.7:字符串常量池在堆中 JDK1.8:字符串常量池在方法去中,但是此时方法区的实现为元空间。
# 如何判断一个类是无用的类?
需要满足以下条件:
- 该类所有的实例都已经被回收,Java堆中不存在该类的实例。
- 该类的加载器ClassLoader已经被回收。
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足上述3个条件的无用类可以被回收,但是并不一定被回收。
# 垃圾回收算法
# 垃圾收集有哪些算法,各自的特点?
- 标记-清除算法:首先标记出不需要回收的对象,统一回收掉没有被标记的对象。
- 标记清除后会产生大量不连续的碎片,空间碎片太多可能会导致分配较大对象的时候,无法找到足够的连续内存而不得不触发一次垃圾收集。
- 适合在老年代进行垃圾回收,如CMS就采用该方法。
- 标记-复制算法:将内存分为大小相同的两块,每次只使用其中一块。这一块内存使用完后,将存活的对象复制到另一块去,再把使用的空间清理掉。
- 没有内存碎片,继续移动堆顶指针,按顺序分配内存。
- 适合新生代去进行垃圾回收,serial new、parallel new采用该算法。
- 标记-整理算法:根据老年代的特点提出的算法。标记不需要回收的对象,让所有存活的对象移动到一端,然后直接清理掉端边界以外的内存。
- 不会产生空间碎片,但会花一定时间整理。
- 适合在老年代进行垃圾回收,如Parallel scanvange、Serial old就采用该算法。
- 分代收集算法:java堆分为新生代和老年代,根据各个年代的特点选择合适的垃圾收集算法。新生代,每次收集都要大量对象死去,可以使用标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。老年代,对象生成几率比较高,而且没有额外的空间对他们进行分配担保,所以使用
标记-清除
或标记-整理
算法进行垃圾回收。
标记-复制算法正常要使用两个内存相等的区域,但新生代将其划分为一块较大的Eden区和两块较小的Survivor。当垃圾回收的时候,将Eden和其中一个Survivor中还存活的对象一次性的复制到另一块Survivor空间,最后清理掉Eden和刚使用过的Survivor空间。Eden:Survivor的大小比例为8:1,所以需要老年代进行分配担保。
# HotSpot 为什么要分为新生代和老年代?
java堆分为新生代和老年代,根据各个年代的特点选择合适的垃圾收集算法,提高垃圾回收的效率。新生代,每次收集都要大量对象死去,可以使用标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。老年代,对象生存几率比较高,而且没有额外的空间对他们进行分配担保,所以使用标记-清除
或标记-整理
算法进行垃圾回收。
# 并行收集、并发收集、吞吐量
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾线程同时工作(不一定是并行,可能是交替进行)。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值。 $$ 吞吐量=\frac{运行用户代码时间}{运行用户代码时间+垃圾收集时间} $$
# 垃圾回收器
# 常见的垃圾回收器
可以分为四大类:串行、并行、CMS、G1
新生代垃圾回收器:Serial、ParNew、Parallel Scavenge
老年代垃圾回收器:CMS、Serial Old、Parallel Old
整堆垃圾回收器:G1
在源码里可以看到6种(没有 SerialOld,被淘汰了)
# 查看默认垃圾收集器
命令行输入:
>java -XX:+PrintCommandLineFlags -version
输出:
-XX:InitialHeapSize=267755904 -XX:MaxHeapSize=4284094464 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_321"
Java(TM) SE Runtime Environment (build 1.8.0_321-b07)
Java HotSpot(TM) 64-Bit Server VM (build 25.321-b07, mixed mode)
2
3
4
可以看到使用的是-XX:+UseParallelGC
# JVM垃圾回收器参数说明
DefNew:Default New Generation
Tenured:Old
ParNew:Parallel New Generation
PSYoungGen:Parallel Scavenge
ParOldGen:Parallel Old Generation
# Serial收集器
单线程垃圾收集器,在进行垃圾收集工作时必须要暂停其他所有工作线程(Stop The World),直到收集结束。
- 优点:简单高效,单线程,没有线程交互的开销。
- 缺点:Stop The World带来不良用户体验;
- 应用场景:适用于Client模式下的虚拟机
- JVM参数:-XX:+UseSerialGC,开启后会使用Serial + Serial Old收集器组合
# ParNew收集器
Serial收集器的多线程版本。除了使用多线程进行垃圾回收,其余行为与Serial收集器一样。
- 特点:多线程
- 应用场景:Server模式下的虚拟机
- 与CMS收集器配合工作
- JVM参数:-XX:+UserParNewGC,开启后会使用ParNew + Serial Old收集器组合,但是Java8不推荐这么使用
- ParNew
# Parallel Scavenge收集器
关注吞吐量,也称吞吐量优先收集器。
- 特点:采用复制算法,并行的多线程收集器
- 可控制吞吐量。高吞吐量意味着高效利用CPU的时间,它多用于在后台运算而不需要太多交互的任务。
- 自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,可自动设置新生代空间比例、晋升老年对象年龄,动态设置这些参数以提供最优的停顿时间和最高的吞吐量。
- JDK1.8默认收集器,Parallel + Parallel Old收集器组合
- JVM参数:-XX:+UseParallelGC 或 -XX:+UseParallelOldGC (可互相激活) ,-XX:ParallelGCThread=N,启动N个GC线程
# Serial Old收集器
Serial Old,Serial收集器的老年代版本
- 特点:单线程收集器,采用标记-整理算法。
# Parallel Old收集器
Parallel Old,Parallel Scavenge收集器的老年代版本。
- 特点:多线程,采用标记-整理算法。
- 应用场景:注重高吞吐量以及CPU资源敏感的场合。
# CMS收集器
CMS(Concurrent Mark Sweep)收集器
以获取最短停顿时间为目标。非常符合在注重用户体验的应用上使用。
第一款真正意义上的并发收集器,让垃圾收集线程与用户线程同时工作。
适合应用在互联网站或BS系统的服务器上,这类应用重视服务器的响应速度,希望系统停顿时间最短。
适合堆内存大、CPU核数多的服务器端应用。
使用标记-清除算法,分为四个步骤:
- 初始标记:暂停所有其他线程,并记录下直接与GC Roots相关联的对象,速度很快。
- 并发标记:同时开启GC和用户线程,去记录可达对象。但在这个阶段结束,并不能保证记录当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性,但这个算法会跟踪记录引用更新变化的地方。
- 重新标记:修正并发标记期间,因为用户程序运行而导致标记变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,比并发标记阶段的时间短。
- 并发清除:开启用户线程,同时GC线程开始对未标记的区域做清扫。
优点:并发收集、低停顿
缺点:
- 对CPU资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但会因为占用一部分线程而导致应用程序变慢,总吞吐量会降低。
- 无法处理浮动垃圾,CMS并发清理阶段用户线程还在运行,伴随着程序运行自然会有新的垃圾不断产生,这部分垃圾产生在标记之后,CMS无法处理他们,只能留到下一次GC中再清理,这就是浮动垃圾。
- 使用标记-清除算法,容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
JVM参:-XX:+UserConcMarkSweepGc 开启该参数会自动将-XX:+UserParNewGC打开。使用ParNew(Young区)+CMS(Old区)+Serial Old(CMS出错后备收集器)的收集器组合。
# G1收集器
G1(Garbage-First),是面向服务器的垃圾收集器,抛弃分代的概念,将堆内存划分为大小固定的几个独立区域,并维护一个优先级列表,在垃圾回收过程中根据允许的最长垃圾回收时间,优先回收垃圾最多的区域。
H:专门存放大对象
优点:
- 基于标记整理算法,不会产生空间碎片
- 可精确的控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。
JVM参数:-XX:+UseG1GC,可通过参数-XX:G1HeapRegionSize=n指定分区大小(1MB~32MB,必须是2的幂),默认2047个分区
设置停顿时间:-XX:MaxGCPauseMillis=100,最大GC停顿时间(毫秒),JVM将尽可能停顿小于这个时间
针对于Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集 + 形成连续的内存块,避免内存碎片
- Eden区的数据移动到Survivor区,假如出现Survivor区空间不够,Eden区数据会晋升到Old区
- Survivor区数据移动到新的Survivor区,部分晋升到Old区
- 最后Eden区收拾干净,GC结束,用户应用程序继续执行。
回收的四个步骤:
- 初始标记:只标记GC Roots能直接关联到的对象
- 并发标记:进行GC Roots Tracing的过程
- 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那部分对象
- 筛选回收:根据时间来进行价值最大化的回收
G1为什么能建立可预测的停顿时间模型?
因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。
G1与其他收集器的区别?
其他收集器的工作范围是整个新生代或老年代,G1收集器的工作范围是整个Java堆。使用G1收集器时,它将整个Java堆划分成多个大小相等的独立区域Region。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region的集合。
G1收集器存在的问题?
Region不可能是孤立的,分配在Region中的对象可以与Java堆中的任意对象发生引用关系。在采用可达性分析算法来判断对象是否存活时,得扫描整个Java堆才能保证准确性。其他收集器也存在这种问题,G1更加突出而已。这回导致Minor GC效率下降。
# G1和CMS比较
没有垃圾碎片、G1可以精确的控制停顿时间
# JDK默认的垃圾收集器
jdk1.6:Parallel Scavenge(新生代) + Serial Old (老年代)
jdk1.7:Parallel Scavenge(新生代) + Parallel Old(老年代)
jdk1.8:Parallel Scavenge(新生代) + Parallel Old(老年代)
jdk1.9:G1
# 垃圾收集器组合选择
单CPU或小内存,单机程序
-XX:+UseSerialGC
多CPU,需要最大吞吐量,如后天计算型应用
-XX+UseParallelGC 或者 -XX:+UseParallelOldGC
多CPU,最求低停顿时间,需快速响应,如互联网应用
-XX:+UseConcMarkSweepGC 或者 -XX:+ParNewGC
参数 | 新生代垃圾收集器 | 新生代算法 | 老年代垃圾收集器 | 老年代算法 |
---|---|---|---|---|
-XX:+UseSerialGC | SerialGC | 标记复制 | SerialOldGC | 标记整理 |
-XX:+UseParNewGC | ParNew | 标记复制 | SerialOldGC | 标记整理 |
-XX:+UseParallelGC/-XX:+UseParallelOldGC | Parallel Scavenge | 标记复制 | Parallel Old | 标记整理 |
-XX:+UseConcMarkSweepGC | ParNew | 标记复制 | CMS + Serial Old(CMS出错后备收集器) | 标记清除 |
-XX:+UseG1GC | 整体采用标记-整理算法 | 局部同复制算法,不会产生内存碎片 |
# 垃圾回收过程
# JVM GC + SpringBoot 微服务的生产部署和调参优化
内部调优:IDEA里设置Configurations的VM参数
外部调优:java -server 各种参数 -jar jar包
# Minor GC 、Major GC、 Full GC 有什么不同呢?
- Minor GC:清理新生代。
- Major GC:清理老年代。
- Full GC:清理整个堆空间,包含新生代和老年。
Minor GC:清理新生代
- 当JVM无法为一个新的对象分配空间时会触发Minor GC,比如Eden区满了,Survivor区满不会触发。
- 使用标记-复制算法。
- Minor GC操作时应用程序停顿导致的延迟可以忽略不计。
- Java对象大多具有朝生夕灭的特性,所以MInor GC非常频繁,一般回收速度也比较快。
- 虚拟机会给每个对象定一个对象年龄计数器,如果对象Eden出生并且第一次Minor GC后仍然存活,并且能被Survivor容纳的话,就被移动到Survivor空间,并将对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当达到一定程度(默认为15),就会被晋升到老年代中。
- 在正式Minor GC前,JVM会先检查新生代中对象,是比老年代中剩余空间大还是小。
- 老年代大的话,就直接Minor GC,survivor不够放时,老年代也够放;
- 老年代小的时候,启用老年代分配担保规则,如果老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,那就允许进行Minor GC,因为从概率上讲,以前放的下,这次也放的下;如果小于历次Minor GC之后剩余的大小,进行Full GC,把老年代空出来再检查。
Major GC:清理老年代
- 老年代满了会触发Major GC;
- 只有CMS收集器会单独收集老年代;其他收集器都不支持单独回收老年代。
- 会比Minor GC慢10倍。
Full GC:清理新生代、老年代和方法区,产生条件如下:
- 调用 System.gc 时,系统建议执行 Full GC,但不一定执行。
- 老年代空间不足。
- 方法区空间不足,类卸载
- 通过 Minor GC 后进入老年代的空间大于老年代的可用内存。
- 内存空间担保。
# 对象如何晋升到老年代?
虚拟机给每个对象定义了一个对象年龄 Age 计数器,存储在对象头中。对象通常在 Eden 区里诞生,经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,年龄增加到默认值,就会晋升到老年代中。
如果 Survivor 区中相同年龄的对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象直接进入老年去。
# JVM中一次完整的GC流程
新创建的对象会被分配到新生代中,常用的新生代垃圾回收器是 ParNew 垃圾回收器,它按照 8:1:1 将新生代分为 Eden 区以及两个Survivor 区。某一时刻,创建的对象将Eden区全部挤满,此时就触发了Minor GC。
在 Minor GC 之前,JVM 会先检查新生代中的对象,是比老年代中剩余空间大还是小。
- 老年代剩余空间大于新生代中的对象,直接 Minor GC,GC 完 survivor 不够放,老年代也够放。
- 老年代剩余空间小于新生代中的对象,开启老年代空间分配担保规则(要设置启用这个规则)。
- 老年代剩余空间大于历次 Minor GC之后剩余对象的大小,进行 Minor GC。(以前放的下,这次也应该放的下)
- 老年代剩余空间小于历次 Minor GC之后剩余对象的大小,进行 Full GC,把老年代空出来再检查。
开启老年代空间分配担保规则后,进行了 Minor GC 会出现三种情况:
- Minor GC之后的对象够放在 Survivor 区,GC 结束。
- Minor GC之后的对象不够放在 Survivor 区,直接放到老年代,老年代能放下,GC 结束。
- Minor GC之后的对象不够放在 Survivor 区,老年代也不放不下,进行 Full GC。
前面都是成功的GC案例,还有3种情况会导致GC失败,报OOM:
- 未开启老年代分配担保机制,且一次Full GC后,老年代任然放不下剩余对象,只能OOM。(未开启分配担保机制——Full GC——老年代放不下)
- 开启分配担保机制,担保通过,但老年代放不下,当进行Full GC之后,老年代仍然便不下剩余对象,只能OOM。(分配担保机制——担保成功——老年代放不下——Full GC——老年代依然放不下)
- 开启老年代分配担保机制,但是担保不通过,一次Full GC后,老年代放不下剩余对象,只能OOM。(开启分配担保机制——担保失败——Full GC——老年代放不下)
新生代、老年代占用内存的比例1:2。
# Full GC 会导致什么?
Stop The World,即在GC期间全部暂停用户的应用程序。
# JVM什么时候触发 GC,如何减少 Full GC 的次数?
当Eden区的空间耗尽时会触发一次Minor GC来收集新生代的垃圾,存活下来的对象会被送到Survivor区。
Major GC中,老年代剩余空间小于之前新生代晋升老年代的平均值,进行Full GC。在CMS等并发收集器中则时每隔一段时间来检查一下老年代内存使用量,超过一定比例时进行Full GC回收。
减少Full GC次数:
- 增加方法区、老年代、新生代空间;
- 禁止使用System.gc()方法;
- 使用标记-整理算法,尽量保持较大的连续内存空间;
- 排查代码中无用的大对象。
# 如何减少 GC 出现的次数?
- 对象不用时显式置为Null,一般而言,对象引用置为Null后,其堆中的对象实例都将被作为垃圾处理。
- 尽量少使用System.gc()
- 尽量少用静态变量,静态变量属于全局变量,不会被GC回收,他会一直占用内存。
- 尽量少用finalize函数,他会增加GC的工作量。
- 尽量使用StringBuffer、StringBuilder而不用String类累加字符串。
- 能用基本数据类型就不用包装类。
- 分散对象创建和删除时间。
- 增大-Xmx的值。
# 方法区会进行垃圾回收吗?
会,方法区在元空间内(MetaSpace),当元空间的大小达到阈值时会进行 Full GC。下面举个例子看一下:
设置VM参数:
-XX:MaxMetaspaceSize=10240000
:设置元空间大小-XX:+PrintGCDetails
:设置打印垃圾回收信息
测试代码:通过CGLib反射创建新的代理类,这些类的元数据将不断添加到方法区内
public class MetaSpaceOOMTest {
// 设置VM
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Object.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, objects);
}
});
Object o = enhancer.create();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
垃圾回收详情:
[GC (Allocation Failure) [PSYoungGen: 65536K->2552K(76288K)] 65536K->2560K(251392K), 0.0137046 secs] [Times: user=0.05 sys=0.02, real=0.02 secs]
[GC (Allocation Failure) [PSYoungGen: 68088K->2784K(141824K)] 68096K->2800K(316928K), 0.0168467 secs] [Times: user=0.07 sys=0.02, real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen: 24538K->3040K(141824K)] 24554K->3064K(316928K), 0.0140900 secs] [Times: user=0.06 sys=0.02, real=0.02 secs]
[Full GC (Metadata GC Threshold) [PSYoungGen: 3040K->0K(141824K)] [ParOldGen: 24K->2928K(76288K)] 3064K->2928K(218112K), [Metaspace: 9034K->9034K(1058816K)], 0.0117568 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
[GC (Last ditch collection) [PSYoungGen: 0K->0K(245248K)] 2928K->2928K(321536K), 0.0003042 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Last ditch collection) [PSYoungGen: 0K->0K(245248K)] [ParOldGen: 2928K->1376K(140800K)] 2928K->1376K(386048K), [Metaspace: 9034K->9034K(1058816K)], 0.0088830 secs] [Times: user=0.04 sys=0.01, real=0.01 secs]
[GC (Metadata GC Threshold) [PSYoungGen: 4876K->64K(262656K)] 6253K->1440K(403456K), 0.0003653 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Metadata GC Threshold) [PSYoungGen: 64K->0K(262656K)] [ParOldGen: 1376K->1375K(219136K)] 1440K->1375K(481792K), [Metaspace: 9034K->9034K(1058816K)], 0.0094953 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
[GC (Last ditch collection) [PSYoungGen: 0K->0K(271360K)] 1375K->1375K(490496K), 0.0002275 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Last ditch collection) [PSYoungGen: 0K->0K(271360K)] [ParOldGen: 1375K->1375K(342528K)] 1375K->1375K(613888K), [Metaspace: 9034K->9034K(1058816K)], 0.0044636 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
2
3
4
5
6
7
8
9
10
GC (Allocation Failure)
:因为内存分配失败导致垃圾回收,只有年轻代发生了垃圾回收,所以是 Minor GC
GC (Metadata GC Threshold)
:元空间达到阈值导致垃圾回收,进行 Full GC
Full GC (Last ditch collection)
:经过 Metadata GC Threshold
触发的 Full GC 后还是不能满足条件,这个时候会触最后 Last ditch collection,这次 Full GC 会清理掉软引用(Soft Reference)
最后因为元空间的内存不足导致OOM
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace