JVM

JVM学习笔记二

Java垃圾收集器与内存分配策略

Posted by Emo on July 20, 2022

JVM

EMO’s Blog

程序计数器还有栈空间都是线程独占的,在内存分配和垃圾回收上他们不太需要考虑。

  • 栈空间的内存大小在编译初期就能够确定
  • 栈空间的内存随着方法或者线程进行回收

但是堆空间不一样,因为接口涉及到的各种不同实现会导致不同的内存占比,基本不可能在编译初期就决定好内存空间,所以动态的内存分配策略是很需要的。除此之外,堆空间是线程共享的,所以也无法随着线程的销毁而进行垃圾回收,我们也需要有一个垃圾回收策略来确保哪些对象是已经不需要再使用了。

垃圾回收策略

对象已死?

在垃圾回收中,我们需要判断对象是否“存活”。如果一个对象不会再被使用到了,那么他就“死亡”了,可以被回收掉

引用计数算法

这是一种很常见的简单的垃圾回收算法。开辟额外的内存空间,为每个对象构建一个计数器。如果产生了一个该对象的引用,则计数器+1,如果一个引用失效了,则该计数器-1。当计数器值为0是,意味着所有该对象的引用的已经失效了,那么就可以进行垃圾回收了。

这种方式虽然简单好用,但是有一些很明显的弊端,也就是需要处理大量的特殊情况。比如循环引用:

objA中存在objA.instance = objBobjB中存在objB.instance = objA。当他们只有彼此的互相引用时,原则上他们两个都已经“死亡”了,但是却不会被引用计数算法回收掉。

可达性分析算法

这是一种Java,C#等语言中比较常用的一种GC算法。其原理是通过一些起始的GC ROOTs对象开始,往下延伸引用,如果一个对象不存在一条到达GC ROOTs的路径,那么认为这个对象是不可达的,则可以回收掉。在这里我们可以思考一下引用计数算法中提到的那个特例,在这里就无法生效了。因为即使objAobjB间存在互相引用,但是他们没有一条能到达GC ROOTs的路径。当然这两个对象都不具备成为GC ROOTs的条件。GC ROOTs有以下几种:

  • 栈空间当中引用的对象
  • 方法区当中的静态属性或者常量引用的对象
  • Java虚拟机的内部引用
  • 被同步锁持有的对象
  • 反应虚拟机内部情况的对象

不可达对象死亡,两次标记法

当可达性分析算法筛选出一个对象不可达后,不会立马清除它,会对它进行一次标记,而后丢进F-Queue中去排队执行其finalize方法(条件是finalize方法被覆写且未被调用过),如果该对象在finalize方法中又重新构建了引用联系,该对象将被拯救。之后,算法将在F-Queue中进行第二次标记,被标记了两次的对象将被回收。

再谈引用

无论是上诉的哪种算法都涉及到一个很重要的东西,引用。

一开始对于引用的定义,局限于

如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,那么称其为那块内存、某个对象的引用。

这种定义让一个对象只能存在被引用未被引用两种状态。当我们定义一个可被抛弃的对象(当内存空间不够时可以清除)时,光是这两种状态就不够了

后来Java将引用扩充为了四种引用强度:

  1. 强引用:存在类似“Object obj = new Object()”这种引用关系的。这种引用对象绝对不会被清除
  2. 软引用:还有用但非必须,当即将发生内存溢出异常之前,会提前将这些引用对象清除
  3. 弱引用:比软引用还要弱一点,引用对象只会被保留到下一次垃圾回收发生之前,无论内存是否充足
  4. 虚引用:最弱的一种引用,不会对对象的回收产生任何影响,只是为了在对象被回收时收到一个提醒。

方法区GC

方法区中的 GC效率比较低下,Java规范中不要求对此进行设计,但是这部分也是可以进行回收的。

方法区中涉及到需要回收的有两个部分:

  • 废弃常量 这个回收就跟堆中的资源回收差不多
  • 无用类型 这个的回收判定就比较复杂,因为其要求的条件比较多,有以下三个:
    1. 该类的所有实例都已被回收
    2. 该类的类加载器已被回收
    3. 该类对应的java.lang.Class对象没有在任何地方被引用过,无法在任何地方通过反射访问该类的方法。

垃圾回收算法

GC算法根据使用的GC策略可以分为两种:

  • 引用计数式垃圾回收(直接垃圾收集)
  • 追踪式垃圾回收(间接垃圾收集) 引用计数式垃圾回收使用场景有限,主要介绍几种追踪式垃圾回收的算法

分代收集(Generational Collection)理论

该理论建立于两个假说之上:

弱分代假说(Week Gererational Hypothesis) 绝大多数对象都是朝生夕灭的。

强分代假说(Strong Generational Hypothesis) 熬过多次GC的对象将越发难以消亡

