一、前言
上一篇《Java-JVM-内存结构》讲述了 Java 虚拟机运行时的数据区域,那么这些内存里面,哪些需要回收,什么时候回收,如何回收呢?本篇将主要回顾JVM的垃圾收集算法:
- 垃圾收集
- 垃圾收集算法
- 垃圾收集器
二、背景
最近准备巩固已学的 Java 知识,同时《面试为什么需要了解JVM》 一文更加坚定了信念。
从《Java - JVM内存结构 vs Java内存模型 vs Java对象模型》 开始,先准备对JVM相关的知识点进行回顾:
- Java-JVM-类加载机制
- Java-JVM-内存结构
- Java-JVM-GC算法(本篇)
- Java-内存模型
三、垃圾收集
垃圾收集,Garbage Collection,通常被称为”GC”。这里所谓的垃圾指的是在系统运行过程当中所产生的一些无用的对象,这些对象 占据着一定的内存空间,如果长期不被释放,可能导致OOM。 在C/C++里是由开发人员自己去申请、管理和释放内存空间,因此没有GC的概念。而在Java中,后台专门有一个专门用于垃圾回收的线程 来进行监控、扫描,自动将一些无用的内存进行释放,这就是垃圾收集的一个基本思想,目的在于防止由开发人员引入的人为的内存泄露。
1. 哪些需要回收
上文说到,Java虚拟机运行时的数据区域主要有程序计数器、Java 虚拟机栈、 本地方法栈、Java 堆、方法区、运行时常量池。
其中,程序计数器、Java 虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地 执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。在这几个区域不需要过多考虑回收的问题,因为方法 结束或者线程结束时,内存自然就跟着回收了。
而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样, 我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的也是这部分内存,后面的文章中如果 涉及到”内存”分配与回收也仅指着一部分内存。
2. 什么时候需要回收
Java 堆里存放着 JVM 中几乎所有的对象实例,判断什么时候回收,就得看这些对象实例是否已经”死去”,即不可能再被使用。 判断对象是否存活一般有两种方式:
-
引用计数(Reference Counting) 每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。 该方法简单,但无法解决对象相互循环引用的问题,主流的Java虚拟机里面都没有采用该方法来管理内存。
-
可达性分析(Reachability Analysis) 从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
在Java语言中,GC Roots包括:
- 虚拟机栈中引用的对象。
- 方法区中类静态属性实体引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(Java Native Interface)引用的对象。
3. 一些概念
3.1 新生代、老年代、永久代
Java中的堆是JVM所管理的最大的一块内存空间,主要用于存放各种类的实例对象,它被划分成两个不同的区域:新生代 (Young)、 老年代 (Tenured)。
新生代
又被划分为Eden、From Survivor、To Survivor。
- Eden:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC, 对新生代区进行一次垃圾回收。
- From Servivor:上一次GC的幸存者,作为这一次GC的被扫描者。
- To Servivor:保留了一次MinorGC过程中的幸存者。
老年代
主要存放应用程序中生命周期长的内存对象。
永久代
永久代是Hotspot虚拟机特有的概念,是方法区的一种实现,不是Java堆内存的一部分。 永久代或者”Perm Gen”包含了JVM需要的应用元数据,这些元数据描述了在应用里使用的类和方法。永久代对垃圾回收没有显著影响, 但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
元空间
有的虚拟机并没有永久代,Java8 开始持久代也已经被彻底删除了,取代它的是另一个内存区域也被称为元空间。 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中, 而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java堆中。这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制.
3.2 Minor GC 、Major GC、Full GC
- Minor GC:指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Major GC:指发生在老年代的GC,Major GC的速度一般会比Minor GC慢10倍以上。
- Full GC: 指回收整个堆区,包括新生代和老年代。
3.3 吞吐量
吞吐量就是CPU运行代码时间与CPU总消耗时间的比值,即:
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
高吞吐量可以高效利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互任务。
3.4 并行和并发
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。 而垃圾收集程序运行在另一个CPU上。
四、垃圾收集算法
1. 标记-清除算法
标记-清除(Mark-Sweep)算法
,算法分为”标记”和”清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后 统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的 内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发 另一次垃圾收集动作。
2. 复制算法
为了解决效率问题,复制(Copying)算法
出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配 内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半.
3. 标记-整理算法
复制收集算法
在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间, 就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种标记-整理(Mark-Compact)算法
,标记过程仍然与标记-清除
算法一样, 但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4. 分代收集算法
分代收集(Generational Collection)算法
,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的 复制成本就可以完成收集。 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理
或标记-整理
算法来进行回收。
五、垃圾收集器
如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。下面将列举7种垃圾收集器,3种新生代收集器, 3种老年代收集器,最后一种G1垃圾收集器是横跨整个堆内存的。
1. Serial收集器
串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。垃圾收集的过程中会Stop The World(服务暂停)。
到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器,与其他收集器相比,对于限定在单个CPU的运行环境来说, Serial收集器由于没有线程交互的开销,专心做垃圾回收自然可以获得最高的单线程收集效率。
2. ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本。
3. Parallel Scavenge收集器
Parallel Scavenge收集器,和Parnew收集器一样,是一个新生代收集器,使用复制算法,是并行的多线程收集器,但是它更关注系统的 吞吐量(Throughput)。
4. Serial Old 收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理”算法。
这个收集器的主要意义也是被Client模式下的虚拟机使用。在Server模式下,它主要还有两大用途:一个是在JDK1.5及以前的版本中 与Parallel Scanvenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。
5. Parallel Old 收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和”标记-整理”算法。这个收集器是在JDK 1.6中才开始提供。
6. CMS收集器
CMS(Concurrent Mark Swep)收集器是一种获取最短回收停顿时间为目标的收集器,这使得它很适合用于和用户交互的业务。 从名字(Mark Swep)就可以看出,CMS收集器是基于标记清除算法实现的。它的收集过程分为四个步骤:
- 初始标记(initial mark)
- 并发标记(concurrent mark)
- 重新标记(remark)
- 并发清除(concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要”Stop The World”。初始标记
仅仅只是标记一下GC Roots能直接关联到的对象, 速度很快,并发标记
阶段就是进行GC Roots Tracing的过程,而重新标记
阶段则是为了修正并发标记
期间,因用户程序继续运作而导致 标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记
的时间短。 由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说, CMS收集器的内存回收过程是与用户线程一起并发地执行。
CMS关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短,就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
CMS的缺点:
- 吞吐量低
- 无法处理浮动垃圾,导致频繁Full GC
- 使用”标记-清除”算法产生碎片空间
7. G1收集器
G1(Garbage-First)
是一款面向服务端应用的垃圾收集器。G1 是未来要替换掉 CMS 成为 JVM 主流收集器的方案, 已经被 Java8 设定为默认收集器。
在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。G1在使用时,Java堆的内存布局与其他收集器 有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是 物理隔离的了,而都是一部分Region(不需要连续)的集合。
其实现思路是将整个Java堆划分为多个大小相等的独立区域(Region),将新生代老年代打乱后分配到不同的 Region,各个Region 单独GC从而避免整个Heap的扫描,再对Region GC
价值排序,维护一个优先列表以达到GC最高效率。每次根据优先级从列表中挑选回收 价值最大的Region进行回收(这也就是Garbage First名称的由来)。
G1 收集器运作大概分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
与其他GC收集器相比,G1具备如下特点:
- 并行与并发:G1能更充分的利用CPU、多核环境下的硬件优势来缩短stop the world的停顿时间。
- 分代收集:和其他收集器一样,分代的概念在G1中依然存在,不过G1不需要其他的垃圾回收器的配合就可以独自管理整个GC堆。
- 空间整合:G1收集器有利于程序长时间运行,分配大对象时不会无法得到连续的空间而提前触发一次GC。
- 可预测的非停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,能让使用者明确指定在一个长度为M毫秒 的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
六、收集器组合及比较
1. 比较
|收集器|串行/并行/并发|新生代/老年代|算法|目标|适用场景| |:—|:–:|:–:|:—:|:—:|——–| |Serial|串行|新生代|复制算法|响应速度优先|单CPU环境下的Client模式| |Serial Old|串行|老年代|标记-整理|响应速度优先|单CPU环境下的Client 模式、CMS的后备预案| |ParNew|并行|新生代|复制算法|响应速度优先|多CPU环境时在Server模式下与CMS配合| |Parallel Scavenge|并行|新生代|复制算法|吞吐量优先|在后台运算而不需要太多交互的任务| |Parallel Old|并行|老年代|标记-整理|吞吐量优先|在后台运算而不需要太多交互的任务| |CMS|并发|老年代|标记-清除|响应速度优先|集中在互联网站或B/S系统服务端上的Java应用| |G1|并发|both|标记-整理</br>+</br>复制算法|响应速度优先|面向服务端应用,将来替换CMS|
2. 组合
Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。
序号 | 新生代GC策略 | 老年老代GC策略 |
---|---|---|
1 | Serial | Serial Old |
2 | Serial | CMS+Serial Old |
3 | ParNew | CMS |
4 | ParNew | Serial Old |
5 | Parallel Scavenge | Serial Old |
6 | Parallel Scavenge | Parallel Old |
7 | G1GC | G1GC |
最后
本文从三个问题”哪些内存需要回收”、”什么时候回收内存”、”如何回收内存”出发,讲述了新生代、老年代这些概念,并介绍了4种 垃圾回收算法(标记-清除算法、复制算法、标记-整理算法、分代收集算法),7种垃圾收集器(Serial、ParNew、Parallel Scavenge、 Serial Old、Parallel Old、CMS、G1),并对这些垃圾收集器进行了对比和常见组合列举。
至于JVM GC日志的分析,这里就不多做说明,可以参考《Garbage Collection Handbook》一书,其中涉及到的每个算法章节,都有对应的日志分析。