tulip notes
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Star-Lord

希望一天成为大师的学徒
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Java基础与面向对象

  • 高级进阶

  • 并发合集

  • JVM合集

    • JVM的作用跟字节码
      • JVM是什么
        • 调优的作用
        • 系统性能参数
      • Java字节码
        • Jar跟Class文件
        • 字节码简介
        • 编译与反编译
        • 常量池
        • 线程栈与字节码执行模型
        • 栈帧(Frame)
        • 线程私有栈(JVM Stack)
        • 对象初始化指令
        • 进一步解释
    • 类加载的过程
  • 实战与细节

  • 代码之丑与提升

  • 《Java》学习笔记
  • JVM合集
EffectTang
2025-03-23
目录

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’ 很容易理解, 表示对整数执行加法运算。

根据指令的性质,主要分为四个大类:

  1. 栈操作指令,包括与局部变量交互的指令
  2. 程序流程控制指令
  3. 对象操作指令,包括方法调用指令
  4. 算术运算以及类型转换指令

此外还有一些执行专门任务的指令,比如同步(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  获取详情
1
2
3
4
5
6
7

使用 javac 编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。

javac demo/jvm0104/HelloByteCode.java
1

通过反编译我们可以看到更多的信息,比如指令相关的信息,再比如常量池信息。

# 常量池

运行时常量池属于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
1

在反编译 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"
1
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>
......
1
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)。

以下是线程栈中包含的主要内容:

  1. 栈帧(Stack Frame):每次方法调用时,都会在线程栈中创建一个栈帧(Stack Frame)。栈帧是线程栈的核心组成部分,包含了方法执行所需的所有信息。每个栈帧对应一个未完成的方法调用。
  2. 方法调用的栈结构:当线程执行方法时,JVM 会按照方法调用的顺序,依次将栈帧压入线程栈中。方法执行完毕后,对应的栈帧会被弹出。

此外,你还需要注意,线程栈是有隔离性的,也正因为如此,才确保了多线程并发执行时,各线程的方法调用互不干扰。

# 对象初始化指令

介绍了这么多,估计你也对JVM和编译后的字节码有一个大致的了解了,那现在加个餐,从指令层面来看下对象初始化过程。

我们都知道 new是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:

         0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V
1
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得到的那个引用),然后执行构造函数中的代码,初始化对象的状态。这时候,构造函数执行完毕,对象才算是完全创建好了,可以被正常使用。

上次更新: 2025/04/23, 16:23:16
锁的探究
类加载的过程

← 锁的探究 类加载的过程→

最近更新
01
面向切面跟自定义注解的结合
05-22
02
时间跟其他数据的序列化
05-19
03
数据加密与安全
05-17
更多文章>
Theme by Vdoing | Copyright © 2023-2025 EffectTang
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式