JVM的垃圾回收策略

作者: 彳亍口巴 | 来源:发表于2020-07-28 09:49 被阅读0次

    一、概念

    这里说的GC回收,指的是 Java 堆的地方,我们知道了程序计算器,虚拟机栈和本地方法栈都是随线程开启,随线程关闭的,因此这几块区域的内存分配和回收都具备确定性。而Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有程序在运行时,才知道创建了哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

    而 GC 关注的也就3个点

    1、哪些对象需要回收

    2、什么时候回收

    3、如何回收

    二、哪些对象需要回收

    怎么判断对象是“存活” 的,还是已经"死亡"呢?主要有以下方法:

    2.1 引用计算算法

    给对象添加一个引用计算器,每当有一个地方引用它,则加1,当引用失效,则减1;任何时刻计算器为0的对象就是不可能再被使用的。但它很难解决对象之间相互循环引用的问题,所以主流的Java虚拟机都没有采用这种算法。

    2.2 可达性分析算法

    通过一系列的 “GC Roots” 的对象作为起始点,从这些起始点开始向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots 没有任何引用链项链,即GC Roots 不可达,则证明此对象是不可用的,如下图(java 虚拟机第二版)

    image

    在 Java 虚拟机中,可作为 **GC Roots **的对象包含以下几种

    1、虚拟机栈(栈帧中的本地变量表)中引用的对象

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

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

    4、本地方法栈中 JNI (Native方法)引用的对象

    HotSpot的可达性分析

    HotSpot 虚拟机在实现上面的算法的时候,必须要经过更严格的考量,才能保证虚拟机高效运行.比如,在上面的可达性分析中,就存在执行效率的问题:

    1.从 GC Roots 节点找引用链,可是现在很多应用的引用比较复杂,比如方法区就有数百兆,如果要逐个检查这里面的引用,必然消耗 很多的时间.

    2.为了保证整个分析期间整个执行系统被冻结,而不产生新的引用,会导致java 执行线程停顿(stop the world).

    为了解决上面的两个问题:

    1、准确式GC 枚举根节点,使用一组**OopMap **的数据结构来存放对象引用,这个数据结构在类加载完成的时候,就已经计算出来了,GC在扫描 的时候就可以得知这些信息,从而降低 GC Roots 时间以及减少停顿时间.

    **2、安全点 ** **OopMap 中的引用关系可能会变化.或者 OopMap 的指令太多,反而需要更多的空间.此时解决方案是,OopMap 会根据虚拟机 选定的安全点(safepoint,可以简单理解为执行到哪一行),在这个安全点内去生成指令的 OopMap.在 GC 的时候,驱使所有的线程都"跑 "到最近的安全点,STW **才发生,应用才停顿.

    3、安全区域 对于挂起的线程来说,比如处于 sleep 或者 blocked 状态的,是不能"跑"到安全点的,那么此时解决方案就是,增大安全域(Safe Region).如果线程已经达到安全域,做一个标记,GC 就不需要管这些线程.

    引用判断

    无论是引用计数算法,还是可达性分析算法,对象是否存货都跟 “引用” (reference)有关,在JDK1.2 之后,引用可分为以下4个

    强引用:直接 new ,如 new Object(); 只要这类强引用还在,对象就不会回收

    软引用:(SoftReference类)用来描述一些还有用但非必需的对象;当将来发生内存溢出之前,系统会把这些有软引用的对象列入回收范围中进行二次回收,如果这次回收还没有足够的内存,则内存溢出报异常;

    弱引用:(WakeReference类) 被弱引用的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器开始工作时,无论内存是否足够,都会对这些对象进行回收。

    虚引用(幽灵引用/幻影引用):(PhantomReference类) 一个虚引用的对象,它的唯一目的就是能在这个对象被收集器回收时收到一个系统通知,即一个对象是否有虚引用,完全不会对其生存时间构成影响。

    三、什么时候回收

    对象的自我拯救

    在可达性分析算法不可达的对象,也不一定"非死不可’,它会经历两次标记,一是当不可达 GC Roots 时,标记一次并筛选,筛选的条件是该对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或者已经执行过 finalize 方法时,则认为此对象会被回收。如下图

    image

    这里的执行,是指虚拟机会触发这个 finalize 方法,但并不会等待它结束,这是因为 finalize 方法执行缓慢,可能会导致 F-Queue其他对象处于等待,甚至是崩溃。

    如果在执行 finalize 时,对象重新和其他对象关联上了,则成功拯救了自己

    注意:任何一个对象的 finalize() 只会被系统调用一次,下次不会再执行。

    3.2 回收方法区

    上面都是对 Java 堆进行回收,虽说 Java 堆 可以回收70%~95%的空间,但方法区同样可以回收一些资源,方法区主要回收两个部分废弃常量和无用的类。

    废弃常量:

    当前系统没有任何一个 String 对象引用这个 “abc” 的常量池,也没有其他地方引用了这个字面量,这时可以判断这个常量是可以废弃回收的;其他常量池中的接口,字段的符号引用也以此类似

    无用的类:

    无用类的回收,需要满足三个条件

    1、是该类所有的实例都已经被回收,也就是Java堆中不存在实例

    2、加载该类的 ClassLoader 已经被回收

    3、对应的 java.lang.class 对象没有再任何地方被引用,也无法通过反射拿到该类

    当然,这里跟Java堆一样,也只是 "可以回收"了,是否对类进行回收,可以对虚拟机的参数进行设置,这里就不细讲了。

    四、如何回收

    4.1 标记-清除算法

    这里的标记指的是对象进过前面第三章我们介绍的那样,已经可以判定就是可以回收的意思;这个算法首先标记处所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。

    看似美好,实则不然,主要有以下两个缺点:

    效率问题:标记和清除两个效率都不高

    空间问题:标记清除后,会产生大量空间碎片,在大对象需要分配空间时,找不到内存,从而又触发 GC 操作。

    标记和清除的执行过程如下图:

    image

    4.2 复制算法

    复制算法可以分为等比例的和8:2两种

    4.2.1 1:1 比例

    基于 标记-清除算法的效率问题,复制算法出现的。

    这种算法是把内存分为相等的两块,一块用来存储对象,当GC操作后,把还存留的对象移动到未存对象的那块内存区域,再把使用过的内存清掉。这样每次都对整个半区进行内存回收,就不用担心内存碎片的问题了。执行过程如下图:

    image

    从执行过程来看,要消耗到一半的内存,怎么想都是浪费的,在对象多时,会频繁触发GC。

    4.2.2 8:1 比例

    在说明这些回收机制之前,先说说 新生代和老年代的问题。那么这个分代算法是怎么回事呢?

    首先先要理解,新生代和老年代都是一个内存空间,由参数配置,只是可以根据算法,决定对象是在新生代还是在老年代的内存区域!!!

    一块内存可以分为3个区域,一个 **Eden **和两个 **Survivor **区,当对象在 Eden 创建,并经理了第一次 GC 之后仍然存活,并且能被 survivor 区容纳的话,将移到 survivor 区;对象在Survivor 区中“熬过”一次,年龄增加1,当增加到 15 岁(默认,这个阈值可以通过 -XX:MaxTenuringThreshold 设置),就会晋升成老年代的对象。

    从这里来看,可以得到两个结论

    新生代:对象少,垃圾多

    老年代:对象多,垃圾少 (毕竟经历了10几次GC的老油条)

    由于上面的 1:1 的铺张浪费,基本主流的 Java 虚拟机都是采用 一个 Eden 和 两个 Survivor 空间。因为新生代的对象 98% 都是"朝生夕死"的,每次都是用 Eden 和 一块 Survivor 空间;

    每次GC之后,还存活在 Eden 和 Survivor 空间的对象会被移动到另外一块Survivor上,并清掉 Eden 和刚才用过的 Survivor 空间。当Survivor 空间不够用时,还得依赖其他内存(这里指老年代),进行分配担保。即存活下来的内存不够放新生代上,只有移动到老年代的内存空间上。

    举个简单例子:假如虚拟机中设置了新生代的内存大小为10M,老年代的也为10M,Eden 和 Survivor 为 8:1 的关系,那么Eden就只有8M,Survivor 为1M;下面创建4个对象

    image

    执行 test() ,当要分配 b4 对象时,会执行一次 Minor GC,原因是 Eden 才 6M,被b1,b2,b3填充之后,已经没有数据去填充 b4了,就会触发 GC,而b4没办法,只有移动到老年代的内存区域了。

    那我们可以得到,** 对象存活率较高的情况下,效率较低;如果不想浪费 50% 的控件,就需要额外的控件进行分配担保,也就是内存跑到老年代去了,那这个对象没进行GC就跑到老年代去了,那肯定是很占内存的**

    所以就有了 标记-整理算法

    4.3 标记-整理算法

    标记整理算法,是针对老年代的。它与标记-清除算法一样,但不是对相对进行清除,而是移动到另一端,分两个步骤

    1、标记那些被引用的对象

    2、将被标记的对象移动按顺序移动到一端,然后清除掉可回收的对象

    image

    4.4、分代收集算法

    在商业的虚拟机中,都采用 分代收集算法,根据对象存活周期将内存划分为几块,即新生代和老年代;

    在新生代中,每次都有大量对象死去,只有少量活着,就选用复制算法;而老年代的对象存活率比较高,没有额外空间对它进行分配担保,就必须使用 “标记-整理”或者“标记-清理” 进行回收。

    虚拟机是如何管理新生代和老年代

    虚拟机一般是这样管理新生代和老年代的:

    1.当一个对象被创建的时候(new)首先会在年轻代的 Eden区被创建,直到当GC 的时候,根据可达性算法,看一个对象是否消亡,没有消亡的对象会被放入新生代的 Survivor 区,消亡的直接被Minor GC(次要的,普通的 GC) Kill 掉.

    2.进入到Survivor 区的对象也不是安全的,当下一次 Minor GC 来的时候还是会检查 Enden 和 Survivor 存放对象区域中对象是 否存活,存活放入另外一块 Survivor 区域.

    3.当 2 个 Survivor 区切换几次以后,会直接进入老年代,当然进入到老年代也不是安全的,当老年代内存空间不足的时候,会触发Major GC(主要的,全局的 GC),已经消亡的依然还是被 Kill 掉.

    五、垃圾收集器

    按系统线程分:

    串行收集:串行收集使用单线程处理所有垃圾回收工作, 因为无需多线程交互,实现容易,而且效率比较高。但是,其局限性也比 较明显,即无法使用多处理器的优势,所以此收集适合单处理器机器。当然,此收集器也可以用在小数据量(100M左右)情况下的 多处理器机器上。

    并行收集:并行收集使用多线程处理垃圾回收工作,因而速度快,效率高。而且理论上CPU 数目越多,越能体现出并行收集器的 优势。

    并发收集:相对于串行收集和并行收集而言,前面两个在进行垃圾回收工作时,需要暂停整个运行环境(STW),而只有垃圾回收程序在运行,因此,系统在垃圾回收时会有明显的暂停,而且暂停时间会因为堆越大而越长。

    7种垃圾收集器

    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现.java 虚拟机规范中没有对垃圾收集器应该如何实 现做任何规定,因此,不同的厂商可能会有很大的差别.

    新生代:Serial, ParNew, Parallel Scaveage,G1

    年老代:CMS, Serial Old, Parallel Old, G1

    image

    Serial/Serial Old 收集器

    串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法老年代标记-整理;垃圾收集的过程中会Stop The World(服务暂停)

    参数控制:-XX:+UseSerialGC 串行收集器 + Serial Old

    image

    ParNew 收集器

    ParNew 收集器其实就是 Serial 收集器的多线程版本.用户线程需要等待(STW).

    参数控制:-XX:+UseParNewGC ParNew 收集器 + Serial Old

    -XX:ParallelGCThreads 限制线程数量

    image

    Parallel/Parallel Old 收集器

    Parallel Scavenge 收集器类似 ParNew 收集器,Parallel 收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,

    虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数

    控制 GC 的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

    参数控制:-XX:+UseParallelGC 年轻代使用 Parallel 收集器 + 老年代串行(Serial Old)

    Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供.

    参数控制: -XX:+UseParallelOldGC 年轻代使用 Parallel 收集器 + 老年代并行(ParallelOld)

    注意:此时用户线程处于等待状态(STW).

    image

    CMS 收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用都集中

    在互联网站或 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

    从名字(包含“Mark Sweep”)上就可以看出 CMS 收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收

    集器来说要更复杂一些,整个过程分为 4 个步骤,包括:

    初始标记(CMS initial mark)

    并发标记(CMS concurrent mark)

    重新标记(CMS remark)

    并发清除(CMS concurrent sweep)

    其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

    由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发地执行。是一个老年代收集器(新生代使用 ParNew)

    优点:并发收集、低停顿

    缺点:使用标记--清除算法,产生大量空间碎片、并发阶段会降低吞吐量, 对 CPU 资源敏感. 无法收集浮动垃圾,需要预留一部分内存在 GC 的时候供程序运作,如果预留空间不足,可能会出现”Concurrent Mode Failure”失败而导致触发一次 Full GC.

    参数控制:

    -XX:+UseConcMarkSweepGC 使用 CMS 收集器

    -XX:+ UseCMSCompactAtFullCollection Full GC 后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长

    -XX:+CMSFullGCsBeforeCompaction 设置进行几次 Full GC 后,进行一次碎片整理

    -XX:ParallelCMSThreads 设定 CMS 的线程数量(一般情况约等于可用 CPU 数量)

    image

    G1 收集器

    G1 是目前技术发展的最前沿成果之一,HotSpot 开发团队赋予它的使命是未来可以替换掉 JDK1.5 中发布的 CMS 收集器。与CMS 收集器相比 G1 收集器有以下特点:

    1. 空间整合,G1 收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次 GC。

    2. 可预测停顿,这是 G1 的另一大优势,降低停顿时间是 G1 和 CMS 的共同关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

    上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region 的集合。

    优点:并行与并发、分代收集、空间整合、可预测停顿。

    步骤:

    初始标记(Initial Marking)

    并发标记(Concurrent Marking)

    最终标记(Final Marking)

    筛选回收(Live Data Counting and Evacuation)

    image

    六、理解GC日志

    阅读 GC 日志是处理 java 虚拟机内存问题的基本技能,它只是一些人为确定的规则,没有太多的技术含量.

    在现实应用中,比较常见的组合使用大概就四种:

    年轻代和老年代均使用 Serial GC。

    年轻代和老年代均使用 Parallel GC。

    年轻代使用 ParNew GC,老年代使用 CMS 收集器。

    不进行年轻代和老年代区分,使用 G1 收集器。

    七、内存分配与回收策略

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

    从上面可以看,假如一个 2MB 的数据,这个短命的大对象在新生去上朝生夕死,很容易触发 GC ,所以可以通过设置PretenureSize 设置 阈值,对象大于这个参数,直接进去老年代

    2、 长期存活的对象将进入老年代

    一块内存可以分为3个区域,一个 Eden 和两个 Survivor 区,当对象在 Eden 创建,并经理了第一次 GC 之后仍然存活,并且能被 survivor 区容纳的话,将移到 survivor 区;对象在Survivor 区中“熬过”一次,年龄增加1,当增加到 15 岁(默认,这个阈值可以通过 -XX:MaxTenuringThreshold 设置),就会晋升成老年代的对象。

    从这里来看,可以得到两个结论

    新生代:对象少,垃圾多

    老年代:对象多,垃圾少 (毕竟经历了10几次GC的老油条)

    3、 动态对象年龄判断

    上面说到默认年龄为 15 才进入老年代,其实并不然,只要Survivor 中的对象总和大于Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。如下:

    image

    当运行 test() ,发现 Survivor 依旧为0,b1和b2进入进入老年代,因为它们是同龄的。

    4、 空间分配担保

    在发生GC前,虚拟机会先检查老年代的可用连续空间是否大于新生代的所有对象,如果成立,则发生 GC 是安全的。如果不成立,则会看是否允许担保失败,如果不允许,则进行 Full GC;如果允许,则会继续检查老年代的可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则进行 Minor GC,如果小于,则进行 Full GC ,但如果某次 Minor GC 后存活的对象大于平均值,会导致担保失败,失败之后,也会进行Full GC。

    引用(本文章只供本人学习以及学习的记录,如有侵权,请联系我删除)

    类加载过程

    深入Java虚拟机之 -- 类加载机制

    相关文章

      网友评论

        本文标题:JVM的垃圾回收策略

        本文链接:https://www.haomeiwen.com/subject/jseqrktx.html