美文网首页Java虚拟机
Java虚拟机(四):垃圾回收

Java虚拟机(四):垃圾回收

作者: yeonon | 来源:发表于2018-12-09 16:56 被阅读76次

    1 什么是垃圾回收

    Java包含了自动内存管理机制,使得我们不用像C/C++那样为每个malloc/new都配对一个free/delete操作。当代码复杂的时候,会非常容易遗漏free/delete操作,这样会使得内存无法被释放,无用的对象会占据内存使得内存不可用,久而久之,新创建的对象将无处可放,程序也会随着崩溃,更严重可能导致主机崩溃。

    Java提供了垃圾回收机制帮助程序员释放无用内存,将程序员从繁复的手动释放内存中解放出来。垃圾回收机制主要包括四个大部分:

    • 无用对象判定
    • 垃圾回收算法
    • 垃圾收集器
    • 内存分配和回收策略

    下面我们将详细介绍这四个部分。

    2 无用对象判定

    堆里存放着几乎所有Java程序产生的对象,有些对象还有用,但有些对象已经无用了,所以应该被垃圾收集器回收掉。但在垃圾回收之前,需要知道具体的哪些对象已经无用了,才能进行精准回收。现在主流的两种判断对象是否无用的方式是:

    • 引用计数法
    • 可达性分析

    2.1 引用计数法

    引用计数法是一种非常简单、直观的算法。该算法是这样描述的:给对象添加一个引用计数器,没当有一个地方引用该对象时,引用计数器+1,当引用失效时,引用计数器-1,任何时刻引用计数器为0的对象就是无用的对象。这种方式非常简单,很容易理解,但是缺陷也比较明显。举个例子,如果A对象里的一个引用指向B对象,B对象里的一个引用指向A对象,当A对象和B对象的引用失效时,根据引用计数法就不会得出A对象和B对象是无用对象,如下代码所示:

    public class RCTest {
        public Object instance = null;
        
        public static void main(String[] agrs) {
            RCTest rc1 = new RCTest();
            RCTest rc2 = new RCTest();
            rc1.instance = rc2;      //rc1里有引用指向rc2所指向的对象
            rc2.instance = rc1;     //rc2里有引用指向rc1所指向的对象
            
            rc1 = null;     //使得rc1引用失效
            rc2 = null;     //使得rc2引用失效
        }
    }
    

    在这里,rc1和rc2引用被置为null,即代表这俩引用失效了,它们不再指向任何对象,但它们的instance引用仍然指向对象,没有失效。这就导致rc1和rc2所指向的对象仍然有引用,换句话说,这俩对象的引用计数器的值都还是1,根据引用计数算法,这种情况不会判断为无用对象。实际上,我们已经无法访问对象了,因为引用已经失效了,我们相当于失去了遥控器,但对象仍然占据着内存,无法被释放,最终会导致内存越用越少,直至无内存可用。

    Java并没有采用这种方式(Python采用这种方式,C++的智能指针shared_ptr也采用这种方式),而是用的另一种方法:可达性分析

    2.2 可达性分析

    这个算法的思想是这样的:通过一系列的“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索所经过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链的时候,就证明该对象是无用的。如下图所示:

    iuNNDI.png

    图中object5、object6、object7都没有引用链能到达GC Roots,故他们被判定为无用对象。

    在Java中,可作为GC Roots的对象包括下面几种:

    • 栈中引用的对象,包括虚拟机栈和本地方法栈。
    • 方法区中静态成员和常量引用的对象。

    2.4 枚举根节点

    现在我们已经知道了什么是可达性分析,也知道了“GC Roots”的概念。那在HotSpot中是如何实现该算法的呢?最简单的方法就是遍历方法中的每个参数,检查其引用,我们知道方法区有时候会达到数百兆,如果粗暴的遍历会使得执行效率非常低下。

    注意,方法里的参数不全都是引用,也可以包含基本数据类型,而基本数据类型不可以作为GC Roots的,所以粗暴的遍历检查会导致做很多无用功,平白浪费时间。

    HotSpot虚拟机没有直接使用这种方法,而是使用了一组称组“OopMap”的数据结构来保存这些引用,当垃圾回收开始的时候,垃圾回收器就可以直接得知这些引用所在的位置,避免了遍历。

    上面的方法其实就是常说的“空间换时间”。但如果为每条指令都生成一个OopMap,那将会消耗大量的空间,这样GC的空间成本会变得很高。实际上,HotSpot确实也没有为每条指令都生成OopMap,只是在“特定的位置”记录信息而已,这些“特定的位置”被称作“安全点”,程序执行时并非在所有地方都能停顿并执行GC,而是在到达安全点时在停顿并执行GC。这样就能减少OopMap占用的内存空间了。

    除了安全点,其实还有“安全区域”这个概念,其实本质上来说只是比安全点的范围更宽了,之所以需要这个更宽的范围,是因为当发生停顿的时候,有些线程处于阻塞或者睡眠状态,那这些线程是不可能走到安全点的,使用安全区域可以解决这个问题。只要这些线程在安全区域即可,不需要它们走到安全点,就可以开始执行GC了。

    关于安全点和安全区域的更多内存建议看看《深入理解Java虚拟机》的3.4节内容。

    3 垃圾回收算法

    垃圾回收算法是由具体的虚拟机自行实现的,也就是说各种虚拟机的具体实现可能大相径庭,而且复杂。故本节讨论的算法只涉及算法思想,不涉及具体的细节,想知道具体细节可以去看看虚拟机源码。

    3.1 标记 — 清除算法

    该算法分为两个阶段:标记和清除。首先标记出需要回收的对象,在标记完成之后,统一回收被标记的对象。回收过程参见下图:

    iuN2bq.png

    算法很简单,缺陷也比较明显。最主要的缺陷是效率和空间问题。导致效率问题的原因是标记和清除的效率都不高,导致空间问题的原因是会造成内存碎片,内存碎片太多使得没有足够的连续内存存储较大的对象,这样就不得不提前触发另一次垃圾回收。

    3.2 标记 — 整理算法

    标记 — 整理较标记 — 清除不同的是多了一个整理的过程。算法思想大致是这样的:标记阶段对无用对象进行标记,标记完成之后,将存活的东西移动到内存的一端,然后直接清理边界外的内存区域。回收过程参见下图:

    iuNTxJ.png

    该算法的效率和标记 — 清除差不多,在空间上没有内存碎片。但整理的过程会比较费时,所以整体效率也不算太理想,一般用在老年代。

    3.3 复制算法

    复制算法的思想大致是这样的:它将内存按容量划分成大小相等的两块,每次只使用其中一块,当触发垃圾回收时,会将存活的对象复制到另一块内存,然后清理掉当前块的内存。这种算法每次都直接对整个半区进行垃圾回收,不会有内存碎片,分配空间的时候也只需要移动堆顶指针,顺序分配即可,实现简单,运行高效。缺点是将内存分为了两块,换句话说能使用的内存只是整个内存的一半,太浪费了。HotSpot虚拟机对整个比例做了调整,不再按照1:1划分,而是将内存分为一个Eden区和两个Survivor区,默认的比例是8:1:1。当给对象分配空间时,会在Eden区和其中一块Survivor区中分配,当进行垃圾回收时,会将Eden区和一块Survivor区存活的对象复制到另一块Survivor区,然后对Eden区和Survivor区进行清理,这样的内存使用率将使90%,比之前的50%高了很多。

    但随着而来的一个问题就是:如果Survivor区无法容纳存活的对象时,该怎么办?这时候就要进行“分配担保”了。简单来说就是去其他地方“借”内存(一般来说会是到老年代借),这时候多出来的对象会直接分配在老年代了。

    复制算法的过程如下图所示:

    iuNxPO.png

    3.4 分代收集算法

    严格来说,这不能算是一种新算法,只是将上述讲到的算法组合起来使用。Java一般会把堆内存分为老年代和新生代,新生代的对象大多数存活时间很短的,只有少数对象能挺过垃圾回收,所以采用复制算法。老年代的对象存活率高,而且没有其他内存可以“借”,故只能使用基于标记的两种算法了。

    4 垃圾收集器

    垃圾回收算法是内存回收的方法论,垃圾收集器则内存回收的具体实现。Java虚拟机规范没有对垃圾收集器如何实现做任何规定,因此各个厂商的虚拟机实现的垃圾收集器很可能大相径庭。我们接下来要讨论的是一些垃圾收集器也仅仅涉及到其思想、回收过程,不会涉及到具体实现细节。这些收集器各有优劣,并不存在哪种收集器绝对好,也不存在哪种收集器绝对坏的情况,我们应该知道“永远没有最好的解决方案,只有最适合的解决方案”。

    4.1 Serial收集器

    Serial是最基本的垃圾收集器,它是一个单线程收集器,目标是新生代的内存区域。这个收集器非常简单(只是相较于其他收集器,它本身也算是比较复杂的),当执行GC的时候会停止所有工作线程,然后开启一个GC线程去进行垃圾回收操作,因为垃圾回收操作本身是一项比较耗时的操作,所以停顿的时间会比较长。停顿时间长会造成用户体验差,试想一下系统每运行10分钟就要停顿1分钟,用户不抓狂才怪呢!

    Serial并不是一无是处的,其优点是简单高效,没有线程上下文切换的开销,(说到这,我想起linus喷过多线程既带来了复杂度,对性能有没有什么提升),必须适合一些比较小的系统。下图是Serial的运行流程。

    iudjI0.png

    注意,垃圾收集器并不局限于新生代或者老年代,只是我们经常将某种收集器放到新生代或者老年代而已。例如上图中Serial既可用在老年代也可以用在新生代,但我们在文中描述的Serial是作用于新生代的,这仅仅是因为Serial比较适合新生代而已。

    4.2 ParNew收集器

    ParNew其实是Serial的多线程版本,相比Serial只是使用了多线程而已,其他的包括控制参数,对象分配规则等都和Serial一样,其流程图如下所示:

    iuwCM4.png

    除了多线程这个特性之外,使用ParNew的目的还有一个就是和CMS配合,ParNew用于新生代,CMS用于老年代,主要原因是CMS不能与Paraller Scavenge配合工作,在JDK1.6之前,CMS就只能和ParNew配合工作。

    ParNew虽然是多线程的,但是其实效率真不一定就比Serial高,因为多线程环境下存在线程上下文切换的开销,这个开销其实是很大的,所以Linus要喷多线程这个东西。

    4.3 Parallel Scavenge 收集器

    Parallel Scavenge 在JDK1.4之后发布,是一个新生代收集器,采用的是复制算法,同时也是多线程的,这看起来和ParNew没有太大区别。但实际上,他是一个关注吞吐量的收集器,而其他的收集器例如CMS主要关注的是停顿时间。

    吞吐量这个概念在很多地方都会遇到,不同的场景其定义式可能不一样,例如在网络传输中指的是单位时间内成功传递的数据量,在垃圾回收这个场景,吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集时间),尽管描述不一样,但本质是一样,都是描述系统对输入做出输出的速度,所以其单位通常为xxx/s或者xxx/min。

    Parallel Scavenge有两个重要的虚拟机参数,一个是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis和控制吞吐量大小的-XX:GCTimeRatio。其中最大收集停顿时间的单位是毫秒,我们可以设置任意一个大于0的值(不能为0),收集器将尽力保证停顿时间不超过这个值,但最好不要设置得太小,因为GC停顿时间的缩短是通过缩小新生代来完成的,新生代缩小了,垃圾回收的时间肯定也就少了,反之,GC次数就增多了,而且还很有可能导致Full GC,最终导致性能不增反降。控制吞吐量的这个参数值应该是一个大于0小于100的值,默认值是99,即表示仅仅有1%的时间是垃圾回收的时间。

    除了上述两个参数之外,还有一个-XX:+UseAdaptiveSizePolicy,这个参数是一个开关,打开这个参数就不需要手动设置新生代中Eden区和Suvivor区的比例,以及晋升老年代的年龄等细节信息了,虚拟机会根据系统运行情况来对这些参数做调整,这种调整方式就称作“GC自适应”。如果对虚拟机不是很了解的话,使用这个开关会是一个不错的选择。

    4.4 Serial Old收集器

    Serial Old收集器就是Serial的老年代版本,都是单线程的,因为在老年代,所以使用的是“标记-整理”算法,这个收集器的用途不多,主要用在三个地方,一是Client模式下的虚拟机使用,二是在JDK1.5之前配合Parallel Scavenge使用(CMS收集器不能配合Parallel Scavenge使用),三是作为CMS收集失败的后备方案。

    4.5 Parallel Old收集器

    Parallel Old是Parallel Scavenge的老年代版本,主要用途是和Parallel Scavenge配合使用,因为在此之前能和Parallel Scavenge配合使用的似乎只有 Serial Old了,整体效果不是很好。在Parallel Old推出之后,“吞吐量优先”的收集器组合才算是名副其实。如果系统比较注重吞吐量,例如后端服务器场景,那么可以尝试选择这套组合。

    4.6 CMS收集器

    CMS即Concurrent Mark Sweep,翻译过来大概就是并发标记清除。可见CMS收集器是基于“标记-清除”算法实现的,它的执行过程比之前的几个收集器都要复杂一些,主要分为4个阶段:

    • 初始标记
    • 并发标记
    • 重新标记
    • 并发清除

    初始标记只是简单标记一下GC Roots能关联到的对象,耗时较少,会有GC停顿。并发标记主要进行的是GC Roots追踪,这个阶段没有GC停顿,工作线程和GC线程并发执行。重新标记阶段会对刚刚并发标记期间发生因为程序运行导致引用关系发生变化的地方进行修正,这个阶段会有GC停顿,耗时较初始标记会多一些,但远比并发标记少。并发清除即进行清除操作,这个过程是并发的过程,工作线程和GC线程并发执行。CMS执行过程如下所示:

    iusXdJ.png

    CMS的优缺点都比较明显,优点是停顿时间短。缺点主要有三个:

    1. 对CPU资源敏感。实际上,并发程序都会对CPU比较敏感,当CPU较少的时候,CMS对程序的影响可能会比较大。
    2. 会产生浮动垃圾。因为清除阶段是并发执行的,尽管减少了GC停顿时间,但这样会导致“一边清理,一边产生”的情况,在此阶段产生的垃圾只能留待下次GC才能回收了,所以CMS进行垃圾回收时要预留一些内存给用户线程使用,而不能像其他收集器一样等到快填满老年代的时候才进行回收。如果在CMS进行垃圾回收期间,预留的内存不足以给用户线程使用,那么就会发生CMS失败,此时会启动备案方案,使用Serial Old收集器来进行收集。
    3. 会有内存碎片。因为CMS是基于“标记-清除”算法实现的,之前有提到过该算法会产生内存碎片,当内存碎片多到没有任何一块连续的内存存储较大的对象时,会发生Full GC,Full GC对性能的影响是非常大的。因此,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,打开该开关的话,当CMS将要发生Full GC的时候会开启内存合并整理,尽量避免Full GC。

    关于CMS更多的内容建议看看《深入理解Java虚拟机》3.5.6节,书上讲的比较清楚。

    4.6 G1收集器

    G1收集器在JDK9.0中正式投入使用,作为默认的垃圾收集器,G1收集器和其他收集器最大的不同是G1的收集范围不是整个新生代或者老年代,而是将堆划分成了多个相同大小的独立区域(Region),但分代的思想仍然存在。

    G1收集器的执行过程大致下面4个步骤:

    • 初始标记
    • 并发标记
    • 最终标记
    • 筛选回收

    其中初始标记和并发标记和CMS差不多,最终标记会对并发标记阶段引用发生改变的情况进行修正,会有GC停顿,筛选回收会对各个Region进行价值评估,根据用户期望的GC停顿时间来执行回收计划。其回收流程如下所示:

    iusXdJ.png

    上面提到,G1收集器的收集范围不再是整个新生代和老年代,那其内存布局是怎样的呢?下图可以直观的看到:

    iuyXX8.png

    每一个格子就是一个Region,格子有四种类型,Eden,Survivor,Old和Humongous。前面三种大家应该都很熟悉,那Humongous是个什么东西呢?如果一个对象大于Region的50%时,G1会认为这是一个巨型对象,默认会直接将其放入老年代(Old区域),但如果该巨型对象存活时间很短,直接放入老年代会对垃圾收集任务造成不好的影响,所以G1就搞了一个Humongous区来专门存储这类对象,当一个Humongous不够的时候,G1会找到连续的多个Humongous区域来存放该对象,如果实在找不到这样的连续内存,就不得不启动Full GC了。

    关于G1收集器的详细内容,还是建议看看书上的介绍,这里只是简单的概括了一下。

    5 内存分配和回收策略

    对象的内存分配主要就是堆上进行分配,如果开启本地线程缓冲,将按照线程先将对象分配在TLAB上,不过最终都会分配到堆中。细分的话,大部分会先在Eden区域分配,少数会直接进入老年代,具体的会在下面讨论。

    下面的讨论是基于JDK1.8之前的版本,因为JDK9.0默认使用G1收集器,内存分配和回收策略和其他不太一样。

    5.1 对象优先分配在Eden区

    大多数情况下,对象会先在Eden去进行分配,当Eden区域内存不足时,会进行一次Minor GC,这次GC会将Eden区域存活的对象复制到一个Survivor区,如果Survivor区不足以存储这些对象,将使用内存担保机制,将多出来的部分对象直接放入老年代。

    5.2 大对象直接进入老年代

    大对象是很可怕的,经常出现大对象会导致提前触发GC来找出大块连续内存存储这些大对象,更严重的可能会触发Full GC。虚拟机提供了一个参数-XX:PretenureSizeThreshold,这个参数的值用来代表直接进入老年代的对象大小的阈值,即当对象的大小超过这个值的时候,将直接存入老年代,这样做的目的是防止Eden区和Survivor区的大量复制。

    5.3 长期存活的对象将进入老年代

    有些对象的存活时间比较长,属于常驻内存的对象。虚拟机给每个对象设置了一个年龄计数器,每当对象经历一次GC,年龄计数器就+1,当对象的年龄大于某个阈值(默认是15)的时候就将该对象分配到老年代,这个阈值可以通过-XX:MaxTenuringThreshold设置。这样做的想法是:既然该对象能经历那么多次GC,说明该对象是一个常驻内存的对象,未来可能(仅仅是可能)也能“熬”过多次GC,那么总放在新生代里会影响新生代GC的效率,所以将其放入老年代会比较好。

    5.4 动态对象年龄判断

    刚刚说过,对象进入老年代的条件是年龄达到某个阈值,但实际上,虚拟机不总是要求对象必须达到阈值才能晋升到老年代,如果在Survivor区域里年龄相等的对象大小大于Survivor区的一半时,称这个年龄为A,那些年龄大于A的对象将晋升到老年代。这样做的目的是,防止大量年轻的对象在Survivor区堆积,最终导致内存担保,实际上内存担保是一种不得已的操作,如果能不使用就不要使用。

    5.5 内存担保

    内存担保也不是总能成功的。在发生Minor GC之前,虚拟机会先检查老年的可用空间是否大于新生代所有对象的大小综合,如果条件成立,那么此次Minor GC是安全的,即使Survivor区被填满,内存担保也会成功。如果不成立,虚拟机会查看HandlePromotionFailure值来查看是否允许担保失败,如果允许,那么会继续检查老年代可用空间是否大于历次晋升到老年代的对象的平均大小,如果条件成立,将尝试一次冒险的Minor GC(因为内存担保可能会失败),如果不成立,将不会发生Minor GC,而是改成Full GC。

    这个策略稍微有点复杂,但是逻辑清晰的话,应该不难理解,主要就是看老年代的可用空间大小是否足够。

    6 小结

    本文从无用对象判定开始,讲到垃圾回收算法,垃圾收集器,以及内存分配和回收策略。但只是做了一些简单介绍,没有涉及到细节,关于更多的细节,推荐看看相关书籍或者文章。

    7 参考资料

    《深入理解Java虚拟机》

    相关文章

      网友评论

        本文标题:Java虚拟机(四):垃圾回收

        本文链接:https://www.haomeiwen.com/subject/pclkhqtx.html