1. JVM 垃圾回收算法

1.1. 简介

该篇文章主要介绍一下垃圾回收的算法,包括复制算法标记整理算法

1.2. 复制算法

复制算法是针对新生代的垃圾回收算法。

1.2.1. 背景引入

Java 程序代码在运行的时候,会产生很多对象,方法执行完后,这些对象大部分都会变成垃圾对象。这个频率还是很快的。随着时间变长,新生代的可用空间会越来越小。

这时候就需要进行垃圾回收,如果需要你来设计进行垃圾回收,会怎么处理呢?下面有几种方式,来循序渐进的找到合适的方法 复制算法

1.2.2. 不合理的思路

估计最普遍的想法就是,存储空间快要满的时候,启动一个后台线程去标记,找到垃圾对象,把垃圾对象清除,空出来存储空间。

not_fit_gc_algorithm01

这么做会有什么问题呢?

not_fit_gc_algorithm02

这样做会产生大量的大小不一内存碎片。内存碎片会造成内存的浪费。

not_fit_gc_algorithm03

例如,回收一个小的对象,释放出来的空间很小,如果后面新创建对象比较大,小空间放不进去,长此以往,会导致内存中有很多这种小空间,整体剩余内存空间是足够的,但是没有一快完整的空间来放对象。

1.2.3. 比较合理的思路:复制算法

怎么来避免产生大量内存碎片呢,有一个比较合理的思想,就是将内存分成两块 s1 和 s2。

gc_copy_algorithm01

产生新的对象,都先集中放在其中一块区域 s1 内,当快要满的时候,进行垃圾回收,找到存活对象。

gc_copy_algorithm02

将存活对象复制到另一块空白区域 s2 内,对象是紧挨着的,不会产生内存碎片,复制好后,新产生的对象也放在 s2 区域,然后整体释放 s1 区域。

这就是复制算法,解决了产生内存碎片的问题,两块内存循环重复使用。

该方式也有缺点,就是内存空间利用率太低,一直有一半的内存是没有使用的。

1.2.4. 优化后的复制算法

结合程序运行背景来思考一下,实际项目中,产生大量的对象,基本上生存周期都是很短的,使用几毫秒,方法结束,对象就会变成垃圾对象。

参考这个背景,一次垃圾回收,大概率 99% 的对象都是垃圾对象,仅仅有少部分才能存活下来。

gc_copy_algorithm_optimize01

新的优化方案是,将内存拆分为 3 个区域(一个 Eden区 和 两个 Survivor区)。其中Eden区占 80% 内存空间,每一块Survivor区各占 10% 内存空间。

最开始内存使用的时候,只使用 E区,S1区、S2区空着。这样相当于内存的大部分空间都被使用了。

当空间快满的时候,会进行垃圾回收,回收 E区 的对象,回收后存活的对象,复制到 S1区 内

gc_copy_algorithm_optimize02

然后 E区 会整体被回收后,调整内存使用为 E区+S1区,S2区空着,新创建对象会放在 E区 。

gc_copy_algorithm_optimize03

当空间又快满的时候,会进行垃圾回收,回收 E区+S1区 的对象,回收后存活的对象,复制到 S2区 内

gc_copy_algorithm_optimize04

然后 E区+S1区 整体被回收后,调整内存使用为 E区+S2区,S1区空着,新创建对象放在 E区 ,始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域

这么做最大的好处,就是 大部分 的内存都被使用上了,无论是垃圾回收的性能,内存碎片的控制,还是内存使用率,都进行了优化。

1.3. 标记整理算法

标记整理算法是针对老年代的垃圾回收算法。

1.3.1. 背景引入

该算法是针对老年代垃圾回收的,现在思考一下,老年代的对象都有什么特点呢?

进入老年代的对象,大部分都是存活很长时间的对象或者比较大的对象。老年代垃圾回收一次之后,还是会有很多对象存活的。所以老年代不能使用复制算法,老年代有针对自己的标记整理算法

1.3.2. 标记整理算法简介

