当前位置:网站首页 > 技术博客 > 正文

java虚拟机栈是线程隔离的吗



前面我们讲栈虚拟机和寄存器虚拟机的时候,提到过栈帧结构;在讲字节码指令的时候,又提到了栈帧中的操作数栈,那今天我们就来详细地讲一讲 JVM 的栈帧结构,好让大家对栈帧有一个更加清晰的认知。

我们从下面这幅图开始讲起。

Java 的源码文件经过编译器编译后会生成字节码文件,然后由 JVM 的类加载器进行加载,再交给执行引擎执行。在执行过程中,JVM 会划出一块内存空间来存储程序执行期间所需要用到的数据,这块空间一般被称为运行时数据区。

栈帧(Stack Frame)是运行时数据区中用于支持虚拟机进行方法调用和方法执行的数据结构。每一个方法从调用开始到执行完成,都对应着一个栈帧在虚拟机栈/本地方法栈里从入栈到出栈的过程。

本地方法,也就是 native 方法,我们前面有详细地讲过,由 C/C++ 实现。

每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。

在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中。

方法表、局部变量表我们在讲字节码的时候有讲过,可以戳链接再回头看一下,这篇内容也会继续盘一盘。

一个线程中的方法调用链可能会很长,很多方法都处于执行状态。在当前线程中,位于栈顶的栈帧被称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令都是对当前栈帧进行操作,在概念模型上,栈帧的结构如下图所示:

局部变量表(Local Variables Table)用来保存方法中的局部变量,以及方法参数。当 Java 源代码文件被编译成 class 文件的时候,局部变量表的最大容量就已经确定了。

我们来看这样一段代码。

方法有一个参数 age,一个局部变量 name。

然后用 Intellij IDEA 的 jclasslib 查看一下编译后的字节码文件 LocalVaraiablesTable.class。可以看到 方法的 Code 属性中,Maximum local variables(局部变量表的最大容量)的值为 3。

按理说,局部变量表的最大容量应该为 2 才对,一个 age,一个 name,为什么是 3 呢?

当一个成员方法(非静态方法)被调用时,第 0 个变量其实是调用这个成员方法的对象引用,也就是那个大名鼎鼎的 this。调用方法 ,实际上是调用 。

点开 Code 属性,查看 LocalVaraiableTable 就可以看到详细的信息了。

第 0 个是 this,类型为 LocalVaraiablesTable 对象;第 1 个是方法参数 age,类型为整型 int;第 2 个是方法内部的局部变量 name,类型为字符串 String。

当然了,局部变量表的大小并不是方法中所有局部变量的数量之和,它与变量的类型和变量的作用域有关。当一个局部变量的作用域结束了,它占用的局部变量表中的位置就被接下来的局部变量取代了。

来看下面这段代码。

  • 方法的局部变量表大小为 1,因为是静态方法,所以不需要添加 this 作为局部变量表的第一个元素;
  • ②的时候局部变量有一个 name,局部变量表的大小变为 1;
  • ③的时候 name 变量的作用域结束;
  • ④的时候局部变量有一个 age,局部变量表的大小为 1;
  • ⑤的时候局 age 变量的作用域结束;

关于局部变量的作用域,《Effective Java》 中的第 57 条建议:

将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。

在此,我还有一点要提醒大家。为了尽可能节省栈帧耗用的内存空间,局部变量表中的槽是可以重用的,就像 方法演示的那样,这就意味着,合理的作用域有助于提高程序的性能。是不是很有意思?

局部变量表的容量以槽(slot)为最小单位,一个槽可以容纳一个 32 位的数据类型(比如说 int,当然了,《Java 虚拟机规范》中没有明确指出一个槽应该占用的内存空间大小,但我认为这样更容易理解),像 float 和 double 这种明确占用 64 位的数据类型会占用两个紧挨着的槽。

来看下面的代码。

用 jclasslib 可以查看到, 方法的 Maximum local variables 的值为 4。

为什么等于 4 呢?带上 this 也就 3 个呀?

查看 LocalVaraiableTable 就明白了,变量 i 的下标为 3,也就意味着变量 d 占了两个槽。

同局部变量表一样,操作数栈(Operand Stack)的最大深度也在编译的时候就确定了,被写入到了 Code 属性的 maximum stack size 中。当一个方法刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和取出数据,也就是入栈和出栈操作。

来看下面这段代码。

OperandStack 类共有 2 个方法, 方法中调用了 方法,传递了 2 个参数。用 jclasslib 可以看到, 方法的 maximum stack size 的值为 3。

这是因为调用成员方法的时候会将 this 和所有参数压入栈中,调用完毕后 this 和参数都会一一出栈。通过 「Bytecode」 面板可以查看到对应的字节码指令。

  • aload_0 用于将局部变量表中下标为 0 的引用类型的变量,也就是 this 加载到操作数栈中;
  • iconst_1 用于将整数 1 加载到操作数栈中;
  • iconst_2 用于将整数 2 加载到操作数栈中;
  • invokevirtual 用于调用对象的成员方法;
  • pop 用于将栈顶的值出栈;
  • return 为 void 方法的返回指令。

字节码指令前面我们已经讲过了,忘记的球友可以再回顾一下。再来看一下 方法的字节码指令。

  • iload_1 用于将局部变量表中下标为 1 的 int 类型变量加载到操作数栈上(下标为 0 的是 this);
  • iload_2 用于将局部变量表中下标为 2 的 int 类型变量加载到操作数栈上;
  • iadd 用于 int 类型的加法运算;
  • ireturn 为返回值为 int 的方法返回指令。

操作数中的数据类型必须与字节码指令匹配,以上面的 iadd 指令为例,该指令只能用于整型数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能出现一个 long 型和一个 double 型的数据进行 iadd 命令相加的情况。

每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。

图片来源于网络,作者浣熊say
图片来源于网络,作者浣熊say

①、前面我们就讲过,方法区是 JVM 的一个运行时内存区域,属于逻辑定义,不同版本的 JDK 都有不同的实现,但主要的作用就是用于存储已被虚拟机加载的类信息、常量、静态变量,以及即时编译器编译后的代码等。

②、运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号引用——在类加载后进入运行时常量池。关于方法区我们也会在后面进行详细地讲解。

来看下面这段代码。

大家对 Java 重写有了解的话,应该能看懂这段代码的意思。Man 类和 Woman 类继承了 Human 类,并且重写了 方法。来看一下运行结果:

这个运行结果很好理解,man 的引用类型为 Human,但指向的是 Man 对象,woman 的引用类型也为 Human,但指向的是 Woman 对象;之后,man 又指向了新的 Woman 对象。

从面向对象编程的角度,从多态的角度,我们对运行结果是很好理解的,但站在 Java 虚拟机的角度,它是如何判断 man 和 woman 该调用哪个方法的呢?

用 jclasslib 看一下 main 方法的字节码指令。

  • 第 1 行:new 指令创建了一个 Man 对象,并将对象的内存地址压入栈中。
  • 第 2 行:dup 指令将栈顶的值复制一份并压入栈顶。因为接下来的指令 invokespecial 会消耗掉一个当前类的引用,所以需要复制一份。
  • 第 3 行:invokespecial 指令用于调用构造方法进行初始化。
  • 第 4 行:astore_1,Java 虚拟机从栈顶弹出 Man 对象的引用,然后将其存入下标为 1 局部变量 man 中。
  • 第 5、6、7、8 行的指令和第 1、2、3、4 行类似,不同的是 Woman 对象。
  • 第 9 行:aload_1 指令将第局部变量 man 压入操作数栈中。
  • 第 10 行:invokevirtual 指令调用对象的成员方法 ,注意此时的对象类型为 。
  • 第 11 行:aload_2 指令将第局部变量 woman 压入操作数栈中。
  • 第 12 行同第 10 行。

注意,从字节码的角度来看,(第 10 行)和 (第 12 行)的字节码是完全相同的,但我们都知道,这两句指令最终执行的目标方法并不相同。

究竟发生了什么呢?

还得从 这个指令着手,看它是如何实现多态的。根据《Java 虚拟机规范》,invokevirtual 指令在运行时的解析过程可以分为以下几步:

  • ①、找到操作数栈顶的元素所指向的对象的实际类型,记作 C。
  • ②、如果在类型 C 中找到与常量池中的描述符匹配的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;否则返回 异常。
  • ③、否则,按照继承关系从下往上一次对 C 的各个父类进行第二步的搜索和验证。
  • ④、如果始终没有找到合适的方法,则抛出 异常。

也就是说,invokevirtual 指令在第一步的时候就确定了运行时的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法版本,这个过程就是 Java 重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态链接

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出,可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型根据方法返回的指令来决定,像之前提到的 ireturn 用于返回 int 类型,return 用于 void 方法;还有其他的一些,lreturn 用于 long 型,freturn 用于 float,dreturn 用于 double,areturn 用于引用类型。
  • 异常退出,方法在执行的过程中遇到了异常,并且没有得到妥善的处理,这种情况下,是不会给它的上层调用者返回任何值的。

无论是哪种方式退出,在方法退出后,都必须返回到方法最初被调用时的位置,程序才能继续执行。一般来说,方法正常退出的时候,PC 计数器的值会作为返回地址,栈帧中很可能会保存这个计数器的值,异常退出时则不会。

PC 计数器:JVM 运行时数据区的一部分,跟踪当前线程执行字节码的位置。

方法退出的过程实际上等同于把当前栈帧出栈,因此接下来可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值,找到下一条要执行的指令等。

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

下面这段代码在运行的时候会抛出 StackOverflowError 异常。

我们来看一下异常的堆栈信息。

之所以抛出 StackOverflowError 异常,是因为在执行 方法的时候,会创建一个栈帧,然后调用 方法,又会创建一个栈帧,然后调用 方法,又会创建一个栈帧……这样一直循环下去,直到栈内存溢出。

我们来简单改造了一下代码,看一下异常的堆栈信息。

在 10924 次的时候,抛出了 StackOverflowError 异常。大家可以试试自己的本地环境,看多少次的时候会抛出异常。

栈帧是 JVM 中用于方法执行的数据结构,每当一个方法被调用时,JVM 会为该方法创建一个栈帧,并在方法执行完毕后销毁。

  • 局部变量表:存储方法的参数和局部变量,由基本数据类型或对象引用组成。
  • 操作数栈:后进先出(LIFO)的栈结构,用于存储操作数和中间计算结果。
  • 动态链接:关联到方法所属类的常量池,支持动态方法调用。
  • 方法返回地址:记录方法结束后控制流应返回的位置。

栈帧是线程私有的,每个线程有自己的 JVM 栈。方法调用时,新栈帧被推入栈顶;方法完成后,栈帧出栈。

栈帧的局部变量表的大小和操作数栈的最大深度在编译时就已确定。栈空间不足时可能引发 。理解栈帧对于深入理解 Java 程序的运行机制至关重要。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

前面我们就讲过,Java 源代码文件经过编译器编译后会生成字节码文件,经过加载器加载完毕后会交给执行引擎执行。在执行的过程中,JVM 会划出来一块空间来存储程序执行期间需要用到的数据,这块空间一般被称为运行时数据区,见下图。

根据 Java 虚拟机规范的规定,运行时数据区可以分为以下几个部分:

  • 程序计数器(Program Counter Register)
  • Java 虚拟机栈(Java Virtual Machine Stacks)
  • 本地方法栈(Native Method Stack)
  • 堆(Heap)
  • 方法区(Method Area)

JDK 8 开始,永久代被彻底移除,取而代之的是元空间。元空间不再是 JVM 内存的一部分,而是通过本地内存(Native Memory)来实现的。也就是说,JDK 8 开始,方法区的实现就是元空间。

程序计数器(Program Counter Register)所占的内存空间不大,很小很小一块,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器会在工作的时候改变这个计数器的值来选取下一条需要执行的字节码指令,像分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

在 JVM 中,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且不能互相干扰,否则就会影响到程序的正常执行次序。

也就是说,我们要求程序计数器是线程私有的

《Java 虚拟机规范》中规定,如果线程执行的是非本地方法,则程序计数器中保存的是当前需要执行的指令地址;如果线程执行的是本地方法,则程序计数器中的值是 undefined。

为什么本地方法在程序计数器中的值是 undefined 的?因为本地方法大多是通过 C/C++ 实现的,并未编译成需要执行的字节码指令。

我们来通过代码以及字节码指令来看看程序计数器的作用。

字节码指令大致如下:

现在,让我们逐步分析程序计数器是如何在执行这些指令时更新的:

  1. 初始状态:当方法开始执行时,PC 计数器设置为 0,指向第一条指令 。
  2. 执行第一条指令
    • 执行 指令,将局部变量表中索引为 0 的整数(即方法的第一个参数 )加载到操作数栈顶。
    • 执行完成后,PC 计数器更新为 1,指向下一条指令 。
  3. 执行第二条指令
    • 执行 指令,将局部变量表中索引为 1 的整数(即方法的第二个参数 )加载到操作数栈顶。
    • 执行完成后,PC 计数器更新为 2,指向下一条指令 。
  4. 执行第三条指令
    • 执行 指令,弹出操作数栈顶的两个整数(即 和 ),将它们相加,然后将结果压入操作数栈顶。
    • 执行完成后,PC 计数器更新为 3,指向下一条指令 。
  5. 执行最后一条指令:
    • 执行 指令,弹出操作数栈顶的整数(即 的结果),并将这个值作为方法的返回值。
    • 方法执行完成,控制权返回到方法调用者。

