深入理解Java虚拟机笔记-垃圾收集器和内存分配策略

261 阅读10分钟

GC 需要完成的 3 件事:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

GC 主要发生在 Java 堆和方法区。

对象已死吗

引用计数法

引用计数法无法解决循环引用的问题。

可达性分析算法

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GCRoots到这个对象不可达)时,则证明此对象是不可用的。

GC Roots 到对象不可达时,则此对象可以被回收。

在 Java 中可作为 GC Roots 的对象包括下面几种:

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

引用

jdk 1.2 之后,Java 对引用分为了强引用、软引用、弱引用、虚引用。

  1. 强引用

    Object obj=new Object() 这类的引用就叫强引用,只要强引用还存在,GC就不会去回收被引用的对象。

  2. 软引用

    内存紧张前进行回收, 主要应用于内存敏感的高速缓存.

  3. 弱引用

    下一次 GC 发生时被回收。

  4. 虚引用

    下一次 GC 时被回收。

生存还是死亡

在可达性分析后发现某对象不可达,会被第一次标记并进行一次筛选,筛选的条件是此对象是否需要执行 finalize() 方法,当对象没有覆盖 finalize() 方法或者 finalize() 已经被执行过了,JVM 就不会执行 finalize() 方法。如果覆盖了且没有执行过 finalize(),该对象会被放入一个 F-Queue 队列,虚拟机中的 Finalizer 线程会去触发执行这个方法,但是 JVM 并不会陈诺等待 finalize() 运行结束。万一 finalize() 发生了无限循环,F-Queue 中后面的对象都在等待着执行,会导致 GC 系统无法正常运行的。然后 JVM 对 F-Queue 中的对象进行第二次标记,此时还处于不可达状态时,就会被回收。

回收方法区

回收废弃常量和无用的类。

废弃常量:

假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。

无用的类需要同时满足下面 3 个条件:

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

垃圾收集算法

标记-清除算法

不足:

  1. 效率不高
  2. 标记清除后会产生大量不连续的内存碎片,碎片太多会导致以后在程序运行过程中需要分配较大对象对象时找不到足够大的连续内存提前触发GC。

复制算法

将可用内存分为大小相等的两块。每次使用其中一块,当这一块内存用完了,就把还活着的对象复制到另一块内存上,然后把这块已使用的内存一次清理干净。 优点:

  1. 内存分配时不用考虑内存碎片等复杂情况。
  2. 可用内存空间缩小了一半

这种算法适合回收新生代。现代虚拟机将内存分为一块较大的 Edin 空间和 2 块较小的 Survivor 空间,每次使用 Edin 和其中一块 Survivor。

当回收时,将 Edin 和 Survivor 中还活着的对象一次性复制到另一块 Survivor 空间上,然后将 Edin 和 刚刚用过的 Survivor 一次清理掉。如果存活的对象较多,Survivor 中放不下就要放入老年代的内存中。

标记-整理算法

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

分代收集算法

把 Java 堆分成新生代和老年代,根据各个年代的特点采用最合适的收集算法。

新生代中存活的对象较少,有大批死亡的对象,采用复制算法一次清理掉大批死亡对象是最划算的。

HotSpot 的算法实现

枚举根节点

查找 GC Roots 的节点太耗时。
分析可达性时,对象的引用关系不可以发生变化,必须停顿所有 Java 执行线程,sun 称之为 “stop the world”。

HotSpot 使用 OopMap 的数据结构,GC 停顿后,不需要检查所有的引用位置。快速准确的完成 GC Roots 的枚举。

安全点

程序只在到达安全点时 GC。

安全点不能太少也不能太多,太少 GC 等待时间太长,太多频繁 GC 增加开销。

GC 发生时需要让所有线程跑到安全点,有 2 种方式:

  1. 抢先式中断

    GC 时,把所有线程都中断,如果有的线程不在安全点,恢复它们让它们继续跑到安全点。

  2. 主动式中断

    当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

现在虚拟机基本都用主动式中断。

安全区域

当线程处于 sleep 或者 blocked 状态时,无法响应 JVM 的中断请求。此时需要安全区域来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。

当线程执行到安全区域的代码时,首先标识自己进入安全区域,线程要离开安全区域之前,先检查系统是否完成了根节点遍历或者 GC。等待完成了才可以继续执行。

垃圾收集器

收集算法是方法论,收集器是具体的实现。

图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代还是老年代收集器。

Serial 收集器

单线程收集器,进行 GC 时,必须暂停其他所有的工作线程,知道它 GC 结束。

优点:

  1. 与其他收集器相比,简单高效
  2. 没有线程交互的开销
  3. 新生代内存不大的情况下,停顿时间可以控制在几十毫秒到一百毫秒。对运行在 Client 模式下的虚拟机是一个很好的选择。

ParNew 收集器

是 Serial 收集器的多线程版本。

优点:

  1. 除了 Serial, 只有它能与 CMS 配合工作

Parallel Scavenge 收集器

新生代收集器,并行的多线程收集器。

优点:

  1. 可控的吞吐量。吞吐量 = 运行用户代码时间/(运行用户代码时间+GC 时间)

Serial Old 收集器

Serial 收集器的老年代版本。单线程收集器,使用 “标记-整理”算法。

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。

CMS 收集器

Concurrent Mark Sweep。 一种以获取最短回收停顿时间为目标的收集器。基于 "标记-清除" 算法。

优点:

  1. 并发收集、低停顿。

缺点:

  1. 对 CPU 资源很敏感
  2. 无法处理浮动垃圾,并发清理时,程序还在运行产生垃圾,只能留到下次GC处理。所以 CMS 每次 GC 都要预留一部分空间。
  3. 会产生内存碎片

CMS 的运行步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

G1 收集器

面向服务端应用的垃圾收集器。

从整体看基于 "标记-整理",局部看基于"复制"。 运行期间不会产生内存碎片。 将 java 堆划分为一个个区域,按优先级回收。

优点:

  1. 并行与并发
  2. 分代收集
  3. 空间整合
  4. 可停顿的预测

G1 的运行步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

如果你现在采用的收集器没有出现问题,那就没有任何理由现在去选择G1,如果你的应用追求低停顿,那G1现在已经可以作为一个可尝试的选择,如果你的应用追求吞吐量,那G1并不会为你带来什么特别的好处”。

内存分配与回收策略

  • Minor GC

    指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

  • Full GC/Major GC

    指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

大多数情况下,对象直接在新生代 Edin 区分配,当 Edin 区没有足够空间时,虚拟机触发一次 Minor GC。

需要连续的大块存储空间的大对象直接进入老年代。

长期存活的对象直接进入老年代。 根据年龄计数器判断是否是长期存活。

每个对象有一个年龄计数器:

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenur-ingThreshold设置。

但这不是绝对的。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

发生 Minor GC 之前,虚拟机先检查老年代最大连续可用空间是否大于新生代所有对象总空间。满足的话就是安全的。不满足的话,虚拟机先去检查 HandlePromotionFailure 是否允许担保分配内存失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者Han-dlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

总结

GC 给程序员开发省了不少力,但是不合格的代码容易发生问题,有时候问题隐藏的又很深。了解内存回收的机制有助于我们定位问题,提高性能。