学习笔记【Java 虚拟机②】垃圾回收


若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。


总目录



前言


  • 参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

参考文章


(二)垃圾回收


8.如何判断对象可以回收


8.1.思考


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


下图为 Java 虚拟机运行时数据区

在这里插入图片描述


在 Java 内存运行时区中的三个区域:程序计数器、虚拟机栈、本地方法栈

  • 这 3 个区域随线程而生,随线程而灭,
  • 栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。

每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的

  • (尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的)
  • 因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题
  • 当方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆和方法区这两个区域则有着很显著的不确定性

  • 一个接口的多个实现类需要的内存可能会不一样
  • 一个方法所执行的不同条件分支所需要的内存也可能不一样

只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

垃圾收集器所关注的正是这部分内存该如何管理,后续讨论中的 “内存” 分配与回收也仅仅特指这一部分内存。


在堆里面存放着 Java 世界中几乎所有的对象实例,

垃圾收集器在对堆进行回收前,

第一件事情就是要确定这些对象之中哪些还 “存活” 着,哪些已经“死去”(“死去” 即不可能再被任何途径使用的对象)了。


8.2.引用计数法


8.2.1.概念


在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


很多教科书判断对象是否存活的算法是这样的:

  • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一
  • 当引用失效时,计数器值就减一
  • 任何时刻计数器为零的对象就是不可能再被使用的

客观地说,引用计数算法(Reference Counting 虽然占用了一些额外的内存空间来进行计数。

但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。

有一些比较著名的应用案例都使用到了引用计数算法进行内存管理

  • 微软 COM(Component Object Model)技术
  • 使用 ActionScript 3 的 FlashPlayer
  • Python 语言
  • 在游戏脚本领域得到许多应用的 Squirrel。

但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存。

主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。

譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。


8.2.2.举例分析


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


举个简单的例子:具体见下面的代码块中的 testGC() 方法

  • 对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objBobjB.instance=objA
  • 除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问。
  • 但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

代码块:引用计数算法的缺陷

/**
 * testGC()方法执行后,objA 和 objB 会不会被 GC 呢?
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];
    
    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        // 假设在这行发生GC,objA 和 objB 是否能被回收?
        System.gc();
    }
}

运行结果

[Full GC (System) 

	[Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), 
		[Perm : 2999K->2999K(21248K)],
	0.0150007 secs] 
	
	[Times: user=0.01 sys=0.00, real=0.02 secs] Heap
	def new generation total 9216K, used 82K [0x00000000055e0000, 0x0000000005fe0000, 0x0000000005fe0000)
	Eden space 8192K, 1% used [0x00000000055e0000, 0x00000000055f4850, 0x0000000005de0000)
	from space 1024K, 0% used [0x0000000005de0000, 0x0000000005de0000, 0x0000000005ee0000)
	to space 1024K, 0% used [0x0000000005ee0000, 0x0000000005ee0000, 0x0000000005fe0000)
	tenured generation total 10240K, used 210K [0x0000000005fe0000, 0x00000000069e0000, 0x00000000069e0000)
	the space 10240K, 2% used [0x0000000005fe0000, 0x0000000006014a18, 0x0000000006014c00, 0x00000000069e0000)
	compacting perm gen total 21248K, used 3016K [0x00000000069e0000, 0x0000000007ea0000, 0x000000000bde0000)
	the space 21248K, 14% used [0x00000000069e0000, 0x0000000006cd2398, 0x0000000006cd2400, 0x0000000007ea0000)
	No shared spaces configured.

从运行结果中可以清楚看到内存回收日志中包含 “4603K->210K”

这意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们

这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。


8.3.可达性分析算法


8.3.1.概念


可达性分析(Reachability Analysis)

  • Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。
  • 扫描堆中的对象,看是否能够沿着 GC Root 对象为起点的引用链找到该对象。找不到,表示可以回收。

在这里插入图片描述


8.3.2.案例演示


  • 哪些对象可以作为 GC Root 呢?这里作一下相关的演示来了解这些情况 (自己看视频吧)

/**
 * 演示 GC Roots
 */
public class Demo2_1 {
    public static void main(String[] args) throws InterruptedException, IOException {
        List<Object> list1 = new ArrayList<>();
        list1.add("a");
        list1.add("b");
        System.out.println(1);
        System.in.read();

        list1 = null;
        System.out.println(2);
        System.in.read();
        System.out.println("end...");
    }
}

  1. 首先使用 jps 命令来查看进程 id

在这里插入图片描述


  1. 之后再通过 jmap 工具来转存文件。

(之前介绍过其可以查看堆内存占用情况:jmap -heap 进程id

  • jmap 能够打印给定 Java 进程、核心文件或远程 DEBUG 服务器的共享对象内存映射或堆内存的详细信息。
  • 如果给定的进程运行在 64 位虚拟机上,则必须指定 -J-d64 选项,例如 jmap -J-d64 -heap pid
  • jmap 可能在未来的 JDK 版本中删除。
jmap -dump:format=b,live,file=1.bin 进程id

-dump:[live,]format=b,file=<filename>

  • 将 Java 堆以 hprof 二进制格式转储到 filename 文件中。
  • live 是可选参数,如果指定,则只转储堆中的活动对象。
  • 可以使用 MATMemory Analyzer 工具来分析内容。

在这里插入图片描述


  1. 打开 Eclipse Memory Analyzer(该工具可单独使用,无需下载 Eclipse)对使用 jmap 工具生成的文件来进行分析。

MAT 下载地址:https://www.eclipse.org/mat/downloads.php

  • Eclipse Memory Analyzer

在这里插入图片描述

  • gc roots

在这里插入图片描述
在上面的软件分析中出现了四个对象,这里先稍稍解释以下对象在软件中的情况。

  • System Class
    • 系统类,由启动类加载器加载的类,即一些核心类,都是 java.lang.Class 类的实例对象
  • JNI Global
    • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Thread
    • 一些活动线程。线程运行时都由一次次的方法调用组成,每一次调用都会产生一个栈帧
    • 即栈帧类所使用的一些东西,它们也是可以作为根对象的。
    • 其中线程中用到的局部变量引用的对象
  • Busy Monitor
    • 被同步锁持有的对象

List<Object> list1 = new ArrayList<>();

  • list1 只是一个局部变量,它是存在于 活动 栈帧 里的。
  • new ArrayList<>(); 则是存储在堆中的(通过 new 关键字,创建对象都会使用堆内存)
  • Java 是垃圾收集器管理的内存区域,一些资料中它也被称作 GC 堆”

list1 = null;

  • 此处局部变量指明不再引用之前的变量
  • 当运行到 System.out.println(2); 这里时,我们再次使用了 jmap 工具来转储文件,live 参数主动执行了一次垃圾回收

在这里插入图片描述


8.3.3.可作为 GC Roots 的对象


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象
    • 譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象
    • 譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用
    • 如基本数据类型对应的 Class 对象
    • 一些常驻的异常对象(比如 NullPointExcepitonOutOfMemoryError)等
    • 还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBeanJVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外

根据用户所选用的垃圾收集器以及当前回收的内存区域不同

还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。

譬如后文将会提到的分代收集和局部回收(Partial GC)就会 “临时性” 地加入 GC Roots 集合。


如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),其更不是孤立封闭的

  • 必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的)

所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用。

这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。


目前最新的几款垃圾收集器无一例外都具备了局部回收的特征。

  • 如 OpenJDK 中的 G1、Shenandoah、ZGC 以及 Azul 的 PGC、C4 这些收集器。

为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

关于这些概念、优化技巧以及各种不同收集器实现等内容,都将在本章后续内容中一一介绍。


8.4.四种引用


8.4.1.概念


视频中认为有五种引用

在这里插入图片描述


  1. 强引用

只有所有 GC Roots 对象都不通过 强引用 引用该对象,该对象才能被垃圾回收

  1. 软引用SoftReference

仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象

可以配合引用队列来释放软引用自身

  1. 弱引用WeakReference

仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

可以配合引用队列来释放弱引用自身

  1. 虚引用PhantomReference

必须配合引用队列使用,主要配合 ByteBuffer 使用。

被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存

例如:由 Reference Handler 线程通过 Cleanerclean 方法调用 Unsafe.freeMemory 来释放直接内存

  1. 终结器引用FinalReference)(不推荐使用

无需手动编码

但其内部必须配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收)

再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象

Finalizer 线程的优先级很低,可能导致终结器引用指向的对象迟迟不被回收

故在实际开发中不推荐使用 Finalizer 来回收 GC


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,

判定对象是否存活都和 “引用” 离不开关系。

