写点什么

奇妙 JVM(一):Java 程序员必须知道的神秘黑箱

作者:xfgg
  • 2023-05-24
    福建
  • 本文字数:6218 字

    阅读完需:约 20 分钟

奇妙JVM(一):Java程序员必须知道的神秘黑箱

JVM(Java Virtual Machine)是 Java 开发中最核心的组建之一,是一个虚拟计算机,它可以在不同的平台上运行 Java 程序。

JVM 的主要功能是将编写好的 Java 程序代码(存储在.class 文件中)翻译成计算机能够读取和执行的指令,然后将这些指令执行。在运行 Java 程序时,JVM 创建 Java 环境,加载需要的类和库,并管理程序的运行。它还提供了自动内存管理(即垃圾回收)和内存安全性检查,使程序开发人员无需手动分配和释放内存。

Java 程序在编写完成后,需要经过编译器编译为字节码(bytecode),然后由 JVM 在计算机平台上将字节码解释为可执行代码。这样就实现了 Java“一次编写、到处运行”的特性,使得 Java 成为了跨平台开发的精华。

JVM 如何启动 Java 程序

讲解 JVM 之前,先简单的了解一下 JVM 是如何启动 Java 程序


  1. 进入 JVM:启动 JVM,JVM 自动执行其含有指向 JVM 的方法,启动运行时工作环境

  2. 加载类(ClassLoader): 从指定的 classpath 中,JVM 找到应用启动的 Main Class(含有 main 方法的入口)。然后寻找并加载 Main Class 以及它所依赖并被应用程序引用的其他类。

  3. 验证类(Verification):JVM 对加载的每一个类进行验证,以避免安全性和规范性问题,并预先实例化 Java 对象数据和方法的唯一实现。

  4. 分配存储空间(Memory Allocation): JVM 分配新的分区,包括程序代码所在的 CodeSpace (executable code written on hard-disk, inside a .java or .jar file) 和 JavaHeap。

  5. 类的初始化(Initialization):分配内存和欢叫构建完毕后,JVM 看是否需要执行类的 static{} 。

  6. 执行 byteCode: 最后 JVM 执行 Main()方法的 Java 源码上转化而来的字节码指令,把 Java 代码对象和应用程序中 JVM 正在操作的其他对象运行所需要的类实例放入 Main thread 并运行它。

内部组件

类加载器(Class Loader)

Java 虚拟机(JVM)的类加载器是将类的字节码文件加载到 JVM 中的关键组件。Java 语言的设计将程序作为一系列相互独立的类进行表达,当 JVM 遇到某个类时就需要对其进行加载(即装载)该类的.class 文件,并将其转换成特定的对象供 Java 程序使用。


在 Java 虚拟机中,类加载器并非只有一种,按照加载类的方式的不同,它们分为以下 3 面分类:

  1. 启动类加载器(Bootstrap Classloader):也称为引导类加载器。它负责加载 JVM 自身拥有的,即 Java 核心 API 类库(rt.jar 等),与操作系统直接相关的类,它是任何类加载器之母。

  2. 扩展类加载器(Extension Classloader):用于 Java 的扩展类库的加载。在 Java 虚拟机标准实现中,该种称为「标准的扩展类加载器」的类来取得指定的 jar 文件或目录只有:在 ${java.home}/{lib},或 -Djava.ext.dirs 对应的路径中所有的 jar 以及 *.class.

  3. 系统类加载器(System Classloader):(用的最多)也称为应用程序类加载器,代码中通过 this.getClass().getClassLoader() 或 ClassLoader.getSystemClassLoader() 或 Class.getClassLoader() 取到的就是它。系统类加载器是 Java 中默认的类加载器,用于加载 Java 应用程序的类。


Java 类的加载过程大致可以认为分为三个阶段:

  1. 加载阶段(Loading):查找并且获取目标类的二进制数据文件并且加载到 JVM 内;

  2. 连接阶段(Linking):对目标类进行校验、为类中静态变量分配内存空间、解析成符号引用等前期工作都在这里完成;

  3. 初始化阶段(Initializing):如果上述阶段都没有引发异常,并且需要执行类中特定的一些静态方法,此时 JVM 将执行该类的初始化过程,完成后整个类就准备好进入运行时阶段了。

运行时数据区

程序计数器

Java 虚拟机的程序计数器(Program Counter Register)是一块内存空间,虚拟机中的线程都有一个独立的程序计数器,用于存储当前线程执行的字节码指令的地址。在任何时候,伴随着线程的切换,使得虚拟机总是准确掌握线程执行的位置,这是 Java 虚拟机实现多线程关键之一。


它可以理解为是当前线程所执行的字节码信号指示器。每当线程被 CPU 调度执行时,它的程序计数器都能够表明该线程将要执行的字节码指令的位置。


