写点什么

☕JVM 技术之旅 - 带你认识 GC 回收的原理

发布于: 2021 年 05 月 04 日
☕JVM技术之旅-带你认识GC回收的原理

一、概述

  • Java 虚拟机的内存区域中,程序计数器、虚拟机栈和本地方法栈三个区域是线程私有的,随线程生而生,随线程灭而灭。

  • 栈中的栈帧随着方法的进入和退出而进行入栈和出栈操作,每个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这三个区域的内存分配和回收都具有确定性

  • 垃圾回收重点关注的是堆和方法区部分的内存。

二、如何判断对象是否可达到性

垃圾收集器在对堆中的对象进行垃圾回收前,必须确定该对象是否已经消亡,即无法被引用到。

常用的垃圾回收算法有

  • 引用计数算法给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;当计数器都为 0 的对象就是不再被使用的,垃圾收集器将回收该对象使用的内存。引用计数算法实现简单,效率很高,微软的 COM 技术、ActionScript、Python 等都使用了引用计数算法进行内存管理,但是引用计数算法对于对象之间相互循环引用问题难以解决,因此 java 并没有使用引用计数算法。

  • 根搜索算法通过一系列的名为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象与 GC Root 之间没有任何引用链相连时,则该对象不可达。该对象是不可使用的,垃圾收集器将回收其所占的内存。

主流的商用程序语言 C#、java 都使用根搜素算法进行内存管理。在 java 语言中,可作为 GC Root 的对

象包括以下几种对象:


  1. java 虚拟机栈(栈帧中的局部变量表)中的引用的对象。

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

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

  4. 本地方法栈中 JNI 本地方法的引用对象。


  • ①.为了解决引用计数法的循环引用问题,java 使用了可达性分析的方法;

  • ②.所谓的"GC Roots"或者 Tracing(跟踪)GC 的"根集合"就是一组必须活跃的引用;

  • ③.基本思路就是通过一系列名为"GC Roots"的对象作为起始点,从这个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明该对象不可达/不可用。


从一个给定的集合的引用作为根节点出发,通过引用关系遍历对象图能被遍历到的对象就被判定位存活/可达没有被遍历到的就自然被判定为死亡/不可达

三、关于对象的引用

从 JDK1.2 版本开始,把对象的引用分为四种级别,从而使程序能更加灵活的控制对象的生命周期。这四种级别由高到低依次为:强引用、软引用、弱引用和虚引用


  1. 强引用以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。JVM 宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题


  1. 软引用(SoftReference)如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中


  1. 弱引用(WeakReference)如果一个对象只具有弱引用,那就类似于可有可物的生活用品弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中


  1. 虚引用(PhantomReference)"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用对象的内存被回收之前采取必要的行动


特别注意,在实际程序设计中一般很少使用弱引用与虚引用,使用软用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

四、java 方法区中的垃圾回收


方法区在 Sun HotSpot 虚拟机中被称为永久代或元空间,很多人认为该部分的内存是不用回收的,java 虚拟机规范也没有对该部分内存的垃圾收集做规定,但是方法区中的废弃常量和无用的类还是需要回收以保证永久代不会发生内存溢出。

判断废弃常量的原则

常量池中的某个常量没有被任何引用所引用,则常量是废弃常量。

判断无用的类的原则

  1. 该类的所有实例都已经被回收,即 java 堆中不存在该类的实例对象。

  2. 加载该类的类加载器已经被回收。

  3. 该类所对应 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

五、常用的垃圾收集算法

  • JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此 GC 按照回收的区域又分了两种类型,一种是普通 GC(MinorGC),一种是全局 GC(MajorGC or FullGC)

  • MinorGC 和 FullGC 的区别

  • 普通 GC(MinorGC):只针对新生代区域的 GC,指发生在新生代的垃圾收集动作,因为大多数 Java 对象的存活率都不高,所以 MinorGC 非常频繁,一般回收速度较快;

  • 全局 GC(MajorGC or FullGC)针对年老代的 GC,指发生在老年代的垃圾收集动作,出现了 MajorGC,经常会伴随至少一次的 MinorGC(但并不是绝对的)。MajorGC 的速度一般要比 MinorGC 慢上 10 倍以上,因为 MajorGC 扫描/清理范围更大;