JDK 1.2 版之前,Java 里面的引用是很传统的定义:

  • 如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,
  • 就称该 reference 数据是代表某块内存、某个对象的引用。

这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有 “被引用” 或者 “未被引用” 两种状态,

对于描述一些 “食之无味,弃之可惜” 的对象就显得无能为力。

譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,

如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。

很多系统的缓存功能都符合这样的应用场景。


JDK 1.2 版之后,Java 对引用的概念进行了扩充,

将引用分为强引用(Strongly Re-ference软引用(Soft Reference弱引用(Weak Reference虚引用(Phantom Reference

这 4 种引用强度依次逐渐减弱。


强引用是最传统的 “引用” 的定义,是指在程序代码之中普遍存在的引用赋值

  • 类似 Object obj=new Object() 这种引用关系。
  • 无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用是用来描述一些还有用,但非必须的对象。

  • 只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收
  • 如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • JDK 1.2 版之后提供了 SoftReference 类来实现软引用。

弱引用也是用来描述那些非必须对象。

  • 但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  • 当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。

虚引用也称为 “幽灵引用” 或者 “幻影引用”,它是最弱的一种引用关系。

  • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
  • 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
  • JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

8.4.2.生存还是死亡


其实这里可以对应视频中认为的第五种引用终结器引用


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的。

这时候它们暂时还处于 “缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
  • 假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为 “没有必要执行”。

如果这个对象被判定为确有必要执行 finalize() 方法

  • 那么该对象将会被放置在一个名为 F-Queue 的队列之中,
  • 并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。

这里所说的 “执行” 是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束

这样做的原因是:

  • 如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,
  • 将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,

如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,

譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出 “即将回收” 的集合;

如果对象这时候还没有逃脱,那基本上它就真的要被回收了。


在这里插入图片描述


从下面的代码清单中我们可以看到一个对象的 finalize() 被执行,但是它仍然可以存活。


代码清单

/**
 * 此代码演示了两点:
 * * 1.对象可以在被 GC 时自我拯救。
 * * 2.这种自救的机会只有一次,因为一个对象的 finalize() 方法最多只会被系统自动调用一次
 *
 * @author zzm
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

运行结果

finalize method executed!
yes, i am still alive :)
no, i am dead :(

从上面的运行结果可以看到,SAVE_HOOK 对象的 finalize() 方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。

另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。

这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次。

如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。


还有一点需要特别说明,上面关于对象死亡时 finalize() 方法的描述可能带点悲情的艺术加工,笔者并不鼓励大家使用这个方法来拯救对象。

相反,笔者建议大家尽量避免使用它,因为它并不能等同于 C 和 C++ 语言中的析构函数,

而是 Java 刚诞生时为了使传统 C、C++ 程序员更容易接受 Java 所做出的一项妥协。

它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。

有些教材中描述它适合做 “关闭外部资源” 之类的清理性工作,这完全是对 finalize() 方法用途的一种自我安慰。

finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,

所以笔者建议大家完全可以忘掉 Java 语言里面的这个方法。


8.4.3.软引用的基本使用


代码部分

import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示软引用
 * * -Xmx20m 
 * * * -XX:+PrintGCDetails -verbose:gc
 */
public class Demo2_1_4_1 {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        //strong();
        soft();
    }

    //演示硬引用
    public static void strong() throws IOException {
        List<byte[]> list = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            list.add(new byte[_4MB]);
        }

        /* system.in.read()方法的作用:
         * 是从键盘读出一个字符,然后返回它的 Unicode 码。
         * 按下 Enter 结束输入*/
        System.in.read();
    }

    //演示软引用
    public static void soft() {
        // list --> SoftReference --> byte[]
        List<SoftReference<byte[]>> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);

            System.out.println("ref.get():" + ref.get());
            list.add(ref);
            System.out.println("list.size():" + list.size());
        }

        System.out.println("循环结束:" + list.size());

        for (SoftReference<byte[]> ref : list) {
            System.out.println("ref.get():" + ref.get());
        }
    }
}

