《深入理解Java虚拟机》学习笔记(第三章 垃圾收集器与内存分配策略)

时间:2022-12-27 15:28:30

第三章 垃圾收集器与内存分配策略

要解决的问题

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

概述

  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,需要对内存动态分配和内存回收技术进行必要的监控和调节。

  • 程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知,因此这几个区域的内存分配和回收都具备确定性,因为方法结束或者线程结束时,内存自然就跟着回收。

  • 垃圾收集器主要关注的是Java堆和方法区,因为一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。

对象已死吗?

  1. 引用计数算法
    • 给对象中添加一个引用计数器,每当有一个地方引用他时,计数器值就+1;
      当引用失效时,计数器值就-1;任何时刻计数器为0的对象就是不可能再被使用的。
    • 优点:实现简单,判定效率高。
    • 缺点:很难解决对象之间相互循环引用的问题。
  2. 可达性算法分析
    • 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,
      当一个对象到GC Roots没有任何引用链相连时(GC Roots到这个对象不可达),则证明此对象是不可用的。

    • Java中,可作为GC Roots的对象包括:
      • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
      • 方法区中类静态属性引用的对象。
      • 方法区中常量引用的对象。
      • 本地方法栈中JNI(即Native方法)引用的对象。
  3. 再谈引用(从上到下强度依次减弱)
    • 强引用:类似Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
    • 软引用:描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常
    • 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
    • 虚引用: 幽灵引用/幻影引用。一个对象是否有虚引用不会对其生存时间构成影响。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
  4. finalize()方法
    • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,会被第一次标记并进行一次筛选,筛选的条件是次对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
    • 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它,这里的“执行”指虚拟机会触发这个方法,但并不承诺会等待它运行结束。
    • finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱基本上就真的被回收了。
    • 任何一个对象的finalize()方法都只会被系统自动调用一次。
    • 它的运行代价高昂,不确定性个大,无法保证各个对象的调用顺序,不建议使用。
    • ps:java finalize方法总结、GC执行finalize的过程
  5. 回收方法区
    • 在堆中,尤其是新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,而永久代的垃圾收集效率远低于此。

    • 永久代的垃圾收集主要回收:废弃常量和无用的类。
      • 废弃常量:没有任何对象,没有任何地方引用的常量池中的常量。
      • 无用的类:同时满足以下三个条件:
        Ⅰ 该类的所有实例都已经被回收,即Java堆中不存在该类的任何实例;
        Ⅱ 加载该类的ClassLoader已经被回收;
        Ⅲ 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

  1. 标记-清除算法(Mark-Sweep)
    • 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
    • 缺点:效率问题;空间问题:标记清除后会产生大量不连续的内存碎片。
  2. 复制算法
    • 将可用内存划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后把已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但内存变为原来一般,代价太大。
    • 现在的商业虚拟机都采用这种收集算法来回收新生代。Eden+Survivor+Sirvivor,8:1:1。
  3. 标记-整理算法
    • 老年代,标记过程同标记清除算法,但后续让所有存活的对象都想一端移动,然后清理掉端边界以外的内存。
  4. 分代收集算法
    • 根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,采用复制算法。老年代中对象存活率高,没有额外空间进行分配担保,则“标记清理”或者“标记整理”。

HotSpot算法实现

  1. 枚举根节点
    • 可作为GC Roots的节点主要在全局性的引用与执行上下文中。
    • 可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况。这点是导致GC进行时必须停顿所有Java执行线程(Stop The World)的一个重要原因。
    • 虚拟机通过一组称为OopMap的数据结构来直接得知哪些地方存在着对象引用。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。(OopMap是一个附加信息,告诉你栈上哪个位置本来是什么东西)。
  2. 安全点
    • 在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。
    • HotSpot在特定的位置即安全点记录OopMap,即程序执行时只有在打到安全点才能暂停。安全点的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
    • 安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准选定——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长而过长时间运行,“长时间执行”最明显的特征就是指令序列复用:例如方法调用、循环跳转、异常跳转等。所以具有这些功能的指令才会产生安全点。

    • 如何在GC发生时让所有线程都“跑”到最近的安全点上再停顿下来?
      • 抢先式中断:GC发生时,首先所有线程中断,恢复中断的地方不在安全的线程,让其“跑”到安全点。几乎不使用。
      • 主动式中断:当GC需要中断线程时,设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断 挂起,轮询标志的地方和安全点是重合的。
  3. 安全区域
    - 在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。可以看成是扩展的安全点。
    - 在线程执行到安全区域的中的代码时,首先标识自己已经进入了安全区域,当在这段时间JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。在线程要离开安全区域时,要检查系统是否已经完成了GC过程,完成则继续执行,否则必须等待直到收到可以安全离开安全区域的信号为止。
    - ps:Java系列:JVM中的OopMap
    - ps:JVM源码分析之安全点safepoint

垃圾收集器

  1. Serial收集器:
    • 最基本,最悠久,单线程收集器。虚拟机运行在Client模式下的默认新生代收集器。
  2. ParNew收集器:
    • Serial收集器的多线程版本。许多运行在Server模式下的虚拟机中首选的新生代收集器。
  3. Parallel Scavenge收集器:
    • 新生代,复制算法,吞吐量优先。
    • 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
    • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而此收集器的目标则是达到一个可控制的吞吐量。
    • GC停顿时间的缩短是以牺牲吞吐量和新生代空间换来的。
    • UseAdaptiveSizePolicy:GC自适应调节策略开关。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
  4. Serial Old收集器:
    • Serial收集器的老年代版本
  5. Parallel Old收集器:
    • Parallel Scavenge收集器的老年代版本。在注重吞吐量以及CPU资源敏感的场合,可以考虑这两者的组合。
  6. CMS收集器:
    • 以获取最短回收停顿时间为目标。
    • 运行过程:
      • 初始标记:STW,标记GC Roots能直接关联到的对象。。
      • 并发标记:GC Roots Tracing。
      • 重新标记:STW,修正并发标记期间因用户程序继续运行而标记产生变动的那一部分对象的标记记录。
      • 并发清除
    • 缺点:
      • 对CPU资源敏感。占用了一部分线程而导致应用程序变慢,总吞吐量降低。
      • 无法处理浮动垃圾。
      • 空间碎片的产生。
  7. G1收集器:
    • 整个Java堆划分为多个大小相等的独立区域。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
    • 用Remembered Set来避免全堆扫描。G1中的每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。
    • 运行过程:
      • 初始标记
      • 并发标记
      • 最终标记
      • 筛选回收

内存分配与回收策略

  1. 对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
    • 新生代GC(Minor GC)
    • 老年代GC(Major GC/Full GC)
  2. 大对象直接进入老年代。
  3. 长期存活的对象将进入老年代。
    • 如果对象在Eden出生并经过第一次Minor GC后仍然存活并且能被Survivor容纳的话,将被移动到Survivor空间中,对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄+1,增加到一定程度就可以晋升到老年代中。
  4. 动态对象年龄判定。
    • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  5. 空间分配担保。
    • JDK6 Update24之后规定只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。若担保失败,发起Full GC。

ps:【Java虚拟机学习笔记】《深入理解Java虚拟机》之第三章 - 垃圾收集器与内存分配策略