资讯专栏INFORMATION COLUMN

jvm垃圾回收三部曲

wanghui / 3190人阅读

摘要:强引用中最常见的引用,引用计数算法的就是典型的强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

概述

早在半个世纪以前,第一个使用了内存动态分配和垃圾收集技术的语言Lisp就已经诞生了,从那时,人们就在思考关于gc需要完成的三件事请:

哪些内存需要回收

什么时候回收

如何回收

直到今天已经有越来越多的语言开始内置内存动态分配和垃圾收集技术。经过长时间的发展,这些技术已经相当成熟,一切都看起来已经进入“自动化”,那为什么我们还要去学习gc和内存分配呢?当我们需要去拍查内存溢出和内存泄露时,当垃圾收集成为系统达到高并发量的瓶颈时,我们就需要揭开这些“自动化”技术的内幕,去实施必要的监控和调节。

上篇讲到jvm运行时内存区域主要包括这么几部分区域:

程序计数器

虚拟机栈

本地方法栈

堆内存

方法区

其中程序计数器,虚拟机栈和本地方法栈都会随着线程而生,随着线程而灭,正常情况下不会出现内存溢出和泄露的问题,无需对这块区域多做关心。后文讨论的内存区域都是堆内存或方法区。

对象已死吗

在堆里几乎放着java世界里所有的对象实例,垃圾收集器对齐进行回收的第一件事就是要判断需要回收哪些对象,哪些对象已死(也就是哪些对象已经不可能用到了,但还是存在于堆内存当中)。

引用计数算法
引用计数算法定义很简单,给对象添加一个引用计数器,每当有一个地方引用它时,就进行+1,每当有一个地方失效时,就进行-1。任何时刻当计数器为0时的对象都是不可能再被使用的。这种算法实现简单,判定效率也很高,是一个很不错的算法,也有一些非常著名的案例,例如微软公司的COM技术,Python语言和在游戏脚本领域应用非常广泛的Squirrel都使用了引用计数算法来管理,但至少目前为主流的java商用虚拟机没有选用其来管理内存,其根本原因是它很难解决对象之间互相引用的问题,看下面一个小例子:

    /**
     * vmargs:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xms20m -Xmx20m
     */
    public class Main {
    
        static class ReferenceCount {
            private Object object;
            //大对象,用来感知gc是否被回收
            private byte[] bigObject = new byte[1024 * 1024 * 2];
        }
    
        public static void main(String[] args) {
    
            ReferenceCount referenceCountA = new ReferenceCount();//对象A被引用1次
            ReferenceCount referenceCountB = new ReferenceCount();//对象B被引用1次
    
            referenceCountA.object=referenceCountB;//对象B被引用两次
            referenceCountB.object=referenceCountA;//对象A被引用两次
            //引用失效-1,对象A和对象B均被引用1次
            referenceCountA=null;
            referenceCountB=null;
            //执行fullgc,查看堆内存使用量来判断是否回收
            System.gc();
    
        }
    }

其gc日志为:

或许大家还看不懂gc日志,但没关心,我们只需要关注红色区域,进行System.gc后堆内存区域只用了463k,很明显对象AB已经被回收了。