根据上面两种假说,我们可以将堆划分为新生代老年代两个类别,然后为他们分配不同的GC频率和策略以达到优化的目的。比如新生代中应用更加频繁的GC策略,而新生代中熬过多次GC的对象将被转移到老年代中去。这样我们就可以大大调高我们的GC效率,因为新生代中大多都是朝生夕灭的对象,每次GC回收的内存空间都是可观的。但是这里有一个很明显的问题需要解决。对象与对象之间是有关系的,也就是存在引用的,也就是说新生代与老年代之间不是完全隔离的,那么意味着我们需要扫描老年代以找出这样的引用关系,这样我们的效率又回到最初。这里就需要涉及到第三条假说

跨代引用假说(Intergenerational Reference Hypothesis) 跨代引用相对于同代引用来说仅占很小一部分。

上面这个假说其实时根据前两个假说来推断的,如果一个弱分代被强分代引用,那么它的生命力也会比较强,熬过几次GC后,其也会过渡到强分代中去,也就是所谓的吸引力法则,具备相同属性的东西,总会逐步聚到一起。这个假设告诉我们不应该为了这极少数的跨代引用而去扫描整个老年代。我们只需要在新生代中构建记忆集,将老年代分为若干小块,而后标识存在跨代引用的小块即可。

标记-清除算法(Mark-Sweep)

该算法,分为标记和清除两个阶段,第一个阶段标记出需要回收的资源,然后再统一的进行回收。反之标记不需要回收的资源也可。但是这种算法有几个缺点:

  1. 如果需要回收的对象的很多,比如新生代中,那么标记和清除两个阶段的效率都会变得很低。
  2. 内存碎片问题,这样清理出来的内存空间将可能产生很多不连续的空间碎片,导致后续内存分配的时候,大对象很难分配到合适的内存空间。

标记-复制算法(Mark-Copy)

这种算法将内存空间划分为了两个相等的片区A和B。当片区A当中的内存分配满了后,就直接将A中还存活的对象复制到B中去,而后A整体清空。这种算法在存活对象少的情况下效率比较好,反之如果存活对象很多,将会产生大量的复制开销。因为涉及到A中的存活对象到B中去重新分配内存复制,所以也不存在内存碎片的问题。当然这种算法的缺点就是会浪费大量的空间。

标记-整理算法(Mark-Compact)

这个算法的标记过程和标记-清除算法一致,它的提出是为了解决内存碎片问题。与标记-清除相比,它是一种移动式的内存回收策略。在垃圾回收的时候,我们需要将所有存活的对象往内存的一段移动,也就是Compact,以使得空间规整。但是这种算法的优缺点也比较明显:

  • 优点: 内存分配更容易了,因为没有了内存碎片。
  • 缺点:内存回收过程变得复杂了,因为内存需要移动,而且在Compact的过程中对象将无法提供服务,也就是所谓的stop the world

总结

我们可以看到上诉的三种算法,都有各自的优缺点,根据算法特点和新老生代的特点我们可以总结出两个点:

  1. 新生代中,存活对象较少,比较适合标记-复制算法
  2. 老生代中,有大量对象存活,所以比较适合标记-清除或者标记-整理算法。而这两个算法则根据系统的需求来选用:
    • 看重吞吐量选标记-整理
    • 看重低延迟选标记-清除

HotSpot的算法细节实现

根节点枚举

根节点枚举当中需要考虑的两个点:

  • 可达性分析当中查找引用链涉及的GC ROOTs主要存在于全局变量(常量或者静态属性)以及执行上下文(方法栈中的对象引用)中,如果我们逐个去查找会耗费大量的时间。
  • 根节点枚举需要在一个一致性的状态下进行查找。总之不允许在一个实时变化的系统中去进行根节点枚举。

首先第一个点,HotSpot中是通过构建一个OopMap数据结构来管理根节点的。在类加载完成时就会把对象内什么偏移量上是什么类型的数据计算出来。在即时编译的过程中,也会在特定的位置记录下栈里和寄存器里哪些地方引用。这样收集器就能直接知道根节点在哪里了。

而后第二点是无法规避的,基本上都只能选择暂停程序执行。如果实在不想,那么就只能构建一致性快照,并在此基础上进行GC。

安全点

前面提到了利用OopMap来辅助根节点枚举,如果

  • 每执行一条指令都去修改OopMap或者每执行一条指令都去构建一个新的OopMap的话,开销有些太大了。
  • 而且指令执行的速度是很快的,你可能当前OopMap还没有构建好,下一条指令就已经执行完了。

所以,我们会设立一些特定的安全点,只会在安全点处去更新OopMap。而安全点的选取需要满足是否具备让程序长时间执行的特征,例如方法调用、循环跳转、异常跳转等。这样我们在能在程序执行到这里前,准备好OopMap。在多线程程序当中,我们需要每个线程都到达了最近的安全点,而后再进行中断并开始GC。而确保所有线程都到达了安全点可以进行中断的方法有两种

  • 第一种是抢先式中断,不需要线程配合,当垃圾收集发生时,系统会将所有线程都中断,然后给还没有到达安全点的线程开小灶,让他们重新运行以下再中断,直到到达了安全点。这种方法基本很少用到
  • 第二种是比较常见的主动式中断,当要进行GC时会设立一个和安全点重合的标志,线程每运行到安全点都会去轮询这个安全点是否是这个标志,当为true时,线程就是在当前安全点中断挂起。因为涉及到大量的轮询操作,所以要求其需要足够高效,HotSpot中被精简到了只有一条汇编指令