控制台输出的结果

  • main() 方法调用 strong() 方法时(VM options:-Xmx20m
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at aTest.Demo2_1_4_1.strong(Demo2_1_4_1.java:21)
	at aTest.Demo2_1_4_1.main(Demo2_1_4_1.java:15)
  • main() 方法调用 soft() 方法时(VM options:-Xmx20m
ref.get()[B@6d6f6e28
list.size():1

ref.get()[B@135fbaa4
list.size():2

ref.get()[B@45ee12a7
list.size():3

ref.get()[B@330bedb4
list.size():4

ref.get()[B@2503dbd3
list.size():5

循环结束:5

ref.get():null
ref.get():null
ref.get():null
ref.get():null
ref.get()[B@2503dbd3
  • VM options:-Xmx20m -XX:+PrintGCDetails -verbose:gc
  • 打印垃圾回收的详细参数信息(太长太多了,所以我分成了几块,并调整了一下输出的格式,方便阅览)
ref.get()[B@6d6f6e28
list.size():1

ref.get()[B@135fbaa4
list.size():2

ref.get()[B@45ee12a7
list.size():3
[GC (Allocation Failure) [PSYoungGen: 2251K->488K(6144K)] 14539K->13056K(19968K), 0.0009551 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs] 
# 第四次循环
ref.get()[B@330bedb4
list.size():4

[GC (Allocation Failure) 
	--[PSYoungGen: 4696K->4696K(6144K)] 
	17264K->17280K(19968K), 0.0010460 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs] 

[Full GC (Ergonomics) 
	[PSYoungGen: 4696K->4526K(6144K)] 
	[ParOldGen: 12584K->12547K(13824K)] 
	17280K->17074K(19968K),
	[Metaspace: 3469K->3469K(1056768K)], 0.0046119 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs] 

# 触发了二次回收
[GC (Allocation Failure) 
	--[PSYoungGen: 4526K->4526K(6144K)] 
	17074K->17090K(19968K), 0.0008202 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs] 

[Full GC (Allocation Failure)
	[PSYoungGen: 4526K->0K(6144K)]
	[ParOldGen: 12563K->671K(8704K)] 
	17090K->671K(14848K),
	[Metaspace: 3469K->3469K(1056768K)], 0.0065348 secs] 
[Times: user=0.09 sys=0.00, real=0.01 secs] 
ref.get()[B@2503dbd3
list.size():5

循环结束:5
ref.get():null
ref.get():null
ref.get():null
ref.get():null
ref.get()[B@2503dbd3
# 程序运行结束时,内存的占用情况
Heap
 PSYoungGen      total 6144K, used 4264K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
  eden space 5632K, 75% used [0x00000000ff980000,0x00000000ffdaa080,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 8704K, used 671K [0x00000000fec00000, 0x00000000ff480000, 0x00000000ff980000)
  object space 8704K, 7% used [0x00000000fec00000,0x00000000feca7fe8,0x00000000ff480000)
 Metaspace       used 3476K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 381K, capacity 388K, committed 512K, reserved 1048576K

8.4.4.软引用清理(配合引用队列)


代码

import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示软引用, 配合引用队列
 * -Xmx20m
 */
public class Demo2_1_4_2 {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        List<SoftReference<byte[]>> list = new ArrayList<>();

        // 引用队列
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

        for (int i = 0; i < 5; i++) {
            // 关联了引用队列
            // 当软引用所关联的 byte[] 被回收时,软引用自己会加入到 queue 中去
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
            System.out.println("ref.get():" + ref.get());
            list.add(ref);
            System.out.println("list.size():" + list.size());
        }

        // 从队列中获取无用的 软引用对象,并移除
        Reference<? extends byte[]> poll = queue.poll();
        while (poll != null) {
            list.remove(poll);
            poll = queue.poll();
        }

        System.out.println("===========================");
        for (SoftReference<byte[]> reference : list) {
            System.out.println("ref.get():" + reference.get());
        }

    }
}

控制台输出

ref.get()[B@6d6f6e28
list.size():1
ref.get()[B@135fbaa4
list.size():2
ref.get()[B@45ee12a7
list.size():3
ref.get()[B@330bedb4
list.size():4
ref.get()[B@2503dbd3
list.size():5
===========================
# list 中只存在一个元素
ref.get()[B@2503dbd3
# 显然配合引用队列成功清理了软引用

8.4.5.弱引用的基本使用


代码部分

/**
 * 演示弱引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class Demo2_1_4_3 {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        //  list --> WeakReference --> byte[]
        List<WeakReference<byte[]>> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
            list.add(ref);
            for (WeakReference<byte[]> w : list) {
                System.out.print(w.get()+" ");
            }
            System.out.println();

        }
        System.out.println("循环结束:" + list.size());
    }
}

控制台输出结果

[B@6d6f6e28 
[B@6d6f6e28 [B@135fbaa4 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 
[GC (Allocation Failure) [PSYoungGen: 2251K->488K(6144K)] 14539K->13056K(19968K), 0.0011199 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 [B@330bedb4 
[GC (Allocation Failure) [PSYoungGen: 4696K->488K(6144K)] 17264K->13064K(19968K), 0.0008881 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null [B@2503dbd3 
[GC (Allocation Failure) [PSYoungGen: 4695K->504K(6144K)] 17271K->13096K(19968K), 0.0004643 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null [B@4b67cf4d 
[GC (Allocation Failure) [PSYoungGen: 4710K->488K(6144K)] 17302K->13096K(19968K), 0.0004630 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null [B@7ea987ac 
[GC (Allocation Failure) [PSYoungGen: 4694K->496K(6144K)] 17302K->13136K(19968K), 0.0004816 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null null [B@12a3a380 
[GC (Allocation Failure) [PSYoungGen: 4702K->488K(5120K)] 17342K->13128K(18944K), 0.0004228 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null null null [B@29453f44 
[GC (Allocation Failure) [PSYoungGen: 4674K->32K(5632K)] 17314K->13104K(19456K), 0.0005544 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 32K->0K(5632K)] [ParOldGen: 13072K->689K(8704K)] 13104K->689K(14336K), [Metaspace: 3466K->3466K(1056768K)], 0.0060263 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
null null null null null null null null null [B@5cad8086 
循环结束:10
Heap
 PSYoungGen      total 5632K, used 4278K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
  eden space 4608K, 92% used [0x00000000ff980000,0x00000000ffdadab8,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 8704K, used 689K [0x00000000fec00000, 0x00000000ff480000, 0x00000000ff980000)
  object space 8704K, 7% used [0x00000000fec00000,0x00000000fecac7e8,0x00000000ff480000)
 Metaspace       used 3473K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 381K, capacity 388K, committed 512K, reserved 1048576K

显然,仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

但若要释放弱引用自身,则需要配合引用队列来释放(具体操作与软引用清理无异)


9.垃圾回收算法


9.1.标记清除


  • 定义:Mark Sweep
  • 优点:速度较快
  • 缺点:造成空间不连续,进而会造成内存碎片

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


最早出现也是最基础的垃圾收集算法是 “标记-清除”(Mark-Sweep)算法

该算法分为 “标记” 和 “清除” 两个阶段:

  • 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象
  • 也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
  • 标记过程就是对象是否属于垃圾的判定过程。

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以 标记-清除 算法为基础,对其缺点进行改进而得到的。

它的主要缺点有两个:

  • 第一个是执行效率不稳定。
    • 如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的。
    • 这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题。
    • 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致:
      • 以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存,而不得不提前触发另一次垃圾收集动作。

在这里插入图片描述

“标记”-“清除”算法示意图

9.2.标记复制


  • 定义:Copy
  • 优点:不会有内存碎片
  • 缺点:需要占用双倍内存空间

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


标记-复制算法常被简称为复制算法。

为了解决 标记-清除 算法面对大量可回收对象时执行效率低的问题

1969 年 Fenichel 提出了一种称为 “半区复制”Semispace Copying)的垃圾收集算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

  • 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
  • 但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,
  • 而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,
  • 只要移动堆顶指针,按顺序分配即可。

这样实现简单,运行高效,不过其缺陷也显而易见。

这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。


在这里插入图片描述

标记-复制算法示意图

这里还请结合下面的 “分代垃圾回收” 和 “垃圾回收器” 的内容来看,书中的编排顺序与视频中稍有所不同


现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代。

IBM 公司曾有一项专门研究对新生代 “朝生夕灭” 的特点做了更量化的诠释:新生代中的对象有 98% 熬不过第一轮收集。

因此并不需要按照 1∶1 的比例来划分新生代的内存空间。


在 1989 年,Andrew Appel 针对具备 “朝生夕灭” 特点的对象,

提出了一种更优化的半区复制分代策略,现在称为 Appel 式回收”

HotSpot 虚拟机的 SerialParNew 等新生代收集器均采用了这种策略来设计 新生代 的内存布局。

Appel 式回收 的具体做法是

  • 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间
  • 每次分配内存只使用 Eden 和其中一块 Survivor
  • 发生垃圾搜集时EdenSurvivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上
  • 然后直接清理掉 Eden 和已用过的那块 Survivor 空间

HotSpot 虚拟机默认 EdenSurvivor 的大小比例是 8∶1

  • 也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80% 加上一个 Survivor 的 10%),
  • 只有一个 Survivor 空间,即 10% 的新生代是会被 “浪费” 的。

当然,98% 的对象可被回收仅仅是 “普通场景” 下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活。

因此 Appel 式回收还有一个充当罕见情况的 “逃生门” 的安全设计

  • Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时
  • 就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

内存的分配担保好比我们去银行借款。

如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,

只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。

内存的分配担保也一样。

如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,

这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

关于对新生代进行分配担保的内容,在稍后的章节中介绍垃圾收集器执行规则的时候还会再进行讲解。


9.3.标记整理


  • 定义:Mark Compact
  • 优点:速度慢
  • 缺点:没有内存碎片

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。

更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,

以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的 “标记-整理”Mark-Compact)算法,

其中的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,

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


在这里插入图片描述

“标记-整理”算法示意图

这里还请结合下面的 “分代垃圾回收” 的内容来看,书中的编排顺序与视频中稍有所不同


标记-清除 算法与 标记-整理 算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

是否移动回收后的存活对象是一项优缺点并存的风险决策。

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,

移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,

而且这种对象移动操作必须全程暂停用户应用程序才能进行,

这就更加让使用者不得不小心翼翼地权衡其弊端了,

像这样的停顿被最初的虚拟机设计者形象地描述为 Stop The World

(通常 标记-清除 算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已)


但如果跟 标记-清除 算法那样完全不考虑移动和整理存活对象的话,

弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。

譬如通过 “分区空闲分配链表” 来解决内存分配问题

(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。

内存的访问是用户程序最频繁的操作,甚至都没有之一,

假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。


基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。

从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,

但是从整个程序的吞吐量来看移动对象会更划算

此语境中,吞吐量的实质是 赋值器 与 收集器 的效率总和。

  • 赋值器(Mutator):可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用 “用户程序” 或 “用户线程” 代替

即使不移动对象会使得收集器的效率提升一些,

但因内存分配和访问相比垃圾收集频率要高得多,因为这部分的耗时增加,总吞吐量仍然是下降的。

HotSpot 虚拟机里面关注 吞吐量 的 Parallel Scavenge 收集器是基于 标记-整理 算法的

而关注 延迟 的 CMS 收集器则是基于 标记-清除 算法的,这也从侧面印证这点。


另外,还有一种 “和稀泥式” 解决方案可以不在内存分配和访问上增加太大额外负担,

做法是让虚拟机平时多数时间都采用 标记-清除 算法,

暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,

再采用 标记-整理 算法收集一次,以获得规整的内存空间。

前面提到的基于 标记-清除算法 的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。


10.分代垃圾回收


10.1.分代回收流程


把分代收集理论具体放到现在的商用 Java 虚拟机里

设计者一般至少会把 Java 堆划分为 新生代(Young Generation)老年代(Old Generation) 两个区域。

在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。


在这里插入图片描述


提示:新生代分为一块较大的 Eden 和两块较小的 Survivor,这里面的 fromto 都是 Survivor


  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1,并且交换 fromto
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是 15(4 bit)
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gcstop the world 的时间更长

参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

  • 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC)指目标只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
      • 目前只有 CMS 收集器会有单独收集老年代的行为。
      • 另外请注意 “Major GC” 这个说法现在有点混淆
        • 在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。
  • 整堆收集(Full GC)收集整个 Java 堆和方法区的垃圾收集

10.2.相关 VM 参数


含义参数
堆初始大小-Xms
堆最大大小-Xmx-XX:MaxHeapSize=size
新生代大小-Xmn 或 ( -XX:NewSize=size + -XX:MaxNewSize=size)
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio-XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
打印 GC 详情-XX:+PrintGCDetails -verbose:gc
FullGCMinorGC-XX:+ScavengeBeforeFullGC

10.3.垃圾回收案例


参考博客JVM 垃圾回收 超详细学习笔记(二)


VM options-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC


代码部分

import java.util.ArrayList;

public class Demo10_1 {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    // -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        list.add(new byte[_7MB]);
        list.add(new byte[_1MB]);
    }
}

运行结果

# 显然,这里触发了两次内存回收
[GC (Allocation Failure) 
	# 新生代进行垃圾回收前后的所占用的空间
	[DefNew: 2190K->661K(9216K), 0.0012653 secs]
	# 堆进行垃圾回收前后的所占用的空间
	2190K->661K(19456K), 0.0013016 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs] 

[GC (Allocation Failure) 
	[DefNew: 8157K->28K(9216K), 0.0043199 secs] 
	8157K->7846K(19456K), 0.0043384 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 1134K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  13% used [0x00000000fec00000, 0x00000000fed14930, 0x00000000ff400000)
  from space 1024K,   2% used [0x00000000ff400000, 0x00000000ff4072a8, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 7818K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  76% used [0x00000000ff600000, 0x00000000ffda2818, 0x00000000ffda2a00, 0x0000000100000000)

 # 元空间位于本地内存,其并不属于堆,此处只是打印出来了而已
 Metaspace       used 3470K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 381K, capacity 388K, committed 512K, reserved 1048576K

若是直接一次加满 list.add(new byte[_8MB]);

即一个对象的大小直接超过了新生代的伊甸园的剩余容量,from 中也放不下它

  • 虽然我们这里分配给伊甸园的空间有 8 M
  • java 程序运行时,必须要加载一些类,创建一些对象,这些对象使用的也是伊甸园的区域

其进行垃圾回收也不能将该对象放进去时,则此时新生代不会进行垃圾回收,而是直接将对象晋升到老年代。

就大对象而言,在老年代空间足够,而新生代空间不足时,其必不会在新生代进行垃圾回收,而是直接将对象晋升到老年代

Heap
 def new generation   total 9216K, used 2354K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  # [28%]是因为 java 程序运行时,必须要加载一些类,创建一些对象,这些对象使用的仍是[伊甸园]的区域
  eden space 8192K,  28% used [0x00000000fec00000, 0x00000000fee4cbd0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  80% used [0x00000000ff600000, 0x00000000ffe00010, 0x00000000ffe00200, 0x0000000100000000)
 Metaspace       used 3469K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 381K, capacity 388K, committed 512K, reserved 1048576K

若是进行了 gc,但是内存还是不够,这时就会导致内存溢出。不过在抛出 OOM 异常之前 JVM 还是会进行一次自救

比如这里我加了 16MB,此时是老年代(10M)也放不下,新生代也放不下

list.add(new byte[_8MB]);
list.add(new byte[_8MB]);

运行结果

[GC (Allocation Failure) [DefNew: 2190K->689K(9216K), 0.0015390 secs][Tenured: 8192K->8880K(10240K), 0.0018898 secs] 10382K->8880K(19456K), [Metaspace: 3463K->3463K(1056768K)], 0.0034957 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

[Full GC (Allocation Failure) [Tenured: 8880K->8862K(10240K), 0.0014772 secs] 8880K->8862K(19456K), [Metaspace: 3463K->3463K(1056768K)], 0.0014970 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

Heap
 def new generation   total 9216K, used 246K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,   3% used [0x00000000fec00000, 0x00000000fec3d890, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 8862K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  86% used [0x00000000ff600000, 0x00000000ffea7b58, 0x00000000ffea7c00, 0x0000000100000000)
 Metaspace       used 3494K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 384K, capacity 388K, committed 512K, reserved 1048576K
  
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at cTest.Demo10_1.main(Demo10_1.java:17)

一个线程的 OutOfMemoryError 是不会导致整个程序都停止的

代码部分

import java.util.ArrayList;

public class Demo10_1S {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            ArrayList<byte[]> list = new ArrayList<>();
            list.add(new byte[_8MB]);
            list.add(new byte[_8MB]);
        }).start();

        System.out.println("sleep....");
        Thread.sleep(1000L);
    }
}

运行结果

sleep....

[GC (Allocation Failure) 
	[DefNew: 4365K->892K(9216K), 0.0025394 secs]
	[Tenured: 8192K->9082K(10240K), 0.0034657 secs] 12557K->9082K(19456K), 
	[Metaspace: 4332K->4332K(1056768K)], 0.0060740 secs] 
[Times: user=0.00 sys=0.00, real=0.01 secs] 

[Full GC (Allocation Failure) 
	[Tenured: 9082K->9027K(10240K), 0.0029306 secs] 9082K->9027K(19456K), 
	[Metaspace: 4332K->4332K(1056768K)], 0.0029713 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs] 

# 该线程(Thread-0)内存溢出,但是主线程并未抛出异常
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
	at cTest.Demo10_1.lambda$main$0(Demo10_1.java:17)
	at cTest.Demo10_1$$Lambda$1/1747585824.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)
Heap
 def new generation   total 9216K, used 1329K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  16% used [0x00000000fec00000, 0x00000000fed4c770, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 9027K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  88% used [0x00000000ff600000, 0x00000000ffed0c30, 0x00000000ffed0e00, 0x0000000100000000)

 # 元空间位于本地内存,其并不属于堆
 Metaspace       used 4857K, capacity 4978K, committed 5248K, reserved 1056768K
  class space    used 544K, capacity 591K, committed 640K, reserved 1048576K

11.垃圾回收器


11.1.概念


  1. 串行

底层是一个单线程的垃圾回收器

应用场景:堆内存较小时,适合个人电脑


  1. 吞吐量优先

底层是多线程的垃圾回收区

应用场景:堆内存较大,需要多核 cpu 来支持,适合在服务器上工作

目标:让单位时间内,STW 的时间最短(如:0.2 秒、0.2 秒 ,垃圾回收时间共 0.4 秒),垃圾回收时间占比最低,这样就称吞吐量高


  1. 响应时间优先

底层是多线程的垃圾回收区

应用场景:堆内存较大,需要多核 cpu 来支持,适合在服务器上工作

目标:尽可能让单次 STW 的时间最短(如:0.1 秒、0.1 秒、0.1 秒、0.1 秒、0.1 秒,垃圾回收总时间为 0.5 秒)


11.2.相关概念


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


11.2.1.并行、并发、吞吐量


ParNew 收集器 开始,后面还将会接触到若干款涉及 “并发”“并行” 概念的收集器。

在大家可能产生疑惑之前,有必要先解释清楚这两个名词。

并行并发 都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:

并行(Parallel

  • 并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作。
  • 通常默认此时用户线程是处于等待状态。

并发(Concurrent

  • 并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。
  • 由于用户线程并未被冻结,所以程序仍然能响应服务请求。
  • 但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

吞吐量(Throughput

  • 处理器用于 运行用户代码的时间 与 处理器总消耗时间 的 比值
    运行用户代码时间 运行用户代码时间 + 运行垃圾收集时间 \frac{运行用户代码时间}{运行用户代码时间 + 运行垃圾收集时间} 运行用户代码时间+运行垃圾收集时间运行用户代码时间
  • 如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

垃圾收集时用户线程的停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;

而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。


11.2.2.根节点枚举


我们以可达性分析算法中从 GC Roots 集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。

固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,

尽管目标明确,但查找过程要做到高效并非一件容易的事情。

现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,

若要逐个检查以这里为起源的引用肯定得消耗不少时间。


迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,

因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的 “Stop The World” 的困扰。

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,

根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行

  • 这里 “一致性” 的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上。
  • 不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。

若这点不能满足的话,分析结果准确性也就无法保证。

这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,

即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMSG1ZGC 等收集器,枚举根节点时也是必须要停顿的。


由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,

  • 准确式内存管理(Exact Memory Management)是指虚拟机可以知道内存中某个位置的数据具体是什么类型

所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,

虚拟机应当是有办法直接得到哪些地方存放着对象引用的

在 HotSpot 的解决方案里是使用一组称为 OopMap 的数据结构来达到这个目的

一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来

在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用

这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。


11.2.3.安全点


安全点Safe Point


OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举。

但一个很现实的问题随之而来:

  • 可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多
  • 如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,
  • 这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上 HotSpot 也的确没有为每条指令都生成 OopMap

前面已经提到,只是在 “特定的位置” 记录了这些信息,这些位置被称为安全点(Safepoint

有了安全点的设定,

也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,

而是强制要求必须执行到达安全点后才能够暂停。

因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

安全点位置的选取基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,

因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,

“长时间执行” 的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,

所以只有具有这些功能的指令才会产生安全点。


对于安全点,另外一个需要考虑的问题是:

如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。

这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。


抢先式中断

  • 其不需要线程的执行代码主动去配合,
  • 在垃圾收集发生时,系统首先把所有用户线程全部中断,
  • 如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。

现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。


主动式中断的思想是:

  • 当垃圾收集需要中断线程的时候,不直接对线程操作,
  • 仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,
  • 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

轮询标志的地方和安全点是重合的,

另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方

  • 这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。

HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。


11.2.4.安全区域


使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

但是,程序 “不执行” 的时候呢?

所谓的程序不执行就是没有分配处理器时间,

典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,

这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,

虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

对于这种情况,就必须引入 安全区域(Safe Region 来解决。


安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。

因此,在这个区域中任意地方开始垃圾收集都是安全的。

我们也可以把安全区域看作被扩展拉伸了的安全点。


当用户线程执行到安全区域里面的代码时,

首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),

如果完成了,那线程就当作没事发生过,继续执行;

否则它就必须一直等待,直到收到可以离开安全区域的信号为止。


11.2.5.记忆集与卡表


分代收集理论

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

第三条假说其实是可根据前两条假说逻辑推理得出的隐含推论

  • 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
  • 举个例子,如果某个新生代对象存在跨代引用
    • 由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活
    • 进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据第三条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用

只需在新生代上建立一个全局的数据结构(该结构被称为 “记忆集”,Remembered Set

  • 这个结构把老年代划分成若干小块标识出老年代的哪一块内存会存在跨代引用
  • 此后当发生 Minor GC只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描
  • 虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销
  • 但比起收集时扫描整个老年代来说仍然是划算的。

记忆集

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题

  • 垃圾收集器在新生代中建立了名为 记忆集(Remembered Set 的数据结构
  • 用以避免把整个老年代加进 GC Roots 扫描范围。

事实上并不只是新生代、老年代之间才有跨代引用的问题

所有涉及部分区域收集(Partial GC)行为的垃圾收集器

典型的如 G1ZGCShenandoah 收集器,都会面临相同的问题

因此我们有必要进一步理清记忆集的原理和实现方式,以便理解。


记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。

如下面的代码块(以对象指针来实现记忆集的伪代码)

Class RememberedSet {
	Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。

而在垃圾收集的场景中,

收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。

那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本

下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
    • 机器字长:就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度每个记录精确到一块内存区域该区域内有对象含有跨代指针

卡表

其中,第三种 “卡精度” 所指的是用一种称为 “卡表”(Card Table)的方式去实现记忆集

这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。

前面定义中提到记忆集其实是一种 “抽象” 的数据结构

抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。

卡表就是记忆集的一种具体实现它定义了记忆集的记录精度与堆内存的映射关系等

关于卡表与记忆集的关系,读者不妨按照 Java 语言中 HashMapMap 的关系来类比理解。


卡表最简单的形式可以只是一个字节数组HotSpot 虚拟机确实也是这样做的

以下这行代码是 HotSpot 默认的卡表标记逻辑

CARD_TABLE [this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块

这个内存块被称作 “卡页”(Card Page

一般来说,卡页大小都是以 2 的 N 次幂的字节数

通过上面代码可以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。

那如果卡表标识内存区域的起始地址是 0x0000 的话

数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x01FF0x0200~0x03FF0x0400~0x05FF 的卡页内存块


具体情况如下图

在这里插入图片描述

卡表与卡页对应示意图

一个卡页的内存中通常包含不止一个对象

只要卡页内有一个(或更多)对象的字段存在着跨代指针

  • 那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty
  • 没有则标识为 0

在垃圾收集发生时只要筛选出卡表中变脏的元素就能轻易得出哪些卡页内存块中包含跨代指针把它们加入 GC Roots 中一并扫描


11.2.6.写屏障


我们已经解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题

但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。


卡表元素何时变脏的答案是很明确的

  • 有其他分代区域中对象引用了本区域对象时其对应的卡表元素就应该变脏
  • 变脏时间点原则上应该发生在引用类型字段赋值的那一刻

但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

  • 假如是解释执行的字节码
    • 那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;
  • 但在编译执行的场景中呢?
    • 经过即时编译后的代码已经是纯粹的机器指令流了
    • 这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

注意将这里提到的 “写屏障”,以及在低延迟收集器中提到的 “读屏障” 与解决并发乱序执行问题中的 “内存屏障” 区分开来,避免混淆。

写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面

  • 在引用对象赋值时会产生一个环形Around通知,供程序执行额外的动作
  • 也就是说赋值的前后都在写屏障的覆盖范畴内。

AOPAspect Oriented Programming 的缩写,意为 面向切面编程

  • 通过预编译方式运行期动态代理实现程序功能的统一维护的一种技术。
  • 上面提到的 “环形通知” 也是 AOP 中的概念,使用过 Spring 的读者应该都了解这些基础概念。

在赋值前的部分的写屏障叫作写 前屏障(Pre-Write Barrier,在赋值后的则叫作写 后屏障(Post-Write Barrier

HotSpot 虚拟机的许多收集器中都有使用到写屏障

但直至 G1 收集器出现之前,其他收集器都只用到了写后屏障。

下面这段代码清单是一段更新卡表状态简化逻辑

void oop_field_store(oop* field, oop new_value) {
	// 引用字段赋值操作
	*field = new_value;
	// 写后屏障,在这里完成卡表状态更新
	post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令

一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用

每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。


除了写屏障的开销外,卡表在高并发场景下还面临着 “伪共享”(False Sharing)问题。

伪共享是处理并发底层细节时一种经常需要考虑的问题

现代中央处理器的缓存系统中是以 缓存行Cache Line)为单位存储的

当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低

这就是伪共享问题。


假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行。

这 64 个卡表元素对应的卡页总的内存为 32KB(64×512 字节)

也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

为了避免伪共享问题,一种简单的解决方案是

  • 不采用无条件的写屏障,而是先检查卡表标记
  • 只有当该卡表元素未被标记过时才将其标记为变脏
  • 即将卡表更新的逻辑变为以下代码所示
if (CARD_TABLE [this address >> 9] != 0)
	CARD_TABLE [this address >> 9] = 0;

JDK 7 之后

HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。

开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。


11.3.串行(Serial + SerialOld)


开启串行回收器的语句:-XX:+UseSerialGC = Serial + SerialOld

在这里插入图片描述

Serial / Serial Old 收集器运行示意图

参考博客JVM 垃圾回收 超详细学习笔记(二)

进行垃圾回收的时候为什么需要让其他线程停下?

  • 因为在进行垃圾回收的时候可能(标记整理会导致地址值得的变化)会导致地址的改变
  • 所以需要在安全点停下,由安全点记录相关的引用关系的信息。

在垃圾回收线程运行的时候其他线程都需要阻塞,等垃圾线程运行结束后,其他用户线程才能恢复运行。

因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态。


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


Serial 收集器是一个单线程工作的收集器。

  • 新生代采用复制算法
  • 但它的 “单线程” 的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作
  • 更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束,即 Stop the World
  • 其特点是简单而高效(与其他收集器的单线程相比)
    • 对于内存资源受限的环境来说
      • 它是所有收集器里额外内存消耗(Memory Footprint)最小的;
    • 对于单核处理器或处理器核心数较少的环境来说
      • Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,

收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),

垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,

只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。

所以,Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择


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

  • 使用 标记-整理 算法。
  • 这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用
  • 如果在服务端模式下,它也可能有两种用途
    • 一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用
    • 另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

11.4.吞吐量优先(Parallel Scavenge/Old)


java 8 后 JDK 默认使用该回收器


ParNew 收集器实质上是 Serial 收集器的多线程并行版本

除了同时使用多条线程进行垃圾收集之外

其余的行为包括 Serial 收集器可用的所有控制参数

  • 例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure

以及 Serial 收集器的收集算法、Stop The World、对象分配规则、回收策略等

这些都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。


Parallel Scavenge 收集器是一款新生代收集器

  • 它是基于 标记-复制 算法实现的收集器
  • 也是能够并行收集多线程收集器
  • 目标则是达到一个可控制的 吞吐量Throughput)。
  • 自适应调节策略Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。

Parallel OldParallel Scavenge 收集器的老年代版本

  • 支持多线程并行收集
  • 基于 标记-整理 算法实现

在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel ScavengeParallel Old 收集器这个组合。


在这里插入图片描述

Parallel Scavenge / Parallel Old 收集器运行示意图

  • -XX:+UseParallelGC ~ -XX:+UseParallelOldGC
  • -XX:+UseAdaptiveSizePolicy
  • -XX:GCTimeRatio=ratio
  • -XX:MaxGCPauseMillis=ms
  • -XX:ParallelGCThreads=n

-XX:+UseParallelGC ~ -XX:+UseParallelOldGC

  • 开启 Parallel Scavenge 收集器的指令(jdk 1.8 后默认自开启)
  • 这俩参数,设置使用其中任意一个参数,另一个参数都会被自动关联使用

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量

  • -XX:MaxGCPauseMillis 参数:控制最大垃圾收集停顿时间
    • 允许的值是一个大于 0 的毫秒数(默认值是 200 ms),收集器将尽力保证内存回收花费的时间不超过用户设定值。
  • -XX:GCTimeRatio 参数:直接设置吞吐量大小
    • 该参数的值则应当是一个大于 0 小于 100 的整数(ratio),也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
      1 1 + r a t i o \frac{1}{1 + ratio} 1+ratio1

显然,这两个参数是互相冲突的


-XX:+UseAdaptiveSizePolicy 参数

  • 这是一个开关参数,当这个参数被激活之后,
  • 就不需要人工指定新生代的大小(-Xmn
    EdenSurvivor 区的比例(-XX:SurvivorRatio
    晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了。
  • 虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
  • 这种调节方式称为垃圾收集自适应的调节策略(GC Ergonomics

自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。


-XX:ParallelGCThreads 参数

  • 设置垃圾收集的线程数

该收集器与 ParNew 收集器类似,都默认开启的收集线程数与处理器核心数量相同

在处理器核心非常多的环境下,可以使用上面的参数来限制垃圾收集的线程数。

  • 多处理器核心的环境:譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍

11.5.响应时间优先(CMS)


在这里插入图片描述


  • -XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
  • -XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
  • -XX:CMSInitiatingOccupancyFraction=percent
  • -XX:+CMSScavengeBeforeRemark

-XX:ParallelGCThreads=n

  • 开启并行的垃圾回收的线程数(默认线程数与 cpu 核数相同)

-XX:ConcGCThreads=threads

  • 开启并发的垃圾回收的线程数(一般建议设置为并行线程数的 1/4)
  • 实际的情况就是 1/4 内存做垃圾回收,3/4 内存做用户线程使用

-XX:CMSInitiatingOccupancyFraction=percent

  • 参数值是分配给 CMS 的垃圾回收的百分比
    • 比如设置 percent 为 80,就表示当老年代的内存占比达到 80% 时,就进行一次 CMS 垃圾回收
  • 目的在于控制 CMS 垃圾回收的触发时机,同时预留一些足够的空间给浮动垃圾

-XX:+CMSScavengeBeforeRemark

  • 重新标记 阶段,可能会出现新生代的对象引用老年代的对象的情况
    • 此时可能会扫描整个堆,通过新生代的引用扫描老年代来做可达性分析,这对性能的影响是很大的
    • 这是因为新生代创建对象的数目是很多的,其中不少对象都是要作为垃圾回收的,相当于我们在回收之前多做了无用功
  • 此时我们可以通过上面的这个参数来避免这种情况,+ 为开启,- 为禁用
  • 重新标记 前对新生代进行一次垃圾回收,减少新生代存活对象,这样当我们扫描对象的时候就可以少扫描一些对象

参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


CMSConcurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,

这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。

CMS 收集器就非常符合这类应用的需求。


从名字(包含 Mark Sweep)上就可以看出 CMS 收集器是基于 标记-清除 算法实现的,

它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  1. 初始标记(CMS initial mark
  2. 并发标记(CMS concurrent mark
  3. 重新标记(CMS remark
  4. 并发清除(CMS concurrent sweep

其中 初始标记重新标记 这两个步骤仍然需要 “Stop The World

  • 初始标记 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快
  • 并发标记 阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程
    • 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  • 重新标记 阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生 变动 的那一部分对象的 标记记录
    • 这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
  • 最后是 并发清除 阶段,清理删除掉标记阶段判断的已经死亡的对象
    • 由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

由于在整个过程中耗时最长的 并发标记并发清除 阶段中,垃圾收集器线程都可以与用户线程一起工作

所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起 并发 执行的

通过下图可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的阶段。


在这里插入图片描述

Concurrent Mark Sweep 收集器运行示意图

CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集低停顿

一些官方公开文档里面也称之为 “并发低停顿收集器”(Concurrent Low Pause Collector

CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点


首先,CMS 收集器对处理器资源非常敏感

事实上,面向并发设计的程序都对处理器资源比较敏感

在并发阶段,它虽然不会导致用户线程停顿

但却会因为占用了一部分线程或者说处理器的计算能力而导致应用程序变慢降低总吞吐量

CMS 默认启动的回收线程数是: 处理器核心数量 + 3 4 \frac{处理器核心数量 + 3}{4} 4处理器核心数量+3

  • 也就是说,如果处理器核心数在四个或以上时,
  • 并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。
  • 但是当处理器核心数量不足 4 个时,CMS 对用户程序的影响就可能变得很大。
  • 如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

为了缓解这种情况,虚拟机提供了一种称为 “增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的 CMS 收集器变种

  • 所做的事情和以前单核处理器年代 PC 机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样
  • 是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间
  • 这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些
  • 直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。

实践证明增量式的 CMS 收集器效果很一般

  • JDK 7 开始,i-CMS 模式已经被声明为 “deprecated”,即已过时不再提倡用户使用
  • JDK 9 发布后 iCMS 模式被完全废弃

然后,由于 CMS 收集器无法处理 “浮动垃圾”Floating Garbage

有可能出现 “Con-current Mode Failure” 失败进而导致另一次完全 “Stop The World” 的 Full GC 的产生。

  • CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生
  • 但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
  • 这一部分垃圾就称为 “浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用

因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

JDK 5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置

  • 如果在实际应用中老年代增长并不是太快,可以使用参数 -XX:CMSInitiatingOccu-pancyFraction
  • 通过调高参数值来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能。

到了JDK 6 时,CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:

  • 要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次 “并发失败”(Concurrent Mode Failure
  • 这时候虚拟机将不得不启动后备预案冻结用户线程的执行
  • 临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了

所以参数 -XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低

用户应在生产环境中根据实际应用情况来权衡设置。


还有最后一个缺点,在本节的开头曾提到,CMS 是一款基于 “标记-清除” 算法实现的收集器,

如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生

空间碎片过多时,将会给大对象分配带来很大麻烦

往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。

为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMS-CompactAtFullCollection 开关参数

  • 该参数默认是开启的,此参数从 JDK 9 开始废弃
  • 用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程
  • 由于这个内存整理必须移动存活对象,(在 ShenandoahZGC 出现前)是无法并发的。

这样空间碎片问题是解决了,但停顿时间又会变长,

因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction(此参数从 JDK 9 开始废弃)

  • 这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后
  • 下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

11.6.Garbage First


11.6.1.G1 概要


Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果

它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。

JDK 9 发布之日

  • G1 宣告取代 Parallel Scavenge Parallel Old 组合,成为服务端模式下的默认垃圾收集器
  • CMS 则沦落至被声明为不推荐使用( Deprecate)的收集器

相关时间:2004 论文发布、2009 JDK 6u14 体验、2012 JDK 7u4 官方支持、2017 JDK 9 默认

适用场景

  • 同时注重吞吐量(Throughput低延迟(Low latency,默认的暂停目标是 200 ms
  • 超大堆内存,会将堆划分为多个大小相等的 Region
  • 整体上是 标记 + 整理 算法,两个区域之间是 复制 算法

相关 JVM 参数

  • -XX:+UseG1GC
    • JDK 8 中,G1 回收器还不是默认的,需要通过该参数手动开启
  • -XX:G1HeapRegionSize=size
  • -XX:MaxGCPauseMillis=time

11.6.2.G1 垃圾回收阶段


  • Young Collection:对新生代的垃圾收集
  • Young Collection + Concurrent Mark:对新生代的垃圾收集,同时会执行一些并发的标记
  • Mixed Collection:混合收集

上述的三个阶段是一个循环过程

  1. 开始时是对新生代的垃圾收集
  2. 当老年代内存超过阈值时,其会在进行新生代垃圾收集的同时,做一些并发标记
  3. 等上述阶段完成后,其会对新生代幸存区和老年代都来进行一次规模较大的垃圾收集
  4. 等内存释放完毕,伊甸园内存释放完毕,混合收集结束,会再次进入新生代的垃圾收集

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起 “停顿时间模型”(Pause Prediction Model)的收集器

停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标

这几乎已经是 实时 Java(RTSJ) 的中软实时垃圾收集器特征了。


那具体要怎么做才能实现这个目标呢?

首先要有一个思想上的改变,在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,

  • 垃圾收集的目标范围要么是整个新生代(Minor GC
  • 要么就是整个老年代(Major GC
  • 再要么就是整个 Java 堆(Full GC)。

G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集Collection Set,一般简称 CSet)进行回收,

衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。


G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。

  • 虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:
    • G1 不再坚持固定大小以及固定数量的分代区域划分
    • 而是把连续的 Java 堆划分为多个大小相等的独立区域(Region
    • 每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。
  • 收集器能够对扮演不同角色的 Region 采用不同的策略去处理
  • 这样无论是新创建的对象还是已经存活了一段时间的、熬过多次收集的旧对象都能获取很好的收集效果。

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。

G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。

每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1 MB~32 MB ,且应为 2 的 N 次幂

对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中

G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。


G1 仍然保留新生代和老年代的概念

  • 但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

G1 收集器之所以能建立可预测的停顿时间模型

  • 是因为它将 Region 作为单次回收的最小单元即每次收集到的内存空间都是 Region 大小的整数倍
  • 这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。

更具体的处理思路是G1 收集器去跟踪各个 Region 里面的垃圾堆积的 “价值” 大小

  • “价值” 即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表
  • 每次根据用户设定允许的收集停顿时间使用参数 -XX:MaxGCPauseMillis 指定,默认值是 200 毫秒
  • 优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First” 名字的由来。

这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。


11.6.3.Young Collection


E:伊甸园、S:幸存区、O:老年代、空白:空闲区

在这里插入图片描述


类加载时新创建的对象,开始时都会分配到 伊甸园区

当 伊甸园 的区域被占满时(可以自己设置阈值),触发 minor gc,也就同时触发 Stop The World,当然这个时间相对而言是比较短的。

此时会使用 复制 的算法将 幸存对象 放入 幸存区

再工作一段时间后,当 幸存区 的对象也比较多的时候,也会触发 minor gc

当 幸存区 的对象寿命超过阈值时,会晋升至 老年代;幸存区 的对象寿命未达到阈值时会放到新的 幸存区。

大体上与之前的分代垃圾回收无异。


11.6.4.Young Collection + CM


Young Collection + Concurrent Mark

  • Young GC 时会进行 GC Root初始标记
  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW),由下面的 JVM 参数决定
    • XX:InitiatingHeapOccupancyPercent=percent(默认 45%)

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


初始标记(Initial Marking

  • 仅仅只是标记一下 GC Roots 能直接关联到的对象
  • 并且修改 TAMSTop at Mark Start)指针的值
  • 让下一阶段用户线程并发运行时能正确地在可用的 Region 中分配新对象
  • 这个阶段需要停顿线程但耗时很短
  • 而且是借用进行 Minor GC 的时候同步完成的所以 G1 收集器在这个阶段实际并没有额外的停顿

并发标记(Concurrent Marking

  • GC Root 开始对堆中对象进行 可达性分析递归扫描整个堆里的对象图找出要回收的对象
  • 这阶段耗时较长,但可与用户程序并发执行
  • 当对象图扫描完成以后还要重新处理 SATBSnapshot At The Beginning)记录下的在并发时有引用变动的对象

TAMSTop at Mark Start

  • 程序要继续运行就肯定会持续有新对象被创建
  • G1 为每一个 Region 设计了两个名为 TAMSTop at Mark Start)的指针
  • Region 中的一部分空间划分出来用于并发回收过程中的新对象分配并发回收时新分配的对象地址都必须要在这两个指针位置以上
  • G1 收集器默认在这个地址以上的对象是被隐式标记过的即默认它们是存活的不纳入回收范围

下面的内容只是摘抄了书中的部分片段,详情还请看书:3.4.6.并发的可达性分析

并发的可达性分析(部分内容)

用户线程与收集器并发工作的时候

收集器在对象图上标记,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。

  • 一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的。
    • 只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
  • 另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

解决并发扫描时的对象消失问题有两种解决方案

  • 增量更新Incremental Update)和 原始快照Snapshot At The BeginningSATB)。

增量更新 可以简化理解为:黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照 可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

  • 白色对象:表示对象尚未被垃圾收集器访问过。
    • 显然在可达性分析刚刚开始的阶段,所有的对象都是白色的。
    • 若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色对象:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
    • 黑色的对象代表已经扫描过,它是安全存活的。
    • 如果有其他对象引用指向了黑色对象,无须重新扫描一遍。
    • 黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色对象:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

CMS 是基于增量更新来做并发标记的,G1Shenandoah 则是用原始快照来实现。


11.6.5.Mixed Colletction


会对 E(Eden )、S(Survivor)、O(Old Generation) 进行全面的垃圾回收

  • 最终标记(Remark)会 STW
  • 拷贝存活(Evacuation)会 STW
  • -XX:MaxGCPauseMillis=ms:设置最大暂停时间目标,默认值是 200 毫秒

在这里插入图片描述

问:为什么图中有的老年代被拷贝了,有的没拷贝?

  • 在指定了最大停顿时间的情况下,若要对所有老年代都进行回收,耗时可能过高。
  • 为达成目标,故只回收价值高的老年代。(价值即回收所获得的空间大小以及回收所需时间的经验值)
  • 当然如果最大停顿时间很宽裕,要回收的对象不是很多的话,是可以复制所有的老年代 region 的。这样有利于保存数据和整理内存。

优先处理回收价值收益最大的那些 Region,这也是 “Garbage First” 名字的由来。


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

最终标记(Final Marking

  • 对用户线程做另一个短暂的暂停用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录

筛选回收(Live Data Counting and Evacuation

  • 负责更新 G1 的统计数据
  • 对各个G1 的回收价值和成本进行排序根据用户所期望的停顿时间来制定回收计划可以自由选择任意多个 G1 构成回收集
  • 然后把决定回收的那一部分 G1 的存活对象复制到空的 G1再清理掉整个旧 G1 的全部空间
  • 这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的

通过 初始标记并发标记最终标记筛选回收 的和阶段描述可以看出

G1 收集器除了 并发标记 外,其余阶段也是要完全暂停用户线程的

换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量

所以才能担当起 “全功能收集器” 的重任与期望


Oracle 官方透露出来的信息可获知,回收阶段( Evacuation)其实本也有想过设计成与用户程序一起并发执行

但这件事情做起来比较复杂

考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现

而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。

另外,还考虑到 G1 不是仅仅面向低延迟,

停顿用户线程能够最大幅度提高垃圾收集效率,

为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

通过下图可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段。


在这里插入图片描述

G1 收集器运行示意图

11.6.6.Full GC 对比总结


SerialGC

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足发生的垃圾收集 - full gc

ParallelGC

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足发生的垃圾收集 - full gc

CMS

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足时
    • 并发收集,并发收集失败才会触发 Full GC
    • 这么一看,CMSG1 的老年代回收策略蛮像的。

G1

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足(即超过阈值。默认值是 老年代内存/堆内存 = 45% 及以上时。此时会触发并发标记阶段,以及后续的混合收集阶段)
    • 若垃圾回收速度高于新的用户线程产生垃圾的速度,则不会触发 Full GC,会并发清理
    • 若垃圾回收速度低于新的用户线程产生垃圾的速度,此时会触发 Full GC,当然也会 stop the world,停顿时间更长。

11.6.7.Young Collection 跨代使用


先回顾一下新生代的垃圾回收的过程

  • 找到根对象 —> 对根对象进行可达性分析 —> 找到存活对象 —> 将存活对象复制到幸存区

假设现在要进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的

  • 为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象
  • 以此来确保可达性分析结果的正确性
  • 反过来也是一样。

问题:遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。


解决办法:我们就可以使用 记忆集 避免全堆作为 GC Roots 扫描

而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

  • 卡表与 Remembered Set
  • 在引用变更时通过 post-write barrier,并记录更新信息至 dirty card queue
  • 线程 concurrent refinement threads 通过 dirty card queue 更新 Remembered Set
  • 显然,记录和更新脏卡队列的信息是异步操作

在这里插入图片描述


下图中的粉红色区域即为脏卡区

在这里插入图片描述


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

每个 Region 都维护有自己的记忆集

这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1 的记忆集(Remembered Set)在存储结构的本质上是一种哈希表

  • Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号
  • 这种 “双向” 的卡表结构(卡表是 “我指向谁”,这种结构还记录了 “谁指向我”)比原来的卡表实现起来更复杂
  • 同时由于 Region 数量比传统收集器的分代数量明显要多得多
  • 因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担

根据经验,G1 至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。


11.6.8.Remark 阶段


使用 pre-write barrier + satb_mark_queue 来完成重新标记阶段


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


并发的可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠 可达性分析 算法来判定对象是否存活的

可达性分析算法理论上要求全过程都基于一个能 保障一致性的快照 中才能够进行分析

这意味着必须全程冻结用户线程的运行。


在 根节点枚举 这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数

且在各种优化技巧(如 OopMap)的加持下

它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。


可从 GC Roots 再继续往下遍历对象图

这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了

  • 堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长

这听起来是理所当然的事情。


要知道包含 “标记” 阶段是所有追踪式垃圾收集算法的共同特征

如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器

同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。


想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

为了能解释清楚这个问题,此处借助 三色标记(Tri-color Marking 工具来辅助推导

  • 把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色

  • 白色对象:表示对象尚未被垃圾收集器访问过。

    • 显然在可达性分析刚刚开始的阶段,所有的对象都是白色的。
    • 若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色对象:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。

    • 黑色的对象代表已经扫描过,它是安全存活的。
    • 如果有其他对象引用指向了黑色对象,无须重新扫描一遍。
    • 黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色对象:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

正常情况

在这里插入图片描述

问题

在这里插入图片描述


Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生 “对象消失” 的问题

即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。

由此分别产生了两种解决方案:增量更新Incremental Update)和 原始快照Snapshot At The BeginningSATB)。


增量更新 要破坏的是第一个条件

  • 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来
  • 等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
  • 这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照 要破坏的是第二个条件

  • 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来
  • 在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
  • 这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用

譬如,CMS 是基于增量更新来做并发标记的,G1Shenandoah 则是用原始快照来实现。


问题:在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误


该问题的解决办法是

  • CMS 收集器采用增量更新算法实现
  • G1收集器则是通过原始快照(SATB)算法来实现的。

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上

  • 程序要继续运行就肯定会持续有新对象被创建
  • G1 为每一个 Region 设计了两个名为 TAMSTop at Mark Start)的指针
  • Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
  • G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

CMS 中的 “Concurrent Mode Failure” 失败会导致 Full GC 类似

如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 “Stop The World”。


11.7.G1 的一些优化


11.7.1.JDK 8u20 字符串去重


  • 优点:节省大量内存
  • 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加

去重操作

-XX:+UseStringDeduplication

String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时, G1 并发检查是否有字符串重复
  • 如果它们值一样,让它们引用同一个 char[]

  • 注意,与 String.intern() 不一样
    • String.intern() 关注的是字符串对象
    • 而字符串去重关注的是 char[]
    • JVM 内部,使用了不同的字符串表

11.7.2.JDK 8u40 并发标记类卸载


JDK 8 Update 40 的时候,G1 提供并发的类卸载的支持补全了其计划功能的最后一块拼图。

这个版本以后的 G1 收集器才被 Oracle 官方称为 “全功能的垃圾收集器”(Fully-Featured Garbage Collector)。


在并发标记阶段结束以后,就能知道哪些类不再被使用。

如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类

-XX:+ClassUnloadingWithConcurrentMark 默认启用


11.7.3.JDK 8u60 回收巨型对象


  • 当一个对象大于 region 的一半时,就称为巨型对象
  • G1 不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1 会跟踪老年代所有 incoming 引用,如果老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉

在这里插入图片描述

Region 中一类特殊的 Humongous 区域就是专门用来存储大对象的。上图中的 H 即为 Humongous 区域

图中两个 H 的 incoming 分别为 2 和 3


11.7.4.JDK 9 并发标记起始时间


  • 并发标记必须在堆空间占满前完成,否则退化为 FullGC
  • JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent 来设置栈参数
    • 表示老年代在堆内存中的占比。
    • JDK 8 中默认的默认占比是 45%,超过这个阈值后就会进行垃圾回收
  • JDK 9 可以动态调整
    • -XX:InitiatingHeapOccupancyPercent 用来设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空档空间

11.6.13.JDK 9 更高效的回收


文档链接:https://docs.oracle.com/en/java/javase/12/gctuning/ (版本自己选啰)


12.垃圾回收调优


12.1.预备知识


配置好了 jdk 的环境变量后,便可以使用该命令来查看当前环境的虚拟机参数

java -XX:+PrintFlagsFinal -version | findstr "GC"

12.2.调优领域


  • 内存
  • 锁竞争
  • cpu 占用
  • io

12.3.确定目标


  • 低延迟 还是 高吞吐量,选择合适的回收器
  • CMSG1ZGC
  • ParallelGC
  • Zing

12.4.最快的 GC 是不发生 GC


首先排除减少因为自身编写的代码而引发的内存问题

之后再来查看 FullGC 前后的内存占用,考虑下面几个问题

  • 数据是不是太多?
    • 例:resultSet = statement.executeQuery("select * from 大表 limit n")(使用 limit 来限制行数)
  • 数据表示是否太臃肿?
    • 对象图
    • 对象大小
      • 每个对象自身就占有 16 个字节
      • 像常用的包装类 Integer 也有 24 个字节
  • 是否存在内存泄漏?
    • 例:static Map map = …(不断往该静态对象里放置数据,却又不移除,最后必将造成内存泄露)
    • 解决办法
      • 像长时间存放的对象,可以使用软、弱引用,它们都可以在内存吃紧时做一定的回收
      • 也可以使用第三方缓存(比如 Reids)实现

补充:JAVA 中基本数据类型的内存占用情况

数据类型内存占用字节数
byte1
short2
int4
long8
float4
double8
boolean1
char2

12.5.新生代调优


新生代的特点

  • 所有的 new 操作的内存分配非常廉价
    • TLABthread-local allocation buffer
  • 死亡对象的回收代价是零
  • 大部分对象用过即死
  • Minor GC 的时间远远低于 Full GC

新生代内存越大越好么?当然不是。

# -Xmn

Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery). 

GC is performed in this region more often than in other regions. 

If the size for the young generation istoo small, then a lot of minor garbage collections are performed.

If the size is too large, then only full garbage collections are performed, which can take a long time to complete. 

Oracle recommends that you keep the size for the young generation greater than 25% and less than 50% of the overall heap size.

  • 新生代能容纳所有 并发量 * (请求-响应) 的数据
  • 幸存区大到能保留 当前活跃对象 + 需要晋升对象
  • 晋升阈值配置得当,让长时间存活对象尽快晋升
    • -XX:MaxTenuringThreshold=threshold:调整最大晋升阈值
    • -XX:+PrintTenuringDistribution:打印晋升的详细信息

12.6.老年代调优


CMS 为例

  • CMS 的老年代内存越大越好(比如浮动垃圾过多,导致并发的垃圾回收失败。进而退化成 serals old
  • 先尝试不做调优,如果没有 Full GC,就说明老年代空间很充裕。或者先尝试调优新生代
  • 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
    • -XX:CMSInitiatingOccupancyFraction=percent:设置阈值:老年代内存 占 堆内存 的比例

12.7.三个调优案例


JVM 垃圾回收 超详细学习笔记(二)https://blog.csdn.net/weixin_53142722/article/details/125418216


案例 1:Full GCMinor GC 频繁

分析:

  • GC 频繁 显然这是空间紧张。
  • 首先我们需要分析这是哪一区的空间紧张
  • 如果这是新生代空间紧张,当业务高峰期来临,大量的对象被创建,新生代的空间很快就被塞满了。
  • 同时幸存区的空间也紧张了起来,这就导致对象的晋升阈值降低,许多生存周期很短的对象也会晋升到老年代去,情况进一部恶化了。
  • 老年代存储了大量的这种对象,从而进一步触发了老年代的 Full GC 的发生,进而使得老年代的 Full GC 更加频繁。

解决方法:先使用监测工具查看各个区域的内存使用情况,然后尝试提高新生代的内存空间,以及调高晋升老年代的阈值


案例 2:请求高峰期发生 Full GC,单次暂停时间特别长 (CMS

分析

  • 单次暂停时间特别长,得先去分析是哪一部分的时间耗费比较长
  • 因为我们这里已经确定了垃圾回收器是 CMS,故先看日志
  • 通过这个 CMS 的一些特点我们可以知道, CMS 在重新标记是比较耗时的
  • 因为该阶段中会发生 Stop The World,并且会扫描整个堆里面的对象(包括新生代和老年代中的对象)
  • 如果新生代的对象比较多,那么扫描的时间就会变长(因为还会去找对象的引用)

解决办法:我们可以尝试在重新标记之前进行一次新生代的垃圾回收,减少重新标记阶段的时间

  • 设置 VM options-XX:+CMSScavengeBeforeRemark

案例 3:老年代内存充裕情况下,发生 Full GCjdk1.7 环境下的 CMS

分析

  • 通过日志排除我们并没有发现并发失败的日志,说明老年代的空间充裕;
  • jdk 1.8 下是有一个元空间作为方法区的实现
  • jdk 1.7 及以前的方法区是使用永久代作为方法区的实现,永久代的空间不足也会导致 Full GC 的发生

解决办法:通过参数设置永久代的初始值和最大值

  • -XX:PermSize-XX:MaxPermSize

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值