可达性分析算法
在主流的商用语言中(java、C#甚至Lisp当中)的主流实现中,都是采用可达性分析算法来判定对象是否存活的。这个算法的基本思路就是通过一系列的“GC Roots”的对象作为起点,从这些起点开始向下搜索所走过的路径称为引用链,当一个对象到“GC Roots”没有任何引用链相连,就证明这个对象是不可用的。如下图所示:

在java语言中,可以作为GC Roots的对象包括下面4种:
1.虚拟机栈(栈帧中的本地变量表)中引用的对象
2.方法区中类静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法中JNI(jdk里的native方法)引用的对象

java的四种引用

无论通过哪种算法去判断对象是否存活都与引用相关。在java中,分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,其引用关系依次降低。

强引用:java中最常见的引用,引用计数算法的ReferenceCount referenceCountA = new ReferenceCount()就是典型的强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用:用来描述一些还有用但并不是必须存在的对象(可以与缓存的功能作类比),对于软银用的对象,在系统即将发生内存溢出异常之前,将会把这些对象列入到第二次回收范围中进行回收,如果回收之后还是没有足够的内存,将抛出内存溢出异常。jdk提供了SoftRerence来实现软引用
弱引用:它的作用和软引用类似,区别在于引用关系更弱。只能存活到下次gc发生之前。当gc时,无论当前内存是否足够都会回收掉弱引用的关联的对象。jdk提供了WeakReference引用。
虚引用:它是最弱的一种引用关系,为一个对象设置虚引用关联的唯一目的就是这个对象被收集器回收时收到一个系统通知。jdk提供了PhantomReference来实现虚引用。

回收方法区
在hotspot中,大家更原因将其称为永久代(jdk1.8废除永久代,metaspace元空间出现,咱不讨论)。永久代主要回收两部分内容:废弃常量和无用的类。以一个字符串“abc”已经进入了常量池中,但是系统中没有一个String对象指向abc的,也没有其他地方引用了这个字面量,当发生垃圾回收时,并且必要的话,这个常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。判定一个常量是否存活比较简单,而要判定一个类是否是无用的类的条件就要苛刻很多。必须得满足以下三个条件:1.该类的所有实例已经被回收2.加载该类的ClassLoader已经被回收3.该类对应的Class对象没有在其他任何地方被引用,无法再任何地方通过反射访问该类的方法。只有满足以上三个条件,这个类才有可能被回收。是否对类进行回收,hotspot虚拟机提供了-Xnoclassgc参数进行控制。

垃圾收集算法

标记-清除算法

标记清除算法(Mark-sweep)是最基础的算法,其过程如同名字一样分为标记和清除两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。之所以说它是最基础的算法是因为后续的收集算法都是在它的基础上对其不足进行改进而得到的(但同时也会暴露其他问题,没有最合适,只有更合适)。它的不足主要有两个,一个是效率问题,标记和清除的效率都不高。另一个是空间问题,会产生大量的内存碎片,碎片太多可能会导致以后分配大对象无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。执行过程如下图所示:

复制算法
为了解决标记清除算法的效率问题,复制算法出现了。他将可用内存按大小分为大小相等的两份,每次只使用其中的一份,当其中一份内存使用完了,就将还存活着的对象复制到另外一块上面,然后再将已使用的那份内存空间一次清理掉。这种算法只对整个半区进行内存回收,内存分配时也不再需要考虑内存碎片的问题,只要每次移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点是将可用内存缩小了一半,代价太高。执行过程如下所示:

目前的商业虚拟机都采用复制算法来回收新生代。新生代中的对象98%都是朝生夕死的,所以我们不需要严格的按照1:1的比例来划分内存空间。目前的商用虚拟机都将新生代内存划分为Eden和两块Survivor空间,其比例默认为8:1:1,每次只使用Eden和其中一块Survivor空间。当回收内存时,会将Eden和已经使用的Survivor空间中存活的对象一次性的复制到另外一块Survivor空间,复制完成然后清理Eden区域和刚才用过的Survivor空间。每次只有10%的内存不可用算是对复制算法的一个优化,是可以被接受的。另外前面有提到,一般场景98%的对象都是朝生夕死,但是我们没有办法保证Eden和其中一块使用的Survivor空间存活的对象一定比另外一块未使用的Survivor空间小,如果未使用的Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

标记-整理算法
复制收集算法尤其适合新生代,因为新生代对象一般情境下都是朝生夕死的。但是如果在对象存活率较高甚至极端情况下达到100%的存活率,就要进行较多的复制操作,效率将会变得极其低下,又因为需要额外的空间进行担保,所以老年代不会选用复制算法(老年代的对象一般都活的比较久)。
根据老年代对象的特点,就提出了标记整理算法。第一步仍是标记,但后续步骤不再是清除而是整理,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。执行过程如下图所示:

分代收集算法
前面有提到新生代和老年代,其实是根据对象存活周期的不同来划分的。新生代中,每次垃圾收集的对象都会有大批死去,只有少量存活,那就用复制算法,只需要少量存活对象的复制成本就可以完成收集。而老年代对象存活率高,又没有额外空间对它进行分配担保,就必须使用标记整理和表表情清除算法来进行回收,这就是所谓的分代收集算法。

枚举根节点

从可达性分析中从GC Roots节点找引用链这个操作为例,可以作为GC Roots的节点主要为常量、静态、和栈帧中的本地变量表,现在很多程序仅仅方法区就有数百兆,如果逐个检查里面的引用,那么必然会消耗很多的时间。另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个确保一致性的快照中进行。这个一致性的意思是整个分析期间这个程序看起来就像被冻结在某个时间点一样,不可以出现分析过程中对象引用关系还在不断变化的情况,这点如果不满足的话结果准确性就无从谈起了。这点是导致gc进行时程序必须停顿所有java工作线程的一个重要原因,Sun公司将其称为STW(Stop the World)。听起来很酷,但这很有可能是造成接口超时等其他问题的罪魁祸首。

垃圾收集器

如果说收集算法是垃圾回收的方法论,那么垃圾收集器就是垃圾回收的具体实现。虚拟机规范并没有规定虚拟机如何去实现,因此不同厂商不同版本的虚拟机提供的垃圾收集器都可能会有很大差别。本文只讨论JDK1.7Update14之后的hotspot虚拟机,如下图所示:

Young generation代表年轻代,Tenured generation代表老年代。总共有7款收集器,他们都负责回收自己所在的区域,收集器之间的实线代表着这两款收集器可以合作,一起收集整个堆内存。其中G1是JDK1.7之后才正式被oracle定义为商用虚拟机,G1回收整个新生代和部分老年代比较特殊,本文暂不讨论。

在讲解收集器之前,先给大家介绍下32位和64位的jdk。32位系统只可以装32位jdk,64位系统两者都可以装,但推荐安装64位jdk。在32位的jdk下,虚拟机的模式是可选的,默认为client模式,可以通过修改配置文件为server模式,但在64位的jdk下,虚拟机只能为server模式。目前大部分服务器甚至很多个人电脑都是64bit,也就是默认server模式。

在接下来介绍的六款收集器中,只有serial和serial old是单线程回收内存的收集器。其他都是多条线程回收内存的,有的是并行,有的是并发,在介绍这几款收集器之前,我们先讲解下并行和并发在垃圾回收这个上衣文语境中所代表的含义:

1.并行:多条垃圾收集线程并行工作,但用户线程仍在等待状态
2.并发:用户线程与垃圾收集线程同时执行(可能会交替执行),用户程序在运行在一部分cpu上,而垃圾回收运行在另一部分cpu上。

serial收集器
serial收集器是一款历史很悠久的收集器,在jdk1.3之前是新生代的唯一选择。这是一个单线程的收集器,它只会占用一个cpu启动一个线程去回收内存,但它也是会导致stw的。下图为serila和serial old收集器工作的示意图:

直到今天,它依然是虚拟机运行在client模式下默认的新生代收集器,在用户的桌面场景应用中,分配给虚拟机管理的内存一般不会太大,停顿时间完全可以控制在一百毫秒以内,只要不是频繁发生,这点停顿是可以接受的。

parnew收集器
parnew收集器就是serial收集器的多线程版本,除了使用多个线程进行垃圾回收之外,其余行为包括serial收集器所有可用的控制参数、收集算法、stw、对象分配规则、回收策略斗鱼serial一模一样。在实现上,两种收集器也共用了很多代码。parnew和serial Old收集器工作的示意图:

parnew收集器是虚拟机server模式下默认的新生代虚拟机,但是它和serial相比除了是多线程收集外并没有其他的特色,其中一个与性能无关但很重要的原因,目前除了serial收集器,它是唯一一个可以cms共同工作的一个收集器。在jdk1.5,hotspot推出了一款划时代意义的垃圾收集器-----cms收集器,这款收集器是真正意义上的第一款并发收集器,他第一次实现了让用户线程和垃圾收集线程基本上同时工作(但stw还是存在的,稍后会讲解cms收集器的内容)。不幸的是,cms作为老年代的收集器,却只能和serial和parnew收集器共同工作,parnew收集器也是使用-XX:+UseConcMarkSweepGC参数后的默认新生代收集器。

parallel scavenge收集器
parallel scavenge收集器也是一个新生代的收集器和parnew大致一样,但它的关注点与其他收集器不同。其他收集器都是尽可能地缩短垃圾收集时用户线程的停顿时间,而parallel scavenge收集器的目的是为了达到一个可控制的吞吐量(Throughput)。吞吐量就是cpu用于运行用户代码的时间与cpu工作时间的比值,即吞吐量的计算应该为:吞吐量=用户线程的工作时间/(用户线程的工作时间和垃圾收集的时间),虚拟机总共运行100分钟,垃圾收集器运行2分钟,那吞吐量就是98%。停顿时间越短,就越适合需要与用户交互的系统程序,可以良好的提升用户体验,而吞吐量越高说明cpu的利用效率越高,可以尽快完成的程序的运算任务,主要适合后台运算而不需要太多的交互任务。
parallel scavenge收集器提供了两个参数用于控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills和直接设置吞吐量大小的-XX:GCTimeRatio参数。XX:MaxGCPauseMills允许的值是一个大于0的毫秒数,收集器尽可能保证内存回收花费的时间不超过此值,但并不是将此参数设的越小系统的垃圾收集速度就越快,它是牺牲了新生代空间和吞吐量来换取的:收集500MB的新生代肯定比收集1GB的新生代快,但换来的代价是 ygc会更频繁一些。原来10秒一次ygc,一次停顿100ms,现在5秒一次ygc,一次停顿60ms。停顿时间在下降,但吞吐量也降下来了。GCTimeRatio参数的值应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,如果把参数设置为9,那允许的最大gc时间就占总时间的10%,计算方法是这样子的(1/(1+9))。
parallel scavenge收集器还提供了一个参数-XX:+UserAdaptiveSizePolicy,这是一个开关参数,当这个参数打开后,就不需要手工指定新生代的大小、Eden与Survivor的比例、晋升老年代对象的大小等参数了,虚拟机会根据运行情况寻找最合适的配比。如果对于收集器运作不太了解,可以只配置-Xmx设置最大堆,配置MaxGCPauseMills设置最大停顿时间或GCTimeRatios来设置吞吐量给虚拟机来建立一个优化目标,具体的细节参数交给parallel scavenge收集器来自动调配。

serial old 收集器
serial Old是serial收集起的老年代版本,同样的他也是一个单线程收集器,使用标记整理算法(注意新生代使用的都是复制算法,前面有提到),这个收集器的主要意义也是给client模式下虚拟机来使用。如果在server模式下,它还有两大用途:一种用途是JDK1.5以及之前的版本中配合parallel scavenge使用的,另一种用途就是作为cms收集器的备用方案,在这款收集器发生Concurrent Mode Failure时切换为Serial old收集器

parallel old收集器
parallel old收集器是parallel scavenge收集器的老年代版本,使用多线程和标记整理算法,在jdk1.6才开始提供的。所以在此之前新生代的parallel scavenge一直处于十分尴尬的状态,如果新生代使用了parallel scavenge收集器,老年代只能与serial old配合(parallel scavenge收集器无法与cms工作)。由于serila old收集器在服务器应用性能上的拖累,使用parallel scavenge收集器也未必能够获得吞吐量最大化的效果,其原因是因为serial old是单线程的无法充分利用服务器多cpu的处理能力,在老年代很大且硬件比较高级的环境中,这种组合的吞吐量还不一定有parnew+cms的组合给力。直到parallel old收集器出现后,吞吐量优先收集器才有了比较名副其实的组合,在注重吞吐量和cpu资源非常敏感的情况下,都可以优先考虑parallel old+parallel scavenge收集器,其工作过程如下所示:

cms收集器
cms(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的java应用集中在互联网站或者B/S的系统服务端上,这类应用非常重视响应速度,希望停顿时间最短,cms收集器就非常符合这类需求。
cms是一款基于标记清除算法的收集器,在jdk1.5中提出,也是hotspot第一款并发收集器。它的运作过程相对于前面几款收集器复杂一些,正题分为4个步骤:

7. 初始标记(CMS initial mark):初始标记需要stw,它仅仅只是标记下GC Roots能直接关联到的对象,速度很快。

7. 并发标记(CMS concurrent mark):不需要stw,这个阶段是根据初始标记得到的存活对象进行递归标记可达的对象。
8. 重新标记(CMS remark):并发标记这个阶段是并发执行的(用户线程也在工作),可能发生对象晋升到老年代或者大对象直接分配老年代或者新老年代对象的引用关系被更新等等,对于这些变动的引用关系需要重新标记更正,并且发生stw,会比初始标记时间要长,但远比并发标记时间短。
9. 并发清除(CMS concurrent sweep):并发清除就很简单了,之前已经整理出存活对象,直接清除就是了。

由于最耗时间的并发标记和并发清除都可以和用户线程一起工作,所以总体上来说cms收集器的内存回收过程是与用户线程一起兵法执行的。cms的工作流程如下图所示:

cms是一款优秀的收集器,并发收集和低停顿都是他的特点,但它有3个明显的缺点:

cms收集器对cpu资源非常敏感,因为是并发的会去抢夺cpu资源,造成应用程序突然变慢,总吞吐量降低的情况。

cms基于标记清除算法实现的,无法清除浮动垃圾,可能出现Concurrent Mode Failure失败导致另一次Full GC的产生。由于cms的并发清理阶段用户线程还在运行着,必然会有新的垃圾不断产生,这部分垃圾出现在标记之后,cms无法在当次收集过程回收它,只能留到下一次回收,因此cms不能像其他年老代收集器一样等到年老代几乎被填满了再进行收集,需要预留一部分空间提供并发清理时的程序运作使用。在jdk1.5中cms收集器当老年代使用了68%的空间后就会被激活,在jdk1.5之后,已经将阈值提升至92%。如果Cms预留内存不够用,将会发生Concurrent Mode Failure,这时将会启动备用方案,临时启动serial old来进行老年代的手机,这样停顿时间就很长了。这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction来设置,设置的太高,老年代增长的又比较快,就会导致大量Concurrent Mode failure出现,性能反而降低。

cms是基于一款标记清楚算法实现的收集器,这种算法会导致大量空间碎片产生。这将会给分配大对象带来麻烦,往往老年代空间还很多,但是无法找到足够大的连续空间来分配当前对象,不得不触发一次Full GC。为了解决这个问题,CMS收集器提供了一个开关参数:-XX:+UseCMSCompactAtFullCollection(默认开启),用于cms收集器顶不住要进行fullGC 时开启内存碎片的合并整理过程,这个过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。cms还提供了一个参数-XX:CMSFullGCsBeforeCompaction来设置执行多少次不压缩的Full GC后跟着来一次压缩整理。

总结

本篇博客主要讲解了垃圾回收算法,内存区域的一些细节和收集器大致的工作流程。通过分析比较各个收集器,我们发现没有最好的收集器组合,更没有万能的收集器组合。我们只能通过场景分析来定最合适的收集器。

下节预告

1.gc日志的阅读
2.内存分配和回收策略
3.虚拟机提供的性能监控工具

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/68989.html

相关文章

  • 内存泄露从入门到精通部曲之基础知识篇

    摘要:腾讯特约作者姚潮生首先以一个内存泄露实例来开始本节基础概念的内容。堆内存用于存放所有由创建的对象内容包括该对象其中的所有成员变量和数组。回到我们的问题,为什么内存会泄露堆内存中的长生命周期的对象持有短生命周期对象的强软引用,尽管 腾讯Bugly特约作者: 姚潮生 首先以一个内存泄露实例来开始本节基础概念的内容。 实例1:单例导致内存对象无法释放而泄露 showImg(http://i....

    nicercode 评论0 收藏0
  • 内存泄露从入门到精通部曲之排查方法篇

    摘要:对比操作前后的来定位内存泄露的根因所在。手机管家内存泄露每日监控方案目前手机管家的内存泄露每日监控会自动运行并输出是否存在疑似泄露的报告邮件,不论泄露对象的大小。 腾讯Bugly特约作者: 姚潮生 最原始的内存泄露测试 重复多次操作关键的可疑的路径,从内存监控工具中观察内存曲线,是否存在不断上升的趋势且不会在程序返回时明显回落。这种方式可以发现最基本,也是最明显的内存泄露问题,对用户价...

    李世赞 评论0 收藏0
  • 内存泄露从入门到精通部曲之排查方法篇

    摘要:对比操作前后的来定位内存泄露的根因所在。手机管家内存泄露每日监控方案目前手机管家的内存泄露每日监控会自动运行并输出是否存在疑似泄露的报告邮件,不论泄露对象的大小。 腾讯Bugly特约作者: 姚潮生 最原始的内存泄露测试 重复多次操作关键的可疑的路径,从内存监控工具中观察内存曲线,是否存在不断上升的趋势且不会在程序返回时明显回落。这种方式可以发现最基本,也是最明显的内存泄露问题,对用户价...

    sutaking 评论0 收藏0
  • 细述 Java垃圾回收机制→How Java Garbage Collection Works?

    摘要:当一个实例被创建的时候,它最初被存放在堆内存空间的年轻代的区中。老年代或者永久代是堆内存的第二个逻辑部分。在垃圾回收过程中扫描属于部分的堆内存。一旦实例从堆内存中删除了,它们原来的位置将空出来给以后分配实例使用。 本文非原创,翻译自How Java Garbage Collection Works?在Java中为对象分配和释放内存空间都是由垃圾回收线程自动执行完成的。和C语言不一样的是...

    cc17 评论0 收藏0
  • 细述 Java垃圾回收机制→Java Garbage Collection Introduction

    摘要:在架构中,堆内存和垃圾回收器这两个部分和垃圾回收相关。堆内存在的内存模型中,最重要的是要了解堆内存的概念。在垃圾回收的过程中,这些对象将被从堆内存中清除,同时它们的空间也就被回收了。 本文非原创,翻译自Java Garbage Collection introduction在Java中为对象分配和释放内存空间都是由垃圾回收线程自动执行完成的。和C语言不一样的是Java程序员不需要手动写...

    codergarden 评论0 收藏0

发表评论

0条评论

wanghui

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<