性能文章>Java对象历险记与垃圾回收机制>

Java对象历险记与垃圾回收机制原创

1年前
370135

对象的分配和回收流程

 

为了解读上面的动图,我们先来介绍一下垃圾收集相关的知识。

1、对象回收处理过程

2、判断用户是否可用算法

2.1、引用计数算法

如上图,给对象一个引用计数器refCount。每有一个对象引用它,计数器加1,当refCount=0的时候,表示对象不再可用。

缺点

很难解决循环引用的问题:

objA.instance = objB;
objB.instance = objA;

如上,即使 objA 和 objB 都不再被访问之后,他们依旧互相引用这,所以计数器不为0。

 

2.2、可达性分析算法

 

如上图,从GC Roots开始向下搜索,连接的路径为引用链;

GC Roots不可达的对象被判为不可用;

可作为 GC Root的对象

如上图,虚拟机栈帧中本地变量表引用的对象,本地方法栈中,JNI引用的对象,方法区中的类静态属性引入的对象和常量引用对象都可以作为 GC Root。

关于引用类型

强引用:类似Object a = new Object();

软引用:SoftReference<String> ref = new SoftReference<String>("Hello world");,OOM前,JVM会把这些对象列入回收范围进行二次回收,如果回收后内存还是不做,则OOM。

弱引用:WeakReference<Car> weakCar = new WeakReference<Car>(car);,每次垃圾收集,弱引用的对象就会被清理。

虚引用:PhantomReference<Object> phantom = new PhantomReference<>(new Object(), new ReferenceQueue<>());,幽灵引用,不能用来取得一个对象的实例,唯一用途:当一个虚引用引用的对象被回收,系统会受到这个对象被回收了的通知。

3、HotSpot中如何实现判断是否存在与GC Roots相连接的引用链

第一小节流程图里面的是否存在与GC Roots相连接的引用链这个判断子流程是怎么实现的呢,这节我们来仔细探讨下。

一般的,我们都是选取可达性分析算法,这里主要阐述怎么寻找GC Root以及如何检查引用链。

3.1、枚举根节点

如上图,在一个调用关系为:

ClassA.invokeA() --> ClassB.invokeB() --> doinvokeB() -->ClassC.execute()

的情况下,每个调用对应一个栈帧,栈帧里面的本地变量表存储了GC Roots的引用。

如果直接遍历所有的栈去查找GC Roots,效率太低了。为此我们引入了OopMap和安全点的概念。

安全点和OopMap

如上图,在源代码编译的时候,会在特定位置下记录安全点,一般为:

  • 循环的末尾;

  • 方法返回前或者调用方法的call指令后;

  • 可能抛出异常的位置。

通过安全点把代码分成几段,每段代码一个OopMap。

OopMap记录栈上本地变量到堆上对象的引用关系,每当触发GC的时候,程序都都先跑到最近的安全点然后自动挂起,然后再触发更新OopMap,然后进行枚举GC ROOT,进行垃圾回收:

安全区域:在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。如处于Sleep或者Blocked状态的线程。

为了在枚举GC Roots的过程中,对象的引用关系不会变更,所以需要一个GC停顿。

还有一种抢先式中断的方式,几乎没有虚拟机采用:先中断所有线程,发现线程没中断在安全点,恢复它,继续执行到安全点。

找到了该回收的对象,下一步就是清掉这些对象了,HotSpot将去交给CG收集器,详细见后续小节说明。

4、垃圾回收算法

概览图

4.1、标记-清除算法

4.1.1、算法描述

  • 标记阶段:标记处所有需要回收的对象;

  • 清除阶段:标记完成后,统一回收所有被标记的对象;

4.1.1、优点

4.1.2、不足

  • 效率不高:标记和清除两个过程效率都不高;

  • 空间问题:产生大量不连续的内存碎片,进而无法容纳大对象提早触发另一次GC。

4.2、复制算法