Java 虚拟机栈(JVM 栈)中是一个个栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。

栈帧包含以下 5 个部分,见下图。我们前面已经详细地讲过栈帧了,忘记的球友可以回头去看一下。

假设我们有一个简单的 add 方法,如下所示:

当 方法被调用时,JVM 为这次方法调用创建一个新的栈帧。然后执行方法内的字节码指令,这部分我们前面已经讲过了,大家可以自己通过 javap 查看字节码并模拟一下字节码指令执行的过程。

当 方法执行完毕后,对应的栈帧会从 JVM 栈中弹出。

Java 虚拟机栈的特点如下:

  • 线程私有: 每个线程都有自己的 JVM 栈,线程之间的栈是不共享的。
  • 栈溢出: 如果栈的深度超过了 JVM 栈所允许的深度,将会抛出 ,这个我们讲栈帧的时候讲过了。

大家可以猜一下 JVM 栈的默认大小是多少?

还用我们之前的讲栈帧时候的例子:

默认配置下,堆栈异常出现在 10886 次:

增加 后,来试试。

1991 次出现了堆栈异常。

这之间存在什么关系呢?

通过 这个命令可以查看 JVM 栈的默认大小。

其中 的单位是字节,也就是说默认的 JVM 栈大小是 1024 KB,也就是 1M。

也就是说,默认 1024 KB 的 JVM 栈可以执行 10885 次 方法,而 256 KB 的 JVM 栈只能执行 1990 次 方法,四五倍的样子。

本地方法栈(Native Method Stack)与 Java 虚拟机栈类似,只不过 Java 虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

堆是所有线程共享的一块内存区域,在 JVM 启动的时候创建,用来存储对象(数组也是一种对象)。

以前,Java 中“几乎”所有的对象都会在堆中分配,但随着 JIT 编译器的发展和逃逸技术的逐渐成熟,所有的对象都分配到堆上渐渐变得不那么“绝对”了。从 JDK 7 开始,Java 虚拟机已经默认开启逃逸分析了,意味着如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

栈就是前面提到的 JVM 栈(主要存储局部变量、方法参数、对象引用等),属于线程私有,通常随着方法调用的结束而消失,也就无需进行垃圾收集;堆前面也讲了,属于线程共享的内存区域,几乎所有的对象都在对上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不在被任何变量引用,然后被垃圾收集器回收。

简单解释一下 JIT 和逃逸分析(后面讲 JIT 会细讲)。

常见的编译型语言如 C++,通常会把代码直接编译成 CPU 所能理解的机器码来运行。而 Java 为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由 javac 编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java 可能会干不过 C++ 这类编译型语言。

为了优化 Java 的性能 ,JVM 在解释器之外引入了 JIT 编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。

逃逸分析(Escape Analysis)是一种编译器优化技术,用于判断对象的作用域和生命周期。如果编译器确定一个对象不会逃逸出方法或线程的范围,它可以选择在栈上分配这个对象,而不是在堆上。这样做可以减少垃圾回收的压力,并提高性能。

我们来写一段可能触发栈分配的代码。

  • createAndCalculate 方法创建了一个 Point 对象,并调用它的 calculate 方法。
  • Point 对象在 createAndCalculate 方法中创建,并且不会逃逸到该方法之外。
  • 如果 JVM 的逃逸分析确定 Point 对象不会逃逸出 createAndCalculate 方法,它可能会在栈上分配 Point 对象,而不是在堆上。

堆我们前面已经讲过了,它除了是对象的聚集地,也是 Java 垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度来看,由于垃圾收集器基本都采用了分代垃圾收集的算法,所以堆还可以细分为:新生代和老年代。新生代还可以细分为:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

不要担心,这些我们会放到后面垃圾回收的章节来细讲。

堆这最容易出现的就是 OutOfMemoryError 错误,分为以下几种表现形式:

  • :当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生该错误。
  • :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发该错误。和本机的物理内存无关,和我们配置的虚拟机内存大小有关!

我们先来通过代码模拟一下堆内存溢出的情况。

通过 VM 参数设置堆内存大小为 ,然后运行程序。

可以看到,堆内存溢出发生在 11 个对象后。

默认的堆内存大小是多少呢?

通过 这个命令可以查看 JVM 堆的默认大小。

也可以通过下面这行代码获取:

大家可以通过上面的方法查看一下自己本机电脑的堆内存大小。

方法区是 Java 虚拟机规范上的一个逻辑区域,在不同的 JDK 版本上有着不同的实现。在 JDK 7 的时候,方法区被称为永久代(PermGen),而在 JDK 8 的时候,永久代被彻底移除,取而代之的是元空间。

如果你在有些资料上依然看到了永久代,要么就是二哥这样在给你解释,要么就是内容过时了。

《Java 虚拟机规范》中只规定了有方法区这么一个概念和它的作用,并没有规定如何去实现它。不同的 Java 虚拟机可能就会有不同的实现。永久代是 HotSpot 对方法区的一种实现形式。也就是说,永久代是 HotSpot 旧版本中的一个实现,而方法区则是 Java 虚拟机规范中的一个定义,一种规范。

换句话说,方法区和永久代的关系就像是 Java 中接口和类的关系,类实现了接口,接口还是那个接口,但实现已经完全升级了。

JDK 7 之前,只有常量池的概念,都在方法区中。

JDK 7 的时候,字符串常量池从方法区中拿出来放到了堆中,运行时常量池还在方法区中(也就是永久代中)。

JDK 8 的时候,HotSpot 移除了永久代,取而代之的是元空间。字符串常量池还在堆中,而运行时常量池跑到了元空间。

为什么要废弃永久代,而使用元空间来进行替换呢?

旧版的 Hotspot 虚拟机是没有 JIT 的,而 Oracle 旗下的另外一款虚拟机 JRocket 是有的,那为了将 Java 帝国更好的传下去,Oracle 就想把庶长子 JRocket 的 JIT 技术融合到嫡长子 Hotspot 中。

但 JRockit 虚拟机中并没有永久代的概念,因此新的 HotSpot 索性就不要永久代了,直接占用操作系统的一部分内存好了,并且把这块内存取名叫做元空间。

元空间的大小不再受限于 JVM 启动时设置的最大堆大小,而是直接利用本地内存,也就是操作系统的内存。有效地解决了 OutOfMemoryError 错误。

可以通过 查看 JVM 默认的堆内存大小。

当元空间的数据增长时,JVM 会请求操作系统分配更多的内存。如果内存空间足够,操作系统就会满足 JVM 的请求。那会不会出现元空间溢出的情况呢?

答案是肯定的,这个我们留到内存溢出的章节里来细讲。

在讲字节码的时候,我们详细的讲过常量池,它是字节码文件的资源仓库,先是一个常量池大小,从 1 到 n-1,0 为保留索引,然后是常量池项的集合,包括类信息、字段信息、方法信息、接口信息、字符串常量等。

运行时常量池,顾名思义,就是在运行时期间,JVM 会将字节码文件中的常量池加载到内存中,存放在运行时常量池中。

也就是说,常量池是在字节码文件中,而运行时常量池在元空间当中(JDK 8 及以后),讲的是一个东西,但形态不一样,就好像一个是固态,一个是液态;或者一个是模子,一个是模子里的锅碗瓢盆。

字符串常量池我们在讲字符串的时候已经详细讲过了,它的作用是存放字符串常量,也就是我们在代码中写的字符串。依然在堆中。

OK,方法区(不管是永久代还是元空间的实现)和堆一样,是线程共享的区域

来总结一下运行时数据区的主要组成:

  • PC 寄存器(PC Register),也叫程序计数器(Program Counter Register),是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。
  • JVM 栈(Java Virtual Machine Stack),与 PC 寄存器一样,JVM 栈也是线程私有的。每一个 JVM 线程都有自己的 JVM 栈(也叫方法栈),这个栈与线程同时创建,它的生命周期与线程相同。
  • 本地方法栈(Native Method Stack),JVM 可能会使用到传统的栈来支持 Native 方法的执行,这个栈就是本地方法栈。
  • 堆(Heap),在 JVM 中,堆是可供各条线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域。
  • 方法区(Method area),JDK 8 开始,使用元空间取代了永久代。方法区是 JVM 中的一个逻辑区域,用于存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。不同的是,元空间不再是 JVM 内存的一部分,而是通过本地内存(Native Memory)来实现的。
  • 运行时常量池,运行时常量池是每一个类或接口的常量在运行时的表现形式,它包括了编译器可知的数值字面量,以及运行期解析后才能获得的方法或字段的引用。简而言之,当一个方法或者变量被引用时,JVM 通过运行时常量区来查找方法或者变量在内存里的实际地址。

在 JVM 启动时,元空间的大小由 MaxMetaspaceSize 参数指定,JVM 在运行时会自动调整元空间的大小,以适应不同的程序需求。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

记得以前有这样一副动图,用来嘲笑 JVM 的垃圾回收机制,大致的意思就是,JVM 的垃圾回收机制很工业化,但是好像是在做无用功,垃圾回收不彻底(😂)。

C/C++ 虽然需要手动释放内存,但开发者信誓旦旦,认为自己一定能清理得很彻底。那这次,我们就从头到尾来详细地聊一聊 JVM 的垃圾回收机制,看看到底如何。

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存爆掉。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

Java 语言出来之前,大家都在拼命的写 C 或者 C++ 的程序,而此时存在一个很大的矛盾,C++ 等语言创建对象要不断的去开辟空间,不用的时候又需要不断的去释放空间,既要写构造函数,又要写析构函数。

构造函数和 Java 中的构造方法类似,用来创建对象,析构函数和 Java 中的 finalize 方法有一点类似,可以在对象被垃圾回收器回收之前执行清理操作,但不推荐,因为 finalize 的执行时机并不确定。

于是,有人就提出,能不能写一段程序实现这块功能,每次创建对象、释放内存空间的时候复用这段代码?

牛人还是多啊,1960 年,基于 MIT 的 Lisp 首先提出了垃圾回收的概念,用于处理 C 语言等不停的析构操作,Java 的垃圾回收机制算是发扬光大了。

Lisp 是一种函数式编程语言,我从官网上截幅图大家感受下。

既然 JVM 要做垃圾回收,就要搞清楚什么是垃圾,什么不是垃圾。通常会有这么几种算法来确定一个对象是否是垃圾,这块也是面试当中常考的一个知识点,大家一定要掌握。

  • 引用计数算法
  • 可达性分析算法

引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。

如果该对象被其它对象引用,则它的引用计数加 1,如果删除对该对象的引用,那么它的引用计数就减 1,当该对象的引用计数为 0 时,那么该对象就会被回收。

我们来创建一个字符串,这时候"沉默王二"有一个引用,就是 s。此时 Reference Count 为 1。

然后将 s 设置为 null。

这时候"沉默王二"的引用次数就等于 0 了,在引用计数算法中,意味着这块内容就需要被回收了。

引用计数算法将垃圾回收分摊到整个应用程序的运行当中,而不是集中在垃圾收集时。因此,采用引用计数的垃圾收集不属于严格意义上的"Stop-The-World"的垃圾收集机制(随后我们会细讲)。

引用计数算法看似很美好,但实际上它存在一个很大的问题,那就是无法解决循环依赖的问题。来看下面的代码。

代码中创建了两个 ReferenceCountingGC 对象 a 和 b。

然后使它们相互引用。接着,将这两个对象的引用设置为 null,理论上它们会在接下来被垃圾回收器回收。但由于它们相互引用着对方,导致它们的引用计数永远都不会为 0,通过引用计数算法,也就永远无法通知 GC 收集器回收它们。

可达性分析算法(Reachability Analysis)的基本思路是,通过 GC Roots 作为起点,然后向下搜索,搜索走过的路径被称为 Reference Chain(引用链),当一个对象到 GC Roots 之间没有任何引用相连时,即从 GC Roots 到该对象节点不可达,则证明该对象是需要垃圾收集的。

通过可达性算法,成功解决了引用计数无法解决的问题-“循环依赖”,只要你无法与 GC Root 建立直接或间接的连接,系统就会判定你为可回收对象。

  1. 推荐阅读:GC Roots 是什么?
  2. 推荐阅读:R 大的所谓“GC roots”

所谓的 GC Roots,就是一组必须活跃的引用,不是对象,它们是程序运行时的起点,是一切引用链的源头。在 Java 中,GC Roots 包括以下几种:

  • 虚拟机栈中的引用(方法的参数、局部变量等)
  • 本地方法栈中 JNI 的引用
  • 类静态变量
  • 运行时常量池中的常量(String 或 Class 类型)

大家可以回想一下我们前面讲过的JVM 运行时数据区,关联起来就更容易理解了。

来看下面这段代码:

在 greet 方法中,localVar 是一个局部变量,存在于虚拟机栈中,可以被认为是 GC Roots。

在 greet 方法执行期间,localVar 引用的对象是活跃的,因为它是从 GC Roots 可达的。

当 greet 方法执行完毕后,localVar 的作用域结束,localVar 引用的 Object 对象不再由任何 GC Roots 引用(假设没有其他引用指向这个对象),因此它将有资格作为垃圾被回收掉 😁。

