一、前言
- 学习JVM是进行JVM调优的基础。写的代码部署到线上它会如何运行?要配多少内存?线上环境出问题了,服务崩溃了,应该怎么快速定位?这些问题都与JVM有着一定的关系。好的程序员都应该尽自己的能力把JVM每个底层逻辑整理成自己的知识体系。
- 各种程序语言只能要写出满足JVM规范的class文件,都能够在JVM中运行;
- java文件在JVM中执行的过程:
二、Class文件规范
1、Class文件的结构
- 实际上,Java官方只是定义了JVM的一种执行规范,也就是Class文件的组织规范。理论上,只要你能够写出一个符合这种规范的class文件,就能在JVM中执行,这也是JVM支持多语言的基础。
- class文件本质上是一个二进制文件,可以使用文本工具打开,例如:UItraEdit工具。
- 所有的class文件都必须以十六进制的CAFEBABE开头,这也是JVM规范的一部分。
- JDK自己提供了一个javap指令可以直接来看一些class文件,例如:javap -v TestView.class
- IDEA中可以添加一个ByteCodeView插件来直观的查看一个ClassFile的内容。
2、字节码指令的工作过程
- 在 JVM 虚拟机中,会为每个线程构建一个线程私有的内存区域。其中包含的最重要的数据就是程序计数器和虚拟机栈。其中程序计数器主要是记录各个指令的执行进度,用于在 CPU 进行切换时可以还原计算结果。虚拟机栈中则包含了这个线程运行所需要的重要数据。
- 虚拟机栈是一个先进后出的栈结构,其中会为线程中每一个方法构建一个栈帧。而栈帧先进后出的特性也就对应了我们程序中每个方法的执行顺序。每个栈帧中包含四个部分,局部变量表,操作数栈,动态链接库、返回地址。
- 操作数栈是一个先进后出的栈结构,主要负责存储计算过程中的中间变量。操作数栈中的每一个元素都可以是包括long型和double在内的任意 Java 数据类型。
- 局部变量表可以认为是一个数组结构,主要负责存储计算结果。存放方法参数和方法内部定义的局部变量。以 Slot 为最小单位。
- 动态链接库主要存储一些指向运行时常量池的方法引用。每个栈帧中都会包含一个指向运行时常量池中该栈帧所属方法的应用,持有这个引用是为了支持方法动态调用过程中的动态链接。
- 返回地址存放调用当前方法的指令地址。一个方法有两种退出方式,一种是正常退出,一种是抛异常退出。如果方法正常退出,这个返回地址就记录下一条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定。
- 附加信息主要存放一些 HotSpot 虚拟机实现时需要填入的一些补充信息。这部分信息不在 JVM 规范要求之内,由各种虚拟机实现自行决定。
三、类加载机制
1、JDK8类加载体系
- 有了class文件之后,则需要通过类加载模块才能将这些Class文件加载到JVM内存中。
- 每个类加载器对加载过的类都会保存一个缓存;
- 双亲委派机制,即:向上委托查找,向下委托加载;
- 沙箱保护机制;
2、双亲委派机制
- JDK8中的类加载器都继承于一个统一的抽象类ClassLoader,类的加载核心也在这个父类这。其中,加载类的核心方法如下:
protected Class<?> loadClass(String name,boolean resolve) throw 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;
}
}
3、沙箱保护机制
- 双亲委派机制最大的作用就是保护JDK内部的核心类不会被应用覆盖。而为了保护JDK内部的核心类,JAVA在双亲委派的基础上还加了一层保险。具体内容就是下面这个方法中:
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;
}
4、类和对象的关系
- 首先:类 Class 在 JVM 中的作用其实就是一个创建对象的模板。也就是说他的作用更多的体现在创建对象的过程当中。而在程序具体执行的过程中,主要是围绕对象在进行,这时候类的作用就不大了。因此,在 JVM 中,类并不直接保存在最宝贵最核心的堆内存当中,而是挪到了堆内存以外的一部分内存中。这部分内存,在 JDK8 以前被成为永久带PermSpace,而在 JDK8 之后被改为了元空间 MetaSpace。
- 堆空间可以理解为JVM的客厅,所有重要的事情都在客厅处理。元空间可以理解为JVM的库房,东西扔进去基本上就很少管了。 这个元空间逻辑上可以认为是堆空间的一部分,但是他跟堆空间有不同的配置参数,不同的管理方式。因此也可以看成是单独的一块内存。这一块内存就相当于家里的工具间或者地下室,都是放一些用得比较少的东西。最主要就是类的一些相关信息,比如类的元数据、版本信息、注解信息、依赖关系等等。
- 元空间可以通过-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize参数设置大小。但是大部分情况下,你是不需要管理元空间大小的,JVM 会动态进行分配。
- 另外,这个元空间也是会进行 GC 垃圾回收的。如果一个类不再使用了,JVM 就会将这个类信息从元空间中删除。但是,显然,对类的回收效率是很低的。只有一些自定义类加载器自行加载的一些类有被回收的可能,大部分情况下,类是不会被回收的。所以堆元空间的垃圾回收基本上是很少有效果的。大部分情况下,我们是不需要管元空间的。除非你的JVM 内存确实非常紧张,这时可以设定 -XX:MaxMetaspaceSize参数,严格控制元空间大小。 然后:在我们创建的每一个对象中,JVM也会保存对应的类信息。
四、执行引擎
在Class文件中,已经明确的定义清楚了程序的完整执行逻辑,而执行引擎就是讲这些字节指令转为机器指令去执行。
1、解释执行与编译执行
JVM 中有两种执行的方式:
- 解释执行就相当于是同声传译。JVM 接收一条指令,就将这条指令翻译成机器指令执行。
- 编译执行就相当于是提前翻译。编译执行也就是传说中的 JIT 。
- 大部分情况下,使用编译执行的方式显然比解释执行更快,减少了翻译机器指令的性能消耗。而我们常用的 HotSpot 虚拟机,最为核心的实现机制就是这个 HotSpot 热点。他会搜集用户代码中执行最频繁的热点代码,形成CodeCache,放到元空间中,后续再执行就不用编译,直接执行就可以了。 - 但是编译执行起始也有一个问题,那就是程序预热会比较慢。所以,现在JDK 默认采用的就是一种混合执行的方式。他会自己检测采用那种方式执行更快。
2、编译执行时的代码优化
- 热点代码会触发 JIT 实时编译,而JIT 编译运用了一些经典的编译优化技术来实现代码的优化,可以智能地编译出运行时的最优性能代码。
- HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器采用 Java 语言编写,因此生态的活力更强。并由此衍生出了 GraalVM 这样的支持实时编译的产品。也就是绕过 Class 文件,直接将 Java 代码编译成可在操作系统本地执行的应用程序。这也就是 AOT 技术Ahead Of Time。
- C1 会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小,执行效率没有server快。默认情况下不进行动态编译,适用于桌面应用程序。
- C2 进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高,适用于服务器端应用。 默认情况下就是使用的 C2 编译器。并且,绝大部分情况下也不建议特意去使用 C1。
3、静态执行与动态执行
- 静态执行指在Class文件编译的过程中就已经确定了执行方法。
- 动态执行指需要在运行期间才能够确定调用哪个方法。例如:多个重载方法,需要根据传参来确定调用哪个方法。
五、GC垃圾回收器介绍
执行引擎会将class文件扔到JVM的内存中运行。在运行过程中,需要不断的在内存中创建并销毁对象。在传统C/C++语言中,销毁这些对象需要手动进行内存回收,防止内存泄漏,而在Java中则使用GC垃圾回收机制来自动管理内存的回收。
1、垃圾回收器
- 这里推荐一个工具:阿里开源的 Arthas ,官网地址 。 这个工具功能非常强大,是对 Java进程进行性能调优的一个非常重要的工具,对于了解 JVM 底层帮助也非常大。
- 一个 Java 进程会将他管理的内存分为heap堆区和nonheap非堆区两个部分。其中非堆区的几个核心部分像code_cache(热点指令缓存),metaspace(元空间),compressed_class_space(压缩类空间)我们之前都接触到了。这一部分就相当于 Java 进程中的地下室,属于不太活跃的部分。而中间heap堆区就相当于客厅了,属于Java 中最为核心的部分。而这其中,又大体分为了eden_space,survivor_space和old_gen三个大的部分,这就是 JVM 内存的主体。
- 其中堆区是 JVM 核心的存放对象的内存区域。他的大小可以由参数 -Xms(初始堆内存大小),-Xmx(最大堆内存)参数指令。从这两个参数可以看到,堆内存是可以扩展的。如果初始内存不够,JVM 会扩大堆内存。但是如果内存扩展到了最大堆内存时还不够。这时就无法继续扩展了,而是会抛出 OOM 异常。这两个参数在生产环境中最好设置成一样,减少内存扩展时的性能消耗。 而GC垃圾回收器,就是要对这些内存空间进行及时回收,从而让这些内存可以重复利用。
2、分代收集模型
- 不同GC对内存的管理和回收方式是不同的。而JDK8默认的垃圾回收器是Parallel Scavenge。
- JAVA做过统计,80%的对象都是“朝生夕死”。这些对象,被集中放在了一块比较小的内存空间当中,快速创建,快速回收,这块内存区域就是年轻代。在年轻代会非常频繁的进行垃圾回收,称为YoungGC。而年轻代又会被进一步划分为一个eden_space和两个survivor。这三个区域的大小比例默认是 8:1:1。
- 另外少部分需要长期使用的对象,被放到另一块竞争没有那么激烈的对象,则被放到另外一块比较大的内存空间当中,长期保持,这块内存就是老年代。在老年代,垃圾回收的频率则会相对比较低,只有空间不够时才进行,称为OldGC。
- 年轻代与老年代默认的大小比例是 1:2。
- 常见的分代收集模型中,对象会优先在eden区创建,经过一次YoungGC后,如果没有被回收,就会被移动到一个survivor区。接下来,下一次YoungGC时,又会被移动到另一块Survivor区。每移动一次,记录一个分代年龄。直到分代年龄太大了(默认是16),就会被移动到老年代。到老年代后,对象就不再记录分代年龄了,在老年代安安静静的用到退休。
- 这就是JDK最有代表性的分代年龄收集机制。通过分代收集机制,JVM可以对不同的对象采取不同的回收策略,从而提高垃圾回收的效率。
3、JVM中有哪些垃圾回收器
java从诞生到现在最新的JDK21版本,总共产生了以下10个垃圾回收器:
- 左侧的是分代算法。也就是将内存划分为年轻代和老年代进行管理。而有虚线的部分表示可以协同进行工作。JDK8默认就是使用的Parallel Scavenge和Parallel Old的组合。也就是在arthas的dashboard中看到的ps。
- 右侧的是不分代算法。也就是不再将内存严格划分位年轻代和老年代。JDK9 开始默认使用 G1。而 ZGC是目前最先进的垃圾回收器。shennandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。Epsilon是一个测试用的垃圾回收器,根本不干活。
六、GC日志分析实例
GC可以说是决定了java程序运行效率的关键,所以我们需要学会定制GC参数以及分析GC日志
1、如何定制GC运行参数
- 在现阶段,各种GC垃圾回收器都只适合一个特定的场景,因此,我们也需要根据业务场景,定制合理的GC运行参数。
- 另外,JAVA程序在运行过程中要处理的问题是层出不穷的。项目运行期间会面临各种各样稀奇古怪的问题。比如 CPU 超高,FullGC 过于频繁,时不时的 OOM 异常等等。这些问题大部分情况下都只能凭经验进行深入分析,才能做出针对性的解决。
- 如何定制JVM运行参数呢?首先我们要知道有哪些参数可以供我们选择。
- 关于 JVM 的参数,JVM 提供了三类参数:
- 一类是标准参数,以-开头,所有 HotSpot 都支持。例如java -version。这类参数可以使用java -help 或者java -? 全部打印出来;
- 二类是非标准参数,以-X 开头,是特定 HotSpot版本支持的指令。例如java -Xms200M -Xmx200M。这类指令可以用java -X 全部打印出来。
- 第三类是不稳定参数,这也是 JVM调优的噩梦。以-XX 开头,这些参数是跟特定HotSpot版本对应的,很有可能换个版本就没有了。JDK8 中的以下几个指令可以帮助开发者了解 JDK8 中的这一类不稳定参数:
java -XX:+PrintFlagsFinal:所有最终生效的不稳定指令。 java -XX:+PrintFlagsInitial:默认的不稳定指令 java -XX:+PrintCommandLineFlags:当前命令的不稳定指令 --这里可以看到是用的哪种GC。 JDK1.8默认用的ParallelGC
- 关于 JVM 的参数,JVM 提供了三类参数:
2、打印GC日志
- 对 JVM 虚拟机来说,绝大多数的问题往往都跟堆内存的 GC 回收有关。因此下面几个跟 GC 相关的日志打印参数是必须了解的。这通常也是进行 JVM 调优的基础。
-XX:+PrintGC: 打印GC信息 类似于-verbose:gc
-XX:+PrintGCDetails: 打印GC详细信息,这里主要是用来观察FGC的频率以及内存清理效率。
-XX:+PrintGCTimeStamps 配合 -XX:+PrintGC使用。在 GC 中打印时间戳。
-XX:PrintHeapAtGC: 打印GC前后的堆栈信息
-Xloggc:filename : GC日志打印文件。
- 不同 JDK 版本会有不同的参数。 比如 JDK9 中,就不用分这么多参数了,可以统一使用-X-log:gc* 通配符打印所有的 GC 日志。