查看原文
其他

垃圾回收策略和算法,看这篇就够了

CSDN云计算 2020-12-18

The following article is from 程序员大帝 Author Craig无忌

作者 | Craig无忌
来源 | 程序员大帝(ID:kingcoding)


前言


回收,旧手机,旧冰箱,旧空调,旧洗衣机,电瓶车摩托车,自行车,报纸,塑料......
还记得小时候,我喝完的饮料瓶子都不会扔,每次都放到阳台。小区里听到收废品的吆喝,感觉带着这些瓶瓶罐罐冲下楼,换几块钱买雪糕,想想都是童年的回忆啊。
我一直都觉得骑个三轮车,回收废品的大爷特别酷,因为感觉他的车上面就像哆啦A梦的口袋,翻一翻什么都有。不过这些年,随着垃圾分类,感觉收废品的大爷也越来越少了。
回过头想,如果没有这些收废品的大爷,那我攒的瓶子也卖不了钱,家里阳台那么多瓶子还占地方。所以你大爷就是你大爷,主动过来帮你清理垃圾,还给你钱。
所以为什么 Java 越来越流行,除了说它一处安装,到处运行的机制以外。还因为程序员也越来越懒,跟 C/C++ 相比,Java 最适合懒人的便是引入了自动垃圾回收的机制,也就是Garage Collection(下文简称 GC )。
网上对于 Java 垃圾回收的介绍堪称冠冕堂皇:
让程序员专注于程序本身,不用关心内存回收这些恼人的问题,真正让程序员的生产力得到了释放,程序员不用感知到它的存在。
说这么多,不就是程序员懒么, Java 直接帮你把脏活累活都干了。就像咱们现在人都爱点外卖,为什么?因为不用自己动手,吃完也不用洗碗。还有你去餐盘吃饭,吃完就走,服务员会替你收拾好这些餐盘,你不会关心服务员什么时候来收,怎么收。
大家可能会说既然 Java 这么方便,已经帮我们完成了对垃圾的清理与回收,那 GC 方面的知识我不用了解好像也没事吧。但是人有失手,马有失蹄,假如突然有一天外卖小哥带着你的外卖小哥跑路了,你必须要亲自动手下厨,总不能饿死吧。
所以对于 GC,道理也是一样的,线上的服务不遇到问题还好,出现 Bug 或者想自己做一些性能调优的时候,就需要对 GC 有深入了解才可以,这也是成为一名优秀 Java 程序员的必修课!
今天就把 JVM GC 相关的知识详细介绍一下,本文将会从以下几个方面来讲述相关知识,文字较多,相信大家耐心看了之后肯定有收获,码字不易,别忘了「在看」,「转发」哦。
  • JVM 内存区域
  • 回收策略
  • 垃圾回收经典算法
  • 垃圾回收器对比

JVM 内存区域


我们首先要知道垃圾回收主要回收的是哪些数据,这些数据主要在哪一块区域,所以我们一起来看下 JVM 的内存区域。
JDK8以前
在JDK8之前的虚拟机,主要包含:
(1)堆
对象实例和数组都是在堆上分配的,GC 也主要对这两类数据进行回收,这里是 GC 发生的主要区域!
(2)方法区(永久代)
方法区在 JVM 中是一个非常重要的区域,它与堆一样是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
方法区是堆的一个逻辑部分,为了区分Java堆,它还有一个别名 Non-Heap(非堆)。相对而言,GC 对于这个区域的收集是很少出现的。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
随着动态类加载的情况越来越多,这块内存越来越不太可控。如果设置小了,当JVM加载的类信息容量超过了这个值,系统运行过程中就容易出现内存溢出 OOM:PermGen 的错误,设置大了又浪费内存。
(3)栈
栈是线程私有的,生命周期与线程相同,主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息。这块区域是不需要进行 GC 的。
(4)程序计数器
程序计数器也是线程私有的,它里面记录了下一次需要执行的行号,这块区域也不需要进行 GC。
(5)本地方法栈
本地方法栈主要为了虚拟机执行 Java 的本地方法( Native Method)时服务,这块区域也不需要进行 GC。
JDK8之后
JDK8 最大的变化就是对 JVM 内存空间进行了改造,主要的区别是将方法区进行了移除,并新增了元空间,元空间是放置在 JVM 内存空间之外的直接内存中,并且 JDK8 中对于方法区的参数 PermSize 和 MaxPermSize 已经失效。
上文咱们已经介绍过,JDK8 之前方法区放在 JVM 之中,但是随着动态类加载的情况越来越多,很容易因为大小的限制导致内存溢出 OOM:PermGen 的错误。
所以JDK8 之后把使用元空间替代了原来的方法区,在这种架构下,元空间就突破了原来-XX:MaxPermSize 的限制。这样就从一定程度上解决了原来在运行时生成大量类造成经常 Full GC 问题,如运行时使用反射、代理等,所以升级以后Java堆空间可能会增加。