Java 通过 JNI(Java Native Interface)提供了一种机制,允许 Java 代码调用本地代码(通常是 C 或 C++ 编写的代码)。

当调用 Java 方法时,虚拟机会创建一个栈帧并压入虚拟机栈,而当它调用本地方法时,虚拟机会通过动态链接直接调用指定的本地方法。

pecuyu:动态链接
pecuyu:动态链接

JNI 引用是在 Java 本地接口(JNI)代码中创建的引用,这些引用可以指向 Java 堆中的对象。

在本地(C/C++)代码中,localRef 是对 Java 对象的一个 JNI 引用,它在本地方法执行期间保持 Java 对象活跃,可以被认为是 GC Roots。

一旦 JNI 方法执行完毕,除非这个引用是全局的(Global Reference),否则它指向的对象将会被作为垃圾回收掉(假设没有其他地方再引用这个对象)。

来看下面这段代码:

StaticFieldReference 类中的 staticVar 引用了一个 Object 对象,这个引用存储在元空间,可以被认为是 GC Roots。

只要 StaticFieldReference 类未被卸载,staticVar 引用的对象都不会被垃圾回收。如果 StaticFieldReference 类被卸载(这通常发生在其类加载器被垃圾回收时),那么 staticVar 引用的对象也将有资格被垃圾回收(如果没有其他引用指向这个对象)。

来看这段代码:

在 ConstantPoolReference 中,CONSTANT_STRING 和 CONSTANT_CLASS 作为常量存储在运行时常量池。它们可以用来作为 GC Roots。

这些常量引用的对象(字符串"Hello, World"和 Object.class 类对象)在常量池中,只要包含这些常量的 ConstantPoolReference 类未被卸载,这些对象就不会被垃圾回收。

"Stop The World"是 Java 垃圾收集中的一个重要概念。在垃圾收集过程中,JVM 会暂停所有的用户线程,这种暂停被称为"Stop The World"事件。

这么做的主要原因是为了防止在垃圾收集过程中,用户线程修改了堆中的对象,导致垃圾收集器无法准确地收集垃圾。

值得注意的是,"Stop The World"事件会对 Java 应用的性能产生影响。如果停顿时间过长,就会导致应用的响应时间变长,对于对实时性要求较高的应用,如交易系统、游戏服务器等,这种情况是不能接受的。

因此,在选择和调优垃圾收集器时,需要考虑其停顿时间。Java 中的一些垃圾收集器,如 G1 和 ZGC,都会尽可能地减少了"Stop The World"的时间,通过并发的垃圾收集,提高应用的响应性能。

总的来说,"Stop The World"是 Java 垃圾收集中必须面对的一个挑战,其目标是在保证内存的有效利用和应用的响应性能之间找到一个平衡。

在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于 JVM 规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,这里我们讨论几种常见的垃圾收集算法。

标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为 2 部分,先把内存区域中的这些对象进行标记,哪些属于可回收的标记出来(用前面提到的可达性分析法),然后把这些垃圾拎出来清理掉。

就像上图一样,清理掉的垃圾就变成可使用的空闲空间,等待被再次使用。逻辑清晰,并且也很好操作,但它存在一个很大的问题,那就是内存碎片。碎片太多可能会导致当程序运行过程中需要分配较大对象时,因无法找到足够的连续内存而不得不提前触发新一轮的垃圾收集。

复制算法(Copying)是在标记清除算法上演化而来的,用于解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样就保证了内存的连续性,逻辑清晰,运行高效。

但复制算法也存在一个很明显的问题,合着我这 190 平的大四室,只能当 90 平米的小两室来居住?代价实在太高。

标记整理算法(Mark-Compact),标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法差很多。

分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

根据对象存活周期的不同会将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理算法来进行回收。

堆(Heap)是 JVM 中最大的一块内存区域,也是垃圾收集器管理的主要区域。

堆主要分为 2 个区域,年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。

据 IBM 公司之前的研究表明,有将近 98% 的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,JVM 会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。

通过 Minor GC 之后,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区,如果 From 区不够,则直接进入 To 区。

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。

不就是新生代到老年代吗,直接 Eden 到 Old 不好了吗,为啥要这么复杂。

如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。

这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

设置两个 Survivor 区最大的好处就是解决内存碎片化,我们先假设一下,Survivor 只有一个区域会怎样。

Minor GC 执行后,Eden 区被清空,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。那么问题来了,这时候我们怎么清除它们?

在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。

但因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。

那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?

显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。

老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。

由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记整理算法。

除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代。

大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,得注意了。

虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。当然,这里的 15,JVM 也支持进行特殊设置 。

可通过 查看默认的阈值。

JVM 并不强制要求对象年龄必须到 15 岁才会放入老年区,如果 Survivor 空间中某个年龄段的对象总大小超过了 Survivor 空间的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代,无需等你“成年”。

有点类似于负载均衡,轮询是负载均衡的一种,保证每台机器都分得同样的请求。看似很均衡,但每台机器的硬件不同,健康状况不同,所以我们可以基于每台机器接收的请求数、响应时间等,来调整负载均衡算法。

这种动态调整机制有助于优化内存使用和减少垃圾收集的频率,特别是在处理大量短生命周期对象的应用程序时。

本篇内容我们从头到尾讲了一遍 JVM 的垃圾回收机制,包括垃圾回收的概念、垃圾判断算法、垃圾收集算法、Stop The World、新生代和老年代等等。

  • 参考链接 1:从头到尾再讲一次 Java 的垃圾回收
  • 参考链接 2:详解 Java 的垃圾回收机制
  • 参考链接 3:三大垃圾收集算法

GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

垃圾回收对于 Java 党来说,是一个绕不开的话题,工作中涉及到的调优工作也经常围绕着垃圾回收器展开。面对不同的业务场景,往往需要不同的垃圾收集器才能保证 GC 性能,因此,对于面大厂或者有远大志向的球友可以卷一下垃圾收集器。

就目前来说,JVM 的垃圾收集器主要分为两大类:分代收集器分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC,下面我们来看看这两大类的垃圾收集器。

以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除,详情可见  JEP 363。

CMS(Concurrent Mark Sweep)垃圾收集器是第一个关注 GC 停顿时间(STW 的时间)的垃圾收集器。之前的垃圾收集器,要么是串行的垃圾回收方式,要么只关注系统吞吐量。

CMS 垃圾收集器之所以能够实现对 GC 停顿时间的控制,其本质来源于对「可达性分析算法」的改进,即三色标记算法。在 CMS 出现之前,无论是 Serious 垃圾收集器,还是 ParNew 垃圾收集器,以及 Parallel Scavenge 垃圾收集器,它们在进行垃圾回收的时候都需要 Stop the World,无法实现垃圾回收线程与用户线程的并发执行。

标记-清除算法、Stop the World、可达性分析算法等知识我们上一节也讲过了,忘记的球友可以回顾一下。

CMS 垃圾收集器通过三色标记算法,实现了垃圾回收线程与用户线程的并发执行,从而极大地降低了系统响应时间,提高了强交互应用程序的体验。它的运行过程分为 4 个步骤,包括:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记,指的是寻找所有被 GCRoots 引用的对象,该阶段需要「Stop the World」。这个步骤仅仅只是标记一下 GC Roots 能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快。

并发标记,指的是对「初始标记阶段」标记的对象进行整个引用链的扫描,该阶段不需要「Stop the World」。 对整个引用链做扫描需要花费非常多的时间,因此通过垃圾回收线程与用户线程并发执行,可以降低垃圾回收的时间。

这也是 CMS 能极大降低 GC 停顿时间的核心原因,但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。

重新标记,指的是对「并发标记」阶段出现的问题进行校正,该阶段需要「Stop the World」。正如并发标记阶段说到的,由于垃圾回收算法和用户线程并发执行,虽然能降低响应时间,但是会发生漏标和多标的问题。所以对于 CMS 来说,它需要在这个阶段做一些校验,解决并发标记阶段发生的问题。

并发清除,指的是将标记为垃圾的对象进行清除,该阶段不需要「Stop the World」。 在这个阶段,垃圾回收线程与用户线程可以并发执行,因此并不影响用户的响应时间。

CMS 的优点是:并发收集、低停顿。但缺点也很明显:

①、对 CPU 资源非常敏感,因此在 CPU 资源紧张的情况下,CMS 的性能会大打折扣。

默认情况下,CMS 启用的垃圾回收线程数是,当 CPU 数量很大时,启用的垃圾回收线程数占比就越小。但如果 CPU 数量很小,例如只有 2 个 CPU,垃圾回收线程占用就达到了 50%,这极大地降低系统的吞吐量,无法接受。

②、CMS 采用的是「标记-清除」算法,会产生大量的内存碎片,导致空间不连续,当出现大对象无法找到连续的内存空间时,就会触发一次 Full GC,这会导致系统的停顿时间变长。

③、CMS 无法处理浮动垃圾,当 CMS 在进行垃圾回收的时候,应用程序还在不断地产生垃圾,这些垃圾会在 CMS 垃圾回收结束之后产生,这些垃圾就是浮动垃圾,CMS 无法处理这些浮动垃圾,只能在下一次 GC 时清理掉。

G1(Garbage-First Garbage Collector)在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。G1 有五个属性:分代、增量、并行、标记整理、STW。

①、分代:相信大家还记得我们上一讲中的年轻代和老年代,G1 也是基于这个思想进行设计的。它将堆内存分为多个大小相等的区域(Region),每个区域都可以是 Eden 区、Survivor 区或者 Old 区。

可以通过 来设置 Region 的大小,可以设定为 1M、2M、4M、8M、16M、32M(不能超过)。

G1 有专门分配大对象的 Region 叫 Humongous 区,而不是让大对象直接进入老年代的 Region 中。在 G1 中,大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中,而且一个大对象如果太大,可能会横跨多个 Region 来存放。

G1 会根据各个区域的垃圾回收情况来决定下一次垃圾回收的区域,这样就避免了对整个堆内存进行垃圾回收,从而降低了垃圾回收的时间。

②、增量:G1 可以以增量方式执行垃圾回收,这意味着它不需要一次性回收整个堆空间,而是可以逐步、增量地清理。有助于控制停顿时间,尤其是在处理大型堆时。

③、并行:G1 垃圾回收器可以并行回收垃圾,这意味着它可以利用多个 CPU 来加速垃圾回收的速度,这一特性在年轻代的垃圾回收(Minor GC)中特别明显,因为年轻代的回收通常涉及较多的对象和较高的回收速率。

④、标记整理:在进行老年代的垃圾回收时,G1 使用标记-整理算法。这个过程分为两个阶段:标记存活的对象和整理(压缩)堆空间。通过整理,G1 能够避免内存碎片化,提高内存利用率。

年轻代的垃圾回收(Minor GC)使用复制算法,因为年轻代的对象通常是朝生夕死的。

⑤、STW:G1 也是基于「标记-清除」算法,因此在进行垃圾回收的时候,仍然需要「Stop the World」。不过,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

G1 中存在三种 GC 模式,分别是 Young GC、Mixed GC 和 Full GC。

当 Eden 区的内存空间无法支持新对象的内存分配时,G1 会触发 Young GC。

当需要分配对象到 Humongous 区域或者堆内存的空间占比超过 设置的 InitiatingHeapOccupancyPercent 值时,G1 会触发一次 concurrent marking,它的作用就是计算老年代中有多少空间需要被回收,当发现垃圾的占比达到 中所设置的 G1HeapWastePercent 比例时,在下次 Young GC 后会触发一次 Mixed GC。

Mixed GC 是指回收年轻代的 Region 以及一部分老年代中的 Region。Mixed GC 和 Young GC 一样,采用的也是复制算法。

在 Mixed GC 过程中,如果发现老年代空间还是不足,此时如果 G1HeapWastePercent 设定过低,可能引发 Full GC。 默认是 5,意味着只有 5% 的堆是“浪费”的。如果浪费的堆的百分比大于 G1HeapWastePercent,则运行 Full GC。

在以 Region 为最小管理单元以及所采用的 GC 模式的基础上,G1 建立了停顿预测模型,即 Pause Prediction Model 。这也是 G1 非常被人所称道的特性。

我们可以借助 来设置期望的停顿时间(默认 200ms),G1 会根据这个值来计算出一个合理的 Young GC 的回收时间,然后根据这个时间来制定 Young GC 的回收计划。

ZGC(The Z Garbage Collector)是 JDK11 推出的一款低延迟垃圾收集器,适用于大内存低延迟服务的内存管理和回收,SPEC jbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。

ZGC 的设计目标是:在不超过 10ms 的停顿时间下,支持 TB 级的内存容量和几乎所有的 GC 功能,这也是 ZGC 名字的由来,Z 代表着 Zettabyte,也就是 1024EB,也就是 1TB 的 1024 倍。

不过,我需要告诉大家的是,上面这段是我胡编的(😂),JDK 官方并没有明确给出 Z 的定义,就像小米汽车 su7,7 也是个魔数,没有明确的定义。

总之就是,ZGC 很牛逼,它的目标是:

  • 停顿时间不超过 10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持 8MB~4TB 级别的堆,未来支持 16TB。