4.2.1、算法描述

  • 将可用内存分为容量大小相等的两块,每次只使用其中一块;

  • 当一块用完,就将存活着的对象复制到另一块,然后将这块全部内存清理掉;

4.2.2、优点

  • 不会产生不连续的内存碎片;

  • 提高效率:

    • 回收:每次都是对整个半区进行回收;

    • 分配:分配时也不用考虑内存碎片问题,只要移动堆顶指针,按顺序分配内存即可。

4.2.3、缺点

  • 可用内存缩小为原来的一半了,适合GC过后只有少量存活的新生代,可以根据实际情况,将内存块大小比例适当调整;

  • 如果存活对象数量比较大,复制性能会变得很差。

4.2.4、JVM中新生代的垃圾回收

如下图,分为新生代和老年代。其中新生代又分为一个Eden区和两个Survivor去(from区和to区),默认Eden : from : to 比例为8:1:1

可通过JVM参数:-XX:SurvivorRatio配置比例,-XX:SurvivorRatio=8 表示 Eden区大小 / 1块Survivor区大小 = 8

第一次Young GC

当Eden区满的时候,触发第一次Young GC,把存活对象拷贝到Survivor的from区,清空Eden区。

第二次Young GC

再次触发Young GC,扫描Eden区和from区,把存活的对象复制到To区,清空Eden区和from区。如果此时Survivor区的空间不够了,就会提前把对象放入老年代。

默认的,这样来回交换15次后,如果对象最终还是存活,就放入老年代。

交换次数可以通过JVM参数MaxTenuringThreshold进行设置。

4.2.5、JVM内存模型

JDK8 之前

JDK8

如上图,JDK8的方法区实现变成了元空间,元空间在本地内存中。

 

4.3、标记-整理算法

4.3.1、算法描述

  • 标记过程与标记-清楚算法一样;

  • 标记完成后,将存活对象向一端移动,然后直接清理掉边界以外的内存。

4.3.2、优点

  • 不会产生内存碎片;

  • 不需要浪费额外的空间进行分配担保;

4.3.3、不足

  • 整理阶段存在效率问题,适合老年代这种垃圾回收频率不是很高的场景;

4.4、分代收集算法

当前商业虚拟机都采用该算法。

  • 新生代:复制算法(CG后只有少量的对象存活)

  • 老年代:标记-整理算法 或者 标记-清理算法(GC后对象存活率高)

5、垃圾回收器

这一步就是我们真正进行垃圾回收的过程了。

本节概念约定:

并发:用户线程与垃圾收集线程同时执行,但不一定是并行,可能交替执行;

并行:多条垃圾收集线程并行工作,单用户线程仍处于等待状态。

以下是垃圾收集器概览图

5.1、Serial收集器

5.1.1、特点

串行化:在垃圾回收时,必须赞同其他所有工作线程,知道收集结束,Stop The World

在单CPU模式下无线程交互开销,专心做垃圾收集,简单高效。

5.1.2、适用场景

  • 特别适合限定单CPU的环境;

  • Client模式下的默认新生代收集器,用户桌面应用场景分配给虚拟机的内存一般不会很大,所以停顿时间也是在一百多毫秒以内,影响不大。

5.2、ParNew收集器

5.2.1、特点

  • Serial收集器的多线程版本

5.2.2、适用场景

  • 许多运行在Server模式下的虚拟机中的首选新生代收集器;

  • 除了Serial收集器外,只有它能和CMS收集器搭配使用。

-XX:+UseConcMarkSweepGC选型默认使用ParNew收集器。也可以使用-XX:+UseParNewGC选项强制指定它。

ParNew收集器在单CPU环境比Serial收集器效果差(存在线程交互开销)。

CPU数量越多,ParNew效果越好,默认开启收集线程数=CPU数量。可以使用-XX:ParallelGCThreads参数限制垃圾收集器的线程数。

5.3、Parallel Scavenge收集器