Java 虚拟机中的程序计数器是线程私有的,既不存在“多个线程同时访问”的情况,也不会因为多线程的切换而产生错误。在任何时间,某一个线程都只会执行一条字节码指令,而程序计数器记录的就是该线程正在执行的字节码指令的地址,并指向下一条将要执行的指令的地址。由于程序计数器是线程独有的,因此它具备线程隔离和线程轻量级的特性。


总之,Java 虚拟机的程序计数器承担着很多不同的作用:

  1. 字节码解析器:字节码解析器获取下一条需要执行的指令。

  2. 线程恢复现场:如果线程正在执行 Java 方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果此时发生了异常,Java 虚拟机需要知道偏移量来恢复堆栈中已检查过并且未处理的异常。

  3. 计算跳转:例如,在 if-else 条件判断、for、while、switch-case 等流程语句中,程序计数器记录了各种条件跳转、循环跳转、异常跳转的地址,执行跳转指令时直接读取跳转地址。

  4. 线程安全: 由于程序计数器是线程私有的,多线程间计数器互不干扰,不会出现线程安全的隐患。


但需要注意的是,程序计数器是一块非常小的内存空间,在 32 位 Java 虚拟机中,它就是一个 32 位的寄存器,理论上可达到 2 的 32 次方也就是 4G 的标识能力,实际上远未使用那么大空间,64 位虚拟机的程序计数器通常会更大,足以实现畅快使用。但这种寄存器是由硬件直接支持,在 Java 虚拟机层面不会过多介入计数过程,这也是导致计数 ELP 解读困难的原因之一。

虚拟机栈

JVM 的虚拟机栈又称为 Java 虚拟机栈,是 Java 应用程序执行 Java 方法时的内存模型。对于每一个线程,JVM 就会分配一个对应的虚拟机栈,这个栈用于存储在执行 Java 程序时该线程所涉及到的方法和数据。

虚拟机栈的生命周期与线程相同。当线程被创建时,JVM 会为线程创建一个对应的虚拟机栈。当线程终止时,与该线程对应的虚拟机栈会被销毁。在虚拟机栈中存放的是 Java 方法所用的局部变量和一些中间结果,同时也进行方法调用和返回操作等相关信息,其组成包括:

  1. 局部变量(Local variables):存储方法参数及方法中定义的局部变量。

  2. 操作数栈(Operand stack):存储方法执行过程中的操作数的栈,任何时候栈顶的数值被称为栈顶的操作数。

  3. 帧数据(Frame data):JVM 在每个方法调用时创建一个帧(Frame),帧保持线程执行方法的状态。一个帧包含一些必要的数据,如局部变量表、操作数栈、编译器标识、异常处理信息等。


虚拟机栈就像一摞盘子,无论是新创建的线程还是正在执行的线程,它都存在这么一个摞盘子,也就是一个虚拟机栈变量。每个栈都在方法调用时创建,并在返回后内存移除,该过程可以被形象地成为入栈和出栈。

而虚拟机规范为思考虚拟机栈标准定义了两种异常:

  1. 栈溢出异常(StackOverflowError):当线程请求的栈深度大于 JVM 所允许的深度时,将抛出一个 StackOverflowError 异常。

  2. 虚拟机栈空间不足的异常(OutOfMemoryError):当虚拟机栈的内存大小无法动态扩展时,将抛出一个 OutOfMemoryError 异常。


最后需要强调的是,留给虚拟机栈的空间是由操作系统设定的,随着 JVM 进化的版本更新,虚拟机栈空间也趋向于呈现增长的趋势,保障程序的稳定性。在调试程序时,还可通过“StackTrace”获得当前线程中方法被调用的顺序和当前线程执行到哪个位置,以此快速地定位问题所在

本地方法栈

JVM 中的本地方法栈是 JVM 用于执行本地方法(Native Method)的一段内存区域。本地方法栈与虚拟机栈类似,都是线程私有的,因此也具备线程隔离的特性。不同的是,虚拟机栈为 Java 方法服务,维护 Java 方法执行过程的状态,而本地方法栈则为虚拟机使用到的 native 方法服务,即用于执行非 Java 语言编写的本地代码的栈。


由于 Native 方法是使用非 Java 语言(如 C 和 C++)在本地编写的方法,即 Java 程序可以传递自己无法处理的任务到 Native 接口,让 C 程序去执行,那么在 Native 执行过程中,就需要有更高效、直接的执行方式,因此就需要使用到本地方法栈。

