理解JVM垃圾回收的机制

前言

前面说过JVM虚拟机的核心组件有三个:

(1)类加载系统

(2)运行时数据区

(3)执行引擎(重点是GC部分)

其中(1)和(2)我们在之前已经介绍过了,今天我们来学习一下关于JVM垃圾回收(Garbage Collection)的内容:

Java语言其实屏蔽了内存的动态分配和垃圾回收的底层细节,而C语言里面则完全由开发者控制如何申请和回收内存,自动管理内存的好处就是,由于JVM决定如何分配内存和如何回收分配的内存。

如何判断哪些对象需要回收?

(一)引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用时就加1,当引用失效时就减1,任何时候计数器为0的对象就是不可能再使用的。思想和实现都比较简单,效率也比较高,在大部分情况下都不错,但Java语言却没有选择它来管理内存,最大的原因就是因为它比较难解决对象之间相互引用的问题。

如下:

public class Demo {

    private Object instance;

    public static void main(String[] args) {
        Demo a=new Demo();
        Demo b=new Demo();
        a.instance=b;
        b.instance=a;

        a=null;
        b=null
    }

}

上面的代码,只有这两个对象之间存在引用,除此之外再也没有其他的引用,导致他们的计数器不为0,以至于在引用计数器算法下没有办法进行回收。

(二)根搜索算法

根搜索算法(GC Roots Tracing)的基本思路就是通过一系列名为 “ GC Roots ”的对象为起点,然后开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(在图里面称为路径)时,则证明此对象是不可达的。

面的图里面只有到GC Roots节点有路径可达的时候,证明该对象还在使用,其他的都可以被回收掉。

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

(1)虚拟机栈(也就是方法里面的local变量)引用的对象

(2)方法区中的类静态属性引用的对象

(3)方法区中常量引用的对象

(4)活动的线程对象

(三)Java里面引用的种类

一个对象如果只有引用和非引用两种状态,那么可能有点太生硬,对于一些可有可无的对象就没法描述。比如缓存里面的对象,所以在JDK1.2之后对引用的概念进行了扩充,分别四种:

(1)强引用(Strong Reference) 通过new实例化的对象

(2)软引用(Soft Reference)在内存即将发生溢出前,会把这些对象回收

(3)弱引用(Weak Reference)在下一次垃圾收集发生之前,会被回收

(4)虚引用(Phantom Reference)一个对象是否有虚引用的存在,不会对其生存时间构成影响,也无法通过 虚引用来取得一个对象实例,设置虚引用的目的就是希望这个对象被收集器回收时收到一个系统通知

(四)对象的自救

宣布一个对象死亡时,至少要经历两次标记过程,触发GC之前会执行finalize方法,如果我们重写了这个方法,在这里又重新引用一个对象,那么就不会被回收,但这么做没有什么意义,借用深入理解虚拟机的作者的话,大家可以完全忘掉这个方法的存在,绝大多数是用不到的。

(五)关于方法区

方法区一般是永久代,JVM规范上也不要求回收这个区域的数据,因为性价比太低,主要是一些废弃常量和无用的类。但是如果存储的数据大于了方法区的大小,这个区域依然是会报内存溢出异常的。

垃圾回收算法

(1)标记-清除

先标记出所有需要回收的对象,在标记完成后统一回收掉被标记的对象。这是垃圾回收算法的基础,后面的几种基本都是对这种思路的优化,这种算法主要问题是:首先效率一般,此外清除之后存在大量的不连续的内存碎片,空间碎片如果太多,可能再下一次申请一个大的对象时而无法分配到可容纳的内存,就会触发另一次垃圾收集动作。

(2)标记-复制

为了解决效率问题,另外一种基于复制的思想就出现了,它将可用内存分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就存活着的对象复制到另外一块上面,然后在把前一块已经使用过的内存空间一次清理掉,这样使得每次都是对其中的一块进行内存回收,而且分配时也不用考虑内存碎片等复杂情况,只需要移动堆顶指针按顺序分配内存即可,实现简单,运行高效,唯一的缺点是内存利用率变为原来的一半。因为这个问题,所以适合用在新生代内存,降低内存的浪费情况。