前面讲 G1 垃圾收集器的时候提到过,Young GC 和 Mixed GC 均采用的是复制算法,复制算法主要包括以下 3 个阶段:

①、标记阶段,从 GC Roots 开始,分析对象可达性,标记出活跃对象。

②、对象转移阶段,把活跃对象复制到新的内存地址上。

③、重定位阶段,因为转移导致对象地址发生了变化,在重定位阶段,所有指向对象旧地址的引用都要调整到对象新的地址上。

标记阶段因为只标记 GC Roots,耗时较短。但转移阶段和重定位阶段需要处理所有存活的对象,耗时较长,并且转移阶段是 STW 的,因此,G1 的性能瓶颈就主要卡在转移阶段。

与 G1 和 CMS 类似,ZGC 也采用了复制算法,只不过做了重大优化,ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于 10ms 的关键所在。

ZGC 是怎么做到的呢?

  • 指针染色(Colored Pointer):一种用于标记对象状态的技术。
  • 读屏障(Load Barrier):一种在程序运行时插入到对象访问操作中的特殊检查,用于确保对象访问的正确性。

这两种技术可以让所有线程在并发的条件下就指针的颜色 (状态) 达成一致,而不是对象地址。因此,ZGC 可以并发的复制对象,这大大的降低了 GC 的停顿时间。

在一个指针中,除了存储对象的实际地址外,还有额外的位被用来存储关于该对象的元数据信息。这些信息可能包括:

  • 对象是否被移动了(即它是否在回收过程中被移动到了新的位置)。
  • 对象的存活状态。
  • 对象是否被锁定或有其他特殊状态。

通过在指针中嵌入这些信息,ZGC 在标记和转移阶段会更快,因为通过指针上的颜色就能区分出对象状态,不用额外做内存访问。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

其中,0-4TB 对应 Java 堆,4TB-8TB 被称为 M0 地址空间,8TB-12TB 被称为 M1 地址空间,12TB-16TB 预留未使用,16TB-20TB 被称为 Remapped 空间。

当创建对象时,首先在堆空间申请一个虚拟地址,该虚拟地址并不会映射到真正的物理地址。同时,ZGC 会在 M0、M1、Remapped 空间中为该对象分别申请一个虚拟地址,且三个虚拟地址都映射到同一个物理地址。

下图是虚拟地址的空间划分:

不过,三个空间在同一时间只有一个空间有效。ZGC 之所以设置这三个虚拟地址,是因为 ZGC 采用的是“空间换时间”的思想,去降低 GC 的停顿时间。

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0-41位,而第42-45位存储元数据,第47-63位固定为0。

由于仅用了第 0~43 位存储对象地址,$2^{44}$ = 16TB,所以 ZGC 最大支持 16TB 的堆。

至于对象的存活信息,则存储在42-45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

当程序尝试读取一个对象时,读屏障会触发以下操作:

  • 检查指针染色:读屏障首先检查指向对象的指针的颜色信息。
  • 处理移动的对象:如果指针表示对象已经被移动(例如,在垃圾回收过程中),读屏障将确保返回对象的新位置。
  • 确保一致性:通过这种方式,ZGC 能够在并发移动对象时保持内存访问的一致性,从而减少对应用程序停顿的需要。

ZGC读屏障如何实现呢?

来看下面这段伪代码,涉及 JVM 的底层 C++ 代码:

  • read_barrier 代表读屏障。
  • 如果对象已被移动(is_forwarded(ref)),方法返回对象的新地址(get_forwarded_address(ref))。
  • 如果对象未被移动,方法返回原始的对象引用。

读屏障可能被GC线程和业务线程触发,并且只会在访问堆内对象时触发,访问的对象位于GC Roots时不会触发,这也是扫描GC Roots时需要STW的原因。

下面是一个简化的示例代码,展示了读屏障的触发时机。

ZGC 周期由三个 STW 暂停和四个并发阶段组成:标记/重新映射( M/R )、并发引用处理( RP )、并发转移准备( EC ) 和并发转移( RE )。

  1. 标记开始(Mark Start)STW 暂停:这是 ZGC 的开始,进行 GC Roots 的初始标记。在这个短暂的停顿期间,ZGC 标记所有从 GC Root 直接可达的对象。
  2. 重新映射开始(Relocation Start)STW 暂停:在并发阶段之后,这个 STW 暂停是为了准备对象的重定位。在这个阶段,ZGC 选择将要清理的内存区域,并建立必要的数据结构以进行对象移动。
  3. 暂停结束(Pause End)STW 暂停:ZGC 结束。在这个短暂的停顿中,完成所有与该 GC 周期相关的最终清理工作。
  1. 并发标记/重新映射 (M/R) :这个阶段包括并发标记和并发重新映射。在并发标记中,ZGC 遍历对象图,标记所有可达的对象。然后,在并发重新映射中,ZGC 更新指向移动对象的所有引用。
  2. 并发引用处理 (RP) :在这个阶段,ZGC 处理各种引用类型(如软引用、弱引用、虚引用和幽灵引用)。这些引用的处理通常需要特殊的考虑,因为它们与对象的可达性和生命周期密切相关。
  3. 并发转移准备 (EC) :这是为对象转移做准备的阶段。ZGC 确定哪些内存区域将被清理,并准备相关的数据结构。
  4. 并发转移 (RE) :在这个阶段,ZGC 将存活的对象从旧位置移动到新位置。由于这一过程是并发执行的,因此应用程序可以在大多数垃圾回收工作进行时继续运行。

ZGC 的两个关键技术:指针染色和读屏障,不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42-45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

本篇内容我们主要介绍了 CMS、G1 和 ZGC 三种垃圾收集器,它们都是分区收集器,都是为了降低 GC 停顿时间而生的,但是它们各有优缺点,我们可以根据业务场景选择合适的垃圾收集器。

参考资料:

1、树哥聊编程:CMS 垃圾收集器 2、军哥聊技术:G1 垃圾收集器 3、美团技术专家:G1 GC 的一些关键技术 4、极客时间:为什么 G1 被叫做 GC 中的王 5、得物技术:ZGC 关键技术分析 6、美团技术:ZGC 的探索与实践 7、CoderW:ZGC 垃圾收集器


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

经过前面章节的学习,详细大家都知道了,Java 的对象是在堆中创建的,但堆又分为新生代和老年代,新生代又细分为 Eden、From Survivor、To Survivor。那我们创建的对象到底在哪里

其实这部分内容我们在讲垃圾回收机制的时候提到过了,但没有细讲,这次我们就来详细讲讲。

堆分为新生代和老年代,新生代用于存放使用后就要被回收的对象(朝生夕死),老年代用于存放生命周期比较长的对象。

我们创建的大部分对象,都属于生命周期较短的对象,所以会存放在新生代。新生代又细分 Eden、From Survivor、To Survivor,那我们创建的对象会优先在 Eden 区分配,见下图。

随着对象的不断创建,Eden 剩余地内存空间就会越来越少,随后就会触发 Minor GC,于是 JVM 会把 Eden 区存活的对象转入 From Survivor 空间。

Minor GC 后,又创建的新对象会继续往 Eden 区分配。

于是,随着新对象的创建,Eden 的剩余内存空间就会越来越少,又会触发 Minor GC,此时,JVM 会对 Eden 区和 From Survivor 区中的对象进行存活判断,对于存活的对象,会转移到 To Survivor 区。

下一次 Minor GC,存活的对象又会从 To 到 From,这样就总有一个 Survivor 区是空的,而另外一个是无碎片的。

对于上面的流程,也有例外的存在,如果一个对象很大,一直在 Survivor 空间复制来复制去,就会很浪费性能,所以这些大对象会直接进入老年代。

这种策略的目的是减少垃圾回收时的复制开销,因为大对象的复制比小对象更耗时。

可以通过 参数设置直接分配大对象到老年代的阈值。如果对象的大小超过这个阈值,它将直接在老年代中分配。例如,如果想将阈值设置为 1MB(1024KB),可以这样设置:

对象在每次从一个 Survivor 区转移到另外一个 Survivor 区时,它的年龄就会增加。当对象的年龄达到一定阈值(默认为 15),则它会被转移到老年代。

可以用 来设置年龄。

虚拟机为了给对象计算他到底经历了几次 Minor GC,会给每个对象定义了一个对象年龄计数器。如果对象在 Eden 中经过第一次 Minor GC 后仍然存活,移动到 Survivor 空间年龄加 1,在 Survivor 区中每经历过 Minor GC 后仍然存活年龄再加 1。年龄到了 15,就到了老年代。

除了年龄达到 MaxTenuringThreshold,还有另外一个方式进入老年代,那就是动态年龄判断:JVM 会检查每个年龄段的对象大小,并估算它们在 Survivor 空间中所占的总体积。JVM 会选择一个最小的年龄,使得该年龄及以上的对象可以填满 Survivor 空间的一部分(通常小于总空间的一半),然后将这些对象晋升到老年代。

比如 Survivor 是 100M,Hello1 和 Hello2 都是 3 岁,且总和超过了 50M,Hello3 是 4 岁,这个时候,这三个对象都将到老年代。

上面提到过,存活的对象会放入另外一个 Survivor 空间,如果这些存活的对象比 Survivor 空间还大呢?

整个流程如下:

  • Minor GC 之前,JVM 会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则发起 Minor GC。
  • 如果小于,则看 HandlePromotionFailure 有没有设置,如果没有设置,就发起 Full GC。
  • 如果设置了 HandlePromotionFailure,则看老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于,就发起 Full GC。
  • 如果大于,发起 Minor GC。Minor GC 后,看 Survivor 空间是否足够存放存活对象,如果不够,就放入老年代,如果够放,就直接存放 Survivor 空间。如果老年代都不够放存活对象,担保失败(Handle Promotion Failure),发起 Full GC。

HandlePromotionFailure 的作用,当设置为 true 时(默认值),JVM 会尝试继续 Minor GC,即使老年代空间不足以容纳所有需要晋升的对象。JVM 会尝试清理更多的老年代空间或者采用其他措施来应对空间不足的情况。避免因为老年代空间不足而过早触发 Full GC(全堆回收)。Full GC 通常比 Minor GC 更耗时,会导致更长时间的停顿。

Java 创建的对象几乎都在堆中,这包括通过 new 关键字创建的对象和数组。

对象的引用,通常存放在栈中,比如说当你在方法中声明一个变量 时,变量 obj(一个指向堆中对象的引用)存储在栈上。

方法区用于存储已被 JVM 加载的类信息、常量、静态变量以及即时编译器编译后的代码。

Java 8 中,永久代被元空间(Metaspace)所取代。元空间使用本地内存(操作系统的内存),而非 JVM 内存。

这篇内容我们主要讲了:

  • Java 创建的对象优先在 Eden 区分配。
  • 大对象直接进入老年代。
  • 长期存活的对象将进入老年代。
  • 动态年龄判断。
  • 空间分配担保。

算是对我们之前讲过的垃圾回收机制中对象的转移做了补充。了解这些内存区域对于理解 Java 的内存管理和优化程序性能非常重要。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

前面我们讲了,为了提升 Java 运行时的性能,JVM 引入了 JIT,也就是即时编译(Just In Time)技术。

Java 代码首先被编译为字节码,JVM 在运行时通过解释器执行字节码。当某部分的代码被频繁执行时,JIT 会将这些热点代码编译为机器码,以此来提高程序的执行效率。

那为什么 JIT 就能提高程序的执行效率呢,解释器不也是将字节码翻译为机器码交给操作系统执行吗?

解释器在执行程序时,对于每一条字节码指令,都需要进行一次解释过程,然后执行相应的机器指令。这个过程在每次执行时都会重复进行,因为解释器不会记住之前的解释结果。

与此相对,JIT 会将频繁执行的字节码编译成机器码。这个过程只发生一次。一旦字节码被编译成机器码,之后每次执行这部分代码时,直接执行对应的机器码,无需再次解释。

除此之外,JIT 生成的机器码更接近底层,能够更有效地利用 CPU 和内存等资源,同时,JIT 能够在运行时根据实际情况对代码进行优化(如内联、循环展开、分支预测优化等),这些优化是在机器码级别上进行的,可以显著提升执行效率。

换句话说,解释器是一个循规蹈矩的人,每次都要按照规则来执行,而 JIT 是一个“偷奸耍滑”的人,他会根据实际情况来做出最优的选择。

好,我们再来梳理一下。

Java 的执行过程分为两步,第一步由 javac 将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析。

第二步,解释器会逐行解释字节码并执行,在解释执行的过程中,JVM 会对程序运行时的信息进行收集,在这些信息的基础上,JIT 会逐渐发挥作用,它会把字节码编译成机器码,但不是所有的代码都会被编译,只有被 JVM 认定为热点代码,才会被编译。

怎么样才会被认为是热点代码呢

JVM 中有一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被认定为热点代码,然后编译存入 codeCache 中。当下次执行时,再遇到这段代码,就会从 codeCache 中直接读取机器码,然后执行,以此来提升程序运行的性能。

整体的执行过程大致如下图所示:

这里的 codeCache 让我想起了 Redis,Redis 也是将热点数据存储在内存中,以此来提升访问速度。

OK,解释清楚了 JIT 的原理,我们来看看 JIT 的实现。

