垃圾回收相关概念

System.gc()

  1. 在默认情况下,通过 System.gc() 或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

  2. 然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用 (不能确保立即生效)

  3. JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()

代码示例:手动执行 GC 操作

public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        // 提醒 jvm 的垃圾回收器执行 gc, 但是不确定是否马上执行 gc
        // 与 Runtime.getRuntime().gc();的作用一样。
        System.gc();
        // System.runFinalization();// 强制调用使用引用的对象的 finalize() 方法
    }
    //如果发生了GC,这个finalize()一定会被调用
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 重写了finalize()");
    }
}

输出结果不确定:有时候会调用 finalize() 方法,有时候并不会调用

SystemGCTest 重写了finalize()
或
空

但是如果取消注释 System.runFinalization(); 将一定会运行 finalize()

SystemGCTest 重写了finalize()

手动 GC 理解不可达对象的回收行为

// 加上参数: -XX:+PrintGCDetails
public class LocalVarGC {
    public void localvarGC1() {
        // 10MB
        byte[] buffer = new byte[10 * 1024 * 1024];
        System.gc();
    }

    public void localvarGC2() {
        byte[] buffer = new byte[10 * 1024 * 1024];
        buffer = null;
        System.gc();
    }

    public void localvarGC3() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        System.gc();
    }

    public void localvarGC4() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        int value = 10;
        System.gc();
    }

    public void localvarGC5() {
        localvarGC1();
        System.gc();
    }

    public static void main(String[] args) {
        LocalVarGC local = new LocalVarGC();
        // 通过在 main 方法调用这几个方法进行测试
        local.localvarGC1();
    }
}
  1. localvarGC1

执行 System.gc() 仅仅是将年轻代的 buffer 数组对象放到了老年代,buffer 对象仍然没有回收

[GC (System.gc()) 
  [PSYoungGen: 14205K->840K(76288K)] 14205K->11088K(251392K)] 
[Full GC (System.gc()) 
  [PSYoungGen: 840K->0K(76288K)] 
  [ParOldGen: 10248K->10902K(175104K)] 11088K->10902K(251392K), 
  [Metaspace: 3317K->3317K(1056768K)], 0.0055599 secs] 
  1. localvarGC2

由于 buffer 数组对象没有引用指向它,执行 System.gc() 将被回收

[GC (System.gc()) 
 [PSYoungGen: 14205K->776K(76288K)] 14205K->784K(251392K), 0.0013277 secs] 
[Full GC (System.gc()) 
 [PSYoungGen: 776K->0K(76288K)] 
 [ParOldGen: 8K->660K(175104K)] 784K->660K(251392K), 
 [Metaspace: 3299K->3299K(1056768K)], 0.0047760 secs]
  1. localvarGC3

虽然出了代码块的作用域,但是 buffer 数组对象并**没有被回收**。 因为局部变量表长度为 2 后续没有其他的变量占掉 buffer 的位置

![image-20220208155132260](./垃圾回收相关概念/1646538006144image-20220208155132260.png)

[GC (System.gc()) 
 [PSYoungGen: 12894K->10744K(76288K)] 12894K->10966K(251392K), 0.0290538 secs] 
[Full GC (System.gc()) 
 [PSYoungGen: 10744K->0K(76288K)] 
 [ParOldGen: 222K->10874K(175104K)] 10966K->10874K(251392K), 
 [Metaspace: 2925K->2925K(1056768K)], 0.0750455 secs] 
  1. localvarGC4

因为使用 value 变量顶替 buffer 位置 导致推中的 buffer 字节数组没有引用指向它指向 System.gc() 时回收

[GC (System.gc()) 
 [PSYoungGen: 14205K->808K(76288K)] 14205K->816K(251392K), 0.0018557 secs] 
[Full GC (System.gc()) 
 [PSYoungGen: 808K->0K(76288K)] 
 [ParOldGen: 8K->662K(175104K)] 816K->662K(251392K), 
 [Metaspace: 3317K->3317K(1056768K)], 0.0079504 secs]