垃圾回收策略


凡事都讲解个策略,那么 Java 怎么判断堆中的对象实例或数据是不是垃圾呢,应不应该把它回收掉呢?
引用计数法
第一种最简单粗暴的就是引用计数法。当对象被引用,程序计数器 +1,释放时候 -1,当为 0 时证明对象未被引用,可以回收。

但是这个算法有明显的缺陷,对于循环引用的情况下,循环引用的对象就不会被回收。例如下图:对象 A,对象 B 循环引用,没有其他的对象引用 A 和 B,则 A 和 B 都不会被回收。
可达性分析
第二种策略明显好的多,也就是所谓可达性分析法。它指的,通过一系列称之为“GC Roots” 的对象作为起点;从此起点向下搜索,所走过的路径称之为引用链,当一个对象到 GC Roots 没有任何引用链相连接,代表此对象不可达。

在 Java 可以作为GC Roots 的对象包括:
1、虚拟机栈(帧栈中的局部变量表)中的引用对象;
2、方法区中类静态属性引用的对象;
3、方法区中常量引用的对象;
4、本地方法栈中JNI (即一般说的 Native 方法) 的引用对象;
画外音:GC Roots有哪些这个问题经常在面试中被问到,大家一定牢记!


垃圾回收经典算法


知道了应该对哪些对象进行回收,那接下来就要看如何回收了,经典的垃圾回收算法有三种。
标记 - 清除算法

在gc时候,首先扫描时对需要清理的无用对象进行标记,然后将这些对象直接清理。
操作起来非常很简单,但仔细想想有什么问题呢?
没错,内存碎片!如上图,如果清理了两个 1kb 的对象,再添加一个 2kb 的对象,是无法放入这两个位置的。
怎么解决呢,如果能把这些碎片的内存连起来就可以了!
标记 - 整理算法

标记 - 整理算法就是在标记 - 清理算法的基础上,多加了一步整理的过程,把空闲的空间进行上移,从而解决了内存碎片的问题。
但是缺点也很明显:每进一次垃圾清除都要频繁地移动存活的对象,效率十分低下。
复制算法

复制算法是将空间一分为二,在清理时,将需要保留的对象复制到第二块区域上,复制的时候直接紧凑排列,然后把原来的一块区域清空。
不过复制算法的缺点也显而易见,本来 JVM 堆假设有 100M 内存,结果由于将空间一分为二,真正能用的变成只有 50M 了!这肯定是不能接受的!另外每次回收也要把存活对象移动到另一半,效率低下。
分代算法
分代收集算法整合了以上算法,综合了这些算法的优点,最大程度避免了它们的缺点。与其说它是算法,倒不是说它是一种策略,因为它是把上述几种算法整合在了一起,我们先从下图看看对象的生存规律。
由图可知,大部分的对象都很短命,一般来说,98% 的对象都是朝生夕死的,所以分代收集算法根据对象存活周期的不同将堆分成新生代和老生代。
新生代和老年代的默认比例为 1 : 2,新生代又分为 Eden 区, from Survivor 区(简称 S0 ),to Survivor 区(简称 S1 ),三者的比例为 8: 1 : 1。
根据新老生代的特点选择最合适的垃圾回收算法,我们把新生代发生的 GC 称为 Young GC(也叫 Minor GC ),老年代发生的 GC 称为 Old GC(也称为 Full GC )。
大多数情况下,对象在新生代 Eden区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 MinorGC;
Minor GC 非常频繁,一般回收速度也比较快;出现了 Full GC,经常会伴随至少一次的 Minor GC,Full GC 的速度一般会比 Minor GC 慢10倍以上。
    
整个过程大致分为以下几个步骤:
(1)当 Eden 满了后,进行 Minor GC,将需要保存的数据复制到 S0 中;
(2)然后清空 Eden 和 S1 区域,需要保留的对象目前在 S0 中;
(3)下一次当 Eden 满了后,进行Minor GC,将原来 S0 存在的数据复制到S1中,将 Eden 中需要保存的数据也复制到 S1 中;
(4)清空 Eden 和 S0 区域,需要保存的对象目前都在 S1 中;
(5)Eden+S0 复制到 S1;
(6)Eden+S1 复制到 S0;
(7)Eden+S0 复制到 S1;
周而复始...


垃圾回收器对比