本地方法栈的功能主要是负责:

  1. 管理 Java 虚拟机调用原生方法时候栈帧用于区分错误信息(例如断言、异常等)和存放指令信息:在 JNI(Java Native Interface)操作中,可以使用 Native 方法进行非 Java 语言的交互;本地方法栈主要用于为 native 方法的执行服务。

  2. 执行 Native 方法:与虚拟机栈类似,本地方法栈也包含了很多栈帧,但其执行方式与 Java 特有的方法调用有区别,这种区别最明显的就是本地方法的调用,JVM 中本地级代码执行不使用 R 代乘 W 模式(栈式参数传递),Native 代码执行过程与 X+Y 模式更为相似。


需要注意的是,跟虚拟机栈一样,本地方法栈的内存也是一定量的。因为 Java 虚拟机内部实现机制不够直接调用本地系统的内存接口;所以,如 Java 虚拟机规范 (Java 7 版)所述,“对于使用 Java 虚拟机规范编写的小型应用来说,他们基本上已经有非常大的余地可以定义可反复使用堆和方法区的约数,而本地方法栈或许只会使用与反应与命令绑定的 NI 层 AP 的小量内存”。因此,JVM 中根据操作系统的不同而有大约 1-8M 的区间设计上限值。本地方法栈不同于虚拟机栈有执行 Java 会发生 StackOverflowError 而本地会抛出越界的异常。

堆是 JVM 中最大的一块内存空间,作为存储对象实例和数组的主要场所,它是被所有线程共享的。堆空间的布局也是 Java 虚拟机规范所规定的。JVM 将堆分割成了三个部分:新生代、老年代和永久代(元数据区)。

  1. 新生代:是一块较小的内存区域,被划分成了 Eden 区和 Survivor 区。每个对象在新生代 Eden 区中进行分配时,都是分配到连续的内存区域中,减少了垃圾碎片和内存分配的复杂性。新生代的主要特点是存在少量长期存活的对象,这些对象会被放入 Survivor 区,剩余的对象会在下一次垃圾回收时自动回收。

  2. 老年代:所有存在时间较长的对象都会放在老年代中。老年代的角色很重要,可以一定程度上预测系统的性能。因为内存容量越大,GC 的发生则会越少。所以及时设置堆大小有利于提升程序运行性能。这也是合理采用大对象不会导致由于运行环境限制的原有软件适应不了大数据客计算需求的关键。再多的内存空闲了也不为所动,会极大浪费。“Dan-Linglish 方法论”有驱动再生与淘汰标准的想法。

方法区

Java 虚拟机 (JVM) 是一个执行 Java 字节代码的虚拟机。内存管理在 Java 中是由 JVM 处理的。JVM 内存分为堆、栈、方法区、程序计数器、本地方法栈等几部分。其中,方法区 (Method Area) 是 JVM 虚拟机中的重要概念之一。


方法区保存了已被 Java 虚拟机加载的所有类的信息(构造函数,成员变量,静态变量,运行时常量池,方法信息等)。它是多线程共享的数据结构,并被所有线程共享。方法区通常放在非堆内存中。

方法区中存储了以下信息:

  1. 运行时常量池:是方法区的一部分,用于存储编译器生成的直接引用和符号性常量。

  2. 类型信息:类的基本信息如访问限制修饰字,字段,方法信息,接口,继承等,包括 Method RuntimeConstant 这两个结构的内部数据类型结构也存在于方法区

  3. 静态变量

  4. 编译后的代码(即虚拟机指令)

  5. 类型相关的运行时数据:如可以是类型的初始化参数,类型的状态,实例变量等

  6. 将常量放入字符串池中,可以防止重复,节省内存。


需要注意的是,方法区内存容纳过多数据会导致内存溢出(OutOfMemoryError)错误,所以我们要尽可能地减少类的数量和包的数量,也要减少大量字符串和常量池的使用。

执行引擎

JVM 执行引擎是 JVM 的重要组成部分,它是负责一条一条地解释或执行 Java 程序中所遇到的指令序列的组件。执行引擎的主要任务是执行 Java 程序的机器指令,把 Java 字节码转换成具体的操作。执行引擎在 JVM 分为两个层次:解释器和即时编译器(JIT)。

  1. 解释器

解释器是 JVM 最基础的执行引擎,它可以按照字节码包含的信息逐条地读取和解释字节码并转化成对应的机器指令直接运行,解释的速度比较慢,每条指令的执行都需要解释一次,解释的效率较低,但解释器的优势在于它可以在任意平台上工作,不依赖任何底层硬件和操作系统。

在 Java 程序启动时,首先会创建一个 Java 虚拟机(JVM)实例作为应用程序进程的一部分,然后创建线程栈,并将线程栈推入调用栈中。在处理 Java 程序时,JVM 将其源代码编译成一个或多个字节码文件,具体分类情况没有任何关系。执行引擎读取字节码文件的序列,并根据此执行程序。Java 程序通过 JVM 执行最终转化成或通过 JIT 编译进本地代码。


  1. 即时编译器(JIT)

