读书笔记《supercharge-your-applications-with-graalvm》第一节 JVM的演进
Chapter 1: Evolution of Java Virtual Machine
Introduction to GraalVM
GraalVM 是一种高性能 VM,为现代云原生应用程序提供运行时。云原生应用是基于服务架构构建的。微服务架构改变了构建微应用的范式,挑战了我们构建和运行应用的基本方式。微服务运行时需要一组不同的要求。
- 占用空间更小:云原生应用程序在“按使用付费”模式运行。这意味着云原生运行时需要具有更小的内存占用,并且应该以最佳 CPU 周期运行。这将有助于以更少的云资源运行更多的工作负载。
- 更快的引导:可扩展性是基于容器的微服务架构最重要的方面之一。应用程序的启动速度越快,它扩展集群的速度就越快。这对于无服务器架构更为重要,其中代码被初始化并运行,然后根据请求关闭。
- 多语言和互操作性:多语言是现实;每种语言都有其优势,并将继续如此。云原生微服务正在使用不同的语言构建。拥有一个包含多语言需求并提供跨语言互操作性的架构非常重要。当我们转向现代架构时,尽可能多地重用代码和逻辑非常重要,这对业务来说是经过时间考验的和至关重要的。
GraalVM 为所有这些要求提供了解决方案,并提供了一个通用平台来嵌入和运行多语言云原生应用程序。它建立在 JVM 之上,并带来了进一步的优化。在了解 GraalVM 的工作原理之前,了解 JVM 的内部工作原理很重要。
传统的 JVM(在 GraalVM 之前)已经发展成为最成熟的运行时实现。虽然它有一些前面列出的要求,但它不是为云原生应用程序构建的,而且它带有单体设计原则的包袱。它不是云原生应用程序的理想运行时。
本章将详细介绍 JVM 的工作原理以及 JVM 架构的关键组件。
Learning how JVM works
Java 是最成功和广泛使用的语言之一。 Java 之所以非常成功,是因为它的一次编写,随处运行的设计原则。 JVM 通过位于应用程序代码和机器代码之间并将应用程序代码解释为机器代码来实现这一设计原则。
传统上,有两种运行应用程序代码的方式:
- 编译器:应用程序代码直接编译成机器码(C、C++)。编译器通过将应用程序代码转换为机器代码的构建过程。编译器为特定目标架构生成最优化的代码。必须将应用程序代码编译为目标架构。通常,编译后的代码总是比解释代码运行得更快,并且代码语义问题可以在编译时而不是运行时识别出来。
- 解释器:应用程序代码被逐行解释为机器代码(JavaScript 等)。由于解释器逐行运行,与编译后的代码相比,代码可能没有针对目标架构进行优化,并且运行缓慢。口译员可以灵活地编写一次,随处运行。一个很好的例子是主要用于 Web 应用程序的 JavaScript 代码。这几乎可以在不同的目标浏览器上运行,应用程序代码的更改很少或没有。解释器通常很慢,适合运行小型应用程序。
JVM 充分利用了解释器和编译器。下图说明了 JVM 如何使用解释器和编译器方法运行 Java 代码:
- Java 编译器 (javac) 将 Java 应用程序 源代码编译为 字节码(中间格式)。
- JVM 在运行时将字节码逐行解释为机器码。这有助于将优化的字节码转换为目标机器代码,有助于在不同的目标机器上运行相同的应用程序代码,而无需重新编程或重新编译。
- JVM 还有一个 Just-In-Time (JIT) 编译器,通过分析代码在运行时进一步优化代码。
在本节中,我们研究了 Java 编译器和 JIT 如何协同工作以在更高级别的 JVM 上运行 Java 代码。在下一节中,我们将了解 JVM 的架构。
Understanding the JVM architecture
多年来,JVM 已演变成最成熟的 VM 运行时。它有一个非常结构化和复杂的运行时实现。这是 GraalVM 构建为利用 JVM 的所有最佳功能并提供云原生世界所需的进一步优化的原因之一。为了更好地理解 GraalVM 架构和它在 JVM 之上带来的优化,了解 JVM 架构非常重要。
本节将详细介绍 JVM 体系结构。下图展示了 JVM 中各个子系统的高级架构:
Class loader subsystem
类加载器子系统负责分配所有相关的.class
文件并将这些类加载到内存中。类加载器子系统还负责在类初始化并加载到内存之前链接和验证 .class
文件的示意图。类加载器子系统具有以下三个关键功能:
- 正在加载
- 链接
- 初始化
下图显示了类加载器子系统的各个组件:
Loading
在 C/C++ 等传统的基于编译器的语言中,源代码被编译为目标代码,然后在最终的可执行文件被链接器链接之前,所有依赖的目标代码都由链接器链接。建成。所有这些都是构建过程的一部分。一旦构建了最终的可执行文件,它就会被加载程序加载到内存中。 Java 的工作方式不同。
Java源代码(.java
)被Java编译器(javac
)编译成字节码( .class
) 文件。类加载器是 JVM 的关键子系统之一,它负责加载运行应用程序所需的所有依赖类。这包括 由应用程序开发人员编写的类、库和 Java 软件开发工具包(SDK) 类。
作为该系统的一部分,有三种类型的类加载器:
- Bootstrap:Bootstrap 是 第一个加载
rt.jar
的类加载器,它包含所有Java标准版JDK类,如java.lang
、java.net
、java.util
和java.io
。 Bootstrap 负责加载运行任何 Java 应用程序所需的所有类。这是 JVM 的核心部分,以本机语言实现。 - Extensions:扩展类加载器将所有扩展加载到JDK中
jre
/lib
/ext
目录。扩展类加载器类通常是用 Java 实现的引导程序的扩展类。扩展类加载器是用 Java 实现的(sun.misc.Launcher$ExtClassLoader.class
)。 - Application:应用程序类加载器(也称为系统类加载器)是扩展类加载器。应用程序类加载器负责加载应用程序类路径(
CLASSPATH
环境变量)中的应用程序类。这也在 Java 中实现(sun.misc.Launcher$AppClassLoader.class
)。
引导、扩展和应用程序类加载器负责加载运行应用程序所需的所有类。如果类加载器找不到所需的类,则抛出 ClassNotFoundException
。
类加载器实现委托层次算法。下图显示了类加载器如何实现委托层次算法来加载所有需要的类:
- JVM 在方法区寻找类(这将在本节后面详细讨论)。如果没有找到该类,它将要求应用程序类加载器将该类加载到内存中。
- 应用程序类加载器将调用委托给扩展类加载器,而扩展类加载器又委托给引导类加载器。
- 引导类加载器在引导
CLASSPATH
中查找类。如果它找到类,它将加载到内存中。如果没有找到该类,则将控制权委托给扩展类加载器。 - 扩展类加载器 将尝试在扩展
CLASSPATH
中查找类。如果它找到类,它将加载到内存中。如果它没有找到该类,则将控制权委托给应用程序类加载器。 - 应用程序类加载器将尝试在
CLASSPATH
中查找类。如果没有找到,就会抛出ClassNotFoundException
,否则,类被加载到方法区,JVM会开始使用它。
Linking
一旦类被加载到 内存(到方法区域,在 内存子系统部分进一步讨论),类加载器子系统将执行链接。链接过程包括以下步骤:
- 验证:已加载的类经过验证是否符合语言语义。加载的类的二进制表示被解析为内部数据结构,以确保方法正常运行。这可能需要类加载器递归地加载继承类的层次结构,一直到
java.lang.Object
。验证阶段验证并确保方法运行没有任何问题。 - 准备:一旦所有的类都加载并验证完毕,JVM就会为类变量(静态变量)分配内存。这还包括调用静态初始化(静态块)。
- 解析:JVM然后通过定位符号表中引用的类、接口、字段和方法来解析。 JVM 可能会在初始验证期间解析符号(静态解析),或者可能在验证类时解析(延迟解析)。
ClassNotFoundException
NoClassDefFoundError
ClassCastException
UnsatisfiedLinkError
ClassCircularityError
ClassFormatError
ExceptionInInitializerError
您可以参考 Java 规范了解更多详情:https://docs.oracle.com/en/ java/javase.
Initializing
一旦加载了所有类并解析了符号,初始化阶段就开始了。在这个阶段,类被初始化(新的)。这包括初始化静态变量、执行静态块和调用反射方法(java.lang.reflect
)。这也可能导致加载这些类。
类加载器在应用程序运行之前将所有类加载到内存中。大多数时候,类加载器必须加载类和依赖类的完整层次结构(尽管存在延迟解析)来验证原理图。这很耗时,也占用了大量内存。如果应用程序使用反射并且需要加载反射的类,则速度会更慢。
在了解了类加载器子系统之后,现在让我们了解内存子系统是如何工作的。
Memory subsystem
内存子系统是JVM 最关键的子系统之一。内存子系统,顾名思义,负责管理分配给方法变量、堆、栈和寄存器的内存。下图显示了内存子系统的架构:
内存子系统有两个区域:JVM 级别和线程级别。让我们详细讨论一下。
JVM level
JVM 级别的内存,正如 名称所暗示的那样,是 对象在 JVM 级别存储的地方。这不是线程安全的,因为多个线程可能正在访问这些对象。这解释了为什么建议程序员在更新该区域的对象时编写线程安全(同步)的代码。 JVM级内存有两个区域:
Thread level
线程级内存是 所有 线程本地对象的存储位置。这对各个线程是可访问/可见的,因此它是线程安全的。线程级内存分为三个区域:
- 堆栈:对于每个方法调用,都会创建一个堆栈帧,其中存储所有< /a>方法级数据。堆栈帧由在方法范围内创建的所有变量/对象、操作数堆栈(用于执行中间操作)、帧数据(存储与方法对应的所有符号)和异常捕获块信息组成。
- 寄存器:PC寄存器跟踪指令执行并指向当前正在执行的指令。这是为每个正在执行的线程维护的。
- Native Method Stack:native方法栈是一种特殊类型的存储native方法信息的栈,即在调用和执行本机方法时很有用。
现在类已加载到内存中,让我们看看 JVM 执行引擎是如何工作的。
JVM execution engine subsystem
JVM 执行引擎是 JVM 的 核心,所有的执行都发生在这里。这是解释和执行字节码的地方。 JVM 执行引擎使用内存子系统来存储和检索对象。 JVM执行引擎有三个关键组件,如图所示:
我们将在以下部分详细讨论每个组件。
Bytecode interpreter
正如本章前面提到的,字节码(.class
)是 JVM 的输入。 JVM 字节码解释器 从 .class
文件中挑选每条指令,并将其转换为机器码并执行。 解释器的明显缺点是它们没有被优化。指令是按顺序执行的,即使多次调用同一个方法,它也会遍历每条指令,对其进行解释,然后执行。
JIT compiler
JIT 编译器通过分析由解释器执行的 代码,识别代码可以优化的区域并将它们编译为目标机器代码,以便它们可以更快地执行。字节码和编译代码片段的组合提供了执行类文件的最佳方式。
下图说明了 JVM 的详细工作原理,以及 JVM 用于优化代码的各种 JIT 编译器:
- JVM 解释器遍历每个字节码,并使用机器码解释它,使用字节码到机器码的映射。
- JVM 始终使用计数器对代码进行分析,以计算代码执行的次数,如果计数器达到阈值,它会使用 JIT 编译器编译该代码以进行优化并将其存储在代码缓存中。
- JVM 然后检查该编译单元(块)是否已经编译。如果 JVM 在代码缓存中找到编译后的代码,它将使用编译后的代码来加快执行速度。
- JVM 使用两种类型的编译器,C1 编译器和 C2 编译器来编译代码。
如图 1.7 所示,JIT 编译器通过分析正在运行的代码进行优化,并在一段时间内识别出可以编译的代码。 JVM 运行已编译的代码片段,而不是解释代码。它是运行解释代码和编译代码的混合方法。
JVM 引入了两种类型的编译器,C1(客户端)和 C2(服务器),最新版本的 JVM 使用两者中最好的来优化和编译运行时的代码。让我们更好地理解这些类型:
- C1 编译器:引入了性能计数器,用于计算特定方法/代码片段的执行次数。一旦方法/代码片段被使用了特定次数(阈值),那么该特定代码片段就会被C1编译器编译、优化和缓存。下次调用该代码片段时,它会直接从缓存中执行已编译的机器指令,而不是通过解释器。这带来了第一级优化。
- C2 编译器:当代码被执行时,JVM 将执行运行时代码分析并提出代码路径和热点。然后它运行 C2 编译器以进一步优化热代码路径。这是也称为热点。
C1 速度更快,适用于短时间运行的应用程序,而 C2 速度较慢且繁重,但非常适合长时间运行的 进程,例如守护进程和服务器,因此代码性能优于时间。
在 Java 6 中,有一个命令行选项可以使用 C1 或 C2 方法(使用命令行参数 -client
(for C1) 和 -server
(用于 C2))。在 Java 7 中,有一个命令行选项可以同时使用两者。从 Java 8 开始,C1 和 C2 编译器都作为默认行为用于优化。
编译有五个层次/级别。可以生成编译日志以了解使用哪个编译器层/级别编译了哪个 Java 方法。以下是编译的五个层次/级别:
- 解释代码(0级)
- 简单的 C1 编译代码(级别 1)
- 有限的 C1 编译代码(2 级)
- 完整的 C1 编译代码(级别 3)
- C2编译代码(4级)
现在让我们看看 JVM 在编译期间应用的各种类型的代码优化。
Code optimizations
JIT 编译器生成正在编译的代码的内部表示,以理解语义和语法。这些内部 表示是树形数据结构,JIT 将在其上运行代码优化(作为多个线程,可以使用 XcompilationThreads
命令行选项)。
以下是 JIT 编译器对代码执行的一些优化:
- 内联:面向对象编程中最常见的一种编程实践是访问成员通过getter 和setter 方法的变量。内联优化用实际变量替换了这些 getter/setter 方法。 JVM 还分析代码并识别其他可以内联的小方法调用,以减少方法调用的数量。这些是称为热方法。根据调用方法的次数和方法的大小做出决定。 JVM 用于决定内联的大小阈值可以使用
-XX:MaxFreqInlineSize
标志进行修改(默认为 325 字节)。 - 逃逸分析:JVM剖析变量,分析变量的使用范围。如果变量没有脱离本地范围,则执行本地优化。 Lock Elision 是 一种这样的优化,JVM 决定变量是否真的需要同步锁。同步锁对处理器来说非常昂贵。 JVM 还决定将对象从堆移到堆栈。这对内存使用和垃圾回收有积极影响,因为一旦执行该方法,对象就会被销毁。
- DeOptimization: DeOptimization is another critical optimization technique. The JVM profiles the code after optimization and may decide to deoptimize the code. Deoptimizations will have a momentary impact on performance. The JIT compiler decides to deoptimize in two cases:
一个。 Not Entrant Code:这在继承的类或接口实现中非常突出。 JIT 可能已经优化,假设层次结构中有一个 特定类,但随着时间的推移,当它学习到其他情况时,它会去优化并分析以进一步优化更具体的类实现。
湾。 僵尸代码:在Not Entrant代码分析过程中,部分对象得到垃圾收集,导致代码可能永远不会被调用。此代码被标记为僵尸代码。此代码将从代码缓存中删除。
除此之外,JIT 编译器执行其他优化,例如控制流优化,包括重新排列代码路径以提高效率和本机代码生成到目标机器代码以加快执行速度。
JIT 编译器优化是在一段时间内执行的,它们适用于长时间运行的进程。我们将在 章节中详细解释 JIT 编译2、JIT、Hotspot 和 GraalVM。
Java ahead-of-time compilation
提前编译选项 是在 Java 9 中通过 jaotc
引入的,其中 Java 应用程序代码可以直接编译为生成最终机器码。代码被编译为目标架构,因此不可移植。
Java 支持在 x86 架构中同时运行 Java 字节码和 AOT 编译代码。下图说明了它是如何工作的。这是 Java 可以生成的最优化的代码:
字节码将通过之前解释的方法(C1,C2)。 jaotc
将最常用的 java 代码(如库)提前编译成机器码,然后直接加载到代码缓存中。这将减少 JVM 的负载。 Java 字节码通过通常的解释器,并使用来自代码缓存的代码(如果可用)。这减少了 JVM 在运行时编译代码的大量负载。通常,可以对最常用的库进行 AOT 编译以获得更快的响应。
Garbage collector
Java 的复杂性之一是其内置的内存管理。在 C/C++ 等语言中,程序员期望分配和取消分配内存。在 Java 中,JVM 负责清理未引用的 对象并回收内存。垃圾收集器是一个守护线程,它可以自动执行清理,也可以由程序员调用(System.gc()
和 Runtime. getRuntime().gc()
)。
Native subsystem
Java 允许 程序员访问 本机库。原生库通常是那些构建(使用诸如 C/C++ 之类的语言)并用于特定 目标架构的库。 Java Native Interface (JNI) 提供抽象层和接口规范实现桥接以访问 本机库。每个 JVM 都为特定的目标系统实现 JNI。程序员也可以使用 JNI 来调用本地方法。下图说明了本机子系统的组件:
Further reading
- JVM 语言简介,作者 Vincent van der Leun,Packt Publishing (https://www.packtpub.com/product/introduction-to-jvm-languages/9781787127944)
- Java 文档和规范,由 Oracle (https:// docs.oracle.com/en/java/)