JVM 中集成了两种编译器,一种是 Client Compiler,另外一种是 Server Compiler。

Client Compiler 注重启动速度和局部的优化,Server Compiler 则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会慢一些。

两种编译器相辅相成,互为臂膀,共同把 JVM 的性能带到了一个新的高度。

就那虚拟机中的太子 HotSpot 来说吧,它就带有一个 Client Compiler,被称为 C1 编译器,启动速度极快。

C1 通常会做这三件事:

①、局部简单可靠的优化,比如在字节码上进行一些基础优化,方法内联、常量传播等。

我们来举例看一下什么是方法内联,假设我们有两个简单的方法:

在执行 run 方法时,会调用 add 方法,方法内联优化后,会将 add 方法的字节码直接插入到 run 方法中,这样就不用再去调用 add 方法了,直接执行 run 方法就可以了。

②、将字节码编译成 HIR(High-level Intermediate Representation),别计较它中文名叫什么,我觉得与其死板的翻译,不如就记住它叫 HIR,一种比较接近源代码的形式。

通过借助 HIR 我们可以实现冗余代码消除、死代码删除等编译优化工作,我们同样通过代码来看一下。

很明显,上面的代码中,b 的值是可以直接通过 a 的值计算出来的,所以 b 的计算就是冗余的,我们可以通过 HIR 来消除这种冗余计算。

在 HIR 优化阶段,编译器识别到 x + y 的计算是冗余的,因此它将第二次计算的结果用第一次的结果替换。

③、最后将 HIR 转换成 LIR(Low-level Intermediate Representation),比较接近机器码了。这期间会做一些寄存器分配、窥孔优化等。

寄存器分配是指在编译时将程序中的变量分配到 CPU 的寄存器上。由于寄存器的访问速度远快于内存,因此合理的寄存器分配可以显著提高程序的执行效率。

来看这段代码:

在没有寄存器优化的情况下,编译器会将变量 a、b、c 分配到内存中,然后在执行时,再从内存中读取变量的值。有了寄存器分配优化呢?

这样,变量 a、b、c 就被分配到了寄存器 R1、R2、R3 上,而不是内存中,寄存器的访问速度远快于内存,所以这样的优化可以提高程序的执行效率。

窥孔优化(Peephole Optimization)是一种在生成机器码阶段进行的局部优化技术。编译器“窥视”一小段生成的机器码,并尝试找出并替换更高效的指令序列。

假设有这样一段简单的机器码:

这段代码首先将寄存器 R1 置零,然后再将 5 加到 R1 上,窥孔优化会将这两条指令合并成一条:

这样,仅用一条指令就完成了同样的操作,显然会提高代码执行的效率。

Server Compiler 主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比 Client Compiler 高 30%以上。目前,Hotspot 虚拟机中使用的 Server Compiler 有两种:C2 和 Graal。

Hotspot 中,默认的 Server Compiler 是 C2 编译器。

C2 编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为 Ideal Graph,我愿称之为“理想图”。

Ideal Graph 表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的优化步骤)会变得不那么复杂。

解析字节码的时候,C2 会向一个空的 Graph 中添加节点,Graph 中的节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM 会利用一些优化技术对这些指令进行优化,比如 Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。

生成 Ideal Graph 后,会在这个基础上结合收集到的程序运行信息来进行一些全局的优化。

无论是否进行全局优化,Ideal Graph 都会被转化为一种更接近机器层面的 MachNode Graph,最后编译的机器码就是从 MachNode Graph 中得到的。

从 JDK 9 开始,Hotspot 中集成了一种新的 Server Compiler,也就是 Graal 编译器。相比 C2,Graal 有这样几种关键特性:

①、JVM 会在解释执行的时候收集程序运行的各种信息,然后根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal 比 C2 更加青睐这种优化,所以 Graal 的峰值性能通常要比 C2 更好。

②、与 C2(主要用 C++ 编写)不同,Graal 使用 Java 语言编写。这样做的好处是,Graal 可以直接使用 JVM 的内存管理机制,不需要像 C2 那样自己实现内存管理,这样就可以避免一些内存管理上的问题。

③、Graal 引入了许多现代化的编译优化技术,例如更复杂的内联策略、循环优化等,这些在某些情况下可以比 C2 产生更优化的代码。

④、改进的逃逸分析有助于更好地进行栈上分配和锁消除,从而提升性能。

⑤、Graal 不仅能作为 JIT 编译器使用,还支持 Ahead-of-Time(AOT)编译,这有助于减少 Java 应用的启动时间和内存占用。

Graal 编译器可以通过 JVM 参数 启用。当启用时,它将替换掉 HotSpot 中的 C2,并响应原本由 C2 负责的编译请求。

Java 7 引入了分层编译的概念,它结合了 C1 和 C2 的优势,追求启动速度和峰值性能的一个平衡。分层编译将 JVM 的执行状态分为了五个层次。五个层级分别是:

Java 7 中引入的分层编译(Tiered Compilation)确实是一种结合了 C1 编译器(Client Compiler)和 C2 编译器(Server Compiler)优势的技术。分层编译旨在优化 Java 程序的启动速度和长期运行时的性能。这一机制通过在不同的层级应用不同的编译策略,以达到快速启动和最高性能的平衡。在 HotSpot JVM 中,分层编译将程序执行状态分为五个层次:

  1. 层级 0 - 解释器(Interpreter):这是程序最初执行的阶段,代码通过解释器逐行解释执行。这一阶段的目的是尽快开始执行而不等待编译完成。
  2. 层级 1 - C1 编译器带有轻量级优化(C1 with Simple Optimizations):在这一层级,代码首次由 C1 编译器编译,应用了一些基本的优化,如方法内联。这一阶段的编译速度较快,能迅速提供优于解释执行的性能。
  3. 层级 2 - C1 编译器带有完整优化(C1 with Full Optimizations):此层级仍由 C1 编译器处理,但应用了更多优化技术,如逃逸分析。虽然这些优化需要更长的编译时间,但能进一步提升运行性能。
  4. 层级 3 - C1 编译器带有分析数据收集(C1 with Profiling): 在这个层级,C1 编译器除了执行优化,还收集方法执行的详细分析数据(如分支频率、热点代码等)。这些数据将用于 C2 编译器的后续优化。
  5. 层级 4 - C2 编译器优化(C2 Optimizations):最终阶段由 C2 编译器处理,它使用收集的分析数据进行深入优化。C2 编译器的优化更加彻底和复杂,适用于长时间运行的代码,能够提供最佳的运行性能。

下图中列举了几种常见的编译路径:

1)图中第 ① 条路径,代表编译的一般情况,热点方法从解释执行到被 3 层的 C1 编译,最后被 4 层的 C2 编译。

2)如果方法比较小(比如 getter/setter),3 层的 profiling 没有收集到有价值的数据,JVM 就会断定该方法对于 C1 代码和 C2 代码的执行效率相同,就会执行图中第 ② 条路径。

在这种情况下,JVM 会在 3 层编译之后,放弃进入 C2 编译,直接选择用 1 层的 C1 编译运行。

3)在 C1 忙碌的情况下,执行图中第 ③ 条路径,在解释执行过程中对程序进行 profiling,根据信息直接由第 4 层的 C2 编译。

4)C1 中的执行效率是 1 层>2 层>3 层,第 3 层一般要比第 2 层慢 35%以上,所以在 C2 忙碌的情况下,执行图中第 ④ 条路径。这时方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。

5)如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第 ⑤ 条执行路径代表的就是反优化。

分层编译通过在不同的阶段应用不同程度的优化,既提供了较快的应用启动时间,又确保了长时间运行的应用能达到峰值性能。这种动态适应的编译策略是 Java 平台持续优化性能的关键手段之一。

从 JDK 8 开始,JVM 默认开启分层编译。

JVM 根据方法的调用次数以及循环回边的执行次数来触发 JIT。

循环回边是一个控制流图中的概念,程序中可以简单理解为往回跳转的指令,比如下面这段代码:

上面这段代码经过编译生成下面的字节码。

其中,偏移量为 18 的字节码将往回跳至偏移量为 4 的字节码中。在解释执行时,每当运行一次该指令,JVM 便会将该方法的循环回边计数器加 1。

在即时编译过程中,编译器会识别循环的头部和尾部。上面这段字节码中,循环体的头部和尾部分别为偏移量为 11 的字节码和偏移量为 15 的字节码。编译器将在循环体结尾增加循环回边计数器的代码,来对循环进行计数。

当方法的调用次数和循环回边的次数的和,超过由参数 指定的阈值时,就会触发即时编译。

C1 默认值为 1500;C2 默认值为 10000。

开启分层编译的情况下, 参数设置的阈值将会失效,触发及时编译会由以下的条件来判断:

  • 方法调用次数大于由参数 指定的阈值乘以系数。
  • 方法调用次数大于由参数 指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数 指定的阈值乘以系数时。

分层编译触发条件公式(i 为调用次数,b 是循环回边次数,s 是系数):

满足其中一个条件就会触发即时编译,并且 JVM 会根据当前的编译方法数以及编译线程数动态调整系数 s。

即时编译器会对正在运行的程序进行一系列优化,包括:

  • 字节码解析过程中的分析
  • 根据编译过程中代码的一些中间形式来做局部优化
  • 根据程序依赖图进行全局优化

最后才会生成机器码。

在编译原理中,通常会把编译器分为前端和后端,前端编译经过词法分析、语法分析、语义分析生成中间表达形式 IR(Intermediate Representation),后端会对 IR 进行优化,生成目标代码。

Java 字节码就是一种 IR,但是字节码的结构复杂,也不适合做全局的分析优化。

现代编译器一般采用图结构的 IR,也就是所谓的静态单赋值——Static Single Assignment,SSA 是目前比较常用的一种 IR。这种 IR 的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。

举个例子(前面也讲过,这里再强调一遍):

我们可以轻易地发现 a = 1 的赋值是冗余的。传统的编译器需要借助数据流分析,从后至前依次确认哪些变量的值被覆盖掉了。不过,如果借助了 SSA IR,编译器则可以很容易识别冗余赋值。

上面代码的 SSA IR 形式的伪代码可以表示为:

由于 SSA IR 中每个变量只能赋值一次,所以代码中的 a 在 SSA IR 中会分成 a_1、a_2 两个变量来赋值,这样编译器就可以很容易通过扫描这些变量来发现 a_1 的赋值后并没有使用,由此认定该赋值是冗余的。

除此之外,SSA IR 对其他优化方式也有很大的帮助,例如下面这个死代码删除(Dead Code Elimination)的例子:

可以得到 SSA IR 伪代码:

编译器通过执行字节码可以发现 else 分支不会被执行。经过死代码删除后就可以得到代码:

我们可以将编译器的每一种优化看成一个图优化算法,它接收一个 IR 图,并输出经过转换后的 IR 图。编译器优化的过程就是一个个图节点的优化串联起来的。

前文提到了 C1 编译器内部使用了高级中间表达形式 HIR,低级中间表达形式 LIR 来进行各种优化,这两种 IR 都是 SSA 形式的。

HIR 是由很多基本块(Basic Block)组成的控制流图结构,每个块包含很多 SSA 形式的指令。基本块的结构如下图所示:

其中,predecessors 表示前驱基本块,由于前驱可能是多个,所以是 BlockList 结构,由多个 BlockBegin 组成的可扩容数组。

同样,successors 表示多个后继基本块 BlockEnd。

除了这两部分就是主体块,里面包含程序执行的指令和一个 next 指针,指向下一个执行的主体块。

从字节码到 HIR 的构造最终调用的是 GraphBuilder,GraphBuilder 会遍历字节码,将所有代码基本块存储为一个链表结构,但是这个时候的基本块只有 BlockBegin,不包括具体的指令。

第二步 GraphBuilder 会用一个 ValueStack 作为操作数栈和局部变量表,模拟执行字节码,构造出对应的 HIR,填充之前空的基本块,这里给出简单字节码块构造 HIR 的过程示例,如下所示:

可以看出,当执行 iload_1 时,操作数栈压入变量 i1,执行 iload_2 时,操作数栈压入变量 i2,执行相乘指令 imul 时弹出栈顶两个值,构造出 HIR i3 : i1 * i2,生成的 i3 入栈。

C1 编译器的大部分优化工作都是在 HIR 之上完成的。当优化完成之后它会将 HIR 转化为 LIR,LIR 和 HIR 类似,也是一种编译器内部用到的 IR,HIR 通过优化消除一些中间节点就可以生成 LIR,形式上更加简化。

C2 编译器中的 Ideal Graph 采用的是一种名为 Sea-of-Nodes 中间表达形式,同样也是 SSA 形式。

它最大的特点是去除了变量的概念,直接采用值来进行运算。为了方便理解,可以利用 IR 可视化工具 Ideal Graph Visualizer(IGV),来展示具体的 IR 图。比如下面这段代码:

对应的 IR 图如下所示:

B0 基本块中 0 号 Start 节点是方法入口,B3 中 21 号 Return 节点是方法出口。

红色加粗线条为控制流,蓝色线条为数据流,其他颜色的线条则是特殊的控制流或数据流。

被控制流所连接的是固定节点,其他的则是浮动节点。

依赖于这种图结构,通过收集程序运行的信息,JVM 可以通过 Schedule 那些浮动节点,从而获得最好的编译效果。