前面的内容更多的是方法论,真正执行垃圾回收的要靠各个垃圾回收器。
Java虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器,主要有以下几种垃圾收集器。
Serial收集器
从名字看出这是一个单线程收集器。串行垃圾回收器在进行垃圾回收时,它会持有所有应用程序的线程,冻结所有应用程序线程,使用单个垃圾回收线程来进行垃圾回收工作。
它是 JDK1.3 之前新生代的回收器的唯一选择,在单线程的情况效果很好,因为单线程没有线程的切换的开销。但是在现在大部分都是多 CPU 的服务器,所以它现在被使用的很少了。
但是它还是 JVM 运行在 Client 模式下的默认垃圾收集器。因为一般桌面应用下新生代空间不是很大,使用这个垃圾回收器也可以保证回收的时间在 100 毫秒左右。
Serial-Old收集器
这个收集器就是 serial 收集器的老年版本,他同样还是单线程的垃圾回收器。它存在的主要意义的还是 JVM 运行在 client 模式下的默认老年代回收器跟 serial收集器一起使用,同样它还作为 CMS 垃圾回收器的后备垃圾回收器。

ParNew收集器
ParNew 垃圾收集器就是 serial 回收器的多线程版本,有很多的代码都是和 serial 收集器公用的。一个很重要的作用就是作为新生代的垃圾回收器跟 CMS垃圾回收器进行组合。但是在单核 CPU 的情况下,效率是没有 serial 垃圾回收器的效果好的。
可以通过-XX:UseConcMarkSweepGC 或者-XX:UseParNewGC 来指定使用它。默认情况它用于回收垃圾的线程的数目跟 CPU 的数目相同。可以通过-XX:parallelGCThreads 来指定使用的垃圾回收的线程的数目。

Parallel Scavenge收集器
与 ParNew 线程一样同样为多线程的垃圾回收器,但是这个垃圾回收器和其他回收器的关注点不同。其他的垃圾回收器是尽可能缩短垃圾回收时对用户线程的缩短时间。但是这个垃圾回收器关注的是一个吞吐量的概念。
吞吐量指的是运行用户代码的时间/(运行用户代码时间+垃圾回收时间)。缩短用户停顿时间对那些高交互性比如一些 web 项目看中的。而吞吐量是一些运行在后台的计算任务是看重的。
Parallel Old收集器
这个回收器是 Parallel scavenge 的老年代版本,经常和 Parallel scavenge 一起使用在对内存比较敏感和对吞吐量比较高的场合下使用,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6 中才开始提供

CMS收集器(划重点!!)
CMS( Concurrent Mark Sweep )收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 基于“标记-清除”算法实现的,整个过程分为 4 个步骤,包括:
(1)初始标记:只标记根节点直接关联的引用对象,需要暂停用户线程(时间短);
(2)并发标记:标记其他引用对象,可以跟用户线程并发同时执行;
(3)重新标记:暂停用户线程,对并发标记期间新增加的引用关系变化再次标记(时间短);
(4)并发清除:跟用户线程并发进行。
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发地执行。
CMS 收集器已经在很大程度上减少了用户线程的停顿时间,但是他也存在下面三个主要的缺点:
(1)跟用户线程竞争资源
CMS 默认的并发线程数目为(CPU 数目+3)/4,当 CPU 线程大于 4 的时候,CMS 垃圾收集器至少要占用 25% 的资源。当小于 4 的时候占用 CPU 资源更加明显。
(2)无法清除浮动垃圾
当收集器在进行并发清除垃圾的时候,由于用户线程还在执行,要预留一定的空间给用户线程进行使用,所以收集器一定不能在老年代已经占用 100% 的情况下再进行垃圾收集。
(3)内存碎片
因为这个垃圾回收器是使用的标记-清除算法,所以会产生大量的内存碎片。
有两个值可以进行控制:
-XX:UseCMSCompactAtFullCollection 默认开启,来指定需要 FULL GC 时,会对内存空间进行一次整理。
-XX:CMSFullGCsBeforeCompaction 来指定多少次不整理之后进行一次整理。

G1收集器(划重点!!)
G1 是目前技术发展的最前沿成果之一,HotSpot 开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS 收集器相比 G1 收集器有以下特点:
(1)空间整合:G1 收集器采用标记-整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次 GC。
(2)可预测停顿:这是 G1 的另一大优势,降低停顿时间是 G1 和 CMS 的共同关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为 N 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分可以不连续 Region 的集合。

本文介绍了 JVM 垃圾回收的原理与垃圾收集器的种类,相信看到这里的各位人才应该对相关知识有了更深刻的认识。
理论有了,接下来我会持续更新相关内容,介绍下真实场景下如何对 JVM 进行调优以及故障排查。

更多推荐阅读

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存