详细

局部变量表长度为 2 ,这说明了出了代码块时,buffer 就出了其作用域范围,此时没有为 value 开启新的槽,value 变量直接占据了 buffer 变量的槽(Slot),导致堆中的字节数组没有引用再指向它,执行 System.gc() 时被回收。看,value 位于局部变量表中索引为 1 的位置。value 这个局部变量把原本属于 buffer 的 slot 给占用了,这样栈上就没有 buffer 变量指向 new byte[10 1024 1024] 实例了。

  1. localvarGC5

局部变量除了方法范围就是失效了,堆中的字节数组铁定被回收

// 调用方法 localvarGC1 GC
[GC (System.gc()) 
 [PSYoungGen: 14205K->10728K(76288K)] 14205K->11003K(251392K), 0.0093915 secs] 
[Full GC (System.gc()) 
 [PSYoungGen: 10728K->0K(76288K)] 
 [ParOldGen: 275K->10902K(175104K)] 11003K->10902K(251392K), 
 [Metaspace: 3317K->3317K(1056768K)], 0.0091319 secs] 
// 方法 GC
[GC (System.gc()) 
 [PSYoungGen: 1310K->64K(76288K)] 12213K->10966K(251392K), 0.0004133 secs] 
[Full GC (System.gc()) 
 [PSYoungGen: 64K->0K(76288K)] 
 [ParOldGen: 10902K->662K(175104K)] 10966K->662K(251392K),
 [Metaspace: 3317K->3317K(1056768K)], 0.0064154 secs] 

内存溢出与内存泄漏

内存溢出

  1. 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

  2. 由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。

  3. 大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。

  4. Javadoc 中对 OutofMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。

原因分析

首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因

  1. Java 虚拟机的堆内存设置不够。

    1. 比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms 、-Xmx 来调整。

  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

    1. 对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见。尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError:PermGen space"。

    2. 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace"。直接内存不足,也会导致 OOM。

  1. 这里面隐含着一层意思是,在抛出 OutofMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

    1. 例如:在引用机制分析中,涉及到 JVM 会去尝试 回收软引用指向的对象 等。

    2. java.nio.Bits.reserveMemory() 方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。

  2. 当然,也不是在任何情况下垃圾收集器都会被触发的

    1. 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutofMemoryError

内存泄漏

  1. 也称作 “存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏

  2. 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 OOM,也可以叫做宽泛意义上的 “内存泄漏”。

  3. 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutofMemory 异常,导致程序崩溃。

  4. 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

常见例子

  1. 单例模式

    1. 单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  2. 一些提供 close() 的资源未关闭导致内存泄漏

    1. 数据库连接 dataSourse.getConnection(),网络连接 socket 和 io 连接必须手动 close,否则是不能被回收的。

Stop the World

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,为什么需要停顿所有 Java 执行线程呢?

  • 分析工作必须在一个能确保一致性的快照中进行

  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上

  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

  1. STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。

  2. 哪怕是 G1 也不能完全避免 Stop-the-world 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

  3. STW 是 JVM 在 后台自动发起和自动完成 的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

  4. 开发中不要用 System.gc() ,这会导致 Stop-the-World 的发生。

垃圾回收的并行与并发

并发的概念

  1. 在操作系统中,是指 一个时间段 中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行

  2. 并发不是真正意义上的 “同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换。由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行

并行的概念

  1. 当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以 同时 进行,我们称之为并行(Parallel)

  2. 其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行

  3. 适合科学计算,后台处理等弱交互场景

并发与并行的对比

  1. 并发,指的是多个事情,在同一时间段内同时发生了。

  2. 并行,指的是多个事情,在同一时间点上(或者说同一时刻)同时发生了。

  3. 并发的多个任务之间是互相抢占资源的。并行的多个任务之间是不互相抢占资源的。

  4. 只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

  1. 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

    1. ParNew、Parallel Scavenge、Parallel Old

  1. 串行(Serial)

    1. 相较于并行的概念,单线程执行。

    2. 如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收(单线程)

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  1. 并发(Concurrent):指