来看下面这段代码:

来看一下 bar 方法的 IR 图:

内联后的 IR 图:

内联将被调用方法的 IR 图节点复制到调用者方法的 IR 图中。在这个例子中,bar 方法的 IR 图中的 0 号 Start 节点被复制到了 foo 方法的 IR 图中,从而避免了方法调用的开销。

逃逸分析是 JIT 用于优化内存管理和同步操作的重要技术。通过分析对象是否逃逸到方法或线程的外部,编译器可以做出更智能的存储和同步决策。

逃逸分析通常是在方法内联的基础上进行的,JIT 可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

下面这段代码的就是对象未逃逸的例子:

在这个例子中, 方法创建了两个对象: 和 。然后, 对象通过 方法引用了 对象。

  1. Foo 对象的逃逸情况
    • 对象被创建并传递给 对象的 方法。
    • 一旦 方法被调用, 对象的引用存储在 对象的实例变量 中。
    • 但是, 对象本身在 方法结束后就不再使用。
    • 这意味着即使 对象的引用被存储在另一个对象中,但由于 对象本身也不会逃逸出 方法,因此 对象实际上也没有逃逸。
  2. Bar 对象的逃逸情况
    • 对象在 方法中被创建并使用,但之后没有被传递到其他方法或返回。
    • 因此, 对象也没有逃逸出 方法。

根据逃逸分析的结果,JIT 可能做出以下优化决策:

①、锁消除:如果 或 类中有同步块(使用 ),由于对象没有逃逸,编译器可以安全地消除这些锁操作。

②、栈上分配:由于 和 对象都没有逃逸到方法之外,编译器可以选择在栈上分配这两个对象,而非在堆上分配。这样可以提高内存分配的效率,并减少垃圾收集器的压力。

堆和栈的区别可以查看这篇内容:JVM 的内存数据区

我们都知道 Java 的对象是在堆上分配的,而堆是对所有对象可见的。同时,JVM 需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。

这样一来,我们便无须借助垃圾收集器来处理不再被引用的对象。

不过 Hotspot 并没有进行实际的栈上分配,而是使用了标量替换的技术。

③、标量替换(Scalar Replacement)

标量替换是一种优化技术,其中编译器将一个聚合对象分解为其各个字段。如果这个对象没有逃逸出方法,那么它的各个字段可以视为独立的局部变量。

这种技术允许编译器进行更细粒度的优化,如更好的寄存器分配和减少不必要的内存分配。

考虑这段代码:

  1. 对象的使用范围
    • 在 方法中创建了一个 对象,并将其字段 和 传递给了 方法。
    • 对象在 方法中创建且只在该方法中使用,没有被传递到方法外部或赋值给外部引用。
  2. 逃逸分析
    • 由于 对象在方法外部没有引用,它没有逃逸出 方法的作用域。
    • 这意味着 对象是一个局部对象,适合进行标量替换。
  3. 标量替换的应用
    • JVM 的 JIT 编译器会分析 对象的使用情况。基于逃逸分析,编译器可以决定不在堆上分配 对象,而是将其分解为两个独立的局部变量 和 。
    • 这样,原本由 对象占用的堆空间就被节省下来,而且减少了垃圾回收的压力。
  4. 优化后的执行
    • 在执行 方法时, 对象的字段 和 直接作为栈上的局部变量处理,避免了堆分配。

标量替换后的伪代码如下所示:

可以看到, 对象被分解为两个局部变量 和 ,并且直接作为参数传递给了 方法。

前面我们也简单分析了一下窥孔优化与寄存器分配,相信大家对这两个概念都有了一定的了解,这里简单总结下。

窥孔优化就是将编译器所生成的中间代码中的某些组合替换为效率更高的指令组,比如强度削减、常数合并等,看下面这个例子就是一个强度削减的例子:

编译器使用移位和加法削减乘法的强度,使用更高效率的指令组。

寄存器分配也是一种编译的优化手段,在 C2 编译器中普遍的使用。它是通过把频繁使用的变量保存在寄存器中,CPU 访问寄存器的速度比内存快得多,可以提升程序的运行速度。

经过寄存器分配和窥孔优化之后,程序就会被转换成机器码保存在 codeCache 中。

本文主要介绍了 JIT 即时编译的原理以及编译优化的过程,包括:

  • JIT 的触发条件
  • JIT 的编译优化
  • JIT 的编译器

JIT 是 JVM 的重要组成部分,它可以根据程序运行的情况,对热点代码进行编译优化,从而提升程序的运行效率。

参考链接:美团技术


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

记得 2014 年我在写大宗期货交易平台的时候,遇到了一些棘手的问题,可能是因为我的并发编程知识掌握的不够扎实,导致出现了内存泄漏的问题。

当时排查了好久,用的工具就是 JDK 自带的 jconsole,之前也没有过类似的性能监控经验,就导致在查找问题的时候非常痛苦,至今印象深刻。

那今天我们就从工具篇出发,来看看这些命令行工具的具体使用方法,以及如何排查问题。

除了我们的老朋友 java 和 javac 命令,在 Java 的 bin 目录下,还有很多其他的命令行工具,比如说用于性能监控的 jps、jstat、jinfo、jmap、jstack、jcmd 等等。

我用的 macOS,Windows 用户看到的可能是带有 .exe 结尾的,但是功能都是一样的,我就不再刻意去截图了。

接下来,我来给大家一一介绍一下这些工具的用途,认个脸熟。

jps(Java Virtual Machine Process Status Tool)类似 Linux 下的 ps,用于快速查看哪些 Java 应用正在运行,以及它们的进程 ID,这对于进一步使用其他 JVM 工具进行诊断是必要的。

jps 命令格式:

jps 命令示例:

①、注意看第三个进程正是我本地运行着的技术派实战项目,一个前后端分离的 Spring Boot+React 的社区项目,帮助不少球友拿到了心仪的校招 Offer。

②、pid 是什么?pid 是进程 ID,是操作系统分配给进程的唯一标识符,可以用来查看进程的详细信息。

通常情况下,我们关闭一个进程可以通过右上角的 X 号来完成,但有了 pid,我们可以直接在命令行通过 kill 命令来关闭进程,比如:

意思是强制关闭 pid 对应的进程,新手可千万别在生产环境下乱 kill 哈(😂)。

再来看一下 jps 的常用选项:

选项列表描述-q只输出进程 ID,忽略主类信息-l输出主类全名,或者执行 JAR 包则输出路径-m输出虚拟机进程启动时传递给主类 main() 方法的参数-v输出虚拟机进程启动时的 JVM 参数

jstat(Java Virtual Machine Statistics Monitoring Tool)用于监控 JVM 的各种运行时状态信息,提供有关垃圾回收、类加载、JIT 编译等运行数据。

jstat 命令格式为:

选项 option 主要分为三类:类加载、垃圾收集、运行期编译状况。

①、:监视类装载、卸载数量、总空间以及类装载所耗费的时间。

如下命令 会输出进程 75952 的类装载信息,每秒统计一次,一共输出两次。

  • Loaded:加载的类的数量。
  • Bytes:所有加载类占用的空间大小。
  • Unloaded:卸载的类的数量。
  • Time:类加载器所花费的时间。

②、:监视 Java 堆状况,包括 Eden 区、2 个 Survivor 区、老年代等容量、已用空间、GC 时间合计等信息。

如下命令 会输出进程 75952 的 GC 信息,每秒统计一次,一共输出两次。结果比较多,我就截断折叠了一下,方便大家查看。

  • S0C, S1C, S0U, S1U:Survivor 区的大小和使用情况,一个 From 一个 To,C 为当前大小(Current),U 为已使用大小(Used)。
  • EC, EU:Eden 区的大小和使用情况。
  • OC, OU:老年代(Old)的大小和使用情况。
  • MC, MU:元空间(Metaspace)的大小和使用情况。
  • GC,GCC:GC 表示垃圾回收器进行 Minor GC(年轻代垃圾回收)的累计次数和总时间;GCC 表示垃圾回收器进行 Major GC(老年代垃圾回收,也称为 Full GC)的累计次数和总时间。

③、:监视 JIT 编译器编译过的方法、耗时等信息。

  • Compiled:编译的方法数量。
  • Failed:编译失败的方法数量。
  • Invalid:失效的编译方法数量。
  • Time:编译所花费的时间。

如下命令 会输出进程 75952 的编译信息,每秒统计一次,一共输出两次。

好,我们再来总结一下 jstat 的主要选项,见下表:

选项列表 描述-class监视类加载、卸载数量、总空间以及类装载所耗费时长-gc监视 Java 堆情况,包括 Eden 区、2 个 Survivor 区、老年代、元空间等,容量、已用空间、垃圾收集时间合计等信息-gccapacity监视内容与-gc 基本一致,但输出主要关注 Java 堆各个区域使用到的最大、最小空间-gcutil监视内容与-gc 基本相同,但输出主要关注已使用空间占总空间的百分比-gccause与 -gcutil 功能一样,但是会额外输出导致上一次垃圾收集产生的原因-gcnew监视新生代垃圾收集情况-gcnewcapacity监视内容与 -gcnew 基本相同,输出主要关注使用到的最大、最小空间-gcold监视老年代垃圾收集情况-gcoldcapacity监视内容与 -gcold 基本相同,输出主要关注使用到的最大、最小空间-compiler输出即时编译器编译过的方法、耗时等信息-printcompilation输出已经被即时编译的方法

jinfo(Configuration Info for Java)用于在补重启应用的情况下,调整虚拟机的各项参数,或者输出 Java 进程的详细信息。

jinfo 命令格式:

如下命令 会输出进程 88952 的 JVM 参数信息。

不过很遗憾的是,我的 macOS 系统上,jinfo 命令无法执行成功,后来经过各种实验找到了解决办法。

可能的原因是,我的 macOS 上装了太多的 JDK 版本,导致 Intellij IDEA 中编译的 JDK 和 jinfo 的版本不一致。

那怎么解决呢?

尝试方案 1:用相同的 JDK 版本编译运行 Java 程序,并使用相同的 JDK 的 jinfo 来查看。

结果依然报错,可能的原因是 JDK 版本过旧。

尝试方案 2: 用 JDK 11 来测试,代码用 JDK 11 编译和运行。

然后用 JDK 11 的 jinfo 来查看,成功了。

再试一下 命令,也 OK。

之所以把这个问题的解决思路同步上来,也是希望能给球友们提供一些日常遇到开发问题时的解决思路。

jmap 命令用于生成堆转储快照(一般称为 heap dump 或 dump 文件)。堆转储包含了 JVM 堆中所有对象的信息,包括类、属性、引用等。这对于分析内存泄漏和优化内存使用非常有帮助。

当然了,jmap 的作用不局限于此,它还可以查看堆的空间使用率、当前用的是哪种垃圾收集器等。

jmap 命令格式:

如下命令 会输出进程 10025 的堆内存中所有对象的数量和占用内存大小的汇总信息,按照内存使用量排序。

如下命令 会输出进程 10025 的堆快照信息,保存到文件 heap.hprof 中。

简单解释一下这条命令:

  • format:文件格式,这里是 b,表示二进制格式。
  • file:文件名。

那么,我们可以用什么工具来打开这个文件呢?后面会讲。我们先来看一下 jmap 的主要选项:

选项 描述-dump生成 Java 堆转储快照。-finalizerinfo显示在 F-Queue 中等待 Finalizer 线程执行 finalize 方法的对象。Linux 平台-heap显示 Java 堆详细信息,比如:用了哪种回收器、参数配置、分代情况。Linux 平台-histo显示堆中对象统计信息,包括类、实例数量、合计容量-F当虚拟机进程对 -dump 选项没有响应式,可以强制生成快照。Linux 平台

jstack 用于打印出 JVM 中某个进程或远程调试服务的线程堆栈信息(一般称为 threaddump 或者 javacore 文件)。它常用于诊断应用程序中的线程问题,比如线程死锁、死循环或长时间等待。

jstack 命令格式:

如下 会输出进程 10025 的线程堆栈信息,包括锁信息。

jstack 工具主要选项:

选项 描述-F当正常输出的请求不被响应时,强制输出线程堆栈-l除了堆栈外,显示关于锁的附加信息-m如果调用的是本地方法的话,可以显示 c/c++的堆栈

我们来通过一个线程死锁的问题,来看一下 jstack 的使用方法。

首先,我们编写一个死锁的程序:

我们创建了两个线程,每个线程都试图按照不同的顺序获取两个锁(lock1 和 lock2)。这种锁的获取顺序不一致很容易导致死锁。

运行这段代码,果然卡住了。

运行 命令,可以看到死锁的线程信息。诚不欺我!

jcmd 是一个多功能命令,可以用于收集堆转储、生成 JVM 和 Java 应用程序的性能数据,以及动态更改某些 Java 运行时参数。jcmd 提供的功能比其他单一命令,如 jstack, jmap, jstat 都要强大。

例如,使用 列出当前的所有 Java 应用,和 jps 类似:

例如,使用 查看进程 10025 支持的命令:

例如,使用 查看进程 10025 的 JVM 参数,相当于 :

例如,使用 查看进程 10025 的线程信息,相当于 :

jmcd 命令格式:

jmcd 的主要选项:

