一、实战 JVM - 基础篇
初识 JVM
什么是 JVM?
Java Virtual Machine(JVM),中文翻译为 Java 虚拟机
JVM 的功能
- 解释和运行:对字节码文件中的指令进行实施的解释成机器码,让计算机执行。
- 自动为对象和方法分配内存:自动的垃圾回收机制,不用自己编写代码进行垃圾回收。
- 即时编译:对热点代码进行优化,提升执行的效率。
即时编译
因为 Java 虚拟机比起诸如 C 或 C ++ 多了一个**解释**功能,这个功能可以支持 Java 语言的**跨平台**的特性,可以将语言转化为对应的系统的机械码,对应的,比起 C 和 C++ 性能要差一些。
跨平台性:
如果一个编程语言没有跨平台的特性,需要在每个不同的操作系统上进行适配才能让程序在该系统上运行。这意味着针对每个操作系统,需要重新编写或修改程序的部分代码,以使其能够与该操作系统兼容。
举例来说,假设某个编程语言的代码只能在特定操作系统(比如只能在 Windows 上运行)上执行,那么如果希望这段代码能在其他操作系统(如 macOS、Linux 等)上运行,就需要对其进行适配或重写,以使其能够兼容其他操作系统的特性和功能。
这种情况下,开发者需要针对不同的操作系统编写特定版本的代码或者使用特定的工具来进行跨平台适配,以确保程序能够在不同的操作系统上运行。这也是为什么具备跨平台能力的编程语言和工具(比如 Java、Python 等)受到青睐,因为它们可以使开发者编写一次代码,然后在不同操作系统上运行而无需针对每个操作系统进行修改。
当 Java 虚拟机发现某一段代码是热点代码,即这段代码会在后面的程序中多次使用的时候,JVM 会将这个代码解释并优化成机械码,然后将其保存在内存中,方便之后的调用。
上述的操作使得 Java 虚拟机实现了即时编译的功能(JIT),能保证性能接近 C 和 C++ 甚至在特定的情况下超越。
常见的 JVM
Java 虚拟机规范
- 《 Java 虚拟机规范 》由 Oracle 指定,内容包含了虚拟机在设计和实现的时候需要遵守的规范,主要包含 class 字节码文件的定义、类和接口的加载和初始化、指令集等内容。
- 这个规范是对虚拟机的要求,但不一定是 Java 虚拟机,其他语言生成的 class 字节码文件之上也可以运行虚拟机。
HotSpot 发展史
字节码文件详解
Java 虚拟机的组成
JVM(Java Virtual Machine,Java 虚拟机)是运行 Java 字节码的虚拟计算机,它由多个组成部分构成,包括以下主要组件:
- 类加载器(Class Loader):负责将类的字节码加载到 JVM 中。它将类文件加载到内存,并生成相应的 Class 对象,用于在运行时创建实例、访问字段和调用方法。
- 运行时数据区域(Runtime Data Areas):包括方法区、堆、虚拟机栈、本地方法栈和程序计数器。
- 方法区(Method Area):存储类信息、常量、静态变量等数据。
- 堆(Heap):存放对象实例。
- 虚拟机栈(Java Virtual Machine Stacks):存储方法调用的局部变量、部分结果和返回值。
- 本地方法栈(Native Method Stack):为 Java 方法调用 Native 方法(非 Java 语言编写的方法)服务。
- 程序计数器(Program Counter):记录当前线程执行的字节码指令地址。
- 执行引擎(Execution Engine):负责执行编译后的字节码。包括解释器和 JIT 编译器。解释器逐行解释字节码,而 JIT 编译器将热点代码(频繁执行的代码)编译为本地机器代码以提高执行效率。
- 本地方法接口(Native Interface):允许 Java 代码调用本地库中的方法。
- 本地库接口(Native Libraries):Java 虚拟机使用的本地库,提供与操作系统交互的能力。
其中的本地方法指的是使用本地语言(如 C 或 C++)编写的方法,通过这些方法,虚拟机可以实现与操作系统或者硬件之间的交互,通过本地方法,虚拟机可以实现如下的功能:
- 操作系统交互:Java 中的一些功能需要直接与操作系统进行交互,比如文件操作、网络通信、图形界面等。为了实现这些功能,Java 使用本地方法调用操作系统提供的相关功能。
- 性能优化:某些对性能要求极高的任务可能通过本地方法来实现,因为本地语言(如C或C++)可以更接近底层硬件,可以更高效地执行某些计算密集型操作。
- 特定硬件功能:有时,需要访问特定硬件设备或执行底层操作,这时候本地方法可以提供直接的接口,以便与硬件交互。
字节码文件
以正确的方式打开文件
字节码文件保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读
jclasslib:https://github.com/ingokegel/jclasslib/releases
同样的,可以选择 IDEA 插件,也叫做 jclasslib,后期主要使用这个插件进行操作
字节码文件的五个组成部分
- 基础信息:魔数、字节码文件对应的 Java 版本号,访问表示(public、final 等等)父类和接口
- 常量池:保存了字符串常量、类或接口名、字段名主要在字节码指令中使用
- 字段:当前类或者接口声明的字段信息
- 方法:当前类或者接口声明的方法信息的字节码指令
- 属性:类的属性,比如源码的文件名、内部类的列表等等
详解字节码文件的组成
这里我们主要说基本信息、常量池和方法
基本信息
魔数、字节码文件对应的 Java 版本号,访问表示(public、final 等等)父类和接口
- 魔数
魔数通常用于识别特定格式的文件,比如图像文件(如JPEG、PNG)、可执行文件(如ELF、PE)或归档文件(如ZIP、RAR)。
- 文件时无法通过文件的拓展名来确定文件的类型的,文件的拓展名可以随意的修改,不影响文件的内容
- 软件使用文件的头几个字节(文件头)去验证文件的类型,如果文件不符合着各种类型就会报错
- Java 字节码文件中,将文件头称为 magic 魔数
我们通过查看文件的十六进制编码就可以在开头看到这些标识。
- 主副版本号
要了解主副版本号,我们首先来回顾一下 JDK(Java Development Environment)
JDK 主要包含了下面这些内容
- JRE(Java Runtime Environment): JDK 中包含了 JRE 的所有内容,因此 JDK 也可以用作运行 Java 程序的环境。JRE 包括 Java 虚拟机(JVM)和运行 Java 应用程序所需的核心类库
- Java 命令行工具: JDK 提供了一系列的命令行工具,用于编译、调试、执行和分析 Java 应用程序。比如 java(运行 Java 程序)、javac(编译 Java 程序)、javadoc(生成 API 文档)、jdb(Java 调试器)等。
- Java API 和类库: JDK 包含了大量的 Java API 和类库,提供了丰富的功能和工具,用于开发各种类型的应用程序。这些类库包括 Java 核心类库、I/O、网络、集合框架、GUI(Swing、JavaFX)、安全性等各个领域的支持。
- 开发工具(IDE)支持: JDK 可与各种 Java 开发工具(如 Eclipse、NetBeans、IntelliJ IDEA 等)集成使用,提供开发和调试 Java 应用程序的环境
- 调试器和性能分析工具: JDK 提供了调试器(jdb)和一系列性能分析工具,用于调试和分析 Java 应用程序的性能问题,帮助开发人员诊断和解决代码中的错误和性能瓶颈。
- JavaFX 和其他扩展: JDK 中还包括 JavaFX 等扩展技术,用于开发富客户端应用程序。此外,JDK 也支持其他扩展和工具,如 Java Mission Control(JMC)等。
我们编写的代码会通过 JDK 中的编译工具编译成字节码文件之后交给虚拟机运行,这个编译过程会首先检查我们编写的代码和当前 JDK 的版本是否兼容。
- 判断当前字节码文件的版本和运行时的 JDK 是否兼容,
我们在开发中可能会遇到下面的报错:
比如我们引用别人的库的时候,调用其中的 class 文件如果和我们当前运行的环境不匹配的
通过上面的 主版本号 - 44
的公式,我们可以看出这个需要的是 JDK 8 但我们的环境是 JDK 6,这时候我们有两种解决方法:
- 升级 JDK 版本
- 降低我们依赖的版本号或者更换依赖
显然后一种最稳健,不会对项目中的其他代码产生影响。
常量池
为了避免相同的内容的重复定义,节省空间
Java中的常量池主要分为两种:
- 编译期常量池(Compile-Time Constant Pool): 这是在编译期间确定的常量池。它包含类文件中的常量池表(Constant Pool Table),存储着类、方法、接口等的符号引用、字面量常量等。
- 运行时常量池(Runtime Constant Pool): 这是JVM在运行时动态生成的常量池。它是方法区的一部分,用于存放在编译期无法确定的常量,比如使用String类的intern()方法在运行时将字符串对象添加到常量池中。
Java常量池的使用方式和注意事项:
- 字符串常量池:Java中的字符串常量池是String类中特有的,它存储着所有的字符串字面量。当创建字符串常量时,如果常量池中已经存在相同内容的字符串,则不会再创建新的对象,而是直接引用已存在的字符串对象。
- 使用final关键字:通过使用final关键字定义的常量会被优化,并在编译时被放入常量池中。这些值在运行时无法修改。
- 自动装箱:在使用自动装箱时,如果值在-128到127之间,Java会将其缓存起来,使得对象引用指向同一个常量。
- 字符串的intern()方法:调用字符串的intern()方法会将字符串对象添加到常量池中(如果池中已经存在相同内容的字符串,则返回池中的对象引用)。
- 编译器优化:编译器会对一些简单的表达式进行计算,并在编译期间将计算结果存入常量池中。
比如看下面这个例子,我们如果写了很多相同的字符串,如果字节码文件采取右边的存储方法,光是存储这些字符串就占了大量的内存,为了节省内存的开支,JVM 虚拟机有一块单独的内存,用于存储被编译器或运行时系统使用的常量和字面量。它包含了编译时生成的各种字面量和符号引用。
下面我们来利用 jclasslib
来观察一下常量池的机制,先编写一个简单代码示例
package com.kq.Basic;
/**
* 演示常量池的特点
*/
public class ConstantPools {
String a = "abc";
String b = "abc";
}
编译后使用 jclasslib
打开
我们打开就可以发现一个指向的是字符串池子中的对象,一个是字符串常量池中的具体元素
这时候查看一下这段程序的执行流程,在方法区域,观察字节码文件
0 aload_0 // 将当前对象的引用加载到操作数栈上
1 invokespecial #1 // 调用超类构造方法
4 aload_0 // 将当前对象的引用加载到操作数栈上
5 ldc #7 <abc> // 将字符串常量"abc"加载到操作数栈上
7 putfield #9 // 将操作数栈上的"abc"赋值给当前对象的字段
// (此处 #9 是字段的索引或标识)
10 aload_0 // 将当前对象的引用加载到操作数栈上
11 ldc #7 <abc> // 将字符串常量"abc"加载到操作数栈上
13 putfield #15 // 将操作数栈上的"abc"赋值给当前对象的字段
// (此处 #15 是字段的索引或标识)
16 return // 返回
注意看这一步 将操作数栈上的"abc"赋值给当前对象的字段
我们通过地址就会发现是我们上面提到的 #7 对象,这时候 a
指向的就是字符串常量池中的对象。
我们顺着这个地址过去,发现是指向的这个对象,这个对象又指向了字符串常量池的另一个对象
,那为什么不直接使得指向的这个对象就是字符串呢,还要多一个中间的步骤?
字符串常量池是为了节省内存并提高性能而设计的特殊机制,主要原因如下:
- 字符串频繁被使用:在许多应用中,字符串是经常使用的数据类型之一。由于字符串的不变性质,它们很容易被共享和重复使用,因此将字符串放入共享的常量池中可以减少内存占用。
- 字符串不变性:字符串在 Java 中是不可变的,即一旦创建就不能被修改。如果将字符串直接放入常量池中,可以保证字符串的不可变性,避免了在运行时更改字符串的可能性。
- 字符串比较效率:使用字符串常量池可以加快字符串的比较速度。由于字符串常量池中的字符串是唯一的,可以通过比较引用地址来进行比较,提高了比较的效率。
- 其他类型的常量(例如整数、浮点数、布尔值等)在 Java 中也有常量池,但并不像字符串常量池那样明确,因为它们通常不会像字符串那样频繁地被使用。字符串常量池的设计是为了针对字符串这种特殊的不变性和频繁使用性质而提出的一种优化策略。
方法
当前类或者接口声明的方法信息,这个部分放在下个博客中详细说明。