Java垃圾回收机制详解

createh51周前 (04-10)技术教程8

一、垃圾回收机制的意义

Java语言中一个显著的特点就是引入了垃圾回收机制,使C或者C++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。

备注:内存泄露是指该内存空间使用完毕之后未回收,在不涉及复杂数据结构的一般情况下,Java 的内存泄露表现为一个内存对象的生命周期超出了程序需要它的时间长度,我们有时也将其称为“对象游离”。

二、对象可被回收的判定方法

1、引用计数法

每个对象都有一个引用计数器,当对象被引用一次计数器就加 1;当引用失效时计数器就减 1。当对象的计数器为 0 时,对象就是要被回收的。简单高效,缺点是无法解决对象之间相互循环引用的问题。

2、可达性分析算法

以 GC Roots节点作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。此算法解决了上述循环引用的问题。如图1,Object6、Object7、Object8、Object9可被回收。

Java 中可作为 GC Root 的对象:
(1)虚拟机栈中引用的对象(本地变量表)
(2)方法区中静态属性引用的对象
(3)方法区中常量引用的对象
(4)本地方法栈中引用的对象(Native对象)

三、垃圾收集算法

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

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的主要不足有两个:
(1)效率问题,标记和清除两个过程的效率都不高;
(2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记—清除算法的执行过程如下图:

2、复制(Coping)算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。复制算法的执行过程如下图:

优点:实现简单,不易产生内存碎片,每次只需要对半个区进行内存回收。
缺点:内存空间缩减为原来的一半;算法的效率和存活对象的数目有关,存活对象越多,效率越低。

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

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下:


4、分代收集(Generational Collection)算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。

一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

四、区域划分

1、年轻代(Young Generation)
(1)所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
(2)新生代内存按照8:1:1的比例分为一个 eden 区和两个 survivor(survivor0,survivor1) 区。一个 Eden 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
(3)当 survivor1区不足以存放 eden 和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC ,也就是新生代、老年代都进行回收。
4.新生代发生的 GC 也叫做 Minor GC ,Minor GC 发生频率比较高(不一定等 Eden 区满了才触发)。

2、年老代(Old Generation)
(1)在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
(2)内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。
3、持久代(Permanent Generation)
用于存放静态文件,如 Java 类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class ,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

五、GC类型

1、Minor GC(新生代 GC):
新生代 GC,指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生熄灭的特点,所以 Minor GC 十分频繁,回收速度也较快。
2、Major GC(老年代 GC):
老年代 GC,指发生在老年代的垃圾收集动作,当出现 Major GC 时,一般也会伴有至少一次的 Minor GC(并非绝对,例如 Parallel Scavenge 收集器会单独直接触发 Major GC 的机制)。 Major GC 的速度一般会比 Minor GC 慢十倍以上。
3、Full GC:
清理整个堆空间—包括年轻代和老年代。Major GC == Full GC

产生 Full GC 可能的原因
(1)年老代被写满。
(2)持久代被写满。
(3)System.gc() 被显示调用。
(4)上一次 GC 之后 Heap 的各域分配策略动态变化。

六、垃圾收集器

1、Serial收集器(复制算法)
单线程收集器,标记和清理都是单线程,优点是简单高效。

Serial收集器的工作流程如下图:

2、ParNew收集器(停止-复制算法)

新生代收集器,可以认为是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现。

ParNew收集器的工作流程如下图:

3、Parallel Scavenge 收集器(停止-复制算法)

并行收集器,追求高吞吐量,高效利用 CPU。吞吐量一般为 99%, 吞吐量 = 用户线程时间 / (用户线程时间 + GC线程时间)。适合后台应用等对交互响应要求不高的场景。

4、Serial Old收集器(标记-整理算法)

老年代单线程收集器,Serial 收集器的老年代版本。

5、Parallel Old 收集器(停止-复制算法)
Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。

6、CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。在重视响应速度和用户体验的应用中,CMS应用很多。
CMS收集器使用“标记-清除”算法,运作过程比较复杂,分为4个步骤:
(1)初始标记(CMS initial mark)
(2)并发标记(CMS Concurrent mark)
(3)重新标记(CMS remark)
(4)并发清除(CMS Concurrent Sweep)
其中,初始标记和并发标记仍然需要Stop the World、初始标记仅仅标记一下GC Roots能直接关联到的对象,速度很快,并发标记就是进行GC RootsTracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段长,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以整体上说,CMS收集器的内存回收过程是与用户线程一共并发执行的。下图是流程图:

CMS的优点就是并发收集、低停顿,是一款优秀的收集器。不过,CMS也有缺点,如下:
(1)CMS收集器对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/4,当CPU个数大于4时,垃圾收集线程使用不少于25%的CPU资源,当CPU个数不足时,CMS对用户程序的影响很大;
(2)CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC;
(3)CMS使用标记-清除算法,会产生内存碎片;

7、G1收集器
G1(Garbage first)收集器是最先进的收集器之一,是面向服务端的垃圾收集器。与其他收集器相比,G1收集器有如下优点:
(1)并行与并发:有些收集器需要停顿的过程G1仍然可以通过并发的方式让用户程序继续执行;
(2)分代收集:可以不使用其他收集器配合管理整个Java堆;
(3)空间整合:使用标记-整理算法,不产生内存碎片;
(4)可预测的停顿:G1除了降低停顿外,还能建立可预测的停顿时间模型;
G1中也有分代的概念,不过使用G1收集器时,Java堆的内存布局与其他收集器有很大的差别,它将整个Java堆划分为多个大小相等的独立区域(Region),G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次优先收集价值最大的那个Region。这样就保证了在有限的时间内尽可能提高效率。


G1收集器的大致步骤如下:
(1)初始标记(Initial mark)
(2)并发标记(Concurrent mark)
(3)最终标记(Final mark)
(4)筛选回收(Live Data Counting and Evacuation)
收集器的流程如下图:

七、四种引用状态

在实际开发中,我们对 new 出来的对象也会根据重要程度,有个等级划分。有些必须用到的对象,我们希望它在其被引用的周期内能一直存在;有些对象可能没那么重要,当内存空间还足够时,可以保留在内存中,如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。
由此,Java 对引用划分为四种:强引用、软引用、弱引用、虚引用,四种引用强度依次减弱。
1、强引用
代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
2、软引用
描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java 中的类 SoftReference 表示软引用。
3、弱引用
描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java 中的类 WeakReference 表示弱引用。
4、虚引用
这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。Java 中的类 PhantomReference 表示虚引用。

相关文章

Ubuntu 下安装 JDK17

Java SE 17 Ubuntu 下 JDK 的安装本文主要针对 Ubuntu 的环境进行 Java 17 的 JDK 安装。下载地址:官方下载地址:https://www.oracle.com/j...

Java 收发邮件 (Jakarta Mail)

Jakarta Mail API提供了一个独立于平台和协议的框架来构建邮件,完成邮件接收与发送功能。它也包含在Java EE平台中,也可以和Java SE平台一起使用。Jakarta Mail的前生是...

Springboot配置文件存放位置及读取顺序

Springboot配置文件可以使用yml格式和properties格式,分别命名为application.yml和application.properties存放目录Springboot配置文件默认...

线上问题解决:java内存溢出问题分析,定位及解决

上次说了full gc的解决方案,这次说说大家常见的内存溢出问题。(一)JVM 内存溢出① 介绍多多少少会碰到内存溢出(OOM)的场景,但造成OOM的原因却是多种多样。一起分析下。① 代码解析-Xmx...