选项描述补充help打印帮助信息,示例:jcmd help []无ManagementAgent.stop停止 JMX Agent无ManagementAgent.start_local开启本地 JMX Agent无ManagementAgent.start开启 JMX Agent无Thread.print参数-l 打印 java.util.concurrent 锁信息,相当于:jstack无PerfCounter.print相当于:jstat -J-Djstat.showUnsupported=true -snap无GC.class_histogram相当于:jmap -histo无GC.heap_dump相当于:jmap -dump:format=b,file=xxx.bin无GC.run_finalization相当于:System.runFinalization()无GC.run相当于:System.gc()无VM.uptime参数-date 打印当前时间,VM 启动到现在的时候,以秒为单位显示无VM.flags参数-all 输出全部,相当于:jinfo -flags , jinfo -flag无VM.system_properties相当于:jinfo -sysprops无VM.command_line相当于:jinfo -syspropsgrep commandVM.version相当于:jinfo -syspropsgrep version

除了 JDK 自带的命令行,我们很多时候还要使用操作系统为我们提供的命令行工具,来完成性能监控的监测。

比如说 top、vmstat、iostat、netstat 等等。

top 命令用于实时显示系统中各个进程的资源占用情况,如 CPU 和内存使用率。常用于快速查看哪些进程占用了较高的资源。

该命令的输出结果是实时变化的,可以使用 来退出。下图是我的 macOS 上输出的结果:

top 命令的输出可以分为两个部分:前半部分是系统统计信息,后半部分是进程信息。

统计信息是针对整个系统的,主要包括系统负载、CPU 使用率、内存使用情况、虚拟内存使用情况、网络和硬盘使用情况等。

  • 第 1 行是进程和线程信息,分别表示总进程数、正在运行的进程数、睡眠的进程数、线程数。
  • 第 2 行是负载均衡和 CPU 使用率信息,:这表示过去 1 分钟、5 分钟和 15 分钟的平均系统负载。负载大于 3 意味着系统相对繁忙;:用户占用了 6.97% 的 CPU,系统占用了 3.54%,还有 89.47% 的 CPU 处于空闲。
  • 第 3 行是共享库(Shared Libraries)内存使用的信息。这一行的数据主要涉及到操作系统加载的共享库(如动态链接库或共享对象文件)。
  • 第 4 行是内存区域(Memory Regions)的使用信息。内存区域是指操作系统为应用程序和进程分配的内存块。每个内存区域都有特定的用途和属性,比如代码、数据、堆、栈等。这一行的数据提供了系统内存使用的更详细的视图。
  • 第 5 行是内存使用情况,:内存总共使用了 30GB,还有大约 1547MB 的内存未使用;
  • 第 6 行是虚拟内存的信息,虚拟内存是计算机内存管理的一种技术,它为每个程序提供一种“虚拟”的地址空间,这些地址空间对于每个程序来说都是连续的,但实际上可能分散在物理内存和磁盘的交换空间(swap space)上。
  • 第 7 行是网络和硬盘信息,:网络接收了 19GB 的数据包;发送了约 11GB 的数据包;:硬盘读取 次;写入了约 次。

在进程信息区中,显示了系统各个进程的资源使用情况。主要字段的含义:

  • PID:进程 id
  • COMMAND:命令名/命令行
  • %CPU:进程占用的 CPU 使用率
  • TIME:进程使用的 CPU 时间总计,单位 1/100 秒
  • MEM:进程使用的物理内存和虚拟内存大小,单位 KB

Windows 用户可以使用 tasklist 命令来查看进程信息。

vmstat 是 Linux 上的一款功能比较齐全的性能监测工具。它可以统计 CPU、内存、swap 的使用情况。

一般 vmstat 工具的使用是通过两个数字参数来完成的,第一个参数是采样的时间间隔数,单位是秒,第二个参数是采样的次数,如:

命令表示每秒采样一次,共三次。

输出的各个列的含义:

vmstat 的用法如下:

vmstat 的主要选项:

  • :提供不同的输出选项,例如 -a 显示活跃和非活跃内存,-d 显示磁盘统计,-s 显示内存统计等。
  • :在连续模式下,两次报告之间的延迟时间(秒)。
  • :要显示的报告数量。

iostat 用于统计 CPU 使用信息和磁盘的 IO 信息。

基本用法如下:

  • :提供不同的输出选项。例如,-c 显示 CPU 使用情况,-d 显示磁盘使用情况,-x 显示扩展统计信息等。
  • :报告之间的延迟时间(秒)。
  • :显示报告的次数。

的输出包括两个主要部分:

  1. CPU 使用情况
    • :用户程序使用的 CPU 时间百分比。
    • :系统(内核)级程序使用的 CPU 时间百分比。
    • :CPU 空闲时间百分比。
  2. 磁盘 I/O 统计
    • (Transfers Per Second):每秒传输次数。
    • :每秒读取的千字节数。
    • :每秒写入的千字节数。
    • 和 :分别是读取和写入的总千字节数。

    如果使用 选项,会显示更详细的统计信息,例如:

    • :表示磁盘的繁忙程度。
    • :I/O 请求的平均等待时间(毫秒)。
    • :服务时间,即完成一个 I/O 请求所需的平均时间。

使用示例如下:

①、查看 CPU 和所有磁盘设备的基本 I/O 统计信息:

②、查看磁盘 I/O 统计信息,每 2 秒更新一次:

③、只查看 CPU 使用情况:

(network statistics)用于监控和显示网络相关信息。基本用法如下:

  • :提供不同的输出选项。常见的选项包括 (显示所有连接和侦听端口),(显示 TCP 连接),(显示 UDP 连接),(以数字形式显示地址和端口号),(显示路由表)等。

的输出通常包括以下几个方面的信息:

①、网络连接:显示活动的或监听的套接字连接,包括服务名、本地地址和端口、远程地址和端口、连接状态等。

②、路由表:显示网络路由表,包括目的地址、网关、子网掩码、使用的接口等。

今天我们介绍了 JDK 自带的性能监控工具,以及操作系统提供的一些命令行工具。

这些工具在排查问题时非常有用,希望大家一定要掌握,以备不时之需。

  • 星球嘉宾三分恶:JVM性能监控命令行篇

GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

前面我们已经讲了 JVM 性能监控工具之命令行篇,本篇我们来介绍一些可视化的性能监控工具,包括 JConsole、VisualVM、Java Mission Control 等,阿里的 Arthas 我们留到后面单独去讲。

可视化工具比命令行工具强大的地方就在于这些工具提供了更直观、更易于理解的性能数据视图,肉眼看上去,脑子就能快速 get 到问题所在,那这篇就来带大家看看这些工具的强大之处。

JConsole(Java Monitoring and Management Console),是一款基于 JMX(Java Manage-ment Extensions)的可视化监视管理工具。

JMX 的全称是 Java Management Extensions,翻译过来就是 Java 管理扩展,既是 Java 管理系统中的一个标准,一个规范,也是一个接口,一个框架。JConsole 就相当于是 JMX 的一个实现类。

JConsole 可以用来监视 Java 应用程序的运行状态,包括内存使用、线程状态、类加载、GC 等,还可以进行一些基本的性能分析。

JConsole 程序位于%JAVA_HOME%bin 目录下,不过我当前的操作系统是 macOS,和 Windows 有一些不一样,我就不再刻意截 Windows 的图了,希望大家可以理解。

直接启动 JConsole,会弹出一个窗口,显示本机正在运行的 Java 程序,选择一个程序(比如说技术派的 28966),点击即可。

还可以进行远程链接,比如说对服务器上的 Java 程序进行监控,需要远程服务器上的 Java 程序在启动的时候加上以下这些参数:

  • 是您想要 JMX 代理监听的端口号,例如 9999。
  • authenticate=false 表示不需要身份验证来连接到 JMX(注意:这在生产环境中可能不安全)。
  • ssl=false 表示不需要使用 SSL 加密连接(同样,这在生产环境中可能不安全)。

使用 JConsole 连接了一个本地程序,在可以看到 Java 程序的运行时概况,包括、、、四项信息的曲线图。

的作用相当于可视化的 jstat 命令(上一节讲过了),用于监视 Java 堆的使用情况,可以更细化到 eden 区、suvivior 区、老年代的使用情况。

为了更加清晰地查看内存变化,可以运行下面这段,然后连接:

这段代码的作用是以 64KB/50ms 的速度向 Java 堆中填充数据,一共填充 1000 次。

观察 Eden 区的运行趋势,发现呈折线趋势增长。

点击「执行 GC」之后,会发现老年代的柱状图会达到峰值状态,是因为执行 GC 之后,Eden 区的对象被回收,存活的对象被移动到老年代。

JConcole 还可以监控线程,相当于可视化的 jstack 命令(上一节讲过了)。

如下图,JConcole 显示了应用系统内的线程数量,左下方显示了程序中所有的线程。点击线程名称,就可以查看线程的栈信息。

使用 JConsole 还可以快速定位死锁问题。上一篇我们曾写过一个死锁的例子,这里我们再来看一下。

运行以上代码,点击 JConsole 线程面板下的 「」按钮,将会看到线程的死锁信息。

如下图,「类」面板显示了已经装载的类数量。在详细信息栏中,还显示了已经卸载的类的数量。

在 面板,JConsole 显示了当前应用程序的运行时环境,包括虚拟机类型、版本、堆信息以及虚拟机参数等。

VisualVM(All-in-One Java Troubleshooting Tool)一款功能强大的运行监视和故障处理工具之一,在很长一段时间内,VisualVM 都是 Oracle 官方主推的故障处理工具。

集成了多个 JDK 命令行工具的功能,提供了一个友好的图形界面,非常适用于开发和生产环境。

VisualVM 的安装非常简单,下载地址:https://visualvm.github.io

安装完成后打开的界面如下所示:

VisualVM 比 JConsole 强的不是一星半点,它不仅拥有更漂亮的身段,还支持插件功能。

点击-> ,在里可以看到大量的插件,按需安装即可。

VisualVM 中、、与 JConsole 差别不大,这里就不在赘述。

在 VisualVM 中生成堆转储快照文件有两种方式,可以执行下列任一操作:

①、在面板中右键选择(也就是 Heap Dump)。

②、在面板中选择应用程序,在“监视”面板中单击。

如果需要把堆转储快照保存文件后分享出去,可以在 heapdump 节点上右键选择“另存为”菜单,否则当 VisualVM 关闭时,生成的堆转储快照文件会被当作临时文件自动清理掉。

如果想对应用程序的 CPU 和内存情况进行分析,可以在「分析 profiler」面板中点击「CPU」或者「Memory」,然后 VisualVM 会记录这段时间中应用程序执行过的所有方法。

比如说 CPU 将会统计每个方法的执行次数、执行耗时。比如说内存将会统计每个方法的内存分配情况。

注意点击开始后,回到应用程序进行操作。等要分析的操作执行结束后,点击“停止”按钮结束监控过程。

JMC 最初是 JRockit VM 中的诊断工具,但在 Oracle JDK7 Update 40 以后,就绑定到了 HotSpot VM 中。不过后来又被 Oracle 开源出来作为一个单独的产品。

GitHub 地址:https://github.com/openjdk/jmc

Oracle 官方下载比较慢,可以通过 jdk.java.net 下载。

https://jdk.java.net/jmc/8/

解压后启动的界面如下所示:

点击本地进程的:

MBean 是管理 Java 应用程序的一种标准方式,它是 Java 管理扩展(JMX)的一部分。MBean 代表可管理的 Java 对象,它们的属性和操作可以通过 JMX 进行访问。

仪表盘显示了 Java 堆的使用率,CPU 使用率和 Live Set+Fragmentation(Live Set 是指存活对象的大小,Fragmentation 是指碎片的大小)。

飞行记录器(JFR)是 JMC 提供的另一功能,通过记录应用程序在一段时间内的运行情况,再进行分析和展示,可以更进一步对应用程序的性能进行分析和诊断。

要使用 JFR,程序启动需要带以下参数:

连接加了相关参数启动的程序,启动飞行记录,进行一分钟的性能记录:

记录结束后,JMC 会自动打开刚才的记录:

JFR 提供的数据质量通常也要比其他工具通过代理形式采样获得的更高。

以垃圾搜集为例,HotSpot 的 MBean 中一般有各个分代大小、收集次数、时间、占用率等数据,这些都属于“结果”类的信息,而 JFR 中还可以看到内存中这段时间分配了哪些对象、哪些对象被回收了,这些都属于“过程”类的信息。

我这里提供一些可供测试的代码,大家可以在本地跑一下,看看 JFR 的效果。

第一个:CPU 使用过高:

第二个:内存使用过高:

完整示例可以参考这个 GitHub 仓库:

https://github.com/itwanger/paicoding/blob/main/paicoding-web/src/test/java/com/github/paicoding/forum/test/javabetter/jvm/HotCode.java

比如说通过「内存面板」可以看出 BigDecimal 对象占用了最多的内存。

如果想进一步分析内存占用来源,可以切到线程页面,勾选三个复选框,可以在 memory 这里看到内存情况。

还可以看到这里的 在不断地计算浮点数,所以占用了较多的 CPU。

死锁的情况也可以在这里看得到。

以上三个都属于 Oracle 官方提供的性能监控工具,除此之外还有一些第三方的性能监控工具。

  • 「MAT」

