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

1. 判断对象是否被回收

1. 引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时, 计数器值加1;当引用失效时, 计数器值减1;任何时刻计数器为零的对象就是不可能再被使用的。(很难解决对象相互循环引用的问题。)

两个对象相互引用时,虚拟机也会进行回收,因此Java虚拟机并不是通过计数算法来判断对象存活的。

2. 可达性分析算法

以 GC Roots 为起始点根据引用关系进行搜索,可达的对象都是存活的,不可达的对象可被回收。

  • 虚拟机栈中局部变量表中引用的对象
  • 本地方法栈中 JNI(Native 方法) 中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

3. 引用

无论是通过引用计数算法判断对象的引用数量, 还是通过可达性分析算法判断对象是否引用可达, 判断对象是否存活都和“引用相关”。

1. 强引用

被强引用的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object()

2. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。

3. 弱引用

被弱引用关联的对象只能存活到下一次垃圾收集发生为止。所以一定会被回收。

使用 WeakReference 类来实现弱引用。

4. 虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

4. finalize()

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。

finalize() 方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。使用 try-finally 或者其他方式可以做得更好。

5. 回收方法区

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要是对常量池的回收和对的卸载。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要同时满足以下三个条件,并且满足了条件也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

2. 垃圾收集算法

1. 分代收集理论

将回收对象依据其年龄(对象熬过垃圾收集过程的次数)将Java堆划分出不同的区域。
一般将堆分为新生代和老年代。

  • 新生代使用:标记 - 复制算法
  • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

优点:

  • 较低的代价回收大量空间;使用较低的频率回收某个区域。兼顾了垃圾收集的时间开销和内存的空间有效利用。
  • 不同区域采用适当的收集算法。

2. 标记-清除算法

分为“标记和清除”两个阶段:首先标记出所有需要回收的对象,然后统一回收掉所有被标记的对象。
另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。

缺点:

  • 执行效率不稳定(可能有大量标记和清除动作)
  • 内存空间的碎片化问题:标记、清除后产生大量不连续的内存碎片,当有大对象需要进行内存分配时,会因为找不到足够内存进行分配对象而造成垃圾回收,频繁的垃圾回收影响效率和性能。

3. 标记-复制算法

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
缺点: 可用内存缩小了一半。

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,这些对象将会用老年代的内存空间,即通过分配担保机制直接进入老年代。

4. 标记-整理算法

让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

优点: 不会产生内存碎片,空间利用率高。
缺点:需要移动大量对象并更新引用是一种极为负担的操作。

3. HotSpot的算法细节实现

先跳过。

4. 经典垃圾收集器

JVM | 垃圾收集器与内存分配策略_第1张图片

1. Serial 收集器

JVM | 垃圾收集器与内存分配策略_第2张图片

  1. 是单线程收集器,新生代收集器。

    • 只会使用一个处理器或者一条收集线程。
    • 必须暂停其他所有工作线程,直到它收集结束。
  2. 优点:简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
  3. 在客户端模式下的默认新生代收集器。因为在该场景下内存一般来说不会很大。它收集一两百兆新生代的停顿时间最多可以控制在一百多毫秒以内,只要不是太频繁发生收集,这点停顿时间是可以接受的。

2. ParNew 收集器

JVM | 垃圾收集器与内存分配策略_第3张图片

  1. 是 Serial 收集器的多线程并行版本,新生代收集器。
  2. 在服务端模式下的默认新生代收集器。除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。

3. Parallel Scavenge 收集器

  1. 多线程并行的新生代收集器。
  2. 主要目标是达到一个可控制的吞吐量,这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
  3. 停顿时间越短就越适合需要与用户交互的程序,而高吞吐量则可以高效率地利用 CPU 时间,适合用在后台运算而不需要太多交互的分析任务。
  4. 缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
  5. 垃圾收集的自适应的调节策略 (GC Ergonomics):
    激活开关参数 -XX:+UseApativeSizePolicy, 不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

4. Serial Old 收集器

  1. 是 Serial 收集器的老年代版本,是一个单线程收集器。
  2. 在客户端模式下使用。
  3. 如果用在 Server 场景下,它有两种用途:

    • 在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用。
    • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

5. Parallel Old 收集器

JVM | 垃圾收集器与内存分配策略_第4张图片

  1. Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。
  2. 在注重吞吐量以及 CPU 资源缺稀的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6. CMS (Concurrent Mark Sweep) 收集器

  1. 以获取最短回收时间为目标的收集器,基于 标记 - 清除算法。
  2. 运作过程

    • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
    • 并发标记: 从 GC Roots 的直接关联对象开始遍历整个对象图的过程,它在整个回收过程中耗时最长,不需要停顿。
    • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    • 并发清除: 删除掉标记阶段判断的已经死亡的对象,不需要停顿。
  3. 缺点:

    • 吞吐量低:在并发阶段会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。
    • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。

      • 浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。
      • 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

6. G1 (Garbage First) 收集器

JVM | 垃圾收集器与内存分配策略_第5张图片

  1. 主要面向服务端应用。
  2. Mixed GC 模式:面向堆内任何部分来组成回收集进行回收,不再区分它是哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。
  3. 基于 Region 的堆内存分布:

    • G1 把堆划分成多个大小相等的独立区域(Region),将 Region 作为单次回收的最小单位,即每次收集到的内存空间都是 Region 大小的整数倍。每个 Region 根据需要可以扮演新生代的Eden空间、Survior空间,或者老年代空间,全局来看,G1 采用了“标记-整理”算法。
    • 通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
    • 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
  4. 如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

    • 初始标记
    • 并发标记
    • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
    • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。把决定回收的那一部分Region的存活对象复制到空的 Region 中,再清理掉整个旧的 Region 的全部空间。这里的操作涉及存活对象的移动,必须暂停用户程序。
  5. 具备如下特点:

    • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

4. 内存分配与回收策略

Minor GC 和 Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

1. 内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。当复制对象时,大对象的复制也意味着高额的内存复制开销。

-XX:PretenureSizeThreshold:大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间来回复制,产生大量的内存复制操作。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,并将其年龄设为 1 岁。对象在 Survivor 区中每熬过一次 Minor GC, 年龄就增加 1 岁,增加到一定年龄(默认为15),则移动到老年代中。

-XX:MaxTenuringThreshold: 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败。如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

2. Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

1. 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2. 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3. 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。。

4. JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5. Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

参考资料

https://github.com/CyC2018/CS-Notes

你可能感兴趣的:(jvm)