爱看书的阿东

赐他一块白色石头,石头上写着新名

深入理解JVM - 分代与垃圾回收

深入理解JVM - 垃圾回收算法

前言:

​ 这一期讲述垃圾回收的算法。我们根据分代的理念讲述一下JVM是使用什么算法对于不同分代的对象进行垃圾回收的的,同样内容十分基础,但是对于学习JVM后续的内容十分重要。

前文回顾

​ 在上一节当中,我们看到了JVM当中堆将分为新生代和老年代,对象优先在新生代分配,以及新生代在长期存活并且满足条件之后进入老年代,介绍了新生代的Minor Gc和老年代的Full GC,最后,我们用下面的一张图了解到一个对象分配的大致流程,以及JVM的内存核心参数配置以及方法区的回收条件等。

对象分配的细节

概述

  1. 分代常用垃圾回收算法介绍
  2. 小结进入老年代的条件以及空间内存分配担保机制
  3. JVM新生代,老年代的回收流程(重点)
  4. 四种引用类型的介绍。
  5. 了解哪些内存是不能进行回收的

常用垃圾回收算法:

​ JVM目前常见的垃圾回收算法是下面三种:标记-清除,复制算法,以及标记-整理算法。

标记-清除算法

标记-清除:实现最为简单的一种算法,就是在新生代当中标记所有的不可用对象并且进行清除,最后保留可用对象。

​ 优点:

​ 清理效率高,实现起来比较简单。

​ 缺点:

​ 容易产生大量的内存碎片

​ 应用:

​ 标记-清除算法通常是配合标记-整理算法使用。

算法实现步骤:

  1. 标记所有的存活对象,比如下图中黄色被标记为存活对象,灰黑色被标记将要被垃圾回收的对象

  2. 执行垃圾回收的时候,清理掉所有的垃圾对象,保留存活对象。

  3. 可以看到清理完成之后整个内存是十分不规整的

新生代:改良复制算法:

​ 复制算法是新生代的常用方法,但是需要注意的是复制算法使用的是改良之后版本,在讲述改良后的算法之前我们先看下早期的形式。

​ 复制算法:

​ 把内存看作是均匀的两块空间,对象总是只使用其中一半的空间,在回收的时候会把存放对象的空间中的存活对象的复制到另一半空间,然后直接清理掉垃圾对象,这样复制之后到内存空间较为规整,同时清理效率十分高。

​ 优点:

​ 复制之后内存比较规整,同时效率较高。

​ 缺点:

​ 这个算法的模式缺点也十分明显,就是实际使用只能使用一半的空间,当垃圾对象塞满一半的情况下就会进行垃圾回收,内存利用率十分低。所以后续有人提出了改良的算法

复制算法

复制算法的改良

​ 复制算法改良之后结构如下图所示:

​ 改良后复制算法的特点:

  • 改良后的复制算法把新生代划分为一个eden区域和两个survivor区域,默认按照8:1:1的比例分配,同时对象优先在eden区域进行分配
  • 当eden区域对象满了之后,会把存活对象复制到survivor区域1,当下一次eden区域再次满了之后,就会把上一次survivor区域中存活对象以及eden区域的存活对象放到survior2区域当中,然后直接清空掉survior1的区域和eden区域的所有垃圾对象。
  • 这样做可以保证每一次都会有一个survivor区域是空着的,对于空间的利用率比改良前的复制算法要高很多。

复制算法改良

老年代:标记 - 整理算法

​ 标记-整理算法是对标记-清除算法的一种改进,主要是在标记清除后加入了一步整理的操作。标记整理算法要比复制算法和标记清除算法要复杂不少,同时性能和效率也更低,通常作为老年代的算法使用。

​ 步骤:先标记所有存活对象,然后清理掉所有垃圾对象。然后将存活对象都挪到一堆,避免出现垃圾的内存碎片。

​ 需要注意的是:老年代的回收算法比新生代的垃圾回收算法慢十倍。

进入老年代条件总结

​ 因为这个被多次提到,这里做了一个表格表示新生代进入老年代的条件:

进入条件 JVM参数 取值范围 案例 注意事项
超过某一限制大小的对象 -XX:PretenureSizeThreshold 根据字节Byte计算 -XX:PretenureSizeThreshold=3145728(4M) 只有在serial 和 ParNew 收集器中有效
Survior区的某一年龄对象累加的总和大于目标存活率 -XX:TargetSurvivorRatio 0 - 100 -XX:TargetSurvivorRatio=50 通常从小开始往大年龄开始取值,比如年龄为3的对象超过50%则大于等于此全部进入老年代
新生代对象存活年龄达到15 -XX: MaxTenuringThreshold 1 - 15(最大15) -XX: MaxTenuringThreshold=6 不能超过15,原因是对象头中的markword只分配了4位空间1111标识年龄
空间分配担保机制 -XX:SurvivorRatio=8 默认值为8,也就是8:1:! -XX:SurvivorRatio=8 参数显示如果超过80就会触发空间内存担保机制的判断
CMS并发整理和并发标记阶段8%的剩余内存无法容纳新生代对象 -XX:CmsInitiatingOccupactAtFullCollection 只有在使用CMS收集器的情况下会有此情况