用户线程与垃圾收集线程同时执行

(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。

  • 比如用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;

  1. 典型垃圾回收器:CMS、G1

HotSpot 的算法实现细节

根节点枚举

  1. 固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。

  2. 迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点 枚举与之前提及的整理内存碎片一样会面临相似的 “Stop The World” 的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里 “一致性” 的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、 ZGC 等收集器,枚举根节点时也是必须要停顿的

  3. 由于目前主流 Java 虚拟机使用的都是 准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构 ( 记录栈上哪些位置代表着引用 ) 来达到这个目的。一旦类加载动作完成的时候, HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找

  4. Exact VM 因它使用 准确式内存管理(Exact Memory Management,也可以叫 Non-Con- servative/Accurate Memory Management)而得名。准确式内存管理是指虚拟机可以知道内存中某个位 置的数据具体是什么类型。譬如内存中有一个 32bit 的整数 123456,虚拟机将有能力分辨出它到底是一 个指向了 123456 的内存地址的引用类型还是一个数值为 123456 的整数,准确分辨出哪些内存是引用类 型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。

安全点与安全区域

安全点(Safepoint)

  1. 程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为 “安全点(Safepoint)”。

  2. Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据 “是否具有让程序长时间执行的特征 为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢

  1. 抢先式中断:(目前没有虚拟机采用了)首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

  2. 主动式中断:设置一个中断标志,各个线程运行到 Safe Point 的时候 主动轮询 这个标志,如果中断标志为真,则将自己进行中断挂起。

安全区域(Safe Region)

  1. Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序 “不执行” 的时候呢?

  2. 例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走” 到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

  3. 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。

安全区域的执行流程

  1. 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Region,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程

  2. 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成根节点枚举(即 GC Roots 的枚举),如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;

记忆集与卡表

什么是跨代引用?

  1. 一般的垃圾回收算法至少会划分出两个年代,年轻代和老年代。但是单纯的分代理论在垃圾回收的时候存在一个巨大的缺陷:为了找到年轻代中的存活对象,却不得不遍历整个老年代,反过来也是一样的。

  1. 如果我们从年轻代开始遍历,那么可以断定 N, S, P, Q 都是存活对象。但是,V 却不会被认为是存活对象,其占据的内存会被回收了。这就是一个惊天的大漏洞!因为 U 本身是老年代对象,而且有外部引用指向它,也就是说 U 是存活对象,而 U 指向了 V,也就是说 V 也应该是存活对象才是!而这都是因为我们只遍历年轻代对象

  2. 所以,为了解决这种跨代引用的问题,最笨的办法就是遍历老年代的对象,找出这些跨代引用来。这种方案存在极大的性能浪费。因为从两个分代假说里面,其实隐含了一个推论:跨代引用是极少的。也就是为了找出那么一点点跨代引用,我们却得遍历整个老年代!从上图来说,很显然的是,我们根本不必遍历 R。

  3. 因此,为了避免这种遍历老年代的性能开销,通常的分代垃圾回收器会引入一种称为记忆集的技术。简单来说,记忆集就是用来记录跨代引用的表。

记忆集与卡表

  1. 为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建 立了名为 记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的 垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便在后续章节里介绍几款最新的收集器相关知识时能更好地理解。

  2. 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。

比如说我们有老年代(非收集区域)和年轻代(收集区域)的对象之间有一条引用链

  1. 这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾 收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针 就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为 粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范 围以外的)的记录精度:

    1. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

    2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

    3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

  2. 其中,第三种 “卡精度” 所指的是用一种称为 “卡表”(Card Table)的方式去实现记忆集,这也是 目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记 忆集其实是一种 “抽象” 的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的 具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。 关于卡表与记忆集的关系,读者不妨按照 Java 语言中 HashMap 与 Map 的关系来类比理解。 卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的