5.3.1、特点

  • 新生代收集器,使用复制算法,并行多线程;

  • 吞吐量优先收集器:CMS等收集器会关注如何缩短停顿时间,而这个收集器是为了吞吐量而设计的。

吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )

也就是说整体垃圾收集时间越短,吞吐量越高。

5.3.2、适用场景

  • 可以高效利用CPU时间,尽快完成程序的运算任务,适合后台运算不需要太多交互的任务;

5.3.3、相关参数

  • -XXMaxGCPauseMillis:设置最大垃圾收集停顿时间,大于0的毫秒数;

    • 缩短GC停顿时间会牺牲吞吐量和新生代空间。新生代空间小,GC回收就快,但是同时会导致GC更加频繁,整体垃圾回收时间更长。

  • -XX:GCTimeRatio:设置吞吞量大小。0~100的整数,垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

    • 19: 1/(1+19)= 5%,即最大GC时间占比5%;

    • 99: 1/(1+99)=1%,即最大GC时间占比1%;

  • -XX:+UseAdaptiveSizePolicy:GC自适应调节策略开关,打开开关,无需手工指定-Xmn(新生代大小)、-XX:SurvivorRatio(Eden与Survivor区比例)、-XX:PretenureSizeThreshold(晋升老年代对象年龄)等参数,虚拟机会收集性能监控信息,动态调整这些参数,确保提供最合适的 停顿时间或者最大吞吐量。

5.4、Serial Old收集器

5.4.1、特点

Serial收集器的老年代版本。使用单线程,标记-整理算法。

5.4.2、适用场景

  • 主要给Client模式下的虚拟机使用;

  • Server模式下,两大用途:

    • JDK1.5版本之前的版本与Parallel Scavenge收集器搭配使用;

    • 作为CMS收集器的后备预案,发生Concurrent Mode Failure时使用。

5.5、Parallel Old收集器

5.5.1、特点

Parallel Scavenge收集器的老年代版本,使用多线程,标记整理算法。

5.5.2、使用场景

  • 主要配合Parallel Scavenge使用,提高吞吐量。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑这个组合。

JDK1.6之后提供,之前Parallel Scavenge只能与Serial Old配合使用,老年代Serial Old无法充分利用服务器多CPU处理器能力,拖累了实际的吞吐量,效果不如ParNew+CMS组合;

5.6、CMS收集器

Concurrent Mark Sweep

5.6.1、特点

  • 设计目标:获得最短回收停顿时间;

  • 注重服务响应速度;

  • 标记-清除算法;

5.6.2、缺点

  • 对CPU资源敏感,虽然不会导致用户线程停顿,但是会占用一部分线程(CPU资源)而导致应用程序变慢,吞吐量降低;

  • CMS收集器无法处理浮动垃圾。在CMS并发清理阶段,用户线程会产生垃圾。如果出现Concurrent Mode Failure失败,会启动后备预案:临时启动Serial Old收集器重新进行老年代垃圾收集,停顿时间更长了。-XX:CM SInitiatingOccupancyFraction设置的太高容易导致这个问题;

  • 基于标记-清除算法,会产生大量空间碎片。

5.6.3、使用场景

  • 互联网网站或者B/S系统的服务器;

5.6.4、相关参数

  • -XX:+UseCMSCompactAtFullCollection:在CMS要进行Full GC时进行内存碎片整理(默认开启)。内存整理过程无法并发,会增加停顿时间;

  • -XX:CMSFullGCsBeforeCompaction:在多少次 Full GC 后进行一次空间整理(默认0,即每一次 Full GC 后都进行一次空间整理);

  • -XX:CM SInitiatingOccupancyFraction:触发GC的内存百分比,设置的太高容易导致Concurrent Mode Failure失败(GC过程中,用户线程新增的浮动垃圾,导致触发另一个Full GC)。

CMS为什么要采用标记-清除算法

