文章目录
- 为什么要学 JVM
- 一、JVM 整体布局
- 二、Class 文件规范
- 三、类加载模块
- 四、执行引擎
- 五、GC 垃圾回收
- 1 、JVM内存布局
- 2 、 JVM 有哪些主要的垃圾回收器?
- 3 、分代垃圾回收工作机制
- 六、对 JVM 进行调优的基础思路
- 七、 GC 情况分析实例
- 八、最后总结
全程可上手JVM调优实战指南
-- 楼兰 JVM 虚拟机,这是一个Java 程序员一直以来熟悉但是又陌生的神秘东东。他是夹在 Java 代码与操作系统之间的一层神秘空间。这一次,楼兰就来带大家以可视化的方式来彻底剖析一下这个神秘的 JVM 虚拟机。
为什么要学 JVM
这可能是很多接触 Java 多年的程序员们需要转变的第一个思维。而只有你先把思维转变过来,你才会有兴趣去接触 JVM 中那些晦涩难懂的内容。所以,我觉得是有必要花一些功夫来带你建立起这样的信念的。
关于 JVM 是什么,这个就不用楼兰来多说了。他是整个 Java 技术体系的奠基石。
对于操作系统,Java 提供了不同的 JRE。这样我们写的 Java 代码,可以通过这些不同的 JRE,在不同的操作系统上运行,而不用关心不同操作系统之间的区别。这就是所谓的一次编写,多次执行。而在语言层面,时至今日,JVM 也远不是 Java 语言的专属。他所执行的是 Java 代码编译后生成的class文件。而其他语言,只要满足 Java 提出的class文件规范,就可以在 JVM 上执行。
Java 发展到现在,已经远远超出了 Java 语言的范围,成了一个庞大的技术体系。正是基于强大的 JVM 虚拟机,我们甚至可以在上层使用多种语言混合进行开发。例如在大数据领域常见的 Spark,Flink 等组件,都提供了 Java、 Scala 等不同语言的客户端,你完全可以在一个项目中,混合使用 Java 和 Scala 进行开发。而如果你愿意,甚至可以再加入 Jython,从而让你的Java 应用可以引入Python的强大类库。
所以,关于哪种语言是最强大的语言,或许还存在”PHP天下第一“的争论。但是,关于最强大的语言虚拟机,则没人可以和 JVM叫板。
但是很可惜,在很多程序员的眼里,往往会将 SpringBoot,SpringCloud 这些应用层面的框架看成 Java 的主体。而对于这些底层的基石却看得不是很重。这不能不说是一种可惜。在学习 Java 的初期,注重应用,就好像学武,先学套路,招数一样,这没有什么问题。但是如果你学习 Java 多年,就好像学武学到了一定的境界,再要想突破瓶颈,那就一定要开始尝试回头来修修内功了。人生至少要有这么一次,跟着楼兰来 JVM 中逛一逛。
首先,JVM 能够解释很多上层语言中的困惑
如果你做过一些 Java 面试题,那么你一定接触过很多让人莫名其妙的折磨人的变态面试题。比如下面这个简单的例子:
public class ByteCodeInterView {
//包装类对象的缓存问题
@Test
public void typeTest(){
Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);//true
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);//false
Boolean b1 = true;
Boolean b2 = true;
System.out.println(b1 == b2);//true
Double d1=1.00;
Double d2=1.00;
System.out.println(d1==d2);//false
}
@Test
public int mathTest(){
int k = 1 ;
k = k++;
return k;
}
}
运行的结果已经写在后面的注释里了。如果之前没有接触过这样的题目,那么 99%的程序员都会是一脸懵逼的。这些莫名其妙的true和false,到底是怎么蹦出来的?相信这种非人类的代码你也应该或多或少接触过。那么要怎么才能彻底把握这些代码呢?死记硬背,见招拆招,那肯定是不够的。这就需要至少深入到 JVM 层面去了解这些莫名其妙的代码到底是怎么运行的。后面章节,我会带你来解密。
当然,JVM 只是操作系统的一层代理。 JVM 的本质只是将程序员写出来的class文件转换为操作系统对应的一些指令。所以,有些与操作系统联系紧密的机制,甚至还需要到操作系统底层去了解。楼兰之前带大家去分析过 NIO 的系统调用,有兴趣的可以去关注一下。
其次,学习 JVM 是进行调优的基础
谈到 JVM 调优,这不光是面试的重灾区,更是所有程序员都应该送给自己的一份礼物。毕竟,这首先就关系到你写的各种各样的 Java 代码,到底是怎么在服务器上运行的。这是对我们自己的一份交代。另外,当然还涉及到一些线上项目出问题了,你怎么快速去找到并解决这些问题。
这方面需要很多的实战经验,但是,毕竟不是每个程序员都有机会去天天接触那些线上的服务器的。那我们到底应该怎么去培养自己的实战经验呢?楼兰给大家的最好建议就是去向开源的服务学习。比如,RocketMQ 中执行 NameServer 的核心JVM 参数是这样的:
这里是针对不同的 JDK 版本定制了不同的参数。主要是用 JDK8 作为一个分水岭。 JDK8之前是上面那一段。 JDK9 以后是下面这一段。这些眼花缭乱的参数是怎么定制出来的?你不可能真的依靠把这些莫名其妙的配置参数背得滚瓜烂熟再来调优吧。这就需要你对 JVM 至少要建立一个足够精细的理论模型。
接下来,RocketMQ 如果真的线上出现了OOM 内存溢出这样的问题,怎么快速去分析并定位呢?这当然也需要从这些优化配置入手。比如在这个 JDK8的执行指令当中,RocketMQ 就打印了 GC 垃圾回收的日志。这些日志显然是所有 Java 应用都可以打印出来的。接下来,要怎么对这些日志进行分析呢?这也需要配合 JVM 底层模型去进行定位。
当然,这里面的东西是很复杂的,很多底层的东西全是理论,看不到也摸不着。并且很难实操。无法验证的技术就会很容易让人觉得枯燥迷茫。那么接下来,就跟楼兰一起用可视化的方式来分析一波 JVM 吧。
一、JVM 整体布局
咱们不说废话,先总后分。先整体来预览一下对于 JVM,楼兰会带你分析哪些东西。然后,再来慢慢拆解。
我们写的 Java 文件,都要先通过编译器编译成.class文件,然后才能进入 JVM 执行。而在 Java 发展的过程当中,其实也有有很多的具体 JVM 实现产品。比如,我们现在用 Java 指令就能看到,现在用的是HotSpot 的 JVM。
# java -version
java version "1.8.0_391"
Java(TM) SE Runtime Environment (build 1.8.0_391-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.391-b13, mixed mode)
这也是 Oracle 官方现在支持的 JVM 。而在此之外,其实还有很多其他的 JVM 虚拟机。比如Java 最早的Classic 虚拟机,还有 JRockit虚拟机,以及可以直接将 Java 程序编译成本地指令的 GraalVM 虚拟机。我们接下来,还是以HotSpot 虚拟机来进行分析。
二、Class 文件规范
实际上,我们需要了解的是,Java 官方实际上只定义了JVM的一种执行规范,也就是class文件的组织规范。理论上,只要你能够写出一个符合标准的class文件,就可以丢到 JVM 中执行。至于这个class文件是怎么来的,JVM 虚拟机是不管的。甚至更极端的,class文件是不是从文件读取,JVM 都不关心。这也是 JVM 支持多语言的基础。
这个规范到底是什么样子呢?如果你足够硬核,当然可以直接去看 Oracle 的官方文档。JDK8 的文档地址:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html 。不过,研究这么复杂的文档,显然不是我们的取死之道。接下来,我们就从class文件入手,来抽取一些对我们比较有用的规范信息。
首先,我们要知道,class文件本质是一个二进制文件,虽然不能直接用文本的方式阅读,但是我们是可以用一些文本工具打开看看的。比如,我们可以用 UltraEdit 工具打开一个class文件,看到的内容部分是这样的:
中间这一部分就是他的二进制内容。当然这是十六进制的表达。空格隔开的部分代表了 8 个bit,而每一位代表的是 4 个 bit字节,也就是一个十六进制的数字。例如 第一个字母 C 就表示十六进制的 12,二进制是 1100。而所有的class文件,都必须以十六进制的 CAFEBABE 开头,这就是 JVM 规范的一部分。以后再有人问你Java 这个词到底是一种咖啡,还是爪哇岛,你知道怎么装13 了吗?
后面的部分就比较复杂了,没法直接看。这时我们就需要用一些工具来看了。这样的工具很多。 JDK 自己就提供了一个 javap 指令可以直接来看一些class文件。例如可以用 javap -v ByteCodeInterView.class 查看到这个class文件的详细信息。
当然,这样还是不够直观。我们可以在 IDEA 里添加一个 ByteCodeView 插件来更直观的查看一个 ClassFile 的内容。看到的大概内容是这样的:
插件安装以及使用,这里就不多说了,相信对各位都是小菜一碟。
可以看到,一个class文件的大致组成部分。你可以理解Class文件就是这些内容紧凑的排布在一起构成的。当然,是以二进制的方式。比如前面的次版本号,就是 0,而主版本号,52 用 16进制表示就是 34。这就是 CAFEBABE 后面的那部分数字。
然后再结合官方的文档,或许能够让你开始对class文件有一个大致的感觉。
、
例如,前面u4表示四个字节是magic魔数,而这个魔数就是不讲道理的 CAFEBABE 。这种规定并不是 Java 的特例,很多其他的文件系统也是采用这种方式将文件的格式固定在文件开头,比如 PNG,JPG 这些图片都是这样做的。
而后面的两个u2,表示两个字节的版本号。例如我们用 JDK8 看我们之前的class文件,minor_version就是 00 00,major_version就是 00 34。换成二进制就是 52,这就是 JVM 给 JDK8 分配的版本号。这两个版本号就表示当前这个class文件只能用 JDK8 以前的 JDK 版本执行。你想用 JDK9 就无法执行。而这也是 Java 能够保证版本向下兼容的基础。
接下来,如果你感兴趣,可以结合官方文档,更详细的去解析这个class文件。其中常量池是最复杂的部分,包含了表示这个class文件所需要的几乎所有常量。比如接口名字,方法名字等等。而后面的几个部分,比如方法,接口等都是引用常量池中的各种变量。比如本来索引cp_info#16就表示引用了常量池中的第 16 项。
而这其中,我们重点关注的是方法,也就是class文件是如何记录我们写的这些关键代码的。例如我们之前写的typeTest这个方法,在class文件中就是这样记录的:
这些东西你看不懂?没关系,至少现在,你可以知道你写的代码在 Class 文件当中是怎么记录的了。另外,如果你还想更仔细一点的分辨你的每一样代码都对应哪些指令,那么在这个工具中还提供了一个LineNumberTable,会告诉你这些指令与代码的对应关系。
起始 PC 就是这些指令的字节码指令的行数,行号则对应 Java 代码中的行数。
实际上,Java 程序在遇到异常时给出的堆栈信息,就是通过这些数据来反馈报错行数的。
字节码中像 bipush 这些就对应一个 JVM 的字节码指令。 JVM 中的字节码指令是用一个字节来表示操作,再用后面若干个字节表示操作的参数。参数个数根据不同的指令确定。
也就是说JVM 中的指令最多也不超过 255 个。这些指令相比于庞大的操作系统来说,已经是非常小的了。另外其中还有很多差不多的。 比如aload_1,aload_2 这些,明显就是同一类的指令。这些指令在官方文档中都有详细的记录。
要了解这些指令的作用,就不得不先了解一下 JVM 中两个重要的数据结构:局部变量表和操作数栈。
在 JVM 虚拟机中,会为每个线程构建一个线程私有的内存区域。其中包含的最重要的数据就是程序计数器和虚拟机栈。其中程序计数器主要是记录各个指令的执行进度,用于在 CPU 进行切换时可以还原计算结果。虚拟机栈中则包含了这个线程运行所需要的重要数据。
虚拟机栈是一个先进后出的栈结构,其中会为线程中每一个方法构建一个栈帧。而栈帧先进后出的特性也就对应了我们程序中每个方法的执行顺序。每个栈帧中包含四个部分,局部变量表,操作数栈,动态链接库、返回地址。
- 操作数栈是一个先进后出的栈结构,主要负责存储计算过程中的中间变量。主要操作就是入栈和出栈。
- 局部变量表可以认为是一个数组结构,主要负责存储计算过程中的结果变量。主要存放方法参数和方法内部定义的局部变量。以slot为最小单位。
- 动态链接库主要存储一些指向运行时常量池的方法引用。每个栈帧中都会包含一个指向运行时常量池中该栈帧所属方法的应用,持有这个引用是为了支持方法动态调用过程中的动态链接。
- 返回地址存放调用当前方法的指令地址。一个方法有两种退出方式,一种是正常退出,一种是抛异常退出。如果方法正常退出,这个返回地址就记录下一条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定。
其中最为重要的就是操作数栈和局部变量表了。例如,对于初学者最头疼的++操作,下面的 mathTest 方法
public int mathTest(){
int k = 1 ;
k = k++;
return k;
}
我们都知道k的返回结果是 1,但是++自增操作到底有没有执行呢?就可以按照指令这样进行解释:
0 iconst_1 //往操作数栈中压入一个常量1
1 istore_1 // 将 int 类型值从操作数栈中移出到局部变量表1 位置
2 iload_1 // 从局部变量表1 位置装载int 类型的值到操作数栈中
3 iinc 1 by 1 // 将局部变量表 1 位置的数字增加 1
6 istore_1 // 将int类型值从操作数栈中移出到局部变量表1 位置
7 iload_1 // 从局部变量表1 位置装载int 类型的值到操作数栈中
8 ireturn // 返回 int 类型的值
这个过程中,k++是在局部变量表中对数字进行了自增,此时栈中还是 1。接下来执行=操作,就对应一个istore指令,从栈中将数字装载到局部变量表中。局部变量表中的k的值(对应索引 1 位置),就还是还原成了 1。
那么接下来,你是不是可以自行理解一下 k=++k,是怎么执行的呢?
另外,这里补充一个在互联网大厂的高级职位面试过程中被问到过的细节问题:
如何确定一个方法需要多大的操作数栈和局部变量?
有些面试时,是会给你一个具体的方法,让你自己一下计算过程中需要几个操作数栈和几个局部变量。但是在工作中,其实class文件当中就记录了所需要的操作数栈深度和局部变量表的槽位数。例如对于 mathTest方法,所需的资源在工具中的纪录是这样的:
这其实是一个很重要的问题。这意味着执行这个方法需要多少资源,其实是在class文件当中就确定了的。而 JVM 在执行时,就可以预先去申请资源。如果资源不够,在方法执行之前就可以知道。 这里会有一个小问题,如果你自己推演过刚才的计算过程,可以看到,局部变量表中,明明只用到了索引为 1 的一个位置而已,为什么局部变量表的最大槽数是 2 呢?
这是因为对于非静态方法,JVM 默认都会在局部变量表的 0 号索引位置放入this变量,指向对象自身。所以我们可以在代码中用this访问自己的属性。
有了这个基础之后,再来看看之前的typeTest方法。其中,我们只来解析最容易让人困惑的这几行代码。
Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);//true
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);//false
首先,我们可以从LineNumberTable 中获取到这几行代码对应的字节码指令:
以前面三行为例,三行代码对应的 PC 指令就是从 0 到 12 号这几条指令。把指令摘抄下来是这样的:
0 bipush 10
2 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
5 astore_1
6 bipush 10
8 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
11 astore_2
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
可以看到,在执行astore指令往局部变量表中设置值之前,都调用了一次Integer.valueOf方法。
而在这个方法中,对于[-128,127]范围内常用的数字,实际上是构建了缓存的。每次都从缓存中获取一个相同的值,他们的内存地址当然就是相等的了。这些童年梦魇,是不是在这个过程中找到了终极答案?
实际上,你甚至可以使用反射来修改这个内部的 IntegerCache 缓存,从而让 Integer 的值发生紊乱。你有试过这样的骚操作吗?
聊到了这些 Java 字节指令,那最后再问一个让人没什么头脑的高端面试题作为部分总结吧。
面试题:Java 当中的静态方法可以重载吗?
普通答案:不能吧,因为没见过这么用的。吧啦吧啦吧啦。。。。。我还是做个例子测测吧。
高手答案:不能。因为在 JVM 中,调用方法提供了几个不同的字节码指令。invokcvirtual 调用对象的虚方法(也就是可重载的这些方法)。invokespecial 根据编译时类型来调⽤实例⽅法,比如静态代码块(通常对应字节码层面的cinit 方法),构造方法(通常对应字节码层面的init方法)。invokestatic 调⽤类(静态)⽅法。invokcinterface 调⽤接⼝⽅法。还有一个invokedynamic指令用于执行一些动态的方法,比如 Lambda 表达式。
静态方法和重载的方法他们的调用指令都是不一样的,那么肯定是无法重载静态方法的。
三、类加载模块
有了 Class 文件之后,接下来就需要通过类加载模块将这些 Class 文件加载到 JVM 内存当中,这样才能执行。而关于类加载模块,以 JDK8 为例,最为重要的内容我总结为三点:
- 每个类加载器对加载过的类保持一个缓存。
- 双亲委派机制,即向上委托查找,向下委托加载。
- 沙箱保护机制。
其核心是 ClassLoader 类中的两个方法:
//类加载器的核心方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 每个类加载器对他加载过的类都有一个缓存,先去缓存中查看有没有加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
//没有加载过,就走双亲委派,找父类加载器进行加载。
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
// 父类加载器没有加载过,就自行解析class文件加载。
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
//这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。
// 运行时加载类,默认是无法进行链接步骤的。
if (resolve) {
resolveClass(c);
}
return c;
}
}
另一个核心方法就是所谓的沙箱机制,保护 JDK 内部的类不会被覆盖。
private ProtectionDomain preDefineClass(String name,
ProtectionDomain pd)
{
if (!checkName(name))
throw new NoClassDefFoundError("IllegalName: " + name);
// 不允许加载核心类
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
if (pd == null) {
pd = defaultDomain;
}
if (name != null) checkCerts(name, pd.getCodeSource());
return pd;
}
然后还需要了解一下类在 JVM 内存中的存在方式,这也是一些面试题喜欢问的问题。
类 Class 在 JVM 中的作用其实就是一个创建对象的模板。也就是说他的作用更多的体现在创建对象的过程当中。而在程序具体执行的过程中,主要是围绕对象在进行,这时候类的作用就不大了。所以,在 JVM 中,类并不直接保存在宝贵的堆内存当中,而是挪到了堆内存以外的一部分内存中。这部分内存,在 JDK8 以前被成为永久带PermSpace,而在 JDK8 之后被改为了元空间 MetaSpace。
这个元空间逻辑上可以认为是堆空间的一部分,但是他跟堆空间有不同的配置参数,不同的管理方式。因此也可以看成是单独的一块内存。这一块内存就相当于家里的工具间或者地下室,都是放一些用得比较少的东西。最主要就是类的一些相关信息,比如类的元数据、版本信息、注解信息、依赖关系等等。
元空间可以通过-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize参数设置大小。但是大部分情况下,你是不需要管理元空间大小的,JVM 会动态进行分配。
另外,这个元空间也是会进行 GC 垃圾回收的。如果一个类不再使用了,JVM 就会将这个类信息从元空间中删除。但是,显然,对类的回收效率是很低的。只有一些自定义类加载器自行加载的一些类有被回收的可能,大部分情况下,类是不会被回收的。所以对元空间的垃圾回收基本上是很少有效果的。大部分情况下,我们是不需要管元空间的。除非你的JVM 内存确实非常紧张,这时可以设定 -XX:MaxMetaspaceSize参数,严格控制元空间大小。
然后在堆中,每一个对象的头部,还会保存这个对象的类指针(classpoint),指向元空间中的类。这样我们就可以通过一个对象的getClass方法获取到对象所属的类了。这个类指针,我们也是可以通过一个小工具观察到的。
例如,下面这个 Maven依赖就可以帮我们分析一个对象在堆中保存的信息。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
然后可以用以下方法简单查看一下对象的内存信息。
public class JOLDemo {
private String id;
private String name;
public static void main(String[] args) {
JOLDemo o = new JOLDemo();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
看到的结果大概是这样:
这里ClassPoint 实际上就是一个指向元空间对应类的一个指针。当然,具体结果是被压缩过的。
另外Markdown标志位就是对象的一些状态信息。包括对象的 HashCode,锁状态,GC分代年龄等等。
这里面锁机制是面试最喜欢问的地方。无锁、偏向锁(新版本 JDK 中已经废除)、轻量级锁、重量级锁这些东西,都是在Markdown中记录的。
至于类加载器的其他骚套路,比如远程加载、热加载、同类多版本加载等,之前已经分析过,有兴趣的可以翻下我的相关文章。
在 JDK9 之后,Java 引入了模块化机制,类加载的体系也发生了一些变化。有兴趣后续再给大家分析。
四、执行引擎
之前已经看到过,在 Class 文件当中,已经明确的定义清楚了程序的完整执行逻辑。而执行引擎就是将这些字节指令转为机器指令去执行了。这一块更多的是跟操作系统打交道,对开发工作其实帮助就不是很大了。所以,如果不是专门研究语言,执行引擎这一块就没有必要研究太深了。
解释执行与编译执行
JVM 中有两种执行的方式:
- 解释执行就相当于是同声传译。JVM 接收一条指令,就将这条指令翻译成机器指令执行。
- 编译执行就相当于是提前翻译。好比领导发言前就将讲话稿提前翻译成对应的文本,上台讲话时就可以照着念了。编译执行也就是传说中的 JIT 。
大部分情况下,使用编译执行的方式显然比解释执行更快,减少了翻译机器指令的性能消耗。而我们常用的 HotSpot 虚拟机,最为核心的实现机制就是这个 HotSpot 热点。他会搜集用户代码中执行最频繁的热点代码,形成CodeCache,放到元空间中,后续再执行就不用编译,直接执行就可以了。
但是编译执行起始也有一个问题,那就是程序预热会比较慢。毕竟作为虚拟机,你不可能提前预知到程序员要写一些什么稀奇古怪的代码,也就不可能把所有代码都提前编译成模板。而将执行频率并不高的代码也编译保存下来,也是得不偿失的。所以,现在JDK 默认采用的就是一种混合执行的方式。他会自己检测采用那种方式执行更快。虽然你可以干预 JDK 的执行方式,但是在绝大部分情况下,都是不需要进行干预的。
另外,现在也有一种提前编译模式,AOT 。可以直接将Java 程序编译成机器码。比如GraalVM,可以直接将 Java 程序编译成可执行文件,这样就不需要 JVM 虚拟机也能直接在操作系统上执行。
关于 AOT 是不是会一统天下,也是现在面试中比较喜欢问的问题。虽然在 SpringBoot3 等框架中已经有了落地,但是从目前来看,AOT还远没有成为主流,离一统天下还有点距离。
少了 JVM 这个中间商之后,虽然大部分情况下是可以提升程序执行性能的,但是,也并不是就完美无缺了。毕竟很显然,这种方式其实是以丧失一定的跨平台特性作为代价的。
另外,目前 AOT 这种方式还是不太安全的。毕竟 JVM 打了这么多年的怪,什么牛鬼蛇神都见多了。现在 AOT 要绕开 JVM,那么这些怪就都要自己去打了。中间有个什么疏忽,那是难免的。
而且,其实JVM 这个中间商为了提升程序执行性能,也是挺劳心费力的。在实际执行这些字节指令时,也并不是埋头搬砖,而是针对不同的应用场景,提供了很多优化的机制。
JDK 中提供了两种 JIT 编译器,一种是-client,客户端模式,也称为 C1 编译器。另一种是 -server,服务端模式,也成为 C2 编译器。
C1 会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小,执行效率没有server快。默认情况下不进行动态编译,适用于桌面应用程序。
C1 的优化策略主要包括:
- 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间把一些不会执行的代码折叠掉。
C2 进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高,适用于服务器端应用。 默认情况下就是使用的 C2 编译器。并且,绝大部分情况下也不建议特意去使用 C1。
C2 的优化策略主要包括:
- 标量替换:用标量值代替聚合对象的属性值
- 栈上分配:对于未逃逸的对象直接分配到栈上,而不是堆上。
- 同步消除:清除同步操作,通常指synchronized
JVM 也提供了参数关闭这些优化行为。但是显然,除了一些偏执狂,没人会愿意干这个事情。
其中这个栈上分配是面试时比较喜欢问的问题。一个方法内部使用的一些小对象,在允许的情况下,JVM 会允许将对象直接在栈上分配,而不用分配在堆上。这样,栈上的对象用完就抛出了,不用进行 GC ,执行的速度更快。
不过在分配对象时,并不会像堆上那样分配一个完整的对象结构。只是简单保存一些对象中的标量,也就是一些属性。这就是标量替换。
然后,这两种模式也可以通过参数-XX:+RewriteFrequentPairs参数控制,client模式默认关闭,server模式默认开启。
另外,在JDK9之后,HotSpot中也集成了一种新的编译器 Graal编译器。未来,他可能会作为 C2 的替代品,响应原本由 C2负责的编译请求。
五、GC 垃圾回收
GC 垃圾自动回收,这个可以说是 JVM 最为标志性的功能。不管是做性能调优,还是工作面试,GC 都是 JVM 部分的重中之重。而对于 JVM 本身,GC 也是不断进行设计以及优化的核心。几乎 Java 提出的每个版本都对 GC 有或大或小的改动。这里,我就用目前还是用得做多的 JDK8,带大家快速梳理一下 GC 部分的主线。
1 、JVM内存布局
在了解 JVM之前,给大家推荐一个工具,阿里开源的 Arthas 。官网地址:https://arthas.aliyun.com/ 。 这个工具功能非常强大,是对 Java进程进行性能调优的一个非常重要的工具,对于了解 JVM 底层帮助也非常大。
具体使用方式参照官方文档。
我们先运行一个简单的 Java 程序:
public class GCTest {
public static void main(String[] args) throws InterruptedException {
List l = new ArrayList<>();
for(int i = 0 ; i < 100_0000 ; i ++){
l.add(new String("dddddddddddd"));
Thread.sleep(100);
}
}
}
运行后,使用Arthas 的dashboard指令,可以查看到这个 Java 程序的运行情况。
重点关注中间的 Memory 部分,这一部分就是记录的 JVM 的内存使用情况。而后面的 GC 部分就是垃圾回收的执行情况。我们就从这些能看到的部分作为入口,来理解一下一个 Java 进程是怎么管理他的内存的。
从 Memory 部分可以看到,一个 Java 进程会将他管理的内存分为heap堆区和nonheap非堆区两个部分。其中非堆区的几个核心部分像code_cache(热点指令缓存),metaspace(元空间),compressed_class_space(压缩类空间)我们之前都接触到了。这一部分就相当于 Java 进程中的地下室,属于不太活跃的部分。而中间heap堆区就相当于客厅了,属于Java 中最为核心的部分。而这其中,又大体分为了eden_space,survivor_space和old_gen三个大的部分,这就是 JVM 内存的主体。我们之前分析的栈区,这里没有列出。
我们画个图把这几部分内存整理一下:
其中堆区是 JVM 核心的存放对象的内存区域。他的大小可以由参数 -Xms(初始堆内存大小),-Xmx(最大堆内存)参数指令。从这两个参数可以看到,堆内存是可以扩展的。如果初始内存不够,JVM 会扩大堆内存。但是如果内存扩展到了最大堆内存时还不够。这时就无法继续扩展了,而是会抛出 OOM 异常。这两个参数在生产环境中最好设置成一样,减少内存扩展时的性能消耗。
对比之前提到的 RocketMQ 的运行脚本理解。
这一块内存就是由 Java 自己进行管理的核心内存。那这一块内存到底是如何使用的呢?这就和具体的垃圾回收器有关了。
2 、 JVM 有哪些主要的垃圾回收器?
java 从诞生到现在最新的 JDK21 版本,总共就产生了以下十个垃圾回收器
其中,左边的都是分代算法。也就是将内存划分为年轻代和老年代进行管理。之前在dashboard中已经看到了老年代,而 eden_space和survivor_space合起来就是年轻代。图中有虚线连接的部分就是可以配合使用的垃圾回收器组合。比如其中 Parallel Scavenge+Parallel Old就是 JDK8 中默认的垃圾回收器。也就是dashboard中看到的ps。
右侧的是不分代算法。也就是不再将内存严格划分位年轻代和老年代。其中 G1 有点特殊。严格来说,G1 也包含老年代和年轻代,但是他的内存划分是动态的,不再那么严格。JDK9 开始默认使用 G1。而 ZGC是目前最主流的垃圾回收器。shennandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。Epsilon是一个测试用的垃圾回收器,根本不干活。
在这些垃圾回收器中,目前主流的 JDK8 还是以分代算法为主。而从 JDK9 开始,G1 为代表的不分代算法正在慢慢成为主流。
这些垃圾回收器应该要如何进行选择呢?主要的选择思路有两个:
一是运行应用的基础设施是怎样的。操作系统,CPU 核心数,内存大小等等。二是应用程序的关注点是什么。比如数据分析、科学计算类的任务,更关注吞吐量。服务端应用,更关注延迟。一些客户端应用或者嵌入式应用,则应该更关注内存占用。
垃圾回收算法的不断演进最早就是随着内存扩大而演进的。GC 要管理的内存越来越大了,所有就需要有不同的算法来进行管理。这就像给学生排课表一样。幼儿园的课表,简单写几个字条就能进行比较好的排列。但是随着学习越来越深入,课程越来越多,要让学生的课程不冲突,排课的逻辑就会越来越复杂,排课的方式也就必须要随着进行升级。像现在,在主流高校中,课程排课就已经不可能用人工的方式进行安排了。
Serial串行标记算法是最早的内存回收算法。所谓串行,就是用户程序执行一会,就停下来,让 GC 线程运行一会。GC 线程运行完了,再让用户程序继续运行。这个停下来的时间,就是 STW 。 Serial通常只适合单 CPU 的运行环境,多 CPU 环境下,效率下降会非常明显。并且,他只适合管理几十兆的内存空间,如果空间过大,STW 时间也会明显增加。
Parallel并行标记算法在 Serial 算法的基础上,增加了多线程 GC 。之前是一个人扫地,太慢了,Parallel就多找几个线程一起扫地。在多 CPU 环境下会比 Serial 更好。他所管理的内存大小,在多 CPU 的加持下,已经可以达到 GB 级别。在 JDK8 中是默认的垃圾回收器。
CMS 则是比较尴尬的一个存在。他的核心思想是尽量让 GC 线程和用户线程一起执行,从而减少 Parallel 的 STW 时长。他已经可以管理GB 级别的内存了,但是他的 STW 时间控制并不太稳定。从上图连线过程可以看到,CMS 是需要 SerialOld垃圾回收器支持的。 CMS 容易产生大量内存碎片,也就是明明有很多空间,但都是零零碎碎的,找不出一块完整的地方来放下一个新的对象。当碎片累计比较多时,就会用 SerialOld 进行一次垃圾回收,而这次回收的效率是比较低的。并且 CMS 的算法也太过复杂。跟 CMS 相关的 JVM 调优参数也是最多的。所以 CMS 在任何一个 JDK 中都不是默认的垃圾回收器。他也只能适合一些业务不太频繁,对执行效率不是太敏感的业务场景。比如 RocketMQ 的 NameServer 。
接下来G1 垃圾回收器就是最为重要的一个垃圾回收器了,是第一个被 JVM 设定为全能 GC 的候选者。他开始推动垃圾回收器从分代管理向不分代管理的过度。虽然 G1 中依然有内存分代模型,但是他的各个内存代就不再是固定的,而是可以灵活变动的。 从 G1 开始,JVM 对 GC 的设计要求也不再只是关注于吞吐量,而是关注于让 STW 停顿时间可控,这也是一个对 GC 发展影响深远的思想转变。在 JDK9 中就被指定为默认的垃圾回收器。而他支持的内存空间已经到了上百 G 的级别。因此可以比较适合一些对执行效率比较敏感的业务场景。比如 RocketMQ 的 Broker
shennandoah则是这些垃圾回收器中最“孤独”的存在了。他的目标是能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内,这个目标与后面的 ZGC 是一致的。而他的实现思路也延续了 G1 的设计思想。但是却因为“出身不正”,导致一直无法进入官方 JDK 中。他在 2014 年由 RedHat 贡献给了 OpenJDK,并积极推动他成为 OracleJDK 的正式垃圾回收器。但是 Oracle 官方却一直拒绝将shennandoah加入到官方版本中。也就是说,shennandoah 是一款只有在 OpenJDK 中才有,在 OracleJDK 里反而不存在的垃圾收集器。“免费开源版本”比“收费商业版”功能更多,也是一个奇葩存在。
与shennandoah相比,ZGC 则是“出身名门”的大家闺秀。由 Oracle 公司研发,是目前最为先进的垃圾回收器。与shennandoah一样,ZGC 做到了几乎整个垃圾收集过程都全程可并发,短暂的 STW 停顿也只跟 GC Roots 有关,而与堆内存大小无关。虽然师出名门,但是 ZGC 的设计思路却与之前的分代思想大相径庭。更像是另外一个收费的产品 C4 的改造品。他彻底摒弃了内存分代的思想(也有说法后续会按数据冷热重新分代,因为他的老东家 C4 是实现了冷热数据分代收集的),完全基于 Region 管理内存。以追求低延迟为首要目标,STW 时间非常短,基本可以和 C 的执行效率相当了。并且 ZGC 最为亮眼的特点是内存的大小基本不会影响 STW 的时间。小到几百兆内存,大到最大可以支持 6TB(2 的 44 次方)的内存空间,STW 时间基本没有什么浮动。大有一统天下的势头。虽然 ZGC 现在还处于实验阶段,但是,官方给出的测试结果来看,在各个方面 ZGC 都已经碾压以往的所有垃圾收集器。算法实现层面,ZGC几乎不需要进行 JVM 参数调优。官方资料显示,ZGC 的调优参数非常少,他的算法已经可以实现自行调优。在目前 JDK21 版本中,ZGC 已经成功转正。并且很有可能成为未来唯一的垃圾回收器。到那个时候,JVM 调优就成了一项考古技能了。
3 、分代垃圾回收工作机制
这其中各个垃圾回收器的工作机制是有比较大的区别的。这里,我们只以目前最为主流的分代垃圾回收算法来进行讨论。这也是目前面试的重点。整体来说,这几个分代垃圾回收机制的内存管理方式是差不多的。区别跟多是在管理内存的实现方式上。
如果对各个算法感兴趣,可以看下我之前出的 GC 基础
分代算法,将内存划分成了两个大的区域。年轻代和老年代。其中发生在年轻代的垃圾回收操作就称为 YoungGC 或者MinorGC。发生在老年代的垃圾回收操作就称为OldGC或者 MajorGC 。多个内存区域一起进行的垃圾回收操作称为 FullGC 。
在内存分代模型中,一个对象“小 O”的典型生命周期是这样的:
- 小 O初始诞生在 Eden_Space中创建。这是一片寸土寸金的内存空间,大部分的对象都是“朝生夕死”的愣头青。
- Eden_space空间不够,就会触发 MinorGC 。清理不再使用的垃圾对象。
- 如果小 O 没有被清理,那么他会被移动到 survivor0 区域。并且给他记录一个 GC 年龄 1.
GC 年龄就记录在小 O 的markdown标志位中。
- 在下一次 MinorGC 中,如果小 O 还没有被清理。那么他将会从survivor0区域移动到survivor1区域,同时 GC 年龄增加 1。
- 在后续的 MinorGC 中,如果小 O 一直没有被清理。小 O 会在两个survivor区域之间不断转移。每次转移增加一次 GC 年龄。
- 当小 O 的 GC 年龄达到了 16,又一直没有被清理掉。那么表明小 O 的地位已经足够高了,就不需要再记录GC 年龄了。在下一次 MinorGC 过程中,小 O 将会从竞争激烈的年轻代,转移到竞争相对平缓的老年代,开始比较安稳的老年生活。
- 老年代依然会有 MajorGC ,不过相比年轻代,不会那么频繁,大家都安安稳稳的用到退休为止。
只是一个典型对象的生命周期。当然,在这其中还有很多优化的机制。
比如,如果小 O 占用内存非常小,那么在创建小 O 时,JVM 会在Eden_space中单独划分出一小片线程专属的内存空间,称为 TLAB 。小 O 就在 TLAB 中创建。由于 TLAB 空间是线程私有的,所以就可以避免多个线程之间的资源争抢。
另外,如果小 O 占用的内存非常大,Eden_space都装不下。这时小 O 就会跳过年轻代,直接进入老年代。
六、对 JVM 进行调优的基础思路
这些垃圾回收算法中有非常多的技术细节。但是我们的目的毕竟是调优。JVM 调优形式上很容易,就是定制各种各样的参数。但是这又是一个非常复杂的问题。项目运行期间会面临各种各样稀奇古怪的问题。比如 CPU 超高,FullGC 过于频繁,时不时的 OOM 异常等等。这些问题大部分情况下都只能凭经验进行深入分析,才能做出针对性的解决。
那么有没有一些比较通用的调优思路呢?毕竟我们不可能等每个项目都遇到问题了才去见招拆招,那样等你把问题调完,项目也差不多要黄了。那最基础的调优步骤应该从哪下手呢?当然是枪打出头鸟。找JVM 中最大头的堆 内存入手。 这时,效果最直接最明显的调优思路就是选择合适的垃圾回收器。比如在 JDK8 中使用参数 +XX:+UseG1GC来强制使用 G1 垃圾回收器。-XX:+UseConcMarkSweepGC使用 CMS 垃圾回收器。
选择好垃圾回收器之后,接下来就需要尽量多的干预到这些算法的实现过程中来。而其实这些算法,在 JVM 的开发过程当中,大部分都已经打磨得非常细致。我们其实很难根据自己的业务场景来干预这些算法的实现细节。我们能够调优的最主要手段,就是定制这些分代算法的各个区域的大小。这往往也是调优效果最明显的部分。
从 RocketMQ 的调优脚本就能看到,虽然选择了 CMS 垃圾回收算法,但是对算法的参数设置非常少。最主要的还是设置各种各样space的大小。
比如,如果应用当中对象创建非常频繁,但是这些对象的大部分都是在方法内部创建使用。这就可以扩大年轻代的内存。让大部分的对象在 MinorGC 阶段就可以被快速回收。相反,如果应用当中对象创建没有那么频繁,有很多需要跨多个方法,长期使用的缓存对象,这就可以扩大 Old 区的内存。这样可以减少 MajorGC 发生的频率。
那么应该要如何定制JVM 内存大小呢?这就需要我们能够尽量多的了解官网提供的各种参数。其中以下一些核心参数是需要重点了解的。
1 、先定制堆空间、栈空间以及非堆空间的大小
-Xss 栈空间大小。这里是设置每个线程的栈空间大小。
-XX:MetaspaceSize 元空间大小。 -XX:MaxMetaspaceSize 元空间最大大小。元空间默认是没有限制的。如果比较注重服务器的内存资源,那么建议设置一个合理的大小。
-Xms:初始堆内存大小,-Xmx:堆内存最大值。这两个值尽量设置成一样。因为 JVM 发现堆内存不够时,会进行内存扩充。在内存扩充过程中,会涉及到大量的对象转移。直接将最大内存和初始内存设置成一样,这样可以减少 JVM 在扩充内存时的性能消耗。
Heap 默认最大值为物理内存的 1/4 最小值默认为物理内存的 1/64。按这个比例,Heap 存在一个理论上的上限:32 位虚拟机,物理内存最大 4G,堆最大 1G 。 64 位虚拟机,物理内存最大 128G,堆最大 32G 。如果你的内存确实特别大,那么转为使用 G1,ZGC 这样的不分代算法可能更合适。
2 、再定制堆空间年轻代和老年代的大小
-XX:+/-UseTLAB 设置是否开启 TLAB 空间。默认是开启的。 TLAB 是 Eden 区为每个线程单独划分出来的一小部分内存。线程中创建的内存会优先分配在 TLAB 上,这样就可以减少线程之间的资源竞争以及 Eden 内存管理时的指针碰撞。
-XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小,默认 1%
-XX:NewSize: 设置年轻代大小,-Xmn:新生代最大内存大小。 老年代大小不需要单独设置。堆内存减去新生代内存,剩下的就是老年代内存。
-XX:NewRatio:设置年轻代与老年代的内存比例,默认是2,表示年轻代与老年代的内存比例是1:2
-XX:SurvivorRatio:设置年轻代中Eden区与Survivor区的内存比例。这里注意下Survivor是有两个的,默认值是8,这就表示Eden:S0:S1的内存大小比例是8:1:1
3 、打印 GC 日志,定期进行分析
这是最重要的一步。因为大部分的JVM 问题,都需要通过日志打印出来。
-XX:+PrintGC: 打印GC信息 类似于-verbose:gc
-XX:+PrintGCDetails: 打印GC详细信息,这里主要是用来观察FGC的频率以及内存清理效率。
-XX:+PrintGCTimeStamps 配合 -XX:+PrintGC使用。在 GC 中打印时间戳。
-XX:PrintHeapAtGC: 打印GC前后的堆栈信息
-Xloggc:filename : GC日志打印文件。
JDK9之后,可以统一使用 -X-log:gc* 通配符打印所有的 GC 日志。
七、 GC 情况分析实例
1 、打印 GC 日志
例如,我们可以用以下这个小实验来上手练练。
public class GcLogTest {
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
byte[] arr = new byte[1024 * 100];//100KB
list.add(arr);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
然后在执行这个方法时,添加以下 JVM 参数:
-Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails
执行后,可以看到类似这样的输出信息。
这里面就记录了两次 MinorGC 和两次 FullGC 的执行效果。另外,在程序执行完成后,也会打印出 Heap 堆区的内存使用情况。
当然,目前这些日志信息只是打印在控制台,你只能凭经验自己强行去看。接下来,就可以添加-Xloggc参数,将日志打印到文件里。然后拿日志文件进行整体分析。
2 、分析 GC 日志
之前那些日志信息只是打印在控制台,你只能凭经验自己强行去看。比如 GC 是否频繁,GC 回收的内存是不是比较多。我们当前这个程序非常简单还好一点。往往一个真实应用的日志非常复杂。一行行自己看就完蛋了。接下来,就可以添加-Xloggc参数,将日志打印到文件里。然后拿日志文件进行整体分析。
这里推荐一个开源网站 https://www.gceasy.io/ 这是国外一个开源的GC 日志分析网站。你可以把 GC 日志文件直接上传到这个网站上,他就会分析出日志文件中的详细情况。
这是个收费网站,但是有免费使用的额度。
比如,可以将我本地部署的一个 RocketMQ 的 NameServer 的日志文件上传到这个网站,就可以拿到这样的分析图。
RocketMQ 的 GC 日志文件默认打印在/dev/shm目录下。
里面会有很多建议以及图形化的分析结果。结合这些建议,以及分析结构,就可以开始逐步分析问题,优化配置的循环了。
八、最后总结
如果你跟着教程走到这,那么恭喜你,JVM 调优你已经真正入门了。接下来,多练,多看,多分析,无他,唯手熟而。 Java 高手之路正在向你敞开。
聊到这里,不知道你对于 JVM 调优这个事情,是不是有了一点感觉?当然,这不会是一帆风顺的,后续还需要针对不同的具体问题进行具体调优。这不会是一个容易的过程,但是,有挑战就更有价值不是吗?
最后,记得给楼兰点个赞吧。