JVM的作用跟字节码
# JVM的作用跟字节码
# JVM是什么
JVM,全称为Java虚拟机(Java Virtual Machine),是运行Java应用程序的核心组件。它是实现Java“编写一次,到处运行”理念的关键技术之一。JVM并不是直接在操作系统上执行Java源代码,而是执行由Java编译器生成的字节码(Bytecode)。这些字节码是一种中间表示形式,可以在任何实现了JVM的平台上运行。
更简单的说,Java虚拟机(JVM)就是负责将Java程序编译后的字节码转换成特定于平台的机器语言,以便计算机系统能够执行这些指令。
大家常说,“JVM 调优” 或者 “JVM 性能优化”。调的究竟是程序还是JVM呢?具体需要从哪儿入手,怎么去做呢?在看后续内容时,希望你能带着这些问题,这样印象会更加深刻。
先回答第一个问题,调的是,JVM本身。
JVM调优主要针对的是JVM本身的配置,而程序调优是针对代码逻辑。但两者共同影响应用性能,因此在实际操作中可能需要综合考虑。例如,即使调整了JVM参数,如果程序存在内存泄漏,效果可能有限,这时候需要先修复程序问题,再调整JVM参数。
# 调优的作用
其实“调优”是一个诊断和处理手段,我们最终的目标是让系统的处理能力,也就是**“性能”达到最优化**,这个过程我们就像是一个医生,诊断和治疗“应用系统”这位病人。
我们以作为医生给系统看病作为对比,“性能优化”就是实现“把身体的大小毛病治好,身体达到最佳健康状态”的目标。
那么去医院看病,医生会是怎么一个处理流程呢?先简单的询问和了解基本情况,发烧了没有,咳嗽几天了,最近吃了什么,有没有拉肚子一类的,然后给患者开了一系列的检查化验单子:去查个血、拍个胸透、验个尿之类的。然后就会有医生使用各项仪器工具,依次把去做这些项目的检查,检查的结果就是很多标准化的具体指标(这里就是我们对 JVM 进行信息收集,变成各项指标)。
然后拿过来给医生诊断用,医生根据这些指标数据判断哪些是异常的,哪些是正常的,这些异常指标说明了什么问题(对系统问题进行分析排查),比如是白细胞增多,说明可能有炎症(比如 JVM 配置不合理)。
最后要“对症下药”,重写编写某部分代码,或者重新设置JVM参数
。
那 系统该如何衡量参数呢?或者说衡量哪些参数?
# 系统性能参数
一般衡量系统性能的维度有 3 个:
- 延迟——RT
延迟(Latency): 一般衡量的是响应时间(Response Time),比如平均响应时间。但是有时候响应时间抖动的特别厉害,也就是说有部分用户的响应时间特别高,这时我们一般假设我们要保障 95% 的用户在可接受的范围内响应,从而提供绝大多数用户具有良好的用户体验,这就是延迟的95线(P95,平均 100 个用户请求中 95 个已经响应的时间),同理还有99线,最大响应时间等(95 线和 99 线比较常用;用户访问量大的时候,对网络有任何抖动都可能会导致最大响应时间变得非常大,最大响应时间这个指标不可控,一般不用)。
- 吞吐量——TPS、QPS
吞吐量(Throughput): 一般对于交易类的系统我们使用每秒处理的事务数(TPS)来衡量吞吐能力,对于查询搜索类的系统我们也可以使用每秒处理的请求数(QPS)。
- 系统容量——Ca
系统容量(Capacity): 也叫做设计容量,可以理解为硬件配置,成本约束。
最后还有一点请记住,“脱离场景谈性能都是耍流氓”。
实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到 3000TPS 如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到 3100TPS 就没有什么意义,同样地如果花一倍成本去优化到 5000TPS 也没有意义。
# Java字节码
开头有说过到,Java通过JVM实现跨平台运行。那为什么同一份代码在不同平台之间不能运行呢?
不同平台(比如Windows、Linux、macOS)有不同的硬件架构和操作系统,它们处理指令的方式可能不同。例如,x86架构的CPU和ARM架构的CPU的机器指令集不同。如果直接编译成机器码,那么每个平台都需要不同的可执行文件。
然而,JVM充当了一个中间层,负责将字节码
转换为特定平台的机器码。这样,只要目标平台有对应的JVM,就可以运行同一份字节码。
而这里的字节码文件,就是源码编译后的产物,也就是那些后缀为class的文件,或者jar文件。
# Jar跟Class文件
.class
文件是由 Java 编译器(javac
)将 .java
源代码文件编译后生成的二进制文件。它是 Java 虚拟机(JVM)能够直接识别和执行的字节码文件。
.jar
文件(Java Archive File)是一个压缩文件格式,它基于 ZIP 格式,用于打包多个 .class
文件以及其他资源文件(如配置文件、图片等)。.jar
文件的主要目的是简化 Java 应用程序的分发和管理。
- 打包多个类和资源文件:
.jar
文件可以包含多个.class
文件、资源文件(如.properties
配置文件、图片等)、以及元信息(如META-INF/MANIFEST.MF
文件)。- 便于分发和部署:相比于单独管理大量的
.class
文件,.jar
文件更易于分发和部署。- 支持模块化开发:
.jar
文件可以作为一个独立的模块,供其他项目引用。例如,Spring、Hibernate 等框架通常以.jar
文件的形式提供。- 可执行 JAR:通过在
META-INF/MANIFEST.MF
文件中指定主类(Main-Class),可以将.jar
文件制作成可执行文件。
所以说,字节码文件是很重要的文件。
从技术人员的角度看,Java 字节码是 JVM 的指令集。JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行。 简单说字节码就是我们编写的 Java 应用程序大厦的每一块砖,如果没有字节码的支撑,大家编写的代码也就没有了用武之地,无法运行。也可以说,Java 字节码就是 JVM 执行的指令格式。
# 字节码简介
Java bytecode
由单字节(byte
)的指令组成,理论上最多支持 256
个操作码(opcode)。实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作。
操作码, 下面称为 指令
, 主要由类型前缀
和操作名称
两部分组成。
例如,’
i
’ 前缀代表 ‘integer
’,所以,’iadd
’ 很容易理解, 表示对整数执行加法运算。
根据指令的性质,主要分为四个大类:
- 栈操作指令,包括与局部变量交互的指令
- 程序流程控制指令
- 对象操作指令,包括方法调用指令
- 算术运算以及类型转换指令
此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等。下文会对这些指令进行详细的讲解。
# 编译与反编译
用 javap
工具来获取 class 文件中的指令清单。 javap
是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。
使用 javap
工具来执行反编译, 获取字节码清单:
javap -c demo.jvm0104.HelloByteCode
# 或者:
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class
# 以下为 同过 -verbose 获取更多信息
javap -c -verbose TsApplication.class 获取详情
2
3
4
5
6
7
使用 javac
编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。
javac demo/jvm0104/HelloByteCode.java
通过反编译我们可以看到更多的信息,比如指令相关的信息,再比如常量池信息。
# 常量池
运行时常量池
属于JVM运行时数据区的一部分,而字节码中的常量池是.class文件的一部分,我们能看见。
运行时常量池则是JVM在加载类时,将.class文件中的常量池内容解析后放入方法区中的数据结构。运行时常量池具有动态性,可以在运行时添加新的常量,例如String.intern()方法可能会将字符串添加到运行时常量池。
简单说,运行时常量池中的数据是根据.class文件中生成的。
每个.class文件都有一个字段————常量池(Constant Pool),用于存放编译期生成的各种字面量和符号引用。例如类名、方法名、字段名、字符串常量等。这些信息在编译时确定,并存储在.class文件中。
包含编译期确定的所有常量,例如:
- 字面量(如字符串
"Hello"
、数字123
) - 符号引用(如类全名
java/lang/Object
、方法名toString
、字段名value
) - 方法类型、方法句柄等元数据。
运行时常量池在JVM规范中是堆的一部分,不同JVM实现可能不同,如JDK8后的元空间。
下面就用一个例子来查查看,要查看常量池信息, 我们得加一点魔法参数:
javap -c -verbose demo.jvm0104.HelloByteCode
在反编译 class 时,指定 -verbose
选项, 则会 输出附加信息
。
结果如下所示:
Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
Last modified 2019-11-28; size 301 bytes
MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 demo/jvm0104/HelloByteCode
#15 = Utf8 java/lang/Object
{
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "HelloByteCode.java"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
其中显示了很多关于 class 文件信息: 编译时间, MD5 校验和, 从哪个 .java
源文件编译得来,符合哪个版本的 Java 语言规范等等。
以下就是对常量池的引用,和信息定义。
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
......
2
3
4
5
6
7
这是摘取的一部分内容, 可以看到常量池中的常量定义。还可以进行组合, 一个常量的定义中可以引用其他常量。
比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
, 解读如下:
#1
常量编号, 该文件中其他地方可以引用。=
等号就是分隔符.Methodref
表明这个常量指向的是一个方法;具体是哪个类的哪个方法呢? 类指向的#4
, 方法签名指向的#13
; 当然双斜线注释后面已经解析出来可读性比较好的说明了。
同学们可以试着解析其他的常量定义。
总结一下,常量池就是一个常量的大字典,使用编号的方式把程序里用到的各类常量统一管理起来,这样在字节码操作里,只需要引用编号即可。
# 线程栈与字节码执行模型
上面说到,其实Java 字节码
就是 JVM 的指令集,那JVM它是如何执行指令的呢?这里就要提到它的执行模型了。
Java虚拟机(JVM)作为一台基于栈
的计算机器,其核心设计围绕线程栈
和栈帧
展开,这些机制共同支撑了Java程序的执行流程。它是基于栈的计算机器,之所以这样设计,是为了屏蔽底层硬件的差异,同时实现指令简洁(无需依赖物理寄存器分配)。
JVM使用操作数栈
作为指令执行的核心载体(而非直接操作物理寄存器)。所有算术运算、方法调用和数据传递都通过“压栈(push)”和“弹栈(pop)”完成。
有点蒙,不要慌,我们一个一个的来解释。
# 栈帧(Frame)
每次方法调用(如main()调用methodA())会创建一个新的栈帧,方法结束时(正常返回或异常)栈帧被销毁。
栈帧
由三部分组成:
操作数栈(Operand Stack):临时存储指令操作的数据(如计算a + b时暂存a和b的值)。
局部变量数组(Local Variable Array):存储方法的参数和内部定义的局部变量(按索引访问)。
Class引用(指向运行时常量池的引用):支持动态链接(如确定方法的具体实现、解析符号引用)。
操作数栈,是栈帧中的一个具体组成部分,它是一个
后进先出(LIFO)的数据结构
,用于存储操作数和中间计算结果。每个栈帧都有自己的操作数栈,其大小在编译时确定。例如,当执行iadd指令时,需要从操作数栈顶弹出两个整数,相加后再将结果压入栈顶。
# 线程私有栈(JVM Stack)
每个线程在创建时会分配一个独立的线程栈,用于存储该线程执行方法时生成的栈帧(Frame)。
以下是线程栈中包含的主要内容:
- 栈帧(Stack Frame):每次方法调用时,都会在线程栈中创建一个栈帧(Stack Frame)。栈帧是线程栈的核心组成部分,包含了方法执行所需的所有信息。每个栈帧对应一个未完成的方法调用。
- 方法调用的栈结构:当线程执行方法时,JVM 会按照方法调用的顺序,依次将栈帧压入线程栈中。方法执行完毕后,对应的栈帧会被弹出。
此外,你还需要注意,线程栈是有隔离性的,也正因为如此,才确保了多线程并发执行时,各线程的方法调用互不干扰。
# 对象初始化指令
介绍了这么多,估计你也对JVM和编译后的字节码有一个大致的了解了,那现在加个餐,从指令层面来看下对象初始化过程。
我们都知道 new
是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new
。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
2
3
当你同时看到 new, dup
和 invokespecial
指令在一起时,那么一定是在创建类的实例对象!
为什么是三条指令而不是一条呢?这是因为:
new
指令只是创建对象,但没有调用构造函数。dup
指令用于复制栈顶的值。invokespecial
指令用来调用某些特殊方法的, 当然这里调用的是构造函数。
由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。
这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。
# 进一步解释
以下是更进一步的解释:
new指令。在Java源代码中,当我们写new Xxx时,编译器会生成对应的字节码。那么在字节码里,new指令应该负责分配内存空间给新对象吧?不过可能这时候对象还没有完全初始化,因为构造函数还没调用。所以new指令可能只是在堆上为对象分配内存,并将对象的引用压入操作数栈。这时候对象的状态可能是原始状态,所有字段都是默认值(比如0、null等),因为构造函数还没执行。
接下来是dup指令。dup是duplicate的缩写,也就是复制栈顶的值。这时候操作数栈顶已经有一个由new指令生成的对象的引用。执行dup之后,栈顶会有两个相同的对象引用。为什么要这样做呢?可能是因为之后调用构造函数(invokespecial)的时候会消耗一个引用,而另一个引用需要保留下来,以便后续使用,比如将对象赋值给变量或者作为方法调用的参数。所以dup是为了复制引用,防止在调用构造函数后丢失引用?
然后是invokespecial指令。这个指令用于调用实例初始化方法,也就是构造函数。在Java中,构造函数的名字是 init,所以这里invokespecial调用的是这个特殊的方法。调用构造函数的时候,会从操作数栈中取出对象的引用(也就是之前通过new得到的那个引用),然后执行构造函数中的代码,初始化对象的状态。这时候,构造函数执行完毕,对象才算是完全创建好了,可以被正常使用。