JVM-垃圾回收详解
前言
常见面试题
- 如何判断对象是否死亡(两种方法)。
- 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
- 如何判断一个常量是废弃常量
- 如何判断一个类是无用的类
- 垃圾收集有哪些算法,各自的特点?
- HotSpot 为什么要分为新生代和老年代?
- 常见的垃圾回收器有哪些?
- 介绍一下 CMS,G1 收集器。
- Minor Gc 和 Full GC 有什么不同呢?
一、堆内存的常见分配策略
1.1 对象优先在eden区分配
目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC
。
1.2 大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
为什么要这样呢?
为了避免为大对象分配内存时由于分配担保机制
带来的复制而降低效率。
虚拟机发起一次 Minor GC 期间发现有对象无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去
1.3 长期存活的对象将进入老年代
虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
1.4 主要GC的区域
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
- 部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC):收集整个 Java 堆和方法区。
二、如何确定垃圾
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即在运行程序中没有任何指针指向的对象)。
2.1 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
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;
}
}
2.2 可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots
” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
2.3 再谈引用
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
1.强引用(StrongReference)
使用最普遍的引用,即类似Object obj = new Object()
这种引用关系。如果一个对象具有强引用,垃圾回收器绝不会回收它。
特点:
- 强引用可直接访问目标对象。
- 当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。 不会被系统回收
- 可能导致内存泄漏。
2.软引用(SoftReference)
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
软引用通常用来实现内存敏感的缓存。比如: 高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
3.弱引用(WeakReference)
在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
弱引用对象与软引用对象的最大不同就在于,当GC在 进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收。
4.虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。并且,虚引用必须和引用队列一起使用。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
2.4 对象的finalization机制
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
2.5 如何判断一个常量是废弃常量?
运行时常量池主要回收的是废弃的常量。那么如何判断一个常量是废弃常量呢?
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。
2.6 如何判断一个类是无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
类需要同时满足下面 3 个条件才能算是 “无用的类” :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class 对象
没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
三、垃圾收集算法
3.1 标记清除(Mark-Sweep)算法
该算法分为“标记”和“清除”阶段:
标记
:Collector从引用根节点开始遍历,标记出所有被引用的对象,在对象的Header中记录为可达对象。清除
:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
缺点:
- 效率不算高。
- 进行GC的时候,需要停止整个应用程序(Stop the world),导致用户体现极差。
- 空间问题,标记清除后会产生不连续的内存碎片。
3.2 复制算法
核心思想:将内存分为大小相同的两块,每次使用其中的一块。在垃圾回收时,将正在使用的内存中的存活对象复制到未被使用的内存块中,然后再把使用的空间一次清理掉,交换两个内存的角色。这样就使每次的内存回收都是对内存区间的一半进行回收。
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:
- 需要两倍的内存空间。
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
特别适合垃圾对象很多,存活对象很少的场景。例如:Young区的Survivor0和Survivor1区。
3.3 标记-压缩(整理)算法
根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理。
优点:
- 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。(指针碰撞,不需要维护空闲列表)
- 消除了复制算法当中,内存减半的高额代价。
缺点:
- 效率低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序。即: STW
3.4 分代收集算法
不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般将 java 堆分为新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法。
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答。
3.5 小结
对比三种清除阶段的算法
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
3.6 分区算法
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若千个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多个小区间。
四、垃圾收集器
Java堆内存被划分为新生代和老年代两部分,新生代主要使用复制和标记清除垃圾回收算法;老年代主要使用标记-整理垃圾回收算法,因此java虚拟中针对新生代和老年代分别提供了多种不同的垃圾收集器,垃圾收集器组合关系如下:
- 两个收集器间有连线,表明它们可以搭配使用
- 其中 Serial Old 作为CMS出现"Concurrent Mode Failure"失败的后 备预案。
- (红色虚线) 由于维护和兼容性测试的成本,在JDK 8时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃,并在JDK 9中完全取消了这些组合的支持。
- (绿色虚线) JDK 14中:弃用Parallel Scavenge 和Serial Old GC组合
- (青色虚线) JDK 14中: 删除CMS垃圾回收器
4.1 Serial回收器: 串行回收 (单线程、复制算法)
Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
-
Serial收集器采用复制算法、串行回收和"Stop-the-World"机制的方式执行内存回收。
-
除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的
Serial Old收集器
。Serial Old收集器同样也采用了串行回收和"stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。- Serial Old是运行在Client模式下默认的老年代的垃圾回收器。
- Serial Old在Server模式下主要有两个用途:
①与新生代的Parallel Scavenge配合使用
②作为老年代CMS收集器的后备垃圾收集方案
优势:
- 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- 运行在Client模式下的虚拟机是个不错的选择。
4.2 ParNew回收器: 并行回收 (Serial + 多线程)
ParNew 收集器其实就是 Serial 收集器的多线程版本。
新生代采用标记-复制算法,老年代采用标记-整理算法。
ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
4.3 Parallel回收器: 吞吐量优先(多线程、复制算法、高效)
Parallel scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。
-
和ParNew收集器不同,Parallel Scavenge收 器的目标则是达到一个可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器。
-
自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。
Parallel Old收集器
采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制。
- 在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。
- 在Java8中,默认是此垃圾收集器。
4.4 CMS回收器:低延迟(多线程、标记清除算法)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS 是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-world"。
4.4.1 CMS工作原理
CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和重新标记这两个阶段中仍然需要执行“Stop-the -World"机制暂停程序中的工作线程。
另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时, 便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
4.4.2 Mark Sweep 会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact更适合“Stop the World"这种场景下使用。
4.4.3 CMS的优缺点
- 优点:
- 并发收集
- 低延迟
- 缺点:
- 会产生内存碎片。
- 对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- 无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
4.4.4 CMS可以设置的参数
4.5 小结
4.6 G1回收器:区域化分代式
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
4.6.1 G1回收器的特点
-
并行与并发:
- 并行性:G1 在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW。
- 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
-
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念,将堆空间分为若干个区域(Region) ,这些区域中包含了逻辑上的年轻代和老年代。
-
空间整合:
- CMS:“标记清除”算法、内存碎片、若干次GC后进行一次碎片整理。
- G1将内存划分为一个个的region。 内存的回收是以region作为基本单位。Region之间是
复制算法
,但整体上实际可看作是标记-压缩
算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1 的优势更加明显。
-
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
缺点:
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload) 都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
4.6.2 G1回收器的参数设置
4.6.3 G1回收器垃圾回收过程
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
当堆内存使用达到一定值(默认45%) 时,开始老年代并发标记过程。
标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分,和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
1. G1回收过程一: 年轻代GC
首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
备注
其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象
脏卡表队列作用:
Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。
2. G1回收过程二:并发标记过程
3. G1回收过程三:混合回收
4. G1回收可选的过程四:Full GC
Evacuation表示回收阶段
4.6.4 G1回收器垃圾回收过程: Remembered Set
4.6.5 G1回收过程:补充
优化建议
4.7 垃圾回收器总结
4.8 垃圾回收器的新发展
4.8.1 垃圾回收器的发展过程
JDK11 的新特性
4.8.2 Shenandoah GC
4.8.3 令人震惊、革命性的ZGC
4.8.4 其他垃圾回收器:AliGC、Zing
-
AliGC是阿里巴巴JVM团队基于G1算法,面向大堆(LargeHeap)应用场景。
-
Zing是低延迟GC。
参考: