垃圾指的是在运行程序中没有任何指针(或引用)指向的对象,这个对象就是需要回收的垃圾。 如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间一直保留到应用程序结束,被保留的空间无法被其他对象使用。可能会导致内存溢出。
对于高级语言来说,如果不进行垃圾回收,因为不断分配内存而不进行回收,内存早晚会被消耗完。除了释放没有用的对象,垃圾回收也可以清除内存里的碎片,碎片整理将所占用的堆内存移动到堆的一端,便于JVM将整理出内存分配给新的对象。特别是大的对象,可能需要一块连续的大的内存空间。
**垃圾回收(Garbage Collection)**作为一⻔实用而又要的技术,可以说拯救了无数苦于内存管理的程序员。尽管很多人认为,GC技术走进大众的视,多是源于Java语言的崛起,但是GC技术本身却相当的古老。早在1960年,Lisp之父John McCarthy已经在其论文中发布了GC算法,Lisp语言也是第 一个实现GC的语言。
在 GC 最开始设计时,人们在思考 GC 时就需要完成三件事情:
垃圾回收与“java面向对象编程”一样是java语言的特性之一;它与“ c/c++语言”最大区别是不用手动调用 free() 和 delete() 释放内存。GC 主要是处理 Java堆Heap ,也就是作用在 Java虚拟机 用于存放对象实例的内存区域,(Java堆又称为GC堆)。JVM能够完成内存分配和内存回收,虽然降低了开发难度,避免了像C/C++直接操作内存的危险。但也正因为太过于依赖JVM去完成内存管理,导致很多Java 开发者不再关心内存分配,导致很多程序低效、耗内存问题。因此开发者需要主动了解GC机制,充分利用有限的内存的程序,才能写出更高效的程序。
垃圾回收机制仍然在不断的迭代中,不同的场景对垃圾回收提出了新的挑战。
Stop-the-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿是产生时整 个应用程序线程会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。Stop-the-world意味着 JVM由于要执行GC而停止了应用程序(用户线程、工作线程)的执行,并且这种情形会在任何一种GC算法中发生。当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成。
STW事件和采用哪款GC无关,所有的GC都有这个事件。哪怕是G1也不能完全避免Stop-the-world 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能缩短了暂停时间。
STW是JVM在后台自动发起和自动完成的,在用户不可⻅的情况下,把用户正常的工作线程全部停掉。
随着应用程序越来越复杂,每次GC不能保证应用程序的正常运行。而经常造成STW的GC跟不上实际的需求,所以才需要不断对GC进行优化。事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有高吞吐 、低停顿的特点。
并发(Concurrent)
在操作系统中,是指一个时间段中有个几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
并发并不是真正意义上的"同时进行",只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序是同时进行的。
并行**(Parallel)**
当系统有一个以上CPU时,当一个CPU执行一个进程时,另外一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel);
其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核可以并行。
适合科学计算,后台处理等弱交互场景。
二者对比:
并发,指的是多个事情,在同一时间段内同时发生了。
并行,指的是多个事情,在同一时间点上同时发生了。
并发的多个任务之间是互相抢占资源的。
并行的多个任务之间是不互相抢占资源的。
只有在多个CPU或者一个CPU多核的情况中,才会发生并行。 否则,看似同时发生的事情,其实都是并发执行的。
JVM在进行回收时,是针对不同的内存区域进行回收的,大多数的回收指的是对新生代的回收。
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
Partial GC:并不收集整个GC堆的模式。其中又分为
新生代的回收:(Minor GC/Young GC),只收集新生代的GC
老年代的回收:(Major GC/Old GC),只收集老年代的GC。
目前只有CMS的concurrent collection是这个模式,只收集老年代。
Mixed GC:收集整个young gen以及部分old gen的GC。
只有G1有这个模式。
Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。
Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年, 外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。
约定: 新生代/新生区/年轻代 养老区/老年区/老年代/年老代 永久区/永久代
最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:
一般情况下,所有新生成的对象首先都是放在新生代的。新生代内存按照 8:1:1 的比例分为一 个eden区和两个survivor(from survivor,to survivor)区,大部分对象在Eden区中生成。
在进行垃圾回收时,先将eden区存活对象复制到from survivor区,然后清空eden区,当这个from survivor区也满了时,则将eden区和from survivor区存活对象复制到to survivor区,然后清空eden和这个from survivor区,此时from survivor区是空的,然后交换from survivor区和to survivor区的⻆色(即下次垃圾回收时会扫描Eden区和to survivor区),即保持from survivor区为空,如此往复。
特别地,当to survivor区也不足以存放eden区和from survivor区的存活对象时,就将存活对象直接存放到老年代。如果老年代也满了,就会触发一次FullGC,也就是新生代、老年代都进行回收。注意,新生代发生的GC叫做MinorGC,MinorGC发生频率比较高,不一定等 Eden区满了才触发。
Minor GC触发比较频繁,一般回收速度也是比较快的。Minor GC会引发STW,暂停用户线程,等待垃圾回收完毕后,用户线程才会恢复。
(1) 由Eden区、from survivor区向to survivor区复制时,对象大小大于to survivor可用内存,则把该对象转存到老年代,会先尝试触发Minor GC,如果之后空间还是不足,则会触发Major GC。
(2) 如果Major GC后还是不足,就会OOM。
(3) 发生Major GC,通常伴随Minor GC,但这并不是绝对的,Parallel Scavage这种收集器就有直接进行Major GC的策略过程。
说明:Major GC的速度一般会比Minor GC的速度慢10倍以上。
(1)System.gc()方法的调用
此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过-XX:+DisableExplicitGC来禁止RMI(Java远程方法调用)调用 System.gc。
(2)老年代空间不足
老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误: java.lang.OutOfMemoryError: Java heap space,为避免以上两种状况引起的FullGC,调优时应尽做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
(3)方法区空间不足
JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,Permanet Generation中存放的为一些class的信息、常量、静态变等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用 CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信 息:
java.lang.OutOfMemoryError: PermGen space
为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转为触发full GC
(5)由Eden区、from survivor区向to survivor区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
在堆里放着几乎所有的java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会在执行垃圾回收,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
那么在JVM中究竟是如何标记一个对象是死亡的呢?简单地说,当一个对象已经不再被任何的存活对象继续引用时,就可以判断为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数算法:通过判断对象的引用数来决定对象是否可以被回收。
引用计数算法是垃圾收集器中的早期策略。在这种方法中,堆中的每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个引用变量,该对象实例的引用计数设置为 1。当 任何其它变被赋值为这个对象的引用时,对象实例的引用计数加 1(a = b,则b引用的对象实例的计数器加1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数减1。特别地,当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器均减1。任何引用计数为0的对象实例可以被当作垃圾收集。
引用计数收集器可以很快的执行,并且交织在程序运行中,对需要不被⻓时间打断的实时环境比较 利,但其很难解决对象之间相互循环引用的问题。如下面示例所示,对象objA和objB之间的引用计数永远不可能为0,那么这两个对象就永远不能被回收。
/**
* -Xms10m -Xmx10m -XX:+PrintGCDetails * 证明java使用的不是引用计数器算法
*/
public class ReferenceCountGC {
public Object instance = null;
private byte[] bigObject = new byte[1024*1024];
public static void main(String[] args){
ReferenceCountGC objA = new ReferenceCountGC ();
ReferenceCountGC objB = new ReferenceCountGC ();
// 对象之间相互循环引用,对象objA和objB之间的引用计数永远不可能为 0 objB.instance = objA;
objA.instance = objB;
objA = null;
objB = null;
System.gc(); //通过注释,打开或关闭垃圾回收的执行
}
}
上述代码最后面两句将objA和objB赋值为null,也就是说objA和objB指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。
优点:
缺点:
扩展知识点
java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。引用计数算法,是很多语言的资源回收选择,例如python,它更是同时支持引用计数和垃圾收集机制。 Python如何解决循环引用?
相对于引用计数算法,这里的可达性分析是java、c# 选择的。这种类型的垃圾收集通常也叫追踪性垃圾收集****(Tracing Garbage Collection)
可达性分析算法是通过判断对象的引用链是否可达来决定对象是否可以被回收。
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链 (Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的,如下图所示。
在java中,可作为 GC Root 的对象包括以下几种:
比如:各个线程被调用的方法中使用的参数、局部变量等。
比如:java类的引用类型静态变量
3)方法区中常引用的对象;
比如:字符串常池(String Table)里的引用
4)本地方法栈中Native方法引用的对象;
5)所有被同步锁synchronized持有的对象;
比如:基本数据类型对应的Class对象,一些异常对象(如:NullPointerException、 OutOfMemoryError),系统类加载器
7)反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
由于Root采用栈方式存放变和指针,所以如果一个指针,它保存了堆内存里面的对象地址,但是自己又不存放在堆内存里面,那它就是一个Root。
下面以一段代码来简单说明一下
class RearchabilityTest {
private static A a = new A(); // 静态变量
public static final String CONTANT = "I am a string"; // 常量
public static void main(String[] args) {
A innerA = new A(); // 局部变量
}
}
class A {
}
首先,类加载器加载RearchabilityTest类,会初始化静态变量a,将常量引用指向常量池中的字符串,完成RearchabilityTest类的加载; 然后main方法执行,main方法会入虚拟机方法栈,执行main方法会在堆中创建A的对象,并赋值给局部变量innerA。
此时GC Roots状态如下:
当main方法执行完出栈后,变为:
第三个对象已经没有引用链可达GC Root。此时,第三个对象被第一次标记。
MAT是一个强大的内存分析工具,可以快捷、有效地帮助我们找到内存泄露,减少内存消耗分析工具。
MAT是Memory Analyzer tool的缩写,是一种快速,功能丰富的Java堆分析工具,能帮助你查找内存泄漏和减少内存消耗。很多情况下,我们需要处理测试提供的hprof文件,分析内存相关问题,那么 MAT也绝对是不二之选。
MAT安装有两种方式,一种是以eclipse插件方式安装,一种是独立安装。在MAT的官方文档中有相应的安装文件下载,下载地址为:https://www.eclipse.org/mat/downloads.php
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Scanner;
public class GCRootsTest {
public static void main(String[] args) {
List<Object> numList = new ArrayList<>();
Date birth = new Date();
for (int i = 0; i < 100; i++)
{
numList.add(String.valueOf(i));
try
{
Thread.sleep(10);
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
System.out.println("数据添加完毕,请下一步操作:");
new Scanner(System.in).next();
numList = null;
birth = null;
System.out.println("numList、birth已置空,请下一步操作:");
new Scanner(System.in).next();
System.out.println("结束");
}
}
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常⻅的三种垃圾回收算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。
标记-清除算法(Mark-Sweep)是一种非常基础和常⻅的垃圾收集算法,该算法被J.McCarthy等人在 1960年提出并应用于Lisp语言。
清除算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收,如下图所示。
标记-清除算法的主要不足有:
为了解决标记-清除算法在垃圾收集效率方面的缺陷, M. L. Minsky 于1963 年发表了著名的论 文“一种使用双存储区的 Lisp 语言垃圾收集器( A LISP Garbage Collector Algorithm Using Serial Secondary Storage )”。 M. L. Minsky 在该论文中描述的算法被人们称为复制算法(Copying),它也被 M. L. Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。
复制算法别出心裁地将堆空间一分为二,并使用简单的复制操作来完成垃圾收集工作,这个思路相当有趣。
复制算法将可用内存按容划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完 了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。该算法示意图如下所示:
应用场景:
事实上,现在商用的虚拟机都采用这种算法来回收新生代。因为研究发现,新生代中的对象每次回收都基本上只有**10%**左右的对象存活,所以需要复制的对象很少,效率还不错。不适合存活对象比较多的场景。
优点:
缺点:
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
标记-整理算法或标记-压缩算法(Mark-Compact)是标记-清除算法和复制算法的有机结合。把标记-清除算法在内存占用上的优点和复制算法在执行效率上的特⻓综合起来,这是所有人都希望看到的结果。不过,两种垃圾收集算法的整合并不像 1 加 1 等于 2 那样简单,我们必须引入一些全新的思路。 1970 年前后, G. L. Steele , C. J. Cheney 和 D. S. Wise 等研究者陆续找到了正确的方向,标记-整理算法的轮廓也逐渐清晰了起来。
标记-整理算法的标记过程类似标记清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,该垃圾回收算法适用于对象存活率高的场景(老年代),其作用原理如下图所示。
标记-整理算法与标记-清除算法最显著的区别是:标记-清除算法不进行对象的移动,并且仅对不存活的对象进行处理;而标记整理算法会将所有的存活对象移动到一端,并对不存活对象进行处理,因此 其不会产生内存碎片。标记-整理算法的作用示意图如下:
标记-整理算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的⻛险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。 如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
缺点:
对比三种算法
效率上来说,复制算法是最快的,但是却浪费了太多的内存。
而为了兼顾上面提到的三个指标,标记**-**整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法(Generational Collecting),是基于这样的一个事实:不同的对象生命周期是不一样 的。因此不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把java堆分成新生 代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
1、新生代(Young Generation)
新生代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
这种情况适合复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关, 因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
2、老年代(Old Generation)
老年代特点:区域较大,对象生命周期⻓、存货效率高,回收不及年轻代频繁
这种情况存在大存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
老年代存放的都是一些生命周期较⻓的对象,就像上面所叙述的那样,在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。此外,老年代的内存也比新生代大很多(大概比例是1:2), 当老年代满时会触发Major GC(Full GC),老年代对象存活时间比较⻓,因此FullGC发生的频率比较低。
3、 永久代(Permanent Generation)
永久代主要用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应 用可能动态生成或者调用一些class,例如使用反射、动态代理、CGLib等bytecode框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
增量式垃圾回收并不是一个新的回收算法, 而是结合之前算法的一种新的思路。
之前说的各种垃圾回收, 都需要暂停程序, 执行GC, 这就导致在GC执行期间, 程序得不到执行. 因此出现了增量式垃圾回收, 它并不会等GC执行完, 才将控制权交回程序, 而是一步一步执行, 跑一点, 再跑一 点, 逐步完成垃圾回收**,** 在程序运行中穿插进行。极大地降低了GC的最大暂停时间。
总体来说,增量式垃圾回收算法的基础仍是传统的标记-清除和复制算法。增量式垃圾回收通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐的 下降。
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间也就越⻓,有关GC产生的停顿也越⻓。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时 间,每次合理回收若干个小区件,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期⻓短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多个小区间。
注意:这些都只是基本的算法思路,实际GC实现过程要复杂得多,目前发展中的前沿GC都是复合算法,并且并行和并发兼备。
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK版本的处于高速迭代过程中,因此java发展至今已经衍生了众多GC版本。从不同⻆度分析垃圾收集器, 可以将GC分为不同的类型。
在诸如单CPU处理器或者较小内存等硬件场合中,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端Client模式下的JVM中。
在并发能力较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗的时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那么吞吐就是99%。这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐的应用程序有更⻓的时间基准,快速响应是不必考虑的。
暂停时间是指一个时间段内应用程序线程暂停,让GC线程执行的状态。比如:GC期间100毫秒的暂停时间意味这在这100毫秒期间内没有应用程序线程是活动的。
**注重吞吐量:**吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 + 0.2= 0.4s
**注重低延迟:**暂停时间优先,意味这尽可能让单次STW的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5s
这三者共同构成一个”不可能三⻆“。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。简单来说,主要抓住两点:
在设计(或使用)GC算法时,必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐或最小暂停时间),或尝试找一个二者的折衷。
现在标准,在最大吞吐量优先的情况下,降低停顿时间。
有了虚拟机,就一定有需要收集垃圾的机制,这就是Garbage Collection,对应的产品我们称之为 Garbage Collector
官方文档参考:
https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1 收集器。不同收集器之间的连线表示它们可以搭配使用。
为什么要有很多收集器,一个不够吗 ?因为java的使用场景很多,移动端、服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海而皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。
如何查看默认的垃圾回收器
import java.util.ArrayList;
import java.util.List;
/**
* -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC */
public class GCUseTest {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true){
byte[] arr = new byte[100];
list.add(arr);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
输出:
-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MaxNewSize=348966912 -XX:MaxTenuringThreshold=6 -XX:OldPLABSize=16 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
Serial/Serial Old收集器是最基本最古老的收集器,Serial是JDK1.3之前回收新生代唯一的选择。它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。
Serial收集器是作为HotSpot中Client模式下的默认新生代垃圾收集器,采用的是复制算法。Serial Old收集器是针对老年代的收集器,采用的是标记-整理算法。
如下是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial 收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。它的”单线程“的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop the world)。
优点:实现简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
缺点:会给用户带来停顿。
适用场景:Client 模式(桌面应用);单核服务器。
可以用 -XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 Serial GC,并且老年代用 Serial Old GC。
import java.util.ArrayList;
import java.util.List;
/**
* -XX:+UseSerialGC -XX:+PrintCommandLineFlags */
public class GCUserTest {
public static void main(String[] args){
List<String> list = new ArrayList<>();
String str = "hey";
while(true){
list.add(str);
str += str; try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseSerialGC
总结
这种垃圾收集器了解即可,现在已经不用串行的了。而且在限定单核CPU才可以使用,现在都不是单核的了。
对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在java web应用程序中是不会使用串行垃圾收集器的。
老年代单线程收集器,Serial收集器的老年代版本;Serial Old是运行在Client模式下默认的老年代的垃圾回收器。Serial Old在server模式下主要有两个用途:
2)作为老年代CMS收集器的后备垃圾收集方法。
如下图是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图:
如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器就是Serial收集器的多线程版本。
Par是Parallel的缩写,New:只能处理的是新生代
新生代收并行集器,ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集。在多核CPU环境下有着比Serial更好的表现;ParNew收集器在年轻代中同样也是采用复制算法、“stop- the-wold”机制。
如下是 ParNew 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后, 用户线程继续开始执行。
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?(扩展点:多线程程序效率一定高于单线程程序吗??)
ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
适用场景
多核服务器;与 CMS 收集器搭配使用(除Serial外,目前只有ParNew GC能与CMS收集器配合工作)。
参数
当使用 -XX:+UseConcMarkSweepGC 来选择 CMS 作为老年代收集器时,新生代收集器默认就是 ParNew,也可以用 -XX:+UseParNewGC 来指定使用 ParNew 作为新生代收集器。
-XX:ParallelGCThreads 限制线程数,默认开启和cpu数据相同的线程数。
HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和“stop the world”机制。
那么Parallel收集器的出现是否是多此一举?
高吞吐量意味着高效利用 CPU。高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务。
如下是 Parallel 收集器和 Parallel Old 收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作, 收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行, Parallel Old 收集器以多线程,采用标记-整理算法进行垃圾收集工作。
适用场景:
注重吞吐量,高效利用 CPU,需要高效运算且不需要太多交互。适合后台应用等对交互相应要求不高的场景;例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
参数配置:
-XX:+UseParallelGC 来选择 Parallel Scavenge 作为新生代收集器,
-XX:+UseParallelOldGC 手动指定老年代都是使用并行回收收集器。
老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
适用场景:与Parallel Scavenge收集器搭配使用;注重吞吐量。jdk7、jdk8 默认使用该收集器作为老年代收集器,使用 -XX:+UseParallelOldGC 来指定使用 Paralle Old 收集器。
在JDK1.5时,HotSpot推出了一款在强交互应用中要的一款垃圾收集器: CMS(Concurrent- Mark-Sweep),这款垃圾收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了 垃圾收集线程与用户线程同时工作。
CMS收集器是一种尽可能缩短用户线程的停顿时间**(低延迟)**收集器,停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
目前很大一部分的java应用集中在B/S系统的服务端上,这类应用尤其视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
它是一种并发收集器,采用的是标记-清除算法。
JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。
整个垃圾收集过程分为 4 个步骤:
整个过程耗时最⻓的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS 收集器垃圾收集可以看做是和用户线程并发执行的。
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和新标记这两个阶段中仍然需要执行"stop-the-world"机制暂停程序中的工作线程,不过暂停时间并不太⻓,因此可以说明目前所 有的垃圾收集器都做不到完全不需要"stop-the-world",只是尽可能地缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代集合完全被填满了再进行收集,而是当堆内存使用率达到某一阀值时,便开始进行回收。以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一 次"Conrurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启动Serial Old****收集器来新进 行老年的垃圾收集,这样停顿时间就很⻓了。
CMS收集器的垃圾收集算法采用是标记-清除算法,这意味这每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可能避免的讲会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为**“指针碰撞”(Bump the Pointer)。如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”**(FreeList)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整 又由所采用的垃圾收集器是否带有压缩整理功能决定。
因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞, 而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
指针碰撞:
空闲列表:
有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact?
答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用?要保证用户线程能继续执行,前提是它允许的资源不受影响。
CMS主要优点:
1.并发收集;
2.低停顿。
CMS明显的缺点:
**CMS收集器对CPU资源非常敏感。**在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC 的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生, 这部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
CMS是基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多, 可能会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发FullGC。
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
2)-XX:CMSInitiatingOccupancyFraction 设置堆内存使用率的阀值,一旦达到该阀值,便开始进行回收。
-XX:CMSInitiatingOccupancyFraction=20 设置到20%时
如果内存增⻓缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,检索老年代回收的次数可以较为明显的改善应用程序性能。
相反,如果应用程序内存使用率增⻓很快,则应该降低这个阀值,以避免频繁触发老年代串行收集器。通过该选项可以有效降低Full GC的执行次数。
HotSpot这么多的垃圾回收器,Serial/Serial Old、Parallel GC、CMS这些GC有什么不同吗?
后续版本
JDK9新特性:CMS被标记**废弃(Deprecate)**了,如果对JDK9 以以上版本的Hotspot虚拟机使用- XX:+UseConcMarkSweepGC参数来开启CMS收集器的话,用户会收到一个警告信息,同时CMS在未来将会被废弃
JDK14新特性:删除CMS垃圾回收器,移除CMS垃圾收集器,如果在JDK14中使用- XX:+UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exit,JVM会自动回退以默认GC的方式启动JVM。
既然已经有了前面的几个强大的GC,为什么还要发布Garbage First(G1)GC ?
原因在于应用程序所对应的业务越来越庞大、复杂、用户越来越多,没有GC就不能保证应用程序正常运行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1垃圾回收器是在java7 update4之后引入的一个新的垃圾回收器,是当前收集器技术发展的最前沿成果之一。 G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起**“全功能收集器”**的任与期望。
G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
因为G1是一个并行回收器,它用堆内存分割为很多不相关的区域(Region)(物理上是不连续的)。 使用不同的Region来表示Eden、survivor、old等。
G1 GC有计划的避免在整个java堆中进行全区域的垃圾收集。G1 跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),**在后台维护一个优先列表,每次根据 允许的收集时间,优先回收价值最大的Region。
由于这种方式的侧点在于回收垃圾最大的区间(Region),所以我们给G1一个名字:垃圾优先 (Garbage First)
G1是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容内存的机器,以极大概率满足GC停顿时间的同时,还兼具高吞吐量的性能特性。
在JDK1.7版本正式启用,移除了Experimetal的标识,是JDK9以后的默认垃圾回收器,取代了CMS 回收器以及Parallel + ParallelOld 组合。被Oracle官方称为**“全功能的垃圾收集器”**。
与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。
与其他GC收集器相比,G1使用全新的分区算法,其特点如下所示:
1) 并行与并发
2)分代收集
从分代上看,G1依然属于分代行垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和 Survivor区。但从堆的结构上,它不要求整个Eden区、Survivor区或者老年代都是联系的,也不再坚持 固定大小和固定数。
将堆空间划分为若干个区域**(Region)**,这些区域包含了逻辑上的年轻代和老年代。
和之前的各类垃圾回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代。
3) 可预测的停顿时间模型(即:软实时 soft real time)
G1会通过一个合理的计算模型,计算出每个Region的收集成本并化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制 条件,以此达到实时收集的目的。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个java堆中进行全区域的垃圾收集。
如何建立可靠的停顿预测模型(满足用户设定的期望停顿时间)?
G1 收集器的停顿模型是以衰减均值(Decaying Average)为理论基础来实现的:垃圾收集过程 中,G1收集器会根据每个 Region 的回收耗时、记忆集中的脏卡数量等,分析得出平均值、标准偏差等。
“衰减平均值”比普通的平均值更能准确地代表“最近的”平均状态,通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才能在不超期望停顿时间的约束下获得最高收益。
相对于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高。 从经验上来说,整体而言:
这个临界点大概是在 6~8G 之间(经验值)
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
G1的设计原则就是简化JVM性能调优,只需要简单三步即可完成:
如下图所示,G1 收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和 CMS 收集器前几步的收集过程很相似:
面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
3)在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部 Region的增式清理在保证每次GC停顿时间不会过⻓) 。
4)用来替换掉JDK1.5中的CMS收集器,以下情况,使用G1可能比CMS好
分区Region:化整为零
使用G1收集器是,它将整个java堆划分为约2048个大小相同的独立Region块,每个Region块大小 根据堆空间的实际大小而定,整体被控制在1mb到32mb之间,且为2的N次幂,即1mb、2mb、 4mb、8mb、16mb、32mb。可以通过‐XX:G1HeapRegionSize设定。所有的Region大小相同,且在 JVM生命周期内不会被改变。
虽然还保留这新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
一个region有可能属于Eden,Survivor,或者Old 内存区域。但是一个region只可能属于一个⻆ 色。图中的E表示region属于Eden内存区域,S表示属于Survivor内存区域,O表示属于Old内存区域。 图中空白的表示未使用的内存空间。
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于 存储大对象,如果超过1.5个region,就放到H区。
设置H的原因:
对于堆中的大对象,默认直接会分配到老年代,但是如果他是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专⻔存放大对象。**如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。**为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代一部分来看待。
4.12.9 主要回收环节
G1 GC的垃圾回收过程主要包含以下三个环节:
(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。Full GC针对GC 的评估失败提供了一 种失败的保护机制,即强力回收。)
Young GC -> Young GC + concurrent mark -> mixed GC 顺序,进行垃圾回收。
年轻代GC
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有的应用程序线程,启动多线程执行年轻代回 收。然后从年轻代区间移动存活对象到Survivor区间或者老年代区间,也有可能是两个区间都会涉及。
老年代并发标记(Concurrent Marking)
当堆内存使用达到一定值(默认是45%)时,开始老年代并发标记过程。
混合回收(Mixed GC)
标记完成⻢上开始混合回收过程。对于一个混合回收期,G1 GC从老年代移动存活对象到空闲区 间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1 的老年代回收器不需要整个老年代被回收,一次主要扫描/回收一小部分老年代Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
举个示例:一个Web服务器,java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新 分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%。会 开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
1)年轻代大小
2)暂停时间目标不要太过严苛
从Oracle官方透露出来的信息可知,回收阶段(Evacuation)其实本也有想过设计成与用户一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐所以才选择了完全暂停用户线程的实现方案。
GC 发展阶段
Serial => Parallel(并行) => CMS(并发) => G1 => ZGC
截止jdk1.8 ,一共有7款不同垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用 的时候,需要根据具体的情况选择不同的垃圾回收器
官方文档:https://docs.oracle.com/en/java/javase/12/gctuning/
ZGC: A Scalable Low-Latency Garbage Collector (Experimental)(ZGC: 可伸缩的低延迟垃圾回收器,处于实验性阶段) http://openjdk.java.net/jeps/333
ZGC的目标是:在尽可能对吞吐量影响不大的前提下,实现任意堆内存大小下都可以把垃圾收集的停 顿时间限制控制在十毫秒以内的低延迟。
《深入理解java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针的内存多映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配-并发重映射等。
ZGC几乎在所有地方都是并发执行的,除了初始标记是STW的。所有停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
虽然ZGC还在试验阶段,没有完成所有特性,但此时性能已经相当亮眼,用**“令人震惊、革命性”**来形容,都不为过。
未来将在服务端、大内存、低延迟应用的场景下首选垃圾收集器。
JDK14之前,ZGC仅在Linux才支持。
尽管许多使用ZGC的用户都使用类Linux的环境,但在Windows和macOS上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macOS 上。
现在mac或Windows上也能使用ZGC了,参数配置如下:
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
后续内容请看JVM垃圾回收机制
关注作者不迷路,持续更新高质量Java内容~
原创不易,您的支持/转发/点赞/评论是我更新的最大动力!