1. 堆内存中对象分配的基本策略

堆空间的基本结构:
JVM 垃圾收集机制 - 图1
上图所示的 eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden区 -> Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。

另外,大对象和长期存活的对象会直接进入老年代。

2. 如何判断对象是否死亡?(两种方法)

2.1 引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。

  • 优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
  • 缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这种情况下,他们的引用计数永远不可能为 0。

    2.2 可达性分析算法

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

在 Java 中,可作为 GC Roots 的对象包括下面几种(主要就是栈和方法区中的对象):

  1. 虚拟机栈中引用的对象(栈帧中的本地变量表)
  2. 本地方法栈中 JNI(Native 方法)引用的对象
  3. 方法区中类静态属性引用的对象
  4. 方法区中常量引用的对象

    3. 垃圾收集的算法

    3.1 标记-清除算法(老年代使用)

    标记-清除算法分为『标记』和『清除』两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

JVM 垃圾收集机制 - 图2

3.2 复制算法(新生代使用)

为了解决效率问题,『复制』收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

因为新生代存活下来的对象较少,因此复制对象的代价较小,所以新生代通常使用复制算法。
JVM 垃圾收集机制 - 图3

3.3 标记-整理算法(老年代使用)

『标记-整理算法』是根据老年代的特点推出的一种标记算法,标记过程仍然与 『标记-清除算法』一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。『标记-整理算法』是在『标记-清除算法』的基础上,又进行了对象的移动,因此成本更高,但是解决了内存碎片的问题。

因为老年代存活下来的对象比较固定,需要移动的对象较少,所以标记-整理算法通常在老年代使用。
JVM 垃圾收集机制 - 图4

4. 分代收集的思想

4.1 什么是分代收集算法?

分代收集算法整合了上面几种方法,它将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

  • 比如在新生代中,每次收集都会有大量对象死去,存活下来的对象较少,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成垃圾收集。
  • 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,且需要移动的对象较少,因此在老年代使用标记-清除标记-整理算法进行垃圾收集比较合适。

    4.2 为什么需要分代收集算法?

    分代的垃圾回收策略,是基于这样一个事实:不同对象的生命周期是不一样的。因此,对于不同生命周期的对象可以采取不同的收集方式,以提高回收效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行『代』的划分,把不同生命周期的对象放在不同『代』上,不同『代』上采用最适合它的垃圾回收方式进行回收。

4.3 分代收集下的年轻代和老年代应该采用什么样的垃圾回收算法?

4.3.1 年轻代的回收算法(主要以 Copying 为主)

  1. 所有新生成的对象首先都是放在年轻代的,年轻代的目标就是尽可能快速地回收那些生命周期短的对象。
  2. 新生代内存按照 8:1:1 的比例分为一个 Eden 区和两个 survivor(survivor0、 survivor1)区。大部分对象在 Eden 区中生成。回收时先将 Eden 区存活对象复制到一个 survivor0 区,然后清空 Eden 区,当这个 survivor0 区也存放满的时候,则将 Eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 Eden 区 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
  3. 当 survivor1 区不足以存放 Eden 区 和 survivor0 区 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。

    4.3.2 老年代的回收算法(主要以 Mark-Compact 为主)

  4. 在年轻代中经历了 N 次(默认 15 次)垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。

  5. 老年代的存储空间也比新生代也大很多(大概比例是2 : 1),当老年代内存满时触发 Major GC,Major GC 发生频率比较低,因为老年代对象存活时间比较长,存活率标记高。

    5. 常见的垃圾收集器

    直到现在还没有最好的垃圾收集器出现,因此我们选择的只是对具体应用最合适的收集器。下图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。不过,在 JDK 9 中,已经取消了 Serial + CMS 以及 ParNew + Serial Old 这两个组合的支持。
    JVM 垃圾收集机制 - 图5

    5.1 Serial(复制算法) 和 Serial Old(标记-整理算法)

    Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。它是一个单线程收集器,单线程的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(“Stop The World”),直到它收集结束。

Serial Old 收集器是 Serial 收集器的老年代版本,它同样是一个单线程收集器。

虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短。但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
JVM 垃圾收集机制 - 图6

5.2 Parallel Scavenge(复制算法) 和 Parallel Old(标记-整理算法)

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU),而 CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑Parallel Scavenge 收集器 和 Parallel Old 收集器。
JVM 垃圾收集机制 - 图7

5.3 ParNew(复制算法)和 CMS(标记-清除算法)

5.3.1 ParNew 收集器

ParNew 属于年轻代的收集器,它是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
JVM 垃圾收集机制 - 图8

5.3.2 CMS 收集器

CMS(Concurrent Mark Sweep)是属于老年代的收集器,它是 HotSpot 虚拟机第一款真正意义上的并发收集器,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 收集器是基于 『标记-清除』算法的,整个过程分为四个步骤:

  1. 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快。
  2. 并发标记:同时开启 GC 线程和用户线程,从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程。
  3. 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那部分对象的标记记录。这个阶段的停顿时间也很短,但会比初始标记慢一些。
  4. 并发清除:清理掉标记的已经死亡的对象,由于不需要移动对象,所以这个阶段也可以和用户线程同时并发。

为什么 CMS 收集器采用标记-清除而不是标记-整理?CMS 主要关注低延迟,因而采用并发方式。在清理垃圾时,应用程序还在运行,如果进行整理,则涉及到要移动应用程序的存活对象,此时不停顿,是很难处理的。所以 CMS 为了低延迟的并发,采用了标记-清除算法。

JVM 垃圾收集机制 - 图9
CMS 收集器的优点是并发收集、低停顿,缺点是:

  • 对 CPU 资源敏感。
  • 无法处理浮动垃圾。
  • 它使用的回收算法——标记-清除算法会导致收集结束时会有大量空间碎片产生。

    5.4 G1 收集器(整体上看是标记-整理算法,局部上看是标记-复制算法)

    Garbage Fist(G1)开创了收集器『面向局部收集』的设计思路和基于 Region 的内存布局形式。

在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java 堆(Full GC)。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
JVM 垃圾收集机制 - 图10
G1从整体来看是基于标记-整理算法实现的收集器,但从局部(两个Region之间)上看又是基于标记-复制算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次垃圾收集。

6. 什么时候会触发 YGC 和 FGC?对象什么时候会进入老年代?

当一个新的对象来申请内存空间的时候,如果 Eden 区无法满足内存分配需求,则触发 YGC,使用中的 Survivor 区和 Eden 区存活对象送到未使用的 Survivor 区。如果 YGC 之后还是没有足够空间,则直接进入老年代分配,如果老年代也无法分配空间,触发 FGC,FGC 之后还是放不下则报出OOM异常。
JVM 垃圾收集机制 - 图11
YGC之后,存活的对象将会被复制到未使用的Survivor区,如果S区放不下,则直接晋升至老年代。而对于那些一直在Survivor区来回复制的对象,通过-XX:MaxTenuringThreshold配置交换阈值,默认15次,如果超过次数同样进入老年代。

此外,还有一种动态年龄的判断机制,不需要等到MaxTenuringThreshold就能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。


参考

  1. JVM 夺命连环问