安全区域

安全点保证了程序运行中能寻机进入GC,但是如果线程没有执行时就会出现问题,比如线程处于Sleep或者Block状态,无法前进到安全点,就一直无法进行GC。为了解决这个问题,我们设立了安全区,安全区是一段不会发生引用变更的代码区间。也就是说只要代码在安全区中,我们就不用担心会OopMap产生不一致的更新。发生垃圾回收的时候,代码可以在安全区中继续运行,直到代码要离开安全区的时候,才需要考虑根节点枚举(或者其他需要暂停用户线程的阶段)是否结束,而后根据此判断能否离开安全区。这个时候即使一些线程处于Sleep的状态,但是Sleep不会导致引用变换,所以该线程处于安全区中,可以进行GC。

记忆集与卡表

在前面的分代收集理论中说到需要构建记忆集去标识出老年代中涉及到跨代引用的部分,以便在GC的时候可以快速访问。而记忆集根据记录的精细度,可以分为三种

  • 字长精度:记录精确到机器字长
  • 对象精度:记录精确到具体对象
  • 卡精度:记录精确到一个内存区域

其中基于卡精度的记忆集实现叫做卡表,是目前最常见的记忆集实现方式。

垃圾收集器

内存回收如何进行是由虚拟机所采用的GC收集器决定的。HotSpot中,通常有这些GC收集器

  • Serial收集器
  • ParNew收集器
  • Parallel Scavenge收集器
  • G1收集器
  • CMS收集器
  • Serial Old收集器
  • Parallel Old收集器

这些垃圾收集器的发展很多时候都是为了优化一个问题,也就是Stop The World。随着垃圾收集器的发展,从Serial收集器到Parallel收集器,再到CMS和G1,甚至后来的Shenandoah和ZGC,收集器的设计越来越复杂,线程中断的时间也越来越短。

Serial收集器

单线程收集器,古老但依旧是目前默认的新生代收集器

  • 简单高效,最高的单线程收集效率
  • 内存消耗低
  • 没有线程交互的开销

客户端模式下,分配给虚拟机管理的内存一般不会特别大,Serial收集器此时就是很好的选择,虽然单线程但也能够将停顿的时间控制到十几毫秒。

ParNew收集器

这个时Serial收集器的并行版本,更适用于服务器场景,能够有效提高垃圾回收的效率并缩短停顿的时间。

Parallel Scavenge收集器

同样是基于标记-复制算法的新生代收集器,与ParNew很类似。但是与其他收集器尽可能缩短用户线程停顿时间的目标不一样的是,Parallel Scavenge的目标是达到一个可控制的吞吐量

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

停顿时间短和吞吐量高有各自的使用场景:

  • 停顿时间短适合用于和用户交互频繁的服务,能很好的提升用户体验。
  • 吞吐量高注重于高效利用处理器资源,尽快的完成任务。适用于后端交互比较少的分析性任务。

但是这个收集器无法和CMS配合使用。

Serial Old收集器

Serial的老年代版本,同样是单线程但使用标记-整理算法。 主要有两种使用场景:

  • 在JDK5或之前版本中与Parallel Scavenge配合使用(因为其无法与CMS配合)
  • 作为CMS收集器发生故障后的后备预案。

Parallel Old收集器

Parallel Scavenge的老年代版本,使用标记-整理算法。这个是JDK6后才出现的,这时候Parallel Scavenge就可以不用再勉强搭配Serial Old使用了

CMS收集器

这是一种以获取最短回收停顿时间为目标的收集器。集中于浏览器之类的交互比较频繁的B/S服务器中。基于标记-清除算法来实现的。他的运作过程如下

  • 初始标记(CMS initial mark) 需要Stop The World,仅仅只是标记GC Roots能关联到的对象,速度很快。
  • 并发标记(CMS concurrent mark) 不需要暂停,可与垃圾回收线程并发运行。
  • 重新标记(CMS remark) 需要暂停,重新标记并发标记过程中,用户线程继续运行产生的对象标记变动。
  • 并发清除(CMS concurrent sweep) 不需要暂停,删除已经死亡的对象,不会影响用户线程的运行。

G1收集器

当前收集器设计最前沿的成果。具备以下优点:

  • 并行与并发:能充分利用多CPU、多核环境下的硬件优势。
  • 分代收集:G1无需其他收集器配合,即可独立管理整个GC堆。可采用不同的方式处理新创建的对象和已存活很久的旧对象,以获取更好的收集效果。
  • 空间整合:G1从整体看上基于“标记-整理”算法实现,从局部(两个Region之间)上看,基于“复制”算法实现。因此能提供规整的可用内存。
  • 可预测的停顿:G1可让使用者明确指定再一个长度为M毫秒的时间片段内,消耗在GC上的时间不超过N毫秒。

{ % if page.mermaid % } { % endif % }