空间内存担保机制

​ 下面回顾下对象内存分配的空间担保机制:

​ 之前的系列文章也有提到:空间内存分配担保机制

  • 当分配对象>survior空间并且大于老年代最大可用连续内存空间

  • 触发minor GC,判断之前每一次晋升老年代平均大小是否小于老年代空间

    • 如果小于survivor空间,直接进入survior区域

    • 如果大于survior小于老年代,则进入老年代

    • 如果大于老年代,进行一次full gc

    • 如果full GC之后还是大于老年代,则oom

新生代老年代回收算法的流程:(重点)

​ 注意这里的回收会结合进入老年代的条件一起进行讲解

新生代回收流程:

​ 首先会根据-XX:SurvivorRatio参数判定,对于新生代进行划分Eden区域,Survior1区域和Survior2区域,如下图所示:

​ 对象会优先分配到eden区域:

​ 当eden区域满了之后,会触发minor gc,同时会把存活的对象拷贝到survior1区域,复制完成之后清空掉eden区域,如下图,灰色对象在复制完成之后会直接清空:

​ 在Minor GC之前会根据下面的判断条件判断是否需要提前执行full gc:

这也意味着 Full GC通常会伴随着一次Minor Gc

空间分配担保

​ 如果是minor GC,则进入下一次对象分配,当新生代又满了的时候,此时会把survior1的存活对象以及eden的存活对象拷贝到survior2区域:

​ 这里还有一个判定,就是如果survior区域超过50%的时候,会把年龄累加对象超过50%的所有对象放到老年代,听起来比较拗口,这里画图说明,如下图所示,对象年龄为4大于4的所有对象进入老年代:

​ 最后,还有一个判断就是上面循环多次都存活的对象超过15年龄之后也会自动进入老年代。

老年代回收流程:

​ 上文提到老年代使用标记-整理算法,当对象进入老年代之后,会经历下面的过程:

​ 首先,当JVM满足了触发FULL GC的条件之后通常会伴随一次minor gc,会把当前老年代的所有存活对象进行标记,同时清理掉所有的垃圾对象,而新生代则根据上文的描述进行复制算法清理对象。

​ 根据上面的图例所示,所有的黑色对象会被清除,同时大对象直接进入到了老年代,而存活的老年代对象会进行整理的工作划分到一处,新生代则使用复制算法将存活对象放入到survior区域。

​ 老年代的回收通常是新生代引发的,所以重点需要记住:新生代进入老年代的条件

​ 切记:老年代的回收效率要比新生代慢十倍。

哪些对象是不能被回收的

​ 经过了上面的解释,我们对于新生代以及老年代的回收有了一定的了解,那么JVM是如何判定哪些对象不能回收呢?

​ 答案是通过可达性分析找到哪些对象是不可以被回收的,可达性分析最为常见的实现就是gcroot 根节点枚举,如果没有找到根节点,证明是一个可以被回收的垃圾对象。

  • 局部变量本身就可以作为GC ROOT
  • 静态变量可以看作是Gc Root
  • Long类型index的遍历循环会作为GT ROOT

总结:当有方法局部变量引用或者类的静态变量引用,就不会被垃圾线程回收。

这里简单介绍一下可达性算法

可达性算法包括:引用计数法,根节点枚举。JVM使用的是根节点枚举。

引用计数法:实现十分简单,效率也很高,对象每存在一个外部引用,就会在内部维护计数器并将对象的引用+1。问题也很明显:循环引用的问题。

根节点枚举:实现稍微复杂一些,效率随着GC ROOT节点的增加而降低,实现方式是根据系统设置的GC ROOT规则,从根节点进行引用遍历形成引用链,存在引用链的就是存活对象,否则就是垃圾对象。根节点枚举也有问题,就是遍历之后的对象失去引用转为垃圾对象的问题。

引用类型:强引用,弱引用,虚引用,软引用

​ 简单介绍一下常见的四种引用类型,具体的作用可以上网搜索资料,本文不做展开讲述。

强引用:通常是被new出来的对象,需要垃圾回收线程启动的时候通过GC ROOT判定是否需要回收。

软引用:在内存空间不足的时候被强制回收,不管是否存在局部变量引用

弱引用:在下一次垃圾回收的时候必定会被回收掉。

虚引用:标记作用,可以用于检查是否触发过垃圾回收,使用频率十分少。(可以忘记)

finalize有什么作用?

​ 对象自救的最后一次机会,可以通过此方法实现自救的动作。作用在《effective java》这本书有过详细讨论,网络上也有很多资料,不过除了面试基本上完全用不上,所以基本可以忘掉。

总结:

​ 根据上一节的介绍,我们了解到JVM的垃圾回收算法有三种:标记-清除,标记-整理和复制算法,复制算法常常用于新生代,而老年代通常使用标记-整理算法,当然也有收集器既使用标记-清除,又使用标记-整理进行垃圾回收,提高回收效率,比如CMS收集器,之后我们介绍了老年代和新生代的垃圾回收图解,最后讲解了对象的引用类型以及简单的了解finalize()方法的作用。