该算法思路比较简单,顾名思义,就是标记一下对象,找出存活对象和垃圾对象,将存活对象移动到一起,然后清除剩下的垃圾对象,释放存储空间。

gc_mark_compact_algorithm01

老年代内对象分布的情况,各种对象很多。

gc_mark_compact_algorithm02

当触发垃圾回收机制时,老年代开始垃圾回收,现标记各种对象,将存活对象移动在一起(不会产生内存碎片),然后回收垃圾对象,释放内存空间。

gc_mark_compact_algorithm03

思考一下,年轻代可以使用标记清理算法吗?

答案是不适合使用,因为年轻代少量对象存活,对象创建使用周期很短,垃圾回收过程需要很快,否则导致整个系统性能问题。标记整理算法整体过程是很慢的,至少比复制算法慢 10倍。

1.4. 结合垃圾回收算法对象流转流程

1.4.1. 对象什么时候进入老年代

了解对象流转流程之前,首先了解一下进入老年代的相关信息。

进入老年代时机1 - 躲过15次GC之后

Java 系统刚启动的时候,创建的各种各样的对象,都是分配在新生代里的。系统运行一段时间,新生代满了,此时会触发Minor GC,可能就1%的少量存活对象转移到空着的Survivor区中。

然后系统继续运行,继续在Eden区里分配各种对象。系统中也有些对象是长期存在的对象,他是不会轻易的被回收掉的,比如全局静态常量等等。

只要引用的类还存在,那么静态变量就会长期存活,所以无论新生代怎么垃圾回收,这种对象都不会被回收掉的。

这类对象每次在新生代里躲过一次GC被转移到一块Survivor区域中,他的年龄就会增长一岁。

默认的设置下,当对象的年龄达到15岁的时候,也就是躲过15次GC的时候,他就会转移到老年代里去。

具体是多少岁进入老年代,可以通过JVM参数-XX:MaxTenuringThreshold来设置,默认是15岁

进入老年代时机2 - 动态对象年龄判断进入老年代

大致规则是,当前放对象的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小的50%,此时大于等于这批对象年龄的对象,就可以直接进入老年代。

假设Survivor2区有两个对象,这俩对象的年龄一样,都是2岁,这俩对象加起来大小超过了50MB,超过Survivor2区的100MB内存大小的一半了,就把Survivor2区里的大于等于2岁的对象,就要全部进入老年代里去。

这就是动态年龄判断的规则,这条规则也会让一些新生代的对象进入老年代。

规则运行的逻辑:年龄max + 年龄max-1 + ... + 年龄n 的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄 n 及以上的对象都放入老年代

无论是15岁的规则,还是动态年龄判断的规则,都是希望可能是长期存活的对象,尽早进入老年代。

进入老年代时机3 - 大对象直接进入老年代

JVM参数-XX:PretenureSizeThreshold,值设置为字节数,如“1048576”字节,就是1MB。该参数来控制直接进入老年代的对象大小

如果创建一个大于这个值大小的对象时,此时就直接把这个大对象放到老年代里去,不会经过新生代。

之所以这么做,就是要避免新生代里出现很大的对象,如果屡次躲过GC,需要大对象在两个Survivor区域里来回复制,才能进入老年代,大对象复制性能很低。所以直接把大对象放到老年代也是很不错的选择。

进入老年代时机4 - Minor GC 后的对象太多无法放入Survivor区进入老年代

年轻代回收后可能会有一种情况,就是Minor GC之后存活的对象很多,一块Survivor区的大小可能放不下

此时该怎么办呢?这个时候这些对象会直接转移到老年代中去。

老年代空间分配担保规则

如果新生代里有大量对象存活下来,Survivor区放不下,必须转移到老年代去,如果老年代里空间也放不下这些对象呢?