标记-清除算法

"标记-清除"算法适合用于存活对象较多的场合,如老年代;算法分为标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象。

最基础的垃圾收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有存活的的对象,在标记完成之后统一回收掉没有被标记的对象

优点

①.可以解决循环引用的问题

②.必要时才进行回收

③.不需要额外的空间,节约空间

缺点
  • 首先,效率问题,标记和清除效率都不高。(首先,他的效率比较低(递归与全堆遍历),扫描两次,非常耗时,而且在进行 GC 的时候,需要停止应用程序,这会导致用户体验非常差劲;

  • 其次,标记清除之后会产生大量的不连续的内存碎片空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

复制算法

将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。这样使得每次都是对其中一块内存进行回收,内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

优点

解决空间碎片化问题,快速,清理干净,因为复制的时候是将存活对象复制到一块非常干净的内存区域中,然后将其他的内存区域全部清空;

缺点

空间浪费,因为对可达对象进行复制的时候当前内存区域可能只是使用了一点点,复制之后就会将他全部清除,每次最多也只能使用一半的空间;

如果对象的存活率非常高,我们可以极端一点,假设是 100%存活,那么我们需要将所有的对象都复制一遍,并将所有对象的引用地址重置一遍.复制这一项工作所花费的时间,在对象存活率达到一定程度时,将会变得不可忽视,所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服 50%的内存浪费;

复制算法的优化

将新生代划分为三块区域(Eden,from,to),区域 Eden 空间最大,区域 fromto 空间较小且大小相等,每次使用区域 Eden 以及区域 fromto 其中的一块。

例如,区域 Eden 和区域 from对象的创建都在区域 Eden 中

  1. 区域 Eden 中的大对象(无法直接放在区域 from/to 中的对象)直接进入老年代;

  2. 进行 MinorGC 垃圾回收时,将存活的对象从 Eden 复制到区域 from 中,将他们排列好,复制完成将区域 Eden 清空,此时区域 from 中存放的就是存活对象;

  3. 然后又在区域 Eden 中创建对象,当再次进行 MinorGC 垃圾回收时,直接将区域 Eden 和区域 From 中那些可用/可达的对象复制到区域 to 中(当某个对象的存活时间/次数超过了对象存活的阈值(默认为 15),那么这个对象就会进入老年代),将他们排列好,复制完成将区域 Eden,from 清空

  4. 此时区域 from 就是一块干净的区域,然后将 fromto 两块区域进行角色互换,谁空谁为区域 to,原来的区域 to 就会变成下一 GC 时的区域 from,以此类推,区域 from 和区域 to 不停复制,不停的角色互换,直到区域 to 被填满,区域 to 被填满之后,会将所有的对象移动到老年代中


HotSpot 虚拟机默认 Eden 和 from 的大小比列为 8:1,也就是每次新生代中可用的内存空间为个新生代容量的 90%(80%+10%),只有 10%的内存会被"浪费",当然,我们没有办法保证每次回收都只有不多于 10%的对象存活,当区域 to 空间不够用时,就需要依赖其他的内存(这里指老年代 Old)进行分配担保,也就是说,如果区域 to 没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代;

标记-整理算法

标记-整理算法在标记-清除算法基础上做了改进,标记阶段是相同的标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。

  • 标记-整理算法相比标记-清除算法的优点是内存被整理以后不会产生大量不连续内存碎片问题

  • 标记-整理算法唯一缺点就是效率也不高,不仅要标记出所有的存活对象,还要整理所有存活对象的引用地址,从效率上来说,标记-整理算法要低于复制算法;

复制算法在对象存活率高的情况下就要执行较多的复制操作,效率将会变低,而在对象存活率高的情况下使用标记-整理算法效率会大大提高

以上三种算法的总结

  • 内存效率(针对时间复杂度): 复制算法>标记清除算法>标记整理算法;

  • 内存整齐度: 复制算法=标记整理算法>标记清除算法;

  • 内存利用率: 标记整理算法=标记清除算法>复制算法;

从效率上来说
  • 复制算法是当之无愧的老大,但是却浪费了太多内存,为了尽量兼顾上面的三个指标。

  • 标记整理算法相对来说更加平滑一些,但是在效率上依然不尽人意,他比复制算法多了一个指针移动等判断操作(CAS 机制)的阶段,又比标记清除算法多了一个整理内存的过程;

分代收集算法(标记整理算法+复制算法)

根据内存中对象的存活周期不同,将内存划分为几块,JVM 中一般把内存划分为新生代年老代,当新创建对象时一般在新生代中分配内存空间,当新生代垃圾收集器回收几次之后仍然存活的对象会被移动到年老代内存中,当大对象在新生代中无法找到足够的连续内存时也直接在年老代中创建

JVM 就联合使用了分代复制、标记-清除和标记-整理算法,JVM 垃圾收集器关注的内存结构如下:

堆内存被分成新生代和年老代两个部分,整个堆内存使用分代垃圾收集算法。

(1) 新生代

新生代使用复制和标记-清除垃圾收集算法,研究表明,新生代中 98%的对象是朝生夕死的短生命周期对象,所以不需要将新生代划分为容量大小相等的两部分内存,而是将新生代分为 Eden 区,Survivor from 和 Survivor to 三部分,其占新生代内存容量默认比例分别为 8:1:1,其中 Survivor from Survivor to 总有一个区域是空白,只有 Eden 和其中一个 Survivor 总共 90%的新生代容量用于为新创建的对象分配内存,只有 10%Survivor 内存浪费,当新生代内存空间不足需要进行垃圾回收时,进行垃圾回收后仍然存活的对象被复制到空白的 Survivor 内存区域中,Eden 和非空白的 Survivor 进行标记-清理回收,两个 Survivor 区域是轮换的。

新生代中 98%情况下空白 Survivor 都可以存放垃圾回收时仍然存活的对象,2%的极端情况下,如果空白 Survivor 空间无法存放下仍然存活的对象时,使用内存分配担保机制,直接将新生代依然存活的对象复制到年老代内存中,同时对于创建大对象时,如果新生代中无足够的连续内存时,也直接在年老代中分配内存空间。

Java 虚拟机对新生代的垃圾回收称为 Minor GC,次数比较频繁,每次回收时间也比较短。可以使用

java 虚拟机-Xmn 参数可以指定新生代内存大小

(2) 年老代

年老代中的对象一般都是长生命周期对象,对象的存活率比较高,因此在年老代中使用标记-整理垃圾回收算法。Java 虚拟机对年老代的垃圾回收称为 MajorGC/Full GC,次数相对比较少,每次回收的时间也比较长。

当新生代中无足够空间为对象创建分配内存,年老代中内存回收也无法回收到足够的内存空间,并且新生代和年老代空间无法在扩展时,堆就会产生 OutOfMemoryError 异常。

java 虚拟机-Xms 参数可以指定最小内存大小,-Xmx 参数可以指定最大内存大小,这两个参数分别减去 Xmn 参数指定的新生代内存大小,可以计算出年老代最小和最大内存容量。

(3) 永久代

java 虚拟机内存中的方法区在 Sun HotSpot 虚拟机中被称为永久代或元数据空间,是被各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。永久代垃圾回收比较少,效率也比较低,但是也必须进行垃圾回收,否则会永久代内存不够用时仍然会抛出 OutOfMemoryError 异常。

根据对象的存活周期进行分类,短命对象归为新生代,长命对象归为老年代;根据不同年代的特点,选取合适的收集算法:

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量对象存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用"标记-清理"或者"标记-整理"算法进行回收;


发布于: 2021 年 05 月 04 日阅读数: 36
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
☕JVM技术之旅-带你认识GC回收的原理