写在最后

​ 到此分代和垃圾回收的内容部分已经总结完成,下一节将会讲解cms收集器的细节,内容较多,同时对于这一节和上一节的内容有深刻的了解是必要的,后续的文章会反复强调这几块基础的内容。

练习作业:

​ 下面用作业的形式回顾一下之前学到的内容,如果能用自己的话描述答案,那么说明对于JVM这段内容算是掌握了。

  1. 对象在新生代如何分配

复制算法改良

  1. 什么时候会尝试minor gc

因为默认情况下新生代eden区域和survior区域的比例是8:1:1,所以默认情况下到达新生代内存的80%左右就会开始进行minor gc

或者还有如下的情况:当大对象进入的时候,根据分配担保的机制检查之前历代晋升老年代的对象平均大小,如果老年代最大连续内容大于整个值,也会minor gc,但是如果小于则会full gc。

老年代回收通常会伴随一次新生代回收。

最后,在parnew收集器或者JDK1.7以上版本中如果对象超过了eden以及survior区域的大小不会触发minor gc而是直接往老年代分配内存。

  1. 触发minor gc之前如何检查老年代大小涉及哪些步骤和条件
  • 检查老年代最大可用连续空间大小是否大于新生代的全部对象大小,如果是则放心的minor gc

  • 如果老年代可用连续空间小于新生代全部对象大小,则查看是否开启允许分配担保失败(jdk6之后默认开启)

  • 检查新生代历代晋升老年代的对象内存的平均大小,如果最大连续内存空间可以放得下,则放心minor GC,回收之后大概率还是差不多大小

    • 如果不满足上述条件,直接进行FULL GC回收内存
    • 如果回收之后小于Survior区域,则移动到Survior区域
    • 如果回收之后老年代还是满的,则OOM
  1. 什么时候会在minor gc 之前会触发一次full gc

​ 当检查到老年代的最大连续可用空间小于新生代EDEN+Survior区域大小时候会进行判断,没有开启分配担保机制或者老年代可用空间历次晋升老年代的新生代对象平均大小(JDK6之后设置无效,默认开启分配担保机制)

  1. Full gc的算法

使用的是“标记整理的算法”,效率大约慢新生代算法10倍,并且会产生更长时间的STOP THE world,需要尽量避免FULL GC

  1. Minor gc之后可能存在哪些对应情况

第一种情况:Eden区域还是满的,同时Survior区域装不下已经满了的eden存活对象,而直接往老年代分配

第二种情况:空的,并且survior区域也是空的

  1. 哪些情况下minor gc会进入老年代。
  • eden区域存货对象大于survior空间根据分配担保进入老年代
  • 对象年龄到达15的时候
  • 根据年龄判断,当相对应年龄对象大于survior区域的50%的时候,大于此年龄的对象全部进入老年代

实战部分:一个上亿的数据计算系统:

​ 业务:从MySQL内部不断从数据源获取大量数据到jvm计算然后放到内存中进行计算。

  • 每分钟500次数据计算任务
  • 每分钟计算100次
  • 4核心8g的配置。老年代1.5和新生代分别是1.5G 和 1.5G
  • 每次大概有一万条数据进入到jvm,每次计算需要10秒钟左右时间。

多久会塞满新生代

​ 如果每次执行一次计算任务,每分钟100次,而且每次计算需要10m大小,则一分钟就有1g多的消耗。一分钟eden区域就满了。

​ 关键内容到了,如果新生代满了,假设有200M的内容需要继续计算和处理,则证明新生代需要200M的存活对象放到S区域。我们知道按照默认的分配规则是:

8:1:1,则根据计算大概S1和S2的空间为100M,是没有办法放到S1的,所以只能分配担保机制丢到老年代,而老年代每次就要加200M,假设200M的对象放到老

年代不进行Gc,则大概8分钟左右,200M的对象就会进入到堆积到1600M,超过老年代的大小,最终因为空间分配担保机制,放不到S1区域,同时超过老年代连

续内存的大小,所以只能进行full GC保证对象分配成功。

这个项目如何优化:

​ 分析:这个系统是典型的会产生大量朝生夕灭对象的案例,所以老年代的压力通常会较小,下面再来定位一下问题点。

​ 问题点:问题出在Survivor区域上面,我们可以看到实际上这一块空间子在整个程序运作的时候几乎没有任何用处,200M的Survior区域和Survior2区域 无法存储存活对象,触发分配担保机制每次对象进入到老年代,所以我们的重点任务就是减少分配担保机制的触发,所以我们有两种策略:

  • 把Survior到区域大小调大,比如6:2:2的大小进行分配,但是这样会减少Eden的区域大小,会更加频繁的minor GC,得不偿失。
    • 如果比例不够大那就把把新生代调大,因为这是一个需要大量计算对象的业务,所以新生代压力较大,老年代压力较小,特别是每次minor Gc之后让Survior区域可以放下存活的对象是最为关键的,因为这些对象在下一次垃圾回收之后基本也会被清理走,这样只会有很少的对象进入到老年代
      关键:把S到区域调到可以容纳存活对象的程度。