为了解决解释器运行速度慢这个缺点,JVM 实现了即时编译器机制(JIT),它可以在解释器解释过程中按照特定的规则将解释器解释时机算法中的代码交给 JIT 编译器,让它把源代码优化和编译成 cimpany 短代码并被动态程序调用。

垃圾收集器

Java 虚拟机中的内存管理机制包括内存分配和垃圾收集。其中,垃圾收集是 Java 虚拟机最基本也是最重要的功能之一。Java 虚拟机中的垃圾回收主要包括:垃圾回收算法、垃圾回收器以及垃圾回收的策略。

垃圾回收算法主要有引用计数法、可达性分析法和复制算法等。其中,Java 虚拟机主要采用的是可达性分析法。


垃圾回收器是 Java 虚拟机中垃圾回收的具体实现,JVM 提供了多种垃圾回收器,用于不同场景下的垃圾回收。主要有以下几种垃圾回收器:

  1. Serial 垃圾回收器: 适用于单线程环境。

  2. Parallel 垃圾回收器: 适用于多核环境,并行垃圾回收可以在多个 CPU 上同时进行工作以提高垃圾回收效率。

  3. CMS 垃圾回收器: 基于标记-清除算法,适用于 WEB 应用场景,具有精准控制垃圾回收的能力。

  4. G1 垃圾回收器: 垃圾回收并不像其他回收器那样对堆分区进行操作,而是把所有堆分成多个区并执行收集,并且能够利用多核环境。


垃圾回收的策略包括堆的大小设置、堆的分代策略等,根据不同的场景和需求进行选择。

内容较多,后面会专门写一篇关于垃圾收集器的文章

Native 接口(JNI)

JVM 提供了 Native Interface(JNI)机制,允许 Java 应用程序调用本地代码库中编写的代码,并让本地构件使用 Java 应用程序中的一些方法。也就是说,它允许 Java 代码和 C 代码实现互操作。下面详细讲解 JVM 的 native 接口。

  1. JNI 接口的工作原理

Java 语言方法从 JVM 中调用某个本地方法时,JVM 首先搜索本地方法库中与该 Java 方法同名、同特征的方法。如果找到了该方法,JVM 将调用该本地方法;否则,JVM 将执行默认机制(JNI_EXPORT 或最简版 jni)。在 native Java 方法中调用本地方法时,JVM 通过执行 JNI 编写的 DLL 或.so 文件加载请求的方法。

  1. native 代码实现与 Java 应用实现的互操作

Java 代码中的本地方法需要与它 Java 运行环境(Java Runtime Environment)岂至相交互,以实现非 Java 代码调用 Java 代码或由 Java 完成特殊环境的控制。

由 Java-origin 的有效方式为:

  1. Java 编写 Callable 接口并调用它的 Future

  2. (1)在 Java 代码中派生(即通过或从 Runnable 或 Thread 继承的派生类)非 native 而非 daemon 线程,并通过基于 JDK 的类型的 Future.run 或 Runnable 短陵。

    (2)在此期限中,线程可以进入 I/O 中枢,但不从计算机中枢为非重新提交作贡献。

  3. 基于 Java 接口 100 完源程序生成本地代码,可比为 JAR 或实现在 DLL/100 页(多数不会 JavaFX 用到)等格式。

  4. 先编译和装载 Java 源程序文件,然后使用第三方转换程序,则可以使用 C 代码运行通过解决方案的 native 字后缀仅为依据的方法(可能为 JDK)调用这些方法,(例如 SYSTEM.etime JavaW()(可为 Unix 秒数数和 Win32 原始计数)或 SCREEN.getFirst ()65536L +nextInt(nil),C 尚可通过 JNIEnv 来操作 Java 大筦以帮助访问逻辑 Java 大筦。


Native 接口是 Java 语言中与操作系统进行交互的重要组成部分,其中 JNI 尤为突出。JNI 允许 Java 应用程序调用原生(本地)系统库中编写的代码。Java 需要与外部环境进行交互时,它可以使用 JNI 作为桥梁。在网上可以找到很多 JNI 的开发示例,学会了 JNI 类似于你掌握无限制来自 Java 系统的能力,这是开发高级系统、游戏的绝佳入门方法


发布于: 刚刚阅读数: 5
用户头像

xfgg

关注

还未添加个人签名 2022-11-03 加入

还未添加个人简介

评论

发布
暂无评论
奇妙JVM(一):Java程序员必须知道的神秘黑箱_Java_xfgg_InfoQ写作社区