一、 前言
本文主要梳理了JVM垃圾回收中的相关知识,从JVM内存的分配,辨别哪些是垃圾,再到怎么回收垃圾这几个方面进行讲解,重点是怎么回收垃圾部分,其中有垃圾回收算法和垃圾回收器两大部分,是面试题高发区。本文内容较多,请选择需要的部分进行阅读。
下面是整篇文章的目录结构。
二、代码中的内存申请和回收
C语言中的内存需要程序员手动进行申请和回收,不然会出现内存泄露的问题。
举个例子,下图是单链表操作中的内存申请和回收
2.1 C语言版本
参考代码链接:<https://gitee.com/qianlilo/linklist
- 插入单链表时,进行内存空间的申请,需要指明内存空间的大小
- 删除单链表中某一个节点时,对该节点的内存空间进行free操作,释放空间。(s是垃圾)
2.2 Java版本
参考代码链接:<https://gitee.com/moline-x/LinkListReview
-
插入单链表时,给前一个节点增加要插入节点的引用,并且将前一个节点的原先的下一个节点赋给插入节点的next指针。(不涉及到内存大小的申请,只设计到new对象操作)
-
删除单链表时,将前一个节点的指针指向下一个节点的指针,并且将要删除的指针的next赋值null操作。其中的retNode是待回收的垃圾。
(只是将不会用到的对象其引用去掉,不涉及内存的释放操作)
由此可见,Java是不需要显式对内存空间的申请和回收的,底层是JVM进行自动化的垃圾回收。
当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。所以,我们要知道JVM是如何进行的垃圾回收。
三、给对象分配内存那点事
3.1 JVM的内存划分
JVM的内存划分一般包括年轻代、老年代 和 永久代,如图所示:
- 伊甸园(Eden):这是对象最初诞生的区域,并且对大多数对象来说,这里是它们唯一存在过的区域。
- 幸存者乐园(Survivor):从伊甸园幸存下来的对象会被挪到这里,通常会有两个区域,一个是From区,一个是To区,和eden区的比例默认是8:1:1。
- 终身颐养园(Tenured):这是足够老的幸存对象的归宿。年轻代收集(Minor-GC)过程是不会触及这个地方的。当年轻代收集不能把对象放进终身颐养园时,就会触发一次完全收集(Major-GC),这里可能还会牵扯到压缩,以便为大对象腾出足够的空间。
- 永久代:通常是指方法区,是非堆内存。
3.2 GC堆
Java 堆(堆内存)是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).现在收集器基本都采用分代垃圾收集算法。进一步划分的目的是更好地回收内存,或者更快地分配内存。
3.3 GC的分类
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
3.4 内存分配与回收策略
3.4.1 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行内存分配时,虚拟机将发起一次Minor GC(新生代内存回收)。
3.4.2 大对象直接进入老年代
大对象是指需要大量连续内存空间的Java对象,如字符串以及数组。虚拟机提供参数-XX:PretenureSizeThreshold参数来指定大对象,大于该值的对象都是大对象。
3.4.3 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活且能被Survivor容纳的话,将被移动到Survivor空间,并且对象年龄设为1,对象在Survivor区中每经过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认是15岁),就会晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
3.4.4 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须到达MaxTenuringThreshold才能晋升到老年代,如果Survivor空间中相同年龄对象大小的总和大于Survivor空间的一半(人多直接变为比较的平均值),年龄大于等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
3.4.5 空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC
。
四、哪些才是垃圾
4.1 Java中什么是垃圾
Java堆内存中几乎存放着所有的Java对象,JVM的垃圾回收器在进行垃圾回收之前,需要将某个实例标记为可回收,这就需要确定Java对象是否存活。
GC就是垃圾回收。
a = new Person();
a = null;
垃圾是指在运行程序中没有任何指针指向的对象
。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
4.2 如何判定哪些对象是垃圾
4.2.1 引用计数算法(Reference Counting)
引用计数器就是给每个对象添加一个引用计数器,当一个地方引用了此对象之后,那么计数器加1;当引用失效之后计数器减1,引用计数器为0的对象,就是无法再被使用的对象,应当回收掉。
引用计数器具有实现简单,效率高的特点,而且有部分产品也是基于引用计数器实现的内存管理。但是在JVM领域中,主流的虚拟机基本上没有采用引用计数器的方式实现对象标记,这是因为Reference Counting很难解决循环引用的问题。
a.b = new B();
b.a = new A();
4.2.2 可达性分析算法(Reachability Analysis)
在主流的JVM中都是通过可达性分析来判定一个对象是否存活的,其基本思想就是通过一系列"GC Roots" 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称之为'引用链'(Reference Chain),当一个对象到GC Roots没有热河引用链时,证明此对象是不可用的,需要被回收。
在JVM中可作为GC Root的对象大致有以下几种:
- 虚拟机栈(Stack Frame)的本地变量表中引用的对象
- 方法区中类的静态属性引用的对象
- 方法区中常亮引用的对象
- 本地方法栈中JNI(Java Native Interface 也就是常说的Native方法)引用的对象
代码举例
`public` `class` `GCRootDemo {`` ``private` `byte``[] byteArray = ``new` `byte``[``100` `* ``1024` `* ``1024``];` ` ``private` `static` `GCRootDemo gc2;`` ``private` `static` `final` `GCRootDemo gc3 = ``new` `GCRootDemo();` ` ``public` `static` `void` `m1(){`` ``GCRootDemo gc1 = ``new` `GCRootDemo();`` ``System.gc();`` ``System.out.println(``"第一次GC完成"``);`` ``}`` ``public` `static` `void` `main(String[] args) {`` ``m1();`` ``}``} `
解释:
gc1:是虚拟机栈中的局部变量
gc2:是方法区中类的静态变量
gc3:是方法区中的常量
图 可达性分析引用链
上图中,从当前运行的栈中引用的了A&B对象,根据引用链标记,下面的绿色标记对象均是可达的,红色不可达,其中F&J是相互循环引用的,可见根搜索算法是可以解决循环依赖的问题。
4.3 引用的分类
无论是通过引用计数器还是可达性分析判定 的对象是否可达,都是判断对象的引用有关。在JDK1.2之前,Reference类型存储的数据的数值代表另外一个内存的起始地址,所以其仅仅能代表两种状态,引用和没有引用。例如无法标识当前对象虽然被引用,但是仍然可被回收的状态。
在JDK1.2之后,Java对引用做了补充,将引用类型划分为,强引用(Strong Reference),软引用(Soft Referene),弱引用(Weak Reference)以及虚引用(Phantom Reference)。
- 强引用:GC时不会被回收
- 软引用:描述有用但不是必须的对象,在发生内存溢出异常之前被回收
- 弱引用:描述有用但不是必须的对象,在下一次GC时被回收
- 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用PhantomReference实现虚引用,虚引用用来在GC时返回一个通知。
4.4 回收方法区
方法区也可以回收,但是性价比很低,其主要回收两部分内容:废弃常量和无用的类
4.4.1 废弃常量
运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?
假如在常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量
的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池。
4.4.2 无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类” :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收
。
4.5 回收不可达的对象不是一定就要被回收掉
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
4.6 对象如何自救
刀下留人,免死金牌只有一次,免死金牌(方法)乱用,可能整个刑场都在等着这个免死金牌的使用完毕,可能会堵死。
需要注意的是,在被标记为可回收的对象中,也并非一定会被回收,在后续的回收途径中,还有一次自救的机会。对象要被回收会经历两次标记阶段:当对象经过Rearchability Analysis之后发现没有有效的与GC Root引用链存在,那么其会被第一次标记为可回收。
筛选条件是此对象复写了Object类的finalize方法,并且该finalize方法没有被执行过,符合上述条件的对象被认为是有必要执行finalize方法的对象,会被放入F-Queue队列中,由JVM创建一个低优先级的线程执行finalize方法,这里仅仅执行此方法,但并不保证执行完成,仅能保证触发执行此方法。这是因为如果在finalize方法中出现了执行缓慢的方法,甚至死循环,对后续F-Queue队列的执行,极端情况下甚至对整个JVM虚拟机都是致命的。
所以,finalize方法,是对象最后的存活的机会,GC稍后会进行第二次标记,如果在执行finalize方法的过程中,对对象进行了重新关联引用,那么在第二次标记的时候回被移除‘即将回收’集合。如果第二次被标记为可回收,那么其真的就被回收掉了。
下面的代码中,展示了Java对象的自我拯救,第二次的自我拯救是失败的,因为finalize方法已被执行,所以没有被重新引用。
/**
* 对象自我拯救
* 1.对象可以在GC前进行自我拯救
* 2.拯救机会只有一次,因为每个对象分finalize方法只会被jvm执行一次
*/
public class Main {
public static void main(String[] args) throws Throwable {
TestObj.SAVE_HOOK = new TestObj();
TestObj.SAVE_HOOK = null;
//手动触发GC
System.gc();
// 延时500ms,保证GC完成
Thread.sleep(500);
// 若此时自我拯救失败,应该输出 is dead
if (null != TestObj.SAVE_HOOK)
TestObj.SAVE_HOOK.isAlive();
else
System.out.println("Oh, t1 is dead");
TestObj.SAVE_HOOK = null;
//第二次自我拯救会失败, 因为finalize只会执行一次(免死金牌只有一次机会)
System.gc();
Thread.sleep(500);
if (null != TestObj.SAVE_HOOK)
TestObj.SAVE_HOOK.isAlive();
else
System.out.println("Oh, t1 is dead");
}
}
class TestObj {
//只要对象被SAVE_HOOK所引用, 他就不会被回收
public static TestObj SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
SAVE_HOOK = this;
}
public void isAlive(){
System.out.println("I am still alive");
}
}
输出结果如下:
- finalize method executed!
- I am still alive
- oh, t1 is dead
这里我们应该避免使用finalize方法,从上面的阐述中我们可以知道,finalize方法执行顺序不明确,甚至有的不能执行完成它,使用该方法来关闭Java对象的一些方法,完全是不可靠且不应该的。
五、JVM 有哪些垃圾回收算法?
5.1 概述
- 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
- 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
- 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
- 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
- 分区算法:用于G1收集器,将整个堆空间分成很多个连续的不同的小空间,每个小空间独立使用,独立回收。
5.2 复制算法
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。
优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
复制算法的执行过程如下图所示
5.3 标记-清除算法(Mark-Sweep)
标记无用对象,然后进行清除回收。
标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。
优点:实现简单,不需要对象进行移动。
缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
标记-清除算法的执行的过程如下图所示
5.4 标记-整理算法(Mark-Compact)
Mark-Compact,又称标记-压缩算法
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。
优点:解决了标记-清理算法存在的内存碎片问题。
缺点:仍需要进行局部对象移动,一定程度上降低了效率。
标记-整理算法的执行过程如下图所示
5.5 分代收集算法
当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代、老年代 和 永久代。下面通过分代垃圾回收器的例子来讲解分代收集算法。
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老年代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
5.6 分区算法
用于G1收集器,将整个堆空间分成很多个连续的不同的小空间,每个小空间独立使用,独立回收。为了更好的控制gc停顿时间,可以根据目标停顿时间合理地回收若干个小区间,而不是整个堆空间,从而减少gc停顿时间。
六、JVM 有哪些垃圾回收器?
6.1 概述
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现
。
图中(该图仅代表JDK8时的垃圾回收器)展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:CMS、Serial Old、Parallel Old
整堆收集器: G1
- 新生代
- Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
- ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
- Parallel Scavenge(并行清除)收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
- 老年代
- Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
- Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
- 整堆
- G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低
;
老年代回收器一般采用的是标记-整理
的算法进行垃圾回收。
一张经典形象的垃圾回收器对比图片
6.2 几个相关概念
6.2.1 并行收集
指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。(STW)
6.2.2 并发收集
指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
6.2.3 吞吐量
即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
6.2.4 经典的垃圾回收器
6.2.5 Stop-the-world(STW)
为了避免在垃圾回收的过程中,由于还有应用线程在运行,导致内存状态的改变,而引起的错误回收。在 Java 虚拟机里,采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。
Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
6.2.6 Safepoint
代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的
,如果有需要GC,线程可以在这个位置暂停。HotSpot采用主动中断的方式,让执行线程在运行期轮询是否需要暂停的标志,若需要则中断挂起
6.3 Serial 收集器
Serial收集器是最基本的、发展历史最悠久的收集器。
特点:单线程、简单高效
(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
应用场景:适用于Client模式下的虚拟机。
Serial / Serial Old收集器运行示意图
6.4 ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本
。
除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
和Serial收集器一样存在Stop The World问题
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
ParNew/Serial Old组合收集器运行示意图如下:
6.5 Parallel Scavenge 收集器
与吞吐量关系密切,故也称为吞吐量优先收集器
。
特点:属于新生代收集器也是采用复制算法
的收集器,又是并行的多线程收集器
(与ParNew收集器类似)。
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小。
6.6 Serial Old 收集器
Serial Old是Serial收集器的老年代版本。
特点:同样是单线程
收集器,采用标记-整理
算法。
应用场景:主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
Server模式下主要的两大用途(在后续中详细讲解···):
- 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器的后备方案,在并发收集Concurent Mode Failure时使用。
Serial / Serial Old收集器工作过程图(Serial收集器图示相同):
6.7 Parallel Old 收集器
是Parallel Scavenge收集器的老年代版本。
特点:多线程
,采用标记-整理
算法。
应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old 收集器。
Parallel Scavenge/Parallel Old收集器工作过程图:
6.8 CMS收集器
6.8.1 前言
一种以获取最短回收停顿时间为目标的收集器。
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。
CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用Serial Old
回收器进行垃圾清除,此时的性能将会被降低。
特点:基于标记-清除
算法实现。并发收集、低停顿。
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
6.8.2 CMS收集器的运行过程
初始标记:标记GC Roots能直接到的对象(原因:就像遍历树形结构,第二层开始往叶子节点遍历是可以并行的,这样停顿时间最短)。速度很快但是仍存在Stop The World问题。
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
被忽略的两个小阶段
1)CMS-concurrent-preclean 执行预清理 注: 相当于两次 concurrent-mark. 因为上一次c mark,太长.会有很多 changed object 出现.先干掉这波.到最好的 stop the world 的 remark 阶段,changed object 会少很多.
2)CMS-concurrent-abortable-preclean 执行可中止预清理
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题。时间比初始标记稍微长一些,但是远比并发标记的时间短(因为前面两个小阶段清理掉一部分的垃圾了,不用遍历那么多的对象)。
并发清除:对标记的对象进行清除回收。
CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的工作过程图:
6.8.3 优点
- 并发收集
- 低迟延
6.8.4 缺点
- 对CPU资源非常敏感。在并发阶段,虽然用户进程不会停顿,但是GC占用了一部分的线程导致应用程序变慢,总吞吐量会变慢。
- 无法处理浮动垃圾(在并发标记中新产生的垃圾对象),可能出现Concurrent Model Failure失败而导致另一次Full GC的产生。
- 因为采用标记-清除算法所以会存在
空间碎片
的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
6.8.5 面试问题
6.8.6 CMS在JDK后续版本中的地位
- JDK9 弃用CMS
- JDK14 删除CMS
6.9 G1收集器
6.9.1 前言
- 一款面向服务端应用的垃圾收集器。
- 适用空间:多核大内存
JDK9之后的默认垃圾回收器
。- 软实时 soft real-time
- 以空间换时间
- 注重吞吐量和低延迟,默认的暂停目标是200ms
- 超大堆内存,划分为多个大小相等的region
- 整体上是标记+整理算法,两个region之间是复制算法
- 简化JVM性能调优
6.9.2 特点
-
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过
并发
的方式让Java程序继续运行。在乎吞吐量,就改为并行的
。- 并行可以多个GC线程同时工作,但是用户线程STW
- 并发时,GC线程可以和用户线程交替进行执行。
-
分代收集:
G1能够独自管理整个Java堆
,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。不要求堆是连续的
。 -
空间整合:【标记-整理算法】G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。
-
可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型`能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。
6.9.3 亿点基本概念
6.9.3.1 指针碰撞
假设JVM堆中内存是规整的,所有用过的内存放在一边,没用过的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存的过程就仅仅是把那个指针向空闲空间的方向挪动一段与对象大小相等的距离,这种分配方式被称为“指针碰撞(Bump the Pointer)”。
6.9.3.2 Region
分区Region:化整为零(分散开)
6.9.3.3 TLAB(线程本地分配缓冲区)
线程本地分配缓冲区Thread Local Allocation Buffer
为了加速对象的分配,并且支持多线程同时创建对象,由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能,每一个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁。当TLAB的剩余空间不满足分配需求,则重新申请一块TLAB空间。
6.9.3.4 Card (卡表)
1、很小的内存区域,G1将Java堆(卡表)划分为相等大小的一个个区域,这个小的区域大小是512 Byte,称为Card
2、Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用这个数组映射着每一个Card
3、Card中对象的引用发生改变时,Card在Card Table数组中对应的值被标记为dirty,就称这个Card被脏化了(point-out
)
4、分配对象会占用物理上连续若干个卡片
6.9.3.5 Rset(已记忆集合)
1、概念
1、每个Region初始化时,会初始化一个remembered set
2、RSet里面记录了引用——就是其他Region中指向本Region中所有对象的所有引用(point-in
),也就是谁引用了我的对象即RSet需要记录的东西应该是 xx Region的 xx Card。
3、RSet其实是一个Hash Table,Key是其他的Region的起始地址,Value是一个集合,里面的元素是Card Table 数组中的index,既Card对应的Index,映射到对象的Card地址。
举例说明,Region的 Rset现在是一个HashMap,里面有key为Region的起始地址,value为{3}的card下标数组等等。
4、 只记录分代引用
写屏障即在改变特定内存的值时,执行一些额外的动作,G1的RSet的更新是通过写屏障完成的。对于一个写屏障来时,过滤掉不必要的写操作是十分必要的,G1进行以下过滤:
不记录新生代到新生代的引用 或者 新生代到老年代的引用
- 过滤一个分区内部的引用
- 过滤空引用
Region1和Region3中有对象引用了Region2的对象,则在Region2的Rset中记录了这些引用。
2、Rset实现过程
为了维护这些RSet,如果每次给引用类型的字段赋值都要更新RSet,这带来的额外开销实在太大,G1中采用post-write barrier(写后栅栏)和concurrent refinement threads(并发后台线程)实现了RSet的更新。
//假设对象young和old分别在不同的Region中
Object young = new Object();
old.p = young;
Java层面给old对象的p字段赋值young对象的前后,JVM会插入一个pre-write barrier(写前栅栏,G1独有,记录原来丧失的对象)或者post-write barrier(写后栅栏,)。
void oop_field_store(oop* field, oop value) {
pre_write_barrier(field); //记录原来的对象
*field = value; // the actual store
post_write_barrier(field, value);//记录新增的引用
}
1、写前栅栏 Pre-Write Barrrier:即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么JVM就需要在赋s值语句生效之前,记录丧失引用的对象
。
2、写后栅栏 Post-Write Barrrier:当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,同样需要记录
其中post-write barrier的最终动作如下:
1、找到该字段所在的位置(Card),并设置为dirty_card
2、如果当前是应用线程,每个Java线程有一个dirty card queue,把该card插入队列
3、除了每个线程自带的dirty card queue,还有一个全局共享的queue
赋值动作到此结束,接下来的RSet更新操作交由多个ConcurrentG1RefineThread并发完成,每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread会取出若干个队列,遍历每个队列中记录的card,并进行处理,逻辑如下:
1、根据card的地址,计算出card所在的Region
2、如果Region不存在,或者Region是Young区,或者该Region在回收集合(CSet)中,则不进行处理(只查找查找老年代的region对新生代的引用,来确定新生代是否有对象是可以存活的)
3、处理该card中的对象,将应用关系写入Rset中。
3、RSet有什么好处?
进行垃圾回收时,如果Region1有根对象A引用了Region2的对象B,显然对象B是活的,如果没有Rset,就需要扫描整个Region1或者其它Region,才能确定对象B是活跃的,有了Rset可以避免对整个堆进行扫描
。
6.9.3.6 CSet(回收集合Collection Set)
1、它记录了GC要收集的Regions集合
2、在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲Region中。
3、CSet包括需要收集的Eden Regions、Survivor Regions,而且还包括部分(1/8,默认分8次回收老年代)Old Regions(混合收集的时候,收集年轻代的时候,会收集一部分老年代的Region)。
6.9.3.7 GC并发标记之三色标记
1. 三色标记遍历过程
为了解决在并发标记过程中,存活对象漏标的情况,GC HandBook把对象分成三种颜色:
1、黑色:自身以及可达对象都已经被标记
2、灰色:自身被标记,可达对象还未标记
3、白色:还未被标记
2. 漏标情况
有用的对象,不是垃圾,却被JVM当成了垃圾
E > G 断开,D引用 G
所以,漏标的情况只会发生在白色对象中,且同时满足以下任意一个条件:
1、并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
2、并发标记时,应用线程删除所有灰色对象到该白色对象的引用
对于第一种情况,利用post-write barrier(写后屏障),记录所有新增的引用关系,然后根据这些引用关系为根重新扫描一遍
对于第二种情况,利用pre-write barrier(写前屏障),将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一遍
3. 漏标问题的解决方案
3.1 CMS 中的解决方案
破坏漏标的第一种条件,使黑色对象对白色对象有引用
使用Incremental Update 算法
当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描。
注意:圈红的位置,触发1线程重新扫描的是A增加一个引用。
小结:Incremental Update关注的是引用关系的增加,当发现有可达的引用增加,便开始重新标记。
3.2 G1 中的解决方案
破坏漏标的第二种情况,保存灰色对象到白色对象的引用
使用SATB算法(snapshot-at-the-beginning 快照)
刚开始做一个快照,当 B 和 C 引用消失的时候要把这个引用推到 GC 的堆栈,保证 C 还能被 GC 扫描到,最重要的是要把这个引用推到 GC 的堆栈,是灰色对象指向白色的引用,如果一旦某一个引用消失掉了,我会把它放到栈(GC 方法运行时数据也是来自栈中),我其实还是能找到它的,我下回直接扫描他 就行了,那样白色就不会漏标。
对应 G1 的垃圾回收过程中的:
最终标记( Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
注意:圈红的位置,触发1线程重新扫描的是B少了一个引用。即使用SATB关注的是引用关系的删除。
小结:SATB通过快照记录引用关系,一旦发现有引用删除,通过查看快照记录的引用关系,重新标记
4. Incremental Update算法和SATB算法对比
Incremental Update 算法关注引用的增加。(A->C 的引用)。 SATB 算法是关注引用的删除。(B->C 的引用)。
G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。 所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法决定的。
6.9.3.8 浮动垃圾
假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null
:
D > E 的引用断开
此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。
这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
6.9.4 G1的GC模式
期间有可能会触发full GC
6.9.4.1 Young GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当所有eden region使用达到最大阀值并且无法申请足够内存时
,会触发一次YoungGC。,G1会启动一次年轻代垃圾回收过程。
年轻代垃圾回收只会回收Eden区和Survivor区。
YGC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
第一阶段,扫描根。
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。跟引用连同RSet记录的外部引用作为扫描存活对象的入口。
第二阶段,更新RSet。
处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用
。
备注:
对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
第三阶段,处理RSet。
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
第四阶段,复制对象。
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
第五阶段,处理引用。
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
6.9.4.2 Young GC + CM(并发标记阶段)
对老年代可以回收的区域标记为X区域,以供下个流程Mixed GC使用
在执行垃圾收集时,G1以类似于CMS收集器的方式运行。 并发回收
1.G1收集器的阶段分以下几个步骤:
1)G1执行的第一阶段:初始标记(Initial Marking )
这个阶段是STW(Stop the World )的,所有应用线程会被暂停,标记出从GC Root开始直接可达的对象。
2)G1执行的第二阶段:并发标记
从GC Roots开始对堆中对象进行可达性分析,找出存活对象,耗时较长。当并发标记完成后,开始最终标记(Final Marking )阶段
3)最终标记(标记那些在并发标记阶段发生变化的对象,将被回收)
4)筛选回收(首先对各个Regin的回收价值和成本进行排序,根据用户所期待的GC停顿时间指定回收计划,回收一部分Region)(可以并发执行)
最后,G1中提供了两种模式垃圾回收模式,Young GC和Mixed GC,两种都是Stop The World(STW)的。
6.9.4.3 Mixed GC
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region(E + S),还会回收一部分的old region(上文YoungGC+CM说到标记出来老年代的X区域)
,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。
G1没有fullGC概念,需要fullGC时,调用serialOldGC进行全堆扫描(包括eden、survivor、o、perm)。
6.9.5 缺点
不同点
6.9.6 G1在JDK后续版本的地位
- 从jdk7开始
- jdk9被设为默认垃圾收集器;目标就是彻底替换掉CMS
6.9.7 面试问题
1. G1为什么能建立可预测的停顿时间模型?
因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
。这样就保证了在有限的时间内可以获取尽可能高的收集效率。
2. G1与其他收集器的区别
其他收集器的工作范围是整个新生代或者老年代、G1收集器的工作范围是整个Java堆。在使用G1收集器时,它将整个Java堆划分为多个大小相等的独立区域(Region)。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region(不需要连续)的集合。
3. G1收集器存在的问题
Region不可能是孤立的,分配在Region中的对象可以与Java堆中的任意对象发生引用关系。在采用可达性分析算法来判断对象是否存活时,得扫描整个Java堆才能保证准确性。其他收集器也存在这种问题(G1更加突出而已)。会导致Minor GC效率下降。
4. G1收集器是如何解决上述问题的?
采用Remembered Set来避免整堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于多个Region中(即检查老年代中是否引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆进行扫描也不会有遗漏。
6.10 总结
6.10.1 根据具体的情况选用不同的垃圾收集器
6.10.2 GC发展阶段
6.10.3 组合
红色虚线:JDK8弃用,JDK9移除
绿色的框,JDK9弃用,JDK14去掉,
绿色的线代表JDK14弃用。
6.10.4 怎么选择垃圾回收器
参考资料
(3条消息)JVM学习笔记(三)------内存管理和垃圾回收_走向架构师之路-CSDN博客
(3条消息)Java虚拟机(JVM)面试题(2020最新版)_ThinkWon的博客-CSDN博客
一句话解释JVM中空间分配担保的问题 - yangchunchun - 博客园
深入剖析JVM之G1收集器、及回收流程、与推荐用例_慕课手记
(6条消息) JVM堆内存(heap)详解_微步的博客-CSDN博客
深入理解G1垃圾收集器 | 并发编程网 – ifeve.com
docs/java/jvm/JVM垃圾回收.md · SnailClimb/JavaGuide - Gitee.com
1、对象已死吗? | 深入理解 JAVA 虚拟机 | 燕归来兮