Java虚拟机作为Java技术体系的核心组成部分,其重要性不言而喻。它不仅为Java提供了跨平台的能力,更是Java程序运行的基石。本文将为您深入解析Java虚拟机的工作原理、作用和应用场景,并通过生动的实例让您彻底理解这一关键技术。
一、Java虚拟机详细介绍
1、什么是Java虚拟机?
Java虚拟机(Java Virtual Machine,JVM)是一个虚拟的计算机,它有自己完善的硬件架构,能够运行Java字节码。JVM有着与硬件无关的指令集,屏蔽了与具体操作系统相关的信息,使得Java语言编译后的字节码可以在多种平台上运行。这正是Java"一次编写,处处运行"的根本所在。
JVM 并非只能运行Java语言,而是面向多语言的运行平台,只要遵守JVM的约定(只要编译成.class文件),就可以运行在JVM虚拟机上。
现在除了Java之外,比如scala、kotlin、groovy等语言都运行在JVM上。
2、Java虚拟机的作用
(1)、内存管理
自动内存管理是JVM最基本的作用之一。JVM通过自动分配和管理内存,防止了手动编码过程中可能出现的内存泄漏等问题。
(2)、跨平台能力
Java字节码可以在任何安装了JVM的设备上运行,这使得Java拥有了出色的跨平台能力,避免了操作系统、硬件的制约。
(3)、自动优化
现代JVM能够对热点代码进行运行时的动态优化,包括生成本地机器代码、内联编译等,大幅提升程序性能。
(4)、安全性
JVM对字节码进行了语法检查、类型检查和安全检查,并提供了沙箱防护机制,确保了Java程序的健壮与安全。
3、JVM、JDK、JRE 的区别与联系
JVM、JDK、JRE是Java开发和运行环境中的三个核心组件,它们之间有着紧密的关系:
(1)、JVM(Java Virtual Machine,Java虚拟机)
JVM是Java平台的核心组成部分,它是一个可以执行Java字节码的虚拟计算机。JVM提供了一个运行时环境,用于加载Java程序的类文件,执行其中的字节码,并管理程序的执行,如内存管理、垃圾回收等。JVM确保了Java的跨平台性,即所谓的“一次编写,到处运行”。
(2)、JRE(Java Runtime Environment,Java运行时环境)
JRE是运行Java应用程序所必需的环境。它包含了JVM以及Java核心类库和其它运行Java程序所需的资源。简单来说,JRE是JVM的宿主环境,它提供了JVM运行所需的所有资源。JRE不包含开发工具,因此它只适用于运行Java程序。
(3)、 JDK(Java Development Kit,Java开发工具包)
JDK是为Java开发者提供的一套工具包,它包含了JRE的所有内容,并且还包括了用于开发Java应用程序的工具和库。JDK中包含了javac编译器、调试器、jar工具、Java文档生成器(javadoc)等开发工具。此外,JDK还包含了一些用于Java性能测量和监控的工具,以及Java平台的源代码。
(4)、JDK、JRE和JVM的关系
-
JVM是JRE的一部分:JRE提供了JVM,使得Java程序可以在不同的平台上运行。
-
JDK包含了JRE:JDK中包含了一个完整的JRE,因此安装了JDK就不需要单独安装JRE。
-
JDK为开发者提供工具:JDK提供了编译器和调试器等开发工具,而JRE和JVM则提供了运行Java程序所需的环境。
(5)、应用场景
- JRE适用于只运行Java程序的用户:如果只需要运行Java程序,不需要开发Java程序,那么安装JRE就足够了。
- JDK适用于Java开发者:如果你是一名Java开发者,需要编写、编译和调试Java程序,那么需要安装JDK。
总之:
JVM作为核心,提供了字节码的执行环境;JRE作为JVM的宿主,提供了运行时所需的资源;JDK则为开发者提供了完整的开发和运行环境。
二、Java虚拟机的工作原理
Java虚拟机的工作过程可以概括为以下几个阶段:
1、编译阶段
首先 java经过javac编译成.class文件。
类的编译阶段主要是把源码文件编译成JVM可以解析的class文件,这个阶段会经过词法分析、语法语义分析、生成字节码,这个几个阶段后生成最后的class,用16进制的方式打开class文件后如图:
class
文件是Java编译器将Java源代码(.java
文件)编译后生成的字节码文件。这个文件是JVM执行引擎执行的基础。.class
文件包含了多种类型的数据,用于确保JVM能够正确地加载、链接和执行代码。以下是.class
文件中包含的主要内容:
-
魔数(Magic Number)
每个.class
文件的开始都是魔数,用于标识这个文件是否是一个有效的类文件。魔数是四个字节,值为0xCAFEBABE
。 -
版本信息
包括主版本号和次版本号,用于确定这个.class
文件是为哪个版本的Java虚拟机编译的。 -
常量池(Constant Pool)
常量池用于存储编译期间生成的各种字面量和符号引用。这部分可能包括类和接口的名称、字段名、方法名、字符串常量等。 -
访问标志(Access Flags)
定义了这个类或接口的访问权限,如是否是公共的(public)、抽象的(abstract)等。 -
类索引、父类索引
类索引用于确定这个类的全名,父类索引指向常量池中的一个引用,表示这个类的直接父类。 -
接口索引表(Interfaces)
如果这个类实现了一个或多个接口,接口索引表将列出这些接口。 -
字段表集合(Fields)
字段表包含了类的字段信息,每个字段都有其名称、描述符、访问修饰符等。 -
方法表集合(Methods)
方法表包含了类的方法信息,包括方法的名称、返回类型、参数列表、局部变量表、字节码指令等。 -
属性表集合(Attributes)
属性表可以包含多种不同的属性,如代码(Code)属性包含了实际的字节码指令、行号表、异常处理表等;SourceFile属性表示源代码文件的名称;还有其他如Deprecated、Synthetic等属性。 -
字节码(Bytecode)
方法的字节码是JVM执行的指令集,定义了方法的行为。 -
栈映射表(Stack Map Table)
在较新的JVM版本中,栈映射表用于支持字节码的验证和JIT编译器的优化。 -
异常表(Exception Table)
如果方法中有try-catch
块,异常表将列出可能抛出的异常类型和异常处理器。
这些组成部分共同定义了Java类的结构和行为,使得JVM能够加载和执行Java程序。开发者通常不会直接编辑.class
文件,而是通过Java源代码进行编程,并利用编译器生成这些字节码文件。
2、类加载阶段
Java 类加载过程是 Java 运行时环境中的一个关键特性,它涉及到将 Java 类(通常是 .class
文件)加载到 JVM(Java 虚拟机)中,并为类的实例化和执行做准备。类加载过程大致可以分为以下几个阶段:
(1)、加载(Loading)
加载是类加载过程的第一个阶段,在这个阶段,JVM 完成以下任务:
-
通过类的全名查找
.class
文件。 -
加载
.class
文件到内存中,通常使用二进制形式。 -
通过解析常量池中的符号引用(如类名、方法名、字段名等),将它们转换为直接引用。
(2)、验证(Verification)
验证阶段确保加载的类信息是合法的、符合JVM规范的,不会危害虚拟机安全。验证包括以下几个方面:
-
文件格式验证:确保
.class
文件符合Java类文件的规范。 -
元数据验证:检查类的结构信息,如访问修饰符、类和接口的继承关系等。
-
字节码验证:确保字节码的指令不会做出危害虚拟机安全的行为。
-
符号引用验证:确保符号引用能正确解析。
(3)、准备(Preparation)
准备阶段是为类的静态变量分配内存,并设置初始值。这里的初始值指的是数据类型的零值,如:
- 整数类型
int
的初始值为0
。 - 浮点类型
float
的初始值为0.0
。 - 引用类型(如类、接口、数组)的初始值为
null
。 boolean
类型的初始值为false
。char
类型的初始值为\u0000
(Unicode编码中的空字符)。
(4)、解析(Resolution)
解析阶段是将常量池中的符号引用转换为直接引用的过程。直接引用是指向内存中的直接地址。解析包括:
-
类或接口的解析。
-
字段的解析。
-
方法的解析。
-
接口方法的解析。
-
字段和方法的访问权限的解析。
(5)、初始化(Initialization)
初始化是类加载的最后一个阶段,它包括:
- 执行静态初始化块(使用
static
关键字声明的代码块)。 - 为静态变量赋予正确的初始值。
- 如果类具有超类(除了
Object
类之外),则先初始化其超类。
执行顺序和触发条件:
- 类的加载过程是按照上面列出的顺序进行的,但并不是所有的类都会经过所有阶段。例如,如果一个类没有进行任何静态变量的赋值操作,那么它可能不会进入初始化阶段。
- 类的加载是由某个特定的动作触发的,如
new
关键字实例化对象、访问静态字段、调用静态方法等。
注意事项:
- 类加载器不仅仅加载
.class
文件,它还负责加载包和其他资源文件。 - 类加载过程是线程安全的。
- 类加载器的父子委派模型确保了Java核心库的安全性,防止核心库被随意替换。
通过以上各个阶段,JVM 确保了类的安全性、一致性和可用性,为类的执行提供了必要的准备和初始化工作。
3、解释和执行阶段
加载完成后,执行引擎负责解释和执行字节码指令。执行引擎主要由解释器和即时编译器(JIT)两部分组成。解释器逐条解释字节码,JIT则能够动态编译热点代码为优化后的本地机器码以提高执行效率。
执行引擎通常包括以下部分:
-
字节码解释器:负责逐条解释执行字节码。
-
即时编译器:负责将热点字节码编译为机器码。
-
代码优化器:负责对编译后的机器码进行进一步的优化。
-
代码缓存:存储编译后的机器码,供后续执行使用。
(1)、解释执行(Interpretation)
执行引擎的一种基本工作模式是解释执行。在这种模式下,执行引擎逐条读取字节码,并根据字节码执行相应的操作。这种方式类似于我们在学习编程时使用的解释器。
过程:
-
第一步,字节码扫描:执行引擎从PC寄存器(程序计数器)指向的字节码指令开始执行。
-
第二步,解释执行:对每条字节码进行解释,并执行其对应的操作。
-
第三步,状态更新:更新操作数栈、局部变量表和程序计数器等状态。
-
第四步,循环执行:继续扫描下一条字节码,直到方法执行完毕或遇到返回指令。
(2)、即时编译(Just-In-Time Compilation, JIT)
为了提高程序的执行效率,现代JVM通常采用即时编译技术。即时编译器将热点代码(频繁执行的代码)编译为本地机器码,以提高执行速度。
过程:
-
第一步,代码选择:JIT编译器选择需要编译的热点代码,这些代码可能是被多次调用的方法或循环体。
-
第二步,编译优化:JIT编译器将字节码编译为机器码,并进行优化,如分支预测、循环展开等。
-
第三步,本地代码缓存:编译后的机器码被存储在执行引擎的代码缓存中,以便重复使用。
-
第四步,执行本地代码:执行引擎直接执行编译后的机器码,而不是逐条解释字节码。
(3)、热点代码探测(HotSpot Code Detection)
为了确定哪些代码需要即时编译,JVM需要能够探测热点代码。这通常通过以下方式实现:
- 计数器:为每个方法入口和循环回边设置计数器,统计执行次数。
- 阈值设定:当计数器达到一定阈值时,认为该代码是热点代码,适合进行即时编译。
(4)、多版本优化(Multi-Versioning)
为了进一步优化性能,JVM可以为同一段代码生成多个版本,以适应不同的执行环境或条件。例如,基于处理器的CPU架构特点,生成优化的机器码。
JVM的执行引擎是实现Java程序高效运行的关键。通过解释执行和即时编译,执行引擎能够在保持跨平台特性的同时,提供接近本地编译语言的性能。随着JVM技术的发展,执行引擎的优化策略也在不断进步,以满足日益增长的性能需求。
4、内存管理阶段
Java虚拟机负责自动分配和管理堆、栈、方法区等运行时数据区域。其中垃圾收集器(GC)通过跟踪可达性分析,自动回收无用对象的内存空间。
JVM内存管理是确保Java程序高效运行的关键环节。JVM内存管理主要包括以下几个阶段:
(1)、类加载过程中的内存分配
当JVM开始加载一个类时,它会为这个类分配内存以存储类的元数据。这包括类的信息、字段、方法等。这个阶段的内存分配是在方法区(Method Area)进行的。
(2)、对象创建时的内存分配
当使用new
关键字创建一个对象时,JVM会在堆(Heap)中为这个新对象分配内存。堆是JVM中用于存储对象实例和数组的部分。
过程:
-
确定对象大小:JVM首先确定对象所需的内存大小。
-
内存分配:在堆中找到足够大的连续空间分配给对象。
-
对象头信息设置:设置对象头(Mark Word),包含对象的哈希码、GC信息、锁状态等。
-
初始化:执行
<init>
方法,对对象进行初始化。
(3)、 运行时的内存管理
在程序运行期间,JVM需要不断管理内存的使用,包括局部变量的创建和销毁,以及对对象的访问。
过程:
-
局部变量表:方法执行时,其局部变量会在栈(Stack)的局部变量表中分配空间。
-
操作数栈:方法执行过程中,临时数据会在操作数栈中存取。
-
引用访问:通过引用访问堆中的对象,进行读写操作。
(4)、垃圾收集(Garbage Collection, GC)
JVM周期性地执行垃圾收集,回收不再被引用的对象所占用的内存。
过程:
- 可达性分析:从一系列的GC Roots开始,标记所有可达的对象。
- 清理:清除未被标记的对象,回收内存。
- 内存整理:可选地整理内存,减少碎片。
(5)、 内存分配和回收策略
JVM使用不同的算法和策略来优化内存的分配和回收。例如:
- 复制算法:用于新生代,将内存分为一个eden区和两个survivor区,对象在这些区域之间复制。
- 标记-清除算法:用于老年代,首先标记所有存活的对象,然后清除未被标记的对象。
- 标记-清除-整理:在标记-清除的基础上,对内存进行整理,消除碎片。
(6)、内存分配的并发处理
为了避免在内存分配和回收过程中的Stop-The-World现象,JVM采用了并发处理机制:
- 并发标记:在应用程序运行的同时进行对象的标记。
- 并发清理:在应用程序运行的同时进行内存的清理。
- 增量收集:将垃圾收集过程分步骤执行,减少GC暂停时间。
(7)、 内存溢出处理
当JVM的内存不足时,会抛出内存溢出异常。例如,堆内存不足时会抛出OutOfMemoryError
。
JVM内存管理是一个复杂的过程,涉及到内存的分配、垃圾收集、内存整理和并发处理等多个方面。通过有效的内存管理策略,JVM确保了Java程序的高效运行,同时提供了自动内存管理和垃圾回收功能,简化了内存管理的复杂性。了解JVM内存管理的工作原理对于Java开发者来说是非常重要的,它有助于我们编写更高效的程序,并在遇到性能问题时能够快速定位和解决。
// GC示例
public static void main(String[] args) {
byte[] array = new byte[1024 * 1024 * 50]; //50MB数组
array = null; // 数组不可访问
System.gc(); // 触发GC
}
5、执行子系统阶段
JVM执行子系统不仅仅是执行字节码的组件,它还提供了与操作系统之间通信的能力,支持线程同步、文件I/O等操作。以下是执行子系统在这些方面的作用和实现方式:
(1)、线程管理
JVM执行子系统负责管理Java程序中的线程。每个线程在操作系统中都对应一个内核线程,JVM需要管理这些线程的创建、同步、调度和终止。
-
线程创建
- Java程序通过
Thread
类或Runnable
接口启动新线程。JVM负责将这些线程映射到操作系统的线程。
- Java程序通过
-
线程同步
- JVM提供了同步机制,包括synchronized关键字和java.util.concurrent包中的并发工具,以确保线程安全。
-
线程调度
-
JVM执行子系统与操作系统的调度器协作,管理线程的执行顺序和时间分配。
-
(2)、系统调用
JVM提供了对本地系统调用的接口,使得Java程序能够执行操作系统级别的操作。
JVM通过本地库接口(如Java Native Interface, JNI)允许Java代码调用本地方法,实现与操作系统的交互。
(3)、文件I/O 及操作
JVM执行子系统支持文件输入输出操作,使得Java程序能够读写文件。
I/O操作:
Java程序使用java.io
和java.nio
包中的类进行文件I/O操作。JVM负责将这些操作映射到对应的操作系统I/O调用。
(4)、网络通信
JVM支持网络通信,使得Java程序能够进行网络连接和数据传输。
网络I/O:
Java程序使用java.net
包中的类进行网络编程。JVM负责网络连接的建立、数据的发送和接收。
(5)、 安全性
JVM执行子系统还负责执行安全策略,确保Java程序不会执行有害的系统操作。
(6)、安全管理器
JVM提供了安全管理器(java.lang.SecurityManager
),可以监控和限制程序对系统资源的访问。
(7)、 性能监控与优化
JVM执行子系统还包括性能监控和优化工具,帮助开发者了解程序的运行情况,并进行性能调优。
JVM执行子系统是Java程序与操作系统之间的桥梁。它不仅负责执行字节码,还提供了线程管理、内存管理、系统调用、文件I/O、网络通信等能力,确保Java程序能够高效、安全地运行。通过JVM执行子系统,Java程序能够充分利用操作系统提供的资源和功能。
以上就是Java虚拟机的基本工作流程,体现了JVM为Java提供了一个安全、高效的运行环境。
三、Java虚拟机的应用场景
Java虚拟机为我们提供了无数的便利,可以在以下场景大显身手:
-
跨平台应用开发
JVM的存在使得Java可以运行于任何安装了虚拟机的平台,从而实现了跨平台的能力,如Android应用、嵌入式设备、云服务器等。
-
大数据分析
Hadoop、Spark等大数据处理框架都是建立在JVM之上,利用其高效的内存管理和多线程支持,极大提高了分布式计算能力。
-
中间件服务
Java的各种应用服务器中间件如WebLogic、WebSphere、Tomcat等,都是基于JVM构建的,发挥了JVM提供的跨平台、高效、安全等特性。
总之,Java虚拟机作为Java技术体系的根基,贯穿于Java应用的方方面面。了解JVM的工作原理和特性,将使我们能够编写出更加高效、可靠的程序。期待在下期与您分享更多关于JVM调优和故障排查的干货知识,敬请期待!