Java 堆内存分析工具。

  • 「GChisto」

GC 日志分析工具。

  • 「GCViewer」

日志分析工具。

  • 「JProfiler」

商用的性能分析利器。

  • 「arthas」

阿里开源诊断工具。

  • 「async-profiler」

Java 应用性能分析工具,开源、火焰图、跨平台。

本篇我们介绍了一些可视化的性能监控工具,包括 JConsole、VisualVM、Java Mission Control 等,阿里的 Arthas 我们留到后面单独去讲。

参考链接:星球嘉宾三分恶 性能监控工具-可视化工具篇


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

Arthas 是阿里开源的一款线上 Java 诊断神器,通过全局的视角可以查看应用程序的内存、GC、线程等状态信息,并且能够在不修改代码的情况下,对业务问题进行诊断,包括查看方法的参数调用、执行时间、异常堆栈等信息,大大提升了生产环境中问题排查的效率。

Arthas 的官方网站是 https://arthas.aliyun.com/doc/,目前最新的版本是 3.7.2。

比我们前面介绍的命令行工具和可视化工具,都要强大得多,如果你再遇到下面这些问题,就可以迎刃而解了。

  • 客户线上问题,应该如何复现,让客户再点一下吗?
  • 异常被吃掉,手足无措,看是哪个家伙写的,竟然是自己!
  • 排查别人线上的 bug,不仅代码还没看懂,还没一行日志,捏了一把汗!
  • 预发 debug,稍微时间长点,群里就怨声载道!
  • 加日志重新部署,半个小时就没了,问题还没有找到,头顶的灯却早已照亮了整层楼......
  • 线上机器不能 debug,也不能开 debug 端口,重新部署会不会破坏现场呢?
  • 怀疑入参有问题,怀疑合并代码有问题,怀疑没有部署成功,全是问号......
  • 一个问题排查一天,被 Diss 排查问题慢......

星球里也有球友一直在呼唤 Arthas 的教程,那这篇内容我们就来详细地盘一盘。

我们先在本地试一下哈,由于我本机是 macOS,所以我这里就以 macOS 为例,Windows 用户可以参考官方文档,非常简单。

我本机已经启动了技术派项目,我们就以技术派为例,来看看 Arthas 的使用。

官方推荐的方式是通过 arthas-boot 来安装,那我们就按照这种来:

执行完上述命令后,Arthas 会列出可以进行监控的 Java 进程,比如说下图中的第 2 个 就是技术派的进程,直接输入 ,然后回车。Arthas 会连接到技术派的进程上,并输出带有 Arthas 的日志,进入 Arthas 的命令交互界面。

本地 OK 后,我们来试一下服务器上的项目,技术派是部署在腾讯云的香港服务器上,我们先登录到服务器上,然后执行下面的命令获取 arthas-boot.jar:

然后执行 ,Arthas 会列出可以进行监控的 Java 进程,我们输入 ,然后回车,Arthas 就会连接到技术派的进程上,并输出带有 Arthas 的日志,进入 Arthas 的命令交互界面。

OK,非常简单,相信大家都能搞定。

Arthas 也提供了 IDEA 插件,可以直接在 IDEA 中使用 Arthas,非常方便,我们来看看怎么安装。

官方文档:

https://www.yuque.com/arthas-idea-plugin/help

Arthas 提供了非常多的命令供我们使用,比如说和 JVM 相关的:

  • :查看 JVM 的实时数据,包括 CPU、内存、GC、线程等信息。
  • :查看 JVM 的信息,包括 JVM 参数、类加载器、类信息、线程信息等。
  • :查看和修改 JVM 的系统属性。
  • :查看和修改 JVM 的启动参数。
  • :生成堆内存快照,类似于 jmap 命令。

比如说和类加载相关的:

  • :查看类的信息,包括类的结构、方法、字段等。
  • :查看方法的信息,包括方法的参数、返回值、异常等。

比如说和方法调用相关的:

  • :统计方法的调用次数和耗时。
  • :跟踪方法的调用过程,包括方法的参数、返回值、异常等。
  • :监控方法的调用过程。

我来带大家体验一些比较常用的命令,其他的命令大家可以参考官方文档。

dashboard 命令可以查看 JVM 的实时数据,包括线程、内存、线程、运行时参数等。

thread 命令可以查看线程的信息,包括线程的状态、线程的堆栈等。

thread 命令的参数如下:

sysprop 命令可以查看和修改 JVM 的系统属性。

支持 TAB 键补全命令哈~

logger 命令可以查看和修改日志的级别,这个命令非常有用。

比如说生产环境上一般是不会打印 DEBUG 级别的日志的,但是有时候我们需要打印 DEBUG 级别的日志来排查问题,这个时候就可以使用 logger 命令来修改日志的级别。

第一步,先用 logger 命令查看默认使用的日志级别:

第二步,使用这个命令,将日志级别修改为 DEBUG,再次查看日志级别,发现已经修改成功了:

sc 命令可以查看类的信息,包括类的结构、方法、字段等。

示例 1:通过 查看包下的所有类:

示例 2:通过 查看类的详细信息:

示例 3:通过 查看类的字段信息:

jad 命令可以反编译类的字节码,如果觉得线上代码和预期的不一致,可以反编译看看。

示例 1:通过 反编译类的字节码:

monitor 命令可以监控方法的执行信息,包括执行耗时等信息。

示例 1:通过 监控方法的执行信息, 参数表示监控的次数:

watch 命令可以观察方法执行过程中的参数和返回值。

示例 1:通过 观察方法的执行过程中的参数和返回值:

Arthas 非常强大,还有很多插件可以配合使用,比如我们前面提到的 Arthas IDEA 插件,支持的命令还有以下这些:

文档写得也非常完整,我就不再赘述了,这篇内容也权当一个抛砖引玉。

等后面遇到线上问题了,再用 Arthas 来实战一把,给大家讲一讲。

推荐阅读:

  • Arthas 的强大
  • Arthas 的热部署
  • IDEA Arthas 插件

GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

,也就是臭名昭著的 OOM(内存溢出),相信很多球友都遇到过,相对于常见的业务异常,如数组越界、空指针等,OOM 问题更难难定位和解决。

这篇内容就以之前碰到的一次线上内存溢出的定位、解决问题的方式展开;希望能对碰到类似问题的球友带来思路和帮助。

主要从 四个步骤来分析和解决问题。

在 Java 中,和内存相关的问题主要有两种,内存溢出和内存泄漏。

  • 内存溢出(Out Of Memory):就是申请内存时,JVM 没有足够的内存空间。通俗说法就是去蹲坑发现坑位满了。
  • 内存泄露(Memory Leak):就是申请了内存,但是没有释放,导致内存空间浪费。通俗说法就是有人占着茅坑不拉屎。

在 JVM 的内存区域中,除了程序计数器,其他的内存区域都有可能发生内存溢出。

大家都知道,Java 堆中存储的都是对象,或者叫对象实例,那只要我们不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么就一定会产生内存溢出。

比如说运行下面这段代码:

运行程序的时候记得设置一下 VM 参数:,限制堆内存大小为 20M,并且不允许扩展,并且当发生 OOM 时 dump 出当前内存的快照。

运行结果如下:

我们在讲运行时数据区的时候也曾讲过。

内存泄露是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

简单来说,就是应该被垃圾回收的对象没有回收掉,导致占用的内存越来越多,最终导致内存溢出。

在上图中:对象 X 引用对象 Y,X 的生命周期比 Y 的生命周期长,Y 生命周期结束的时候,垃圾回收器不会回收对象 Y。

来看下面的例子:

创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。

这样就会导致内存泄露,最终导致内存溢出。

换句话说,内存泄露不是内存溢出,但会加快内存溢出的发生。

之前生产环境爆出的内存溢出问题会随着业务量的增长,出现的频次也越来越高。

应用程序的业务逻辑非常简单,就是从 Kafka 中将数据消费下来,然后批量的做持久化操作。

OOM 现象则是随着 Kafka 的消息越多,出现异常的频次就越快。由于当时还有其他工作所以只能让运维做重启,并且监控好堆内存以及 GC 情况。

不得不说,重启大法真的好,能解决大量的问题,但不是长久之计。

于是我们想根据运维之前收集到的内存数据、GC 日志尝试判断哪里出现了问题。

结果发现老年代的内存使用就算是发生 GC 也一直居高不下,而且随着时间推移也越来越高。

结合 jstat 的日志发现就算是发生了 FGC,老年代也回收不了,内存已经到顶。

甚至有几台应用 FGC 达到了上百次,时间也高的可怕。

这说明应用的内存使用肯定是有问题的,有许多赖皮对象始终回收不掉。

由于生产上的内存 dump 文件非常大,达到了几十 G。也和我们生产环境配置的内存太大有关。

所以导致想使用 MAT 分析需要花费大量时间。

MAT 是 Eclipse 的一个插件,也可以单独使用,可以用来分析 Java 的堆内存,找出内存泄露的原因。

因此我们就想是否可以在本地复现,这样就好定位的多。

为了尽快的复现问题,我将本地应用最大堆内存设置为 150M。然后在消费 Kafka 那里 Mock 了一个 while 循环一直不断的生成数据。

同时当应用启动之后利用 VisualVM 连上应用实时监控内存、GC 的使用情况。

结果跑了 10 几分钟内存使用并没有什么问题。根据图中可以看出,每一次 GC 内存都能有效的回收,所以并没有复现问题。

没法复现问题就很难定位。于是我们就采用了一种古老的方法——review 代码,发现生产的逻辑和我们用 while 循环 Mock 的数据还不太一样。

果然 review 代码是保障程序性能的第一道防线,诚不欺我。大家在写完代码的时候,尽量也要团队 review 一次。

后来查看生产日志发现每次从 Kafka 中取出的都是几百条数据,而我们 Mock 时每次只能产生一条

为了尽可能的模拟生产情况便在服务器上跑了一个生产者程序,一直源源不断的向 Kafka 中发送数据。

果然不出意外只跑了一分多钟内存就顶不住了,观察下图发现 GC 的频次非常高,但是内存的回收却是相形见拙。

同时后台也开始打印内存溢出了,这样便复现出了问题。

从目前的表现来看,就是内存中有许多对象一直存在强引用关系导致得不到回收。

于是便想看看到底是什么对象占用了这么多的内存,利用 VisualVM 的 HeapDump 功能,就可以立即 dump 出当前应用的内存情况。

结果发现 类型的对象占用了将近 50% 的内存。

看到这个包自然就想到了 环形队列了。

Disruptor 是一个高性能的异步处理框架,它的核心思想是:通过无锁的方式来实现高性能的并发处理,其性能是高于 JDK 的 BlockingQueue 的。

再次 review 代码发现:从 Kafka 里取出的 700 条数据是直接往 Disruptor 里丢的。

这里也就能说明为什么第一次模拟数据没复现问题了。

模拟的时候是一个对象放进队列里,而生产的情况是 700 条数据放进队列里。这个数据量就是 700 倍的差距啊。

而 Disruptor 作为一个环形队列,在对象没有被覆盖之前是一直存在的。

我也做了一个实验,证明确实如此。

我设置队列大小为 8 ,从 0~9 往里面写 10 条数据,当写到 8 的时候就会把之前 0 的位置覆盖掉,后面的以此类推(类似于 HashMap 的取模定位)。

所以在生产环境上,假设我们的队列大小是 1024,那么随着系统的运行最终会导致 1024 个位置上装满了对象,而且每个位置都是 700 个!

于是查看了生产环境上 Disruptor 的 RingBuffer 配置,结果是:。

这个数量级就非常吓人了。

为了验证是否是这个问题,我在本地将该值设为 2 ,一个最小值试试。

同样的 128M 内存,也是通过 Kafka 一直源源不断的取出数据。通过监控如下:

跑了 20 几分钟系统一切正常,每当一次 GC 都能回收大部分内存,最终呈现锯齿状。

这样问题就找到了,不过生产上这个值具体设置多少还得根据业务情况测试才能知道,但原有的 1024*1024 是绝对不能再使用了。

虽然到了最后也就改了一行代码(还没改,直接修改配置),但这个排查过程我觉得是很有意义的。

也会让大部分觉得 JVM 这样的黑盒难以下手的球友有一个直观感受。

相关演示代码查看:

https://github.com/crossoverJie/JCSprout/tree/master/src/main/java/com/crossoverjie/disruptor

  • 参考链接 1:内存泄露的排查
  • 参考链接 2:内存溢出和内存泄露

GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,500+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 10000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。

  • 上一篇: c中malloc函数的用法
  • 下一篇: uint8_t char
  • 版权声明


    相关文章:

  • c中malloc函数的用法2024-11-10 11:01:03
  • ir2104驱动电路原理2024-11-10 11:01:03
  • wd硬盘是固态的吗2024-11-10 11:01:03
  • stty命令详解2024-11-10 11:01:03
  • 三态门电路特点2024-11-10 11:01:03
  • uint8_t char2024-11-10 11:01:03
  • pytorch dataloader读取数据2024-11-10 11:01:03
  • struct timeval结构体2024-11-10 11:01:03
  • 统一登录器网关未开放2024-11-10 11:01:03
  • 二阶低通滤波器电路2024-11-10 11:01:03