JVM也有很多具体的实现版本,现在最主流的是Oracle官方的HotSpot虚拟机。
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)
Class 文件规范
所有的class文件,都必须以十六进制的 CAFEBABE 开头,这就是 JVM 规范的一部分。这也解释了 Java 这个词的由来,到底是一种咖啡,还是爪哇岛。
可以在 IDEA 里添加一个 ByteCodeView插件来更直观的查看一个 ClassFile 的内容。可以看到,一个class文件的大致组成部分。
前面u4表示四个字节是magic魔数, CAFEBABE 。
后面的两个u2,表示两个字节的版本号。
理解字节码指令
- JVM 虚拟机的字节码指令由一个字节长度的,代表着某种特定操作含义的数字(称为操作码,OpCode)
- 跟随其后的零至多个代表此操作所需要的参数(称为操作数,Operand)构成。其中操作数可以是一个具体的参数,也可以是一个指向class文件常量池的符号引用,也可以是一个指向运行时常量池中的一个方法。
- Java 虚拟机中的操作码的长度只有一个字节(能表示的数据是0~255),这意味着 JVM 指令集的操作码总数不超过 256 条。
字节码指令解读案例
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方法
也看到了在JVM中,是通过一个invokestatic指令调用一个静态方法。
JDK中还有以下几个跟方法调用相关的字节码指令:
- invokevirtual 指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
- invokeinterface 指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial 指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法私有方法和父类方法。
- invokestatic 指令:用于调用类静态方法(static 方法)。
- invokedynamic 指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固定在 Java 虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。 Java 从诞生到现在,只增加过一条指令,就是invokedynamic。自 JDK7 支持并开始进行改进,这也是为 JDK8 实现Lambda表达式而做的技术储备。
Java 当中的静态方法可以重载吗?
不能。因为在 JVM 中,调用方法提供了几个不同的字节码指令。invokcvirtual 调用对象的虚方法(也就是可重载的这些方法)。invokespecial 根据编译时类型来调⽤实例⽅法,比如静态代码块(通常对应字节码层面的cinit 方法),构造方法(通常对应字节码层面的init方法)。invokestatic 调⽤类(静态)⽅法。invokcinterface 调⽤接⼝⽅法。
静态方法和重载的方法他们的调用指令都是不一样的,那么肯定是无法重载静态方法的。
深入字节码理解try-cache-finally的执行流程
public int inc(){
int x;
try{
x=1;
return x;
}catch (Exception e){
x = 2;
return x;
}finally {
x = 3;
}
}
编译出的字节码是这样的:
try-catch-finally的指令体现在异常表
异常表中每一行代表一个执行逻辑的分支。表示 当字节码从《起始 PC》到《结束 PC》(不包含结束 PC)之间出现了类型为《捕获异常》或者其子类的异常时,就跳转到《跳转 PC》处进行处理。
- 如果try语句块中出现了属于 Exception 或者其子类的异常,转到catch语句块处理。
- 如果try语句块中出现了不属于 Exception 或其子类的异常,转到finally语句块处理。
- 如果catch语句块中出现了任何异常,转到finally语句块处理。
字节码指令是如何工作的?
- 操作数栈是一个先进后出的栈结构,主要负责存储计算过程中的中间变量。操作数栈中的每一个元素都可以是包括long型和double在内的任意 Java 数据类型。
- 局部变量表可以认为是一个数组结构,主要负责存储计算结果。存放方法参数和方法内部定义的局部变量。以 Slot 为最小单位。
- 动态链接库主要存储一些指向运行时常量池的方法引用。每个栈帧中都会包含一个指向运行时常量池中该栈帧所属方法的应用,持有这个引用是为了支持方法动态调用过程中的动态链接。
- 返回地址存放调用当前方法的指令地址。一个方法有两种退出方式,一种是正常退出,一种是抛异常退出。如果方法正常退出,这个返回地址就记录下一条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定。
- 附加信息主要存放一些 HotSpot 虚拟机实现时需要填入的一些补充信息。这部分信息不在 JVM 规范要求之内,由各种虚拟机实现自行决定。
如何确定一个方法需要多大的操作数栈和局部变量?
class文件当中就记录了所需要的操作数栈深度和局部变量表的槽位数。例如对于 mathTest方法,所需的资源在工具中的纪录是这样的:
局部变量表中,明明只用到了索引为 1 的一个位置而已,为什么局部变量表的最大槽数是 2 呢?
这是因为对于非静态方法,JVM 默认都会在局部变量表的 0 号索引位置放入this变量,指向对象自身。所以我们可以在代码中用this访问自己的属性。
一个槽可以存放 Java 虚拟机的基本数据类型,对象引用类型和returnAddress类型
类加载
JDK8的类加载体系
DK8 为例,最为重要的内容总结为三点:
- 每个类加载器对加载过的类保持一个缓存。
- 双亲委派机制,即向上委托查找,向下委托加载。
当前加载器缓存找不到,向上查父加载器直到顶层还找不到,则子加载器才自己加载。
- 沙箱保护机制。
双亲委派机制
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;
}
}
这个方法是protected声明的,意味着,是可以被子类覆盖的,所以,双亲委派机制也是可以被打破的。
沙箱保护机制
双亲委派机制的核心作用是确保JDK内部核心类不会被应用程序覆盖。为了进一步加强保护,Java在双亲委派机制之外还增加了一层防护措施,具体体现在ClassLoader类中的以下方法。
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;
}
行为 | 作用 |
---|---|
禁止非法类名 | 防止字节码注入、破坏类结构安全 |
禁止定义 java.* 包 | 防止伪造核心类,保证 JVM 核心空间安全 |
使用 ProtectionDomain | 管理类的安全域和权限 |
检查证书 | 防止篡改代码,确保代码来源可信 |
类和对象有什么关系
类 Class 在 JVM 中的作用是一个创建对象的模板。JVM 中,类并不直接保存在堆内存当中,挪到了堆内存以外的一部分内存中。这部分内存,在 JDK8 以前被称为永久带PermSpace,在 JDK8 之后改为了元空间 MetaSpace。
元空间逻辑上可认为是堆空间的一部分,但他与堆空间有不同的配置参数,不同的管理方式。也可以看成是单独的一块内存。元空间也是会进行 GC 垃圾回收的。
元空间可以通过-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize参数设置大小。大部分情况下,是不需要管理元空间大小的,JVM 会动态进行分配。
分析一个对象在堆中保存的信息。
<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中记录的。
执行引擎
解释执行与编译执行
JVM 中有两种执行的方式:
- 解释执行就相当于是同声传译。JVM 接收一条指令,就将这条指令翻译成机器指令执行。
- 编译执行就相当于是提前翻译。好比领导发言前就将讲话稿提前翻译成对应的文本,上台讲话时就可以照着念了。编译执行也就是传说中的 JIT 。C1 和 C2 是 HotSpot JVM 中的两种 JIT(即时编译器)
- C1:快速但简单的编译器,适合客户端应用;
- C2:优化更高级但编译较慢,适合服务器端高性能应用。
大部分情况下,使用编译执行的方式显然比解释执行更快,减少了翻译机器指令的性能消耗。常用的 HotSpot 虚拟机,核心的实现机制就是** HotSpot 热点**。他会搜集用户代码中执行最频繁的热点代码,形成CodeCache,放到元空间中,后续再执行就不用编译,直接执行。
编译执行起始也有一个问题,那就是程序预热会比较慢。虚拟机,不可能提前预知到程序员要写一些什么代码,也就不可能把所有代码都提前编译成模板。
现在也有一种提前编译模式,AOT 。可以直接将Java 程序编译成机器码。比如GraalVM,可以直接将 Java 程序编译成可执行文件,这样就不需要 JVM 虚拟机也能直接在操作系统上执行。
以丧失一定的跨平台特性作为代价大部分情况下是可以提升程序执行性能
目前 AOT 这种方式还是不太安全的
编译执行时的代码优化
热点代码会触发 JIT 即时编译,JIT 编译器采用经典优化技术来生成运行时最优性能代码。
- C1 编译器:执行简单可靠的字节码优化,编译速度快,启动快且内存占用小,但执行效率不如 Server 模式。默认不启用动态编译,适合桌面应用场景。
- C2 编译器:执行耗时较长但更激进的优化,生成代码的执行效率更高。启动慢且内存占用多,适合服务器端应用。系统默认使用 C2 编译器,一般情况下不建议专门切换回 C1。
由于即时编译需要消耗程序运行时间,优化程度越高所需编译时间越长。为了平衡启动速度和运行效率,HotSpot 虚拟机引入了分层编译机制,根据编译优化规模和时间消耗划分不同编译层级,包括:
- 第0层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
- 第1层:使用C1编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 第2层:仍然使用C1编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层:仍然使用C1编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 第4层:使用C2编译器将字节码编译为本地代码,相比起C1编译器,C2编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
JDK8 参数 -XX:TieredStopAtLevel=1 指定使用哪一层编译模型。非JVM 的开发者不建议设置
静态执行与动态执行
- 静态执行 程序在**编译阶段(编译器决定)**完成的操作,如语法检查、类型检查、编译器优化
- 动态执行 程序在**运行阶段(运行时由 JVM 决定)**执行的操作,如动态绑定、反射、动态代理、JIT 编译。动态执行机制与invokedynamic指令密切相关。
GC 垃圾回收
在 JVM(Java Virtual Machine)中,GC(Garbage Collection)垃圾回收是指自动识别并释放程序中不再使用的内存对象的过程,避免内存泄漏和内存溢出,提高程序健壮性。GC 垃圾自动回收,可以说是 JVM 最为标志性的功能。(性能调优)
阿里开源的 Arthas 是对 Java进程进行性能调优的一个工具,对于了解 JVM 底层帮助非常大。
分代收集模型
- 年轻代会非常频繁的进行垃圾回收,称为YoungGC。而年轻代又会被进一步划分为一个eden_space和两个survivor。这三个区域的大小比例默认是 8:1:1。
- 老年代,垃圾回收的频率则会相对比较低,只有空间不够时才进行,称为OldGC。
- 年轻代与老年代默认的大小比例是 1:2。
区域 | 作用说明 |
---|---|
Eden 区 | 新创建的对象大多数先分配在 Eden 中 |
Survivor 区(S0/S1) | 存活过一次 GC 的对象会在 S0 和 S1 之间来回复制,经过多次仍然存活就晋升 |
老年代 | 生命周期长、被多次使用的对象被晋升至老年代,空间大,GC 不频繁 |
JVM中有哪些垃圾回收器?
到现在最新的 JDK21 版本,总共产生了十个垃圾回收器
- 分代算法。虚线的部分表示可以协同进行工作。JDK8默认就是使用的Parallel Scavenge和Parallel Old的组合。
在arthas的dashboard中看到的ps。
- 不分代算法。不再将内存严格划分位年轻代和老年代。JDK9 开始默认使用 G1。而ZGC是目前最先进的垃圾回收器。shennandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。Epsilon是一个测试用的垃圾回收器,不干活。
GC 情况分析实例
如何定制GC运行参数
JVM 提供了三类参数:
- 标准参数。以-开头,所有 HotSpot 都支持。例如java -version。这类参数可以使用java -help 或者java -? 全部打印出来
- 非标准参数。以-X 开头,是特定 HotSpot版本支持的指令。例如java -Xms200M -Xmx200M。这类指令可以用java -X 全部打印出来。
- 不稳定参数。以-XX 开头,这些参数是跟特定HotSpot版本对应的,很有可能换个版本就没有了。
java -XX:+PrintFlagsFinal:所有最终生效的不稳定指令。 java -XX:+PrintFlagsInitial:默认的不稳定指令 java -XX:+PrintCommandLineFlags:当前命令的不稳定指令 --这里可以看到是用的哪种GC。 JDK1.8默认用的ParallelGC
打印GC日志
对 JVM 虚拟机来说,绝大多数的问题往往都跟堆内存的 GC 回收有关。
下面是几个跟 GC 相关的日志打印参数:
-XX:+PrintGC: 打印GC信息 类似于-verbose:gc
-XX:+PrintGCDetails: 打印GC详细信息,这里主要是用来观察FGC的频率以及内存清理效率。
-XX:+PrintGCTimeStamps 配合 -XX:+PrintGC使用,在 GC 中打印时间戳。
-XX:PrintHeapAtGC: 打印GC前后的堆栈信息
-Xloggc:filename : GC日志打印文件。
添加这些参数-Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails,再执行可以看到gc回收的细节
不同 JDK 版本会有不同的参数。 比如 JDK9 中,可以统一使用-X-log:gc* 通配符打印所有的 GC 日志。
-Xlog:gc*,gc+age=trace,safepoint:file=/path/to/gc.log:time,uptime,level,tags
GC日志分析
开源的GC 日志分析网站
参数 -Xloggc:./gc.log ,可以将GC日志打印到文件当中。上传到这个网站可以得到分析报告。可以及时发现项目运行可能出现的一些隐藏问题。并且提供了一些具体的修改意见和详细的指标分析。