What’s JVM- 垃圾收集器与内存分配策略

3.1. 对象存在与否

3.1.1. 引用计数算法

  • :green_apple:给对象添加一个计数器,每次引用就把计数器+1;引用失效,计数器-1;当计数器为 0,释放对象。
  • :apple:但是它很难解决对象之间的循环引用问题。
  • 3.1.2. 可达性分析算法

  • 选定一些对象作为根节点,称为 GC Roots,每次从根节点开始遍历,遍历后所有不可达节点(对象)就是不可用的,需要回收。
  • 这个从根节点开始的路径链称为“引用链”。
  • 既然可达性分析判定一个对象是否应该回收取决于根节点是否可达,那么根节点的选取就变得尤为重要。在 Java 中,根节点(GC Roots)可以为如下几种:

    1. 虚拟机栈引用的对象,比如各个线程调用的方法堆栈中的方法参数,局部变量,临时变量。
    2. 方法区中类静态属性引用的对象。
    3. 方法区中常量引用的对象。
    4. 本地方法栈中引用的对象。
    5. JVM 内部的引用,比如基本数据类型对应的 Class 对象,常驻的异常对象。
    6. 所有被同步锁持有的对象。
    7. 反应 JVM 内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。
  • :pear:除了这些可以固定作为 GC Roots 的对象之外,还有其他对象可以临时性的加入。比如进行区域回收时,可能就需要把那些跨区域引用的对象的对象一块放到 GC Roots 集合进行扫描。
  • 其实可以看到,所谓 GC Roots 更像是活跃对象集合哪些对象是存活的,就可以作为 GC Roots

    3.1.3. 再谈引用

  • :tangerine:Java 原本对引用的定义和 C 的指针一样:reference 如果存储的值表示一个内存的地址,那么 reference 就是一个对象的引用。
  • :lemon:但是在 JDK2.0 之后,觉得这样的定义有点不妥,于是引入了四种引用类型。
  • 它们分别是:

    1. 强引用就是最传统的定义,就是最基本的 new 对象然后赋值。任何时候,只要存在强引用,对象就不会被回收。
    2. 软引用就是那些非必需的对象,在内存即将用尽时,会把这些对象进行第二次回收,如果还不够,则抛异常。
    3. 弱引用更弱,它撑不过垃圾回收,任何只被弱引用关联的对象都会在垃圾回收时被回收( ThreadLocalMap 的值就是这种类型,为了防止内存泄漏而引入)。
    4. 虚引用只能用来在对象被回收时得到一个通知,仅此而已。

    3.1.4. 回收方法区

  • :banana:方法区的垃圾回收主要回收两个部分:废弃的常量和不再使用的类型(多用于动态代理生成的动态类型)。
  • :watermelon:回收常量很简单,就是看是否还存在对它的引用。但是回收类型比较复杂且收益低。
  • 对于类型的回收,需要判断一个类型是否属于不再使用的类,条件会比较苛刻:

    1. 该类及其子类的所有实例都被回收。
    2. 加载该 类的类加载器 已经被回收。
    3. 该类的 Class 对象没有在任何地方被引用

    在使用了大量的动态代理,反射,CGLib 的地方,类型回收就显得比较重要。

    3.2. 垃圾收集算法

  • :grapes:垃圾收集算法有引用计数式垃圾收集和 追踪式垃圾收集
  • :strawberry:因为当前主流的垃圾收集都是后者,所以下面的讲解也是围绕后者展开的。
  • 3.2.1. 分代收集理论

  • 目前主流的理论(更多像是经验总结)主要有两个:弱分代假说,强分代假说。
  • :melon:弱分代假说:绝大多数对象都是朝生夕灭的(越往后越容易死亡)。
  • :cherries:强分代假说:熬过越多次垃圾回收的对象越难死亡(越往后越不容易死亡)。
  • 收集器根据这两个假说把 Java 堆划分成了不同区域,根据对象的年龄(指对象熬过垃圾回收的次数)划分出了两个主要的区域: 新生代老年代

    新生代关注的更多是如何保留少量存活,使用弱分代假说;而老年代则可以使用更低的频率来回收,使用强分代假说。新生代回收之后剩下的对象会逐步晋升代老年代。

  • :peach:跨代引用假说:跨代引用相比于同代引用只占少数。这个理论很容易得到推导:如果老年代引用了新生代,随着引用的存在和老年代对象的长久存活,被它引用的新生代对象也会一直存活然后称为老年代。
  • 因为跨代引用假说的存在,所以可以把老年代划分出不同区域,这样在进行 Minor GC 时,仅仅把这些区域内的老年代加入到 GC Roots,以它们为根节点进行扫描。

    3.2.2. 标记-清除算法

  • 标记就是判断对象是否属于垃圾的过程,这个可由可达性分析算法进行标记,在此不再描述。
  • :pineapple:清除就是把标记过的区域清空。
  • 这个算法足够简单,但是它有两个缺点:

    1. 执行效率不稳定,它的执行效率随着堆中需要回收的对象数量增加而下降。
    2. 第二个是 碎片化问题 ,过多的碎片会导致没有足够大的空间分配对象进而触发进一步垃圾收集。

    3.2.3. 标记-复制算法

    为了解决标记清除算法的执行效率不稳定问题,引入了标记-复制算法。

  • 标记还是判断对象是否需要回收。
  • 复制则是把需要保留的对象复制到另一半区域。
  • 此算法会把堆分为两个区域,其中一个保留,另一个存放对象;每次垃圾回收就把需要存活的对象复制到另一半内存,然后清空整个内存半区,这样这一半就完全空闲,而且所有的存活对象都会整齐地排列在另一半中。下次同样这样操作。

    这种算法的缺点显而易见:每次只有一半内存得以使用,未免有点太浪费空间了。

    为了解决这个问题,可以把内存划分比例换一下,因为统计发现,绝大多数新生代对象撑不过第一轮垃圾回收。

    目前有一种更好的解决方案:把内存划分成两个较小的 Survivor(以下简称 S)和一个较大的 Eden(以下简称 E)。 每次只使用一个 S 和一个 E 来分配内存 。垃圾回收时,把这个 E 和 S 上的存活对象移动到另一个 S 上,然后清空 E 和刚刚那个 S。

    凡事总有个例外,如果 S 不够容纳一次 GC 之后的存活对象,就需要老年代的部分区域来存放。这部分实现的安全性由 JVM 担保。

    由此可见这个算法主要用于新生代的 GC。

    3.2.4. 标记-整理算法

  • :tomato:标记依旧是判断对象是否需要回收。
  • :eggplant:而整理则是把存活对象移动到一端,然后直接清除边界以外的内存区域即可。
  • 这个算法如果用在老年代上的话,估计不是很理想,因为老年代存活对象太多了。而且这个算法在移动对象时,必须暂停用户程序,就像 JOJO 里 Dio 喊出:The World!全世界冻结一样。然后搬运它需要搬运的对象。

    是否移动对象有弊有利。移动的好处在于内存空间整齐,方便新空间的申请,且加大系统吞吐量;弊端就是 会因为移动对象而产生过多的停顿

    还有一种中和式,就是在内存碎片多到无法容忍时进行整理,CMS 便是这样的原理。

    3.3. HotSpot 算法细节

    3.3.1. 根节点枚举

  • 在 JVM 里,固定可作为根节点的有全局性引用和执行上下文(栈帧中的本地变量表)。
  • 迄今为止, 所有的根节点枚举都是需要暂停用户程序的
  • 通过一种称为 OOPMap 的数据结构,JVM 可以快速得知所有引用的位置
  • 因为在 类加载时会确定对象偏移量多少的位置上,数据是什么类型 ,加上即时编译时,也会记录栈和寄存器里哪些保存的是引用类型,所以最终可以 直接得到所有引用的位置 。然后把它们加入到 OOPMap,进而选出根节点。

    JIT 记录 OOPMap 具体过程:一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点(见下面)。 GC 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OOPMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OOPMap,通过栈中记录的被引用对象的内存地址,即可找到所有的 GC Roots。

    OOPMap 还可用作准确式 GC(以下内容为 转载 )。

    1. 保守式 GC 在进行 GC 的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断它是不是可能是指向 GC 堆中的一个指针(这里会涉及上下边界检查(GC 堆得上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是 4 字节对齐,那么不能被 4 整除的数字就肯定不是指针),之类的)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向 GC 堆中的指针,所以被命名为保守式 GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,它存在下面两个明显的缺点:
  • 因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC 也就自然不会回收他们,从而引起了无用的内存占用,就是典型的占着茅坑不拉屎,造成资源浪费。
  • 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式 GC 的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK 的 Classic VM 用过这种全 handle 的设计,但效果实在算不上好。
    1. 准确式 GC

    与保守式 GC 相对的就是准确式 GC,何为准确式 GC?就是我们准确的知道,某个位置上面是否是指针,对于 Java 来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向 GC 堆的引用,包括栈和寄存器里的数据。

    上看了下说是实现这种要求的方法有好几种,但是在 java 中实现的方式是:从我外部记录下类型信息,存成映射表,在 HotSpot 中把这种映射表称之为 OOPMap,不同的虚拟机名称可能不一样。

    实现这种功能,需要虚拟机的解释器和 JIT 编译器支持,由他们来生成 OOPMap。生成这样的映射表一般有两种方式:

  • 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
  • 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。总而言之,GC 开始的时候,就通过 OOPMap 这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。
  • 3.3.2. 安全点

  • 因为在一个程序里,导致引用关系变化的指令非常多,所以不可能为此一一记录,那样会造成过多空间来记录。由此引入安全点。
  • 安全点的意义在于,引用关系时常变化,做不到对于所有引用关系的记录,于是当需要 GC 时就把程序停靠在安全点上,然后统计 GCRoots。
  • 安全点的选择基本遵循“是否具有让程序长时间执行的特征”来选择的,比如循环,异常抛出,方法调用等。只有具有这些特征的指令才会被放置安全点。说白了就是 如果一段代码会长时间执行 (比如循环,或者方法调用),那么我们 不能等到这段代码执行完再添加安全点只能把安全点插入到这段代码内部 ,以此防止它长时间执行影响 GC;比如插入在循环块内最后的位置,方法返回的位置(相当于在方法与方法之间插入)。

    另一个问题是,怎么让程序在安全点停下来?有两个方案,抢占式中断和主动式中断。前者基本不再使用,来看看后者。

    主动式中断比较简单,在安全点前面添加一个 test 汇编指令,当希望线程停下时,就通过 JVM 把 test 后面的地址设置为不可读,这样就可以触发线程产生自陷异常,被预先注册好的处理程序挂起。

    在这里可以看出,所谓的枚举 GC Roots 时发动 The World 能力是通过让线程发生中断来实现的。

    线程中断的位置是离它最近的安全点,所以 GC 开始枚举时必须等待所有线程全部跑到最近的安全点才行(此时忽略那些非运行的线程)。因为安全点有很多,所以当 JVM 发动能力后,很快啊!所有还在运行的线程基本马上立刻就停了。

    然后更新 OOPMap,进行枚举 GC Roots。

    3.3.3. 安全区域

  • :corn:安全点很好的解决了程序运行时的 OOPMap 更新问题,但是如果程序不在运行,而是在阻塞或者被调度离开 CPU 怎么办?此时就需要安全区域。
  • 安全区域指的是在某个代码片段内,引用关系不会发生变化,因此在这个区域任何一个位置进行 GC 都是安全的。
  • 安全区域具体过程:

  • 😮ne:当程序进入安全区域时,首先进行标记,表明自己到了安全区域,此时 GC 就可以忽略这些在安全区域内的程序了;
  • :two:然后当它准备离开时,会判断 GC 是否完成了根节点的枚举,如果未完成,则等待,否则继续执行。
  • 3.3.4. 记忆集

  • 记忆集是为了 解决跨代引用的问题 而引入的,有了记忆集,就可以 知道哪块区域存在跨代引用
  • 记忆集一般有这三种:

    1. 😮ne:字长精度:精确代机器字长,就是处理器寻址位数,查看该字是否包含跨代指针。
    2. :two:对象精度:精确到每个对象,查看对象是否含有跨代指针。
    3. :three:卡精度:精确到某个内存区域,查看这个内存区域是否含有跨代指针。

    其中,卡精度是使用最多的,它有点像分页,把内存划分成固定大小的区域,然后 总 内 存 大 小 / 每 个 内 存 区 域 大 小 = 卡 表 长 度

    如果这块内存区域含有跨代指针,则卡表对应元素为 1,否则为 0。

    3.3.5. 写屏障

  • 写屏障的存在是为了实现对卡表的更新,它使用类似 AOP 的技术。
  • 首先,写屏障会对引用赋值代码进行 AOP 切入,使用的是环形通知(Around)。赋值代码之前的部分是 写前屏障 ,而之后的成为 写后屏障

    在应用了写屏障技术后,JVM 就会为赋值指令生成相应的更新卡表指令。

    但是还有一个问题

    声明:本站部分文章内容及图片转载于互联 、内容不代表本站观点,如有内容涉及侵权,请您立即联系本站处理,非常感谢!

    (0)
    上一篇 2021年7月9日
    下一篇 2021年7月9日

    相关推荐