首先,在执行任何一次Minor GC之前,JVM会先检查一下老年代可用的可用内存空间,是否大于新生代所有对象的总大小。因为最极端的情况下,可能新生代Minor GC过后,所有对象都存活下来了,新生代所有对象全部要进入老年代。

  1. 如果老年代的内存大小是大于新生代所有对象的,此时可以放心大胆的对新生代发起一次Minor GC,因为即使Minor GC之后所有对象都存活,Survivor区放不下,也可以转移到老年代去

  2. 如果老年代大小小于新生代所有对象大小,或者是-XX:-HandlePromotionFailure参数没设置或者判断担保失败,就会直接触发一次Full GC,对老年代进行垃圾回收,尽量空出来一些内存空间,然后再执行Minor GC

  3. 如果老年代的可用内存已经小于新生代的全部对象大小,-XX:-HandlePromotionFailure的参数有设置。那么就会继续尝试进行下一步担保判断。看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小满足条件。则会冒点风险尝试一下Minor GC,会有如下几种可能。

    -XX:-HandlePromotionFailure判断
        之前每次`Minor GC`后,平均都有10MB左右的对象会进入老年代,如果老年代可用内存大于10MB。
        说明很可能这次``Minor GC``过后也是差不多10MB左右的对象会进入老年代,就认为老年代空间是够的。
    
    1. Minor GC后,存活对象的大小,小于Survivor区域的大小,则存活对象进入Survivor区域。

    2. Minor GC后,存活对象的大小,大于Survivor区域的大小,但小于老年代可用内存大小的,则直接进入老年代。

    3. Minor GC后,存活对象的大小,大于Survivor区域的大小,也大于老年代可用内存的大小。则会发生“Handle Promotion Failure”的情况。触发一次Full GC,对老年代进行垃圾回收,同时一般也会对新生代进行垃圾回收。回收过后,老年代还是没有足够的空间存放Minor GC过后的存活对象,此时就会导致OOM内存溢出。

1.4.2. 结合回收算法对象创建到回收的流程

  1. 新创建对象一般都会在新生代中,当新生代快满的情况下,会Minor GC,垃圾回收后基本上只有少量对象存活,放在空白的 Survivor区,两块 Survivor区交替使用,存放回收后的存活对象,采用复制算法。

    gc_summary_01 gc_summary_02 gc_summary_03 gc_summary_04

  2. 随着系统运行,部分对象会转移到老年代,如下几种情况对象会进入老年代。

    1. 新生代躲过 15次 垃圾回收的对象(JVM参数-XX:MaxTenuringThreshold来设置)
    2. 满足动态年龄判断的对象(年龄max + 年龄max-1 + ... + 年龄n 的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄 n 及以上的对象都放入老年代)
    3. 大对象直接进入老年代(JVM参数-XX:PretenureSizeThreshold,值设置为字节数,如“1048576”字节,就是1MB。该参数来控制直接进入老年代的对象大小)
    4. Minor GC 后的对象太多无法放入Survivor区进入老年代

    gc_summary_05

  3. 当老年代快满的时候会发生垃圾回收,采用标记清理算法。新生代回收的时候也会判断老年代空间情况。

    1. 老年代空间大于新生代整体空间,放心进行Minor GC
    2. 老年代空间小于新生代整体空间、-XX:-HandlePromotionFailure参数没设置或者判断担保失败,就会直接触发一次Full GC,对老年代进行垃圾回收,尽量空出来一些内存空间,然后再执行Minor GC
    3. 老年代空间小于新生代整体空间,-XX:-HandlePromotionFailure的参数有设置。进行下一步担保判断。判断老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小,满足条件。则会冒点风险尝试一下Minor GC,会有如下几种可能。
      1. Minor GC后,存活对象空间小于Survivor区域的大小,则存活对象进入Survivor区域。
      2. Minor GC后,存活对象空间大于Survivor区域的大小,但小于老年代可用空间,则直接进入老年代。
      3. Minor GC后,存活对象空间大于Survivor区域的大小,也大于老年代可用空间。则发生“Handle Promotion Failure”。触发一次Full GC,对老年代进行垃圾回收,同时一般也会对新生代进行垃圾回收。回收过后,老年代还是没有足够的空间存放Minor GC过后的存活对象,此时就会导致OOM内存溢出。

    gc_summary_06 gc_summary_07

results matching ""

    No results matching ""