CMS主要关注低延迟,所以采用并发方式清理垃圾,此时程序还在运行,如果采用压缩算法,则会涉及到移动应用程序的存活对象,这种场景下不做停顿是很难处理的,一般需要停顿下来移动存活对象,再让应用程序继续运行,但是这样停顿时间就边长了,延迟变长。CMS是容忍了空间碎片来换取回收的低延迟。

5.7、G1收集器

G1:Garbage-First,即优先回收价值最大的Region(注1)。

注1:G1与收集器将整个Java堆换分为多个代销相等的独立区域,跟踪各个Region里面的垃圾堆积的价值大小,优先回收价值最大的Region。

如上图,G1收集器分为四个阶段:

  • 初始标记:只标记GC Roots能直接关联到的对象,速度很快。并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能够在正确可用的Region中创建新对象,这阶段需要停顿线程;

  • 并发标记:GC RootsTracing过程。该阶段对象变化记录在线程Remembered Set Logs中。

  • 最终标记:修正并发期间因用户程序运作而导致标记产生变动的部分对象的标记记录。把Remembered Set Logs数据合并到Remembered Set中。这个阶段需要停顿,但是可并行执行;

  • 筛选回收:对各个Region回收价值和成本进行排序,根据用户期望Gc停顿时间制定回收计划。与CMS不一样,这里不用和用户线程并发执行,提高收集效率,使用标记-整理算法,不产生空间碎片。

    5.7.1、特点

    • 并行与并发:并发标记,并行最终标记与筛选回收;

    • 分代收集

    • 空间整合:基于标记-整理算法,不会产生碎片。

    • 可预测的停顿:G与收集器将整个Java堆换分为多个代销相等的独立区域,避免在整个Java堆中进行全区域的垃圾回收,跟踪各个Region里面垃圾堆积的价值大小,后台维护一个优先列表,每次根据运行的收集时间,优先回收价值最大的Region。

 

6、Java对象历险记

好了,我们重新来解读新这个动图:

如上图动画所示:

1、优先在Eden区分配对象

  • Eden区空间不足,触发Minor GC,标记可回收对象,然后Eden区存活对象拷贝到往Survivor-From区,接下来清空Eden区;

  • 再次触发Minor GC,扫描Eden区和from区,把存活的对象复制到To区,清空Eden区和from区;

  • 如果在Minor GC复制存活对象到Survivor区时,发现Survivor区内存不够,则提前把对象放入老年代;

2、大对象直接进入老年代

如果发现需要大量连续内存空间的Java对象,如很长的字符串或者数组,则直接把对象放入老年代。

可通过-XX:PretenureSizeThreshold参数设置大对象的最小大小,该参数只对Serial和ParNew两款收集器有效。

  • 因为新生代采用复制算法收集垃圾,大对象直接进入老年代,避免在Eden区和Survivor区发生大量内存复制;

  • 写程序的时候尽量避免大对象。

3、长期存活对象进入老年代

固定对象年龄判断:默认的,存活对象在Survivor的From和To区来回交换15次后,如果对象最终还是存活,就放入老年代。可以通过-XX:MaxTenuringThreshold参数来设置对象的年龄。

动态对象年龄判断:如果发现Survivor中有相同年龄的对象空间总和大于Survivor空间的一半,那么年龄大于或者等于该年龄的对象直接晋升到老年代。

4、空间分配担保

为什么需要分配担保:如果Survivor区存活了很多对象,空间不够了,都需要晋升到老年代,那么久需要老年代进行分配担保,也就是将Survivor无法容纳的对象直接进入老年代。

  • 发生Minor GC前,JVM先检查老年代最大可用连续空间是否大于新生代所有对象的总空间

    • 大于:空间足够,直接Minor GC;

    • 小于:进行一次Full GC。

JDK 6 Update 24前会根据HandlePromotionFailure参数判断是否允许担保失败,如果允许,则尝试一次Minor GC;否则,则进行Full GC。

 

欢迎关注微信公众号《Java架构杂谈》。

点赞收藏
arthinking
请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

5
3