在CMS垃圾收集器中,新生代里面分为一个Eden区和两个survivor区,默认Eden与survivor区的占比是8:1:1,也就是说新生代中,内存利用的有效率为80%+10%=90%,仅有10%是浪费掉的。当然并不是每次存活的对象会低于10%,如果大于10%,那么这些对象就会通过分配担保机制进入老年代。在经历一次新生代GC后,后入新到来的对象如果eden区能够容纳,仍然会放在新生代中。

(3)标记-整理

复制收集算法在对象存活率较高的情况,需要执行很多次复制操作,效率将会变低,再一点内存浪费有点严重,所以老年代一般不能使用这种算法。所以就诞生出来了在(1)的基础上优化的算法,与标记-清除一样,但后续不是直接对可回收对象进行清除,而是将所有存活的对象都向一端移动,然后直接清理掉端末的内存即可。

(4)分代收集

当前商业的虚拟机的垃圾收集采用的都是分带收集算法,这种算法没有什么新思想,只是根据对象存活周期的不同将内存划为几块,一般将JVM分为新年代和老年代,新生代对象生命周期短就采用复制算,只需要付出少量存活对象的复制成本就可以完成收集,而老年代对象存活率高且没有额外的空间进行分配担保,所以必须使用标记-清除或者标记整理算法来回收。

垃圾收集器

垃圾收集器就是垃圾回收算法的具体实现

(1)Serial收集器:单线程串行收集,在工作时候会执行STW(Stop The World)动作直到收集完毕,一般用在虚拟机运行在Client模式下的默认新生代收集器。

(2)ParNew其实是Serial收集器的多线程版本,收集算法,STW,对象分配规则,回收策略都一样

(3) Parallel Scavenge是一个新生代的收集器,使用的是复制算法的并行收集器

(4)Serial-Old是Serial的老年代版本,同样是单线程,但采用的是 标记-整理算法。有两个用途在JDK5之前与Parallel Scavenge收集器搭配使用;另外在CMS并发收集发生Concurrent Mode Failure时作为CMS收集器的后备预案。

(5)Parallel Old是Parallel Scavenge的老年代版本,使用多线程和标记整理算法,偏向于吞吐量及CPU资源敏感的场景下。

(6)CMS(Concurrent Mark Sweep)收集器是倾向于响应速度,适合于用在B/S系统的服务器上,从名字上能看出这种算法是基于标记-清除算法实现的,但是也有参数控制是否在清除阶段后整理内存碎片。 其收集分4个步骤:

初始标记->并发标记->重新标记->并发清除

其中初始标记和重新标记这两个步骤仍然需要STW,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记就是GC Tracing的过程,而重新标记是为了修正并发标记期间,用户程序继续运行导致部分发生变动的对象记录。感觉有点像StampLock锁里面乐观读的意思,整个过程耗时最长的在并发标记和并发清除的过程,收集线程可以与用户线程一起工作,总的来说回收过程是并发的。

CMS的缺点:

6.1 CMS收集器对CPU敏感,回收过程中可能会抢占用户线程的资源

6.2 CMS收集器无法处理浮动垃圾,可能会导致Concurrent Mode Failure失败而导致另一次Full GC发生。因为收集过程是并发的,在标记之后,新产生的垃圾,CMS无法在本次处理掉他们,只好等下一次GC时清理。由于用户线程和垃圾收集线程并发执行,所以CMS还需要给用户线程预留一部分内存使用,所以不会像其他的收集器一样,等到老年代满了才触发GC,默认情况下当使用空间超过68%就会被激活,这个可以通过参数控制,如果预留的内存无法满足需要,就会出现一次Concurrent Mode Failure失败,这个时候虚拟机启动预备方案,临时启动Serial Old来重新进行老年代的垃圾收集,这样停顿时间就长了。所以这个参数尽量不要修改。

6.3 因为是CMS主要基于标记-清除算法,所以在收集结束时会产生大量空间碎片,如果碎片太多,将会给大对象的分配带来很大麻烦,如果分配不了,则会再次出发Full GC,所以CMS提供了一个参数可控制多久一次内存整理,当然这个过程是需要STW的。

(7)G1收集器是在JDK1.7中正式发布的,相比CMS有两个大的改进:

第一G1收集器是基于标记-整理算法实现的,也就说不会产生内存碎片。

第二可以比较精确的控制停顿时间,其主要原因是G1算法,将Java堆切成了很多个 大小固定的区域,并跟踪这些区域里面的垃圾堆积程序,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域,区域划分优先度之后,就能保证其在有限时间内获得最大的收集效率。如果区域放不下单个大对象的时候,就会合并多个区域,有可能引发Full GC。

对象分配机制

(1)大多是时候,新对象优先在新生代Eden分配,如果这个空间满了,那么就会触发一次minor gc,我们通过-XX:+PrintGCDetail这个收集器参数可以打印gc日志。

(2)如果是一个大对象(需要大量连续的内存空间)通常指的是数组,可以通过参数控制超过多少,直接进入老年代而不需要经过新生代。

(3)长期存活在新生代的对象,如果age超过了指定的计数器,默认是经过15次Ygc,就会晋升到老年代。

(4)动态对象年龄判定,虚拟机并不总是要求对象的年龄到达15才回收,如果survivor空间中相同年龄所有对象的大小的总和大于survivor空间的一般,年龄大于等于该年龄对象也可以直接进入老年大。无须等到默认的15次。

(5)空间分配担保

在发生Minor GC之前,虚拟机会检测老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则认为进行Minor GC是安全的,如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么继续坚持老年代的最大可用连续空间是否大于以前晋升到老年代对象的平均大小,如果大于,则安全的执行minor gc,但这是有风险的,因为平均值不能代表突变的峰值,如果不允许,那就执行一次FGC回收空间。

总结

本文主要介绍了垃圾收集的思想,算法和Java里面存在的垃圾收集器的分类及特点,GC的话题对于我们日常开发中比较重要,如果想要系统的学习JVM相关是知识,推荐大家读周志明前辈的《深入理解Java虚拟机》一书,非常不错。

原文发布于微信公众号 - 我是攻城师(woshigcs)

原文发表时间:2018-09-27

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏陈树义

JVM技术交流分享 · 第1期

JVM技术每周分享整理了JVM技术交流群每周讨论的内容,由群内成员整理归纳而成。如果你有兴趣入群讨论,请关注「Java技术精选」公众号,通过右下角菜单「入群交流...

1423
来自专栏蘑菇先生的技术笔记

日志系统实战(二)-AOP动态获取运行时数据

2034
来自专栏鸿的学习笔记

聊聊一些垃圾回收算法

不是所有的GC都是完美的,每一个GC算法的选用都有其背后的原因。而我们选择GC算法,有四个评价标准:吞吐量(也就是说,在单位时间内你回收的对象(这里是指通过应用...

762
来自专栏落影的专栏

iOS开发笔记(一)

前言 iOS开发笔记(一) iOS开发笔记(二) iOS开发笔记(三) iOS开发笔记(四) 《开发笔记》系列记录一些开发中遇到的问题以及思考。 本文主...

3277
来自专栏HansBug's Lab

1590: [Usaco2008 Dec]Secret Message 秘密信息

1590: [Usaco2008 Dec]Secret Message 秘密信息 Time Limit: 5 Sec  Memory Limit: 32 MB ...

3586
来自专栏Android开发实战

Android避免内存溢出(Out of Memory)

强引用:强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。 当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误...

1163
来自专栏前端进阶之路

聊聊V8引擎的垃圾回收

我们知道,JavaScript之所以能在浏览器环境和NodeJS环境运行,都是因为有V8引擎在幕后保驾护航。从编译、内存分配、运行以及垃圾回收等整个过程,都离不...

1292
来自专栏Java Web

Java 面试知识点解析(三)——JVM篇

在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Jav...

4327
来自专栏数据结构与算法

家谱树

【问题描述】     有个人的家族很大,辈分关系很混乱,请你帮整理一下这种关系。     给出每个人的孩子的信息。     输出一个序列,使得每个人的后辈都比那...

3184
来自专栏jeremy的技术点滴

golang语言常见范式

4074

扫码关注云+社区

领取腾讯云代金券