堆空间的基本结构

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

堆内存结构

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

为什么要这样分?

因为不同对象的生命周期不一样,大部分对象朝生夕死,而少部分一直存在堆中,所以按照存活时间分区管理更加高效。

也因为不同分区的生命周期不同,所以可以采用不同的清除算法来优化处理,像新生代的对象“死亡率”比较高,因此标记复制比较合适(大部分对象都消失了,把存活的复制到一边,死亡的全部清理即可)

而老年代的对象存活时间比较长,因此标记清除即可(存活对象比较多,整理或复制耗时比较长)

且分区后可以减少 GC 暂停的时间,你想想每次处理一个堆的数据,还是将堆分区处理来的快?

总而言之,分区是为了更高效地管理不同生命周期的对象

为什么 Java 新生代被划分为 S0、S1 和 Eden 区?

因为新生代对象朝生夕死的特性,适合复制算法。按正常思路将新生代一分为二,划两块区域,每次只使用其中一个,GC 后将存活的复制到另一个区域,然后清理老区域非存活对象,这样替换使用两块区域可以避免内存碎片的存在。

但如果一分为二的话,空间利用率只有一半了(每次分配对象只能占据一半的内存大小),这样不太划算。

基于这点,定义了三个区域,Eden 区和两个 Survivor 区,Eden 区 + 1 个 Survivor 区可以比二分之一大,提升利用率,默认 Eden 占 80% ,一个 Survivor 占 10%。

然后利用两个 Survivor 来交替接收 gc 后存活的对象。

比如当前用 Eden + s0 两块区域,gc 的时候将存活的对象拷贝至 s1,然后清理 Eden 和 s0,接着使用 Eden + s1 作为新的对象分配区域。

后面 gc 后,把存活的对象拷贝至 s0,就这样往复使用两个 Survivor 区即可,这种划分手段就提升了内存的利用率。

并且程序可以根据自身的特性调整 Eden 区和 Survivor 区的比例,默认 8:1:1。

如果单个 Survivor 放不下 GC 存活的对象怎么办?

老年代兜底

也就是说如果 Survivor 放不下存活的对象,那么超出的对象直接晋升到老年代。

如果老年代剩余的空间也放不下这些存活的对象怎么办?

如果是 CMS 垃圾回收器,则会触发 CMS 回收。如果 CMS 回收不足以回收足够的空间,会触发 Full GC(Serial Old 回收器)。

如果是 G1 垃圾回收器则会触发 Mixed GC。

内存分配和回收原则

对象优先在 Eden 区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

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

为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

垃圾回收

死亡对象判断方法

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

给对象中添加一个引用计数器:

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

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

对象之间循环引用

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

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

可达性分析算法

哪些对象可以作为 GC Roots 呢?

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

被标记为垃圾的对象一定会被回收吗

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于 “缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

  • 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  • 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

垃圾收集算法

标记 - 清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

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

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

标记-清除算法

复制算法

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

复制算法

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

标记 - 整理算法

标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

分代收集算法

根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

  • 新生代默认的空间占比总空间的 1/3;
  • 老生代的默认占比是 2/3。

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。

因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

  • 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
  • 清空 Eden 和 From Survivor 分区;
  • From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
  • 每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。

老生代一般使用标记整理的执行算法:

  • 当空间占用到达某个值之后就会触发全局垃圾收回。

以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

垃圾收集器

新生代回收器:

  • Serial:最早的单线程串行垃圾回收器。在进行垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,这是其主要缺点。
  • ParNew:是 Serial 的多线程版本。
  • Parallel:和 ParNew 收集器类似,多线程,但 Parallel 是吞吐量优先的收集器,可以牺牲等待时间换取系统的吞吐量。

老年代回收器:

  • Serial Old:Serial 垃圾回收器的老年版本,同样也是单线程的,可以作为 CMS 垃圾回收器的备选预案。
  • Parallel Old 是 Parallel 老生代版本,Parallel 使用的是复制的内存回收算法,Parallel Old 使用的是标记-整理的内存回收算法。
  • CMS(Concurrent Mark Sweep):一种以获得最短停顿时间为目标的收集器,基于 标记-清除 算法实现,非常适用 B/S 系统。

整堆回收器:

  • G1(Garbage-First):一种兼顾吞吐量和停顿时间的 GC 实现,是 JDK 9 以后的默认 GC 选项。

总结:

  • 新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;
  • 老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

如何对 Java 的垃圾回收进行调优?

GC 调优这种问题肯定是具体场景具体分析,但是在面试中就不要讲太细,大方向说清楚就行,不需要涉及具体的垃圾收集器比如 CMS 调什么参数,G1 调什么参数之类的。

GC 调优的核心思路就是尽可能的使对象在年轻代被回收,减少对象进入老年代。

具体调优还是得看场景根据 GC 日志具体分析,常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率、老年代内存占用量等等。

比如发现频繁会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Ful GC。所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor 。

或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏、或者有第三方类库调用了 System.gc 等等。

反正具体场景具体分析,核心思想就是尽量在新生代把对象给回收了。

基本上这样答就行了,然后就等着面试官延伸了。

参考链接

JVM垃圾回收详解(重点) | JavaGuide

Java 与 JVM — 八股文 (interview-points.readthedocs.io)