原创

Java生态系统(第一部分):理解JVM架构

温馨提示:
本文最后更新于 2023年06月17日,已超过 315 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

理解JVM架构以及Java在底层的工作原理对于每个Java开发人员来说都是重要的学习内容,这样才能有效地利用Java生态系统。本博客系列将为您提供关于JVM内部和Java生态系统周围技术的坚实基础。

 

背景

Java是由James Gosling于1995年为Sun Microsystems设计的一种多范式(面向对象、基于类、结构化、命令式、泛型、反射、并发)编程语言,深受数百万开发人员的喜爱。根据任何排名指标,Java在过去15年中一直是最受欢迎的编程语言。在过去15年中,成千上万的企业应用程序主要是用Java编写的,使其成为构建企业级生产软件系统的首选语言。

尽管我自2015年以来一直在使用Java,但最近在进行我本科毕业研究时,我意识到了Java生态系统的强大之处,这激发了我对Java世界的深入探索。我计划撰写一系列关于Java内部原理、性能分析、服务器调优以及许多其他有趣主题的博客文章,并诚邀您与本博客保持联系。目前就说这些,让我们从Java基础知识开始吧!

 

Java环境

对于几乎任何编程语言,您都需要一个特定的环境,其中包括开发、编译、调试和执行该语言程序所需的所有组件、应用程序编程接口和库。Java有两种这样的环境,每个使用Java的人都必须在本地开发或生产环境平台上设置其中之一才能开始工作。

JRE(Java运行时环境):运行Java应用程序所需的最小环境(不支持开发)。它包括JVM(Java虚拟机)和部署工具。

JDK(Java开发工具包):用于开发和执行Java应用程序的完整开发环境。它包括JRE和开发工具。

JRE面向用户,而JDK面向程序员。

 

Java的工作原理

您可以使用任何终端编辑器(vim、nano)或GUI编辑器(gedit、sublime)开始编写简单的Java程序。对于复杂的Java应用程序,您可能需要一个集成开发环境(IDE),如IntelliJ IDEA、Eclipse或Netbeans。一个典型的Java程序应该具有正确的语言语法和.java格式。建议使用面向对象编程(OOP)等编程概念和适当的架构模式来方便结构化和维护您的Java程序。

Java的主要优势在于,它被设计为可以在各种平台上运行,具有“一次编写,随处运行”的概念。尽管像C++这样的语言将其源代码编译成与特定平台匹配的代码,并在其操作系统和硬件上本地运行,但Java源代码使用Java编译器(javac)将其编译为中间状态,称为字节码(即.class文件)。这个字节码是以十六进制格式表示的,其中包含操作码-操作数行,JVM可以解释这些指令(无需进一步重新编译)成为可以被操作系统和底层硬件平台理解的本机机器语言。因此,字节码充当一个独立于平台的中间状态,可以在任何JVM上移植,而不管底层操作系统和硬件架构如何。然而,由于JVM是为了运行和与底层硬件和操作系统结构进行通信而开发的,我们需要为我们的操作系统版本(Windows、Linux、Mac)和处理器架构(x86、x64)选择适当的JVM版本。

我们大多数人都知道Java的上述故事,问题在于这个过程中最重要的组件——JVM被教给我们是一个黑盒,它可以神奇地解释字节码并在程序执行过程中执行许多运行时活动,比如JIT(即时编译)和GC(垃圾回收)。在接下来的几节中,让我们揭示JVM的工作原理。

JVM架构

JVM只是一个规范,其实现因供应商而异。现在,让我们了解JVM的通常接受的架构,如规范中所定义的。

JVM Architecture

1) 类加载子系统

JVM存储在内存中。在执行过程中,使用类加载器子系统将类文件加载到内存中。这被称为Java的动态类加载功能。当在运行时(而不是编译时)首次引用一个类时,它会加载、链接和初始化类文件(.class)。

 

1.1) 加载

加载编译后的类(.class文件)到内存是类加载器的主要任务。通常,类加载过程从加载主类开始(即具有静态main()方法声明的类)。根据已经运行的类中提到的类引用,所有后续的类加载尝试都是根据以下情况进行的:

- 当字节码对一个类进行静态引用时(例如,System.out)。

- 当字节码创建一个类对象时(例如,Person person = new Person("John"))。

 

有三种类型的类加载器(与继承属性相关联),它们遵循四个主要原则。

1.1.1) 可见性原则

该原则指出子类加载器可以看到由父类加载器加载的类,但父类加载器无法找到由子类加载器加载的类。

1.1.2) 唯一性原则

该原则指出,父类加载器加载的类不应由子类加载器再次加载,并确保不会发生重复加载。

1.1.3) 委派层次原则

为了满足上述两个原则,JVM遵循一个委派层次结构,为每个类加载请求选择类加载器。在这里,从最低的子级开始,应用程序类加载器将接收到的类加载请求委派给扩展类加载器,然后扩展类加载器将请求委派给引导类加载器。如果在引导路径中找到请求的类,则加载该类。否则,请求再次返回到扩展类加载器级别,从扩展路径或自定义指定的路径中查找类。如果仍然失败,请求将返回到应用程序类加载器,从系统类路径中查找类,如果应用程序类加载器也无法加载所请求的类,那么将会抛出运行时异常java.lang.ClassNotFoundException

1.1.4) 不卸载原则

尽管类加载器可以加载一个类,但它不能卸载已加载的类。相反,可以删除当前类加载器,并创建一个新的类加载器。

Java Class Loaders — Delegation Hierarchy Principle (Image: StackOverflow.com)

Bootstrap Class Loader(引导类加载器)从rt.jar中加载标准的JDK类,例如在引导路径($JAVA_HOME/jre/lib目录)中存在的核心Java API类(如java.lang.*包中的类)。它是用C/C++等本地语言实现的,并且充当Java中所有类加载器的父级。

Extension Class Loader(扩展类加载器)将类加载请求委托给其父加载器(引导类加载器),如果不成功,则从扩展路径($JAVA_HOME/jre/lib/ext目录)或java.ext.dirs系统属性指定的任何其他目录中加载类(例如安全扩展功能)。这个类加载器是由sun.misc.Launcher$ExtClassLoader类用Java实现的。

System/Application Class Loader(系统/应用程序类加载器)从系统类路径加载特定于应用程序的类,可以在使用-cp或-classpath命令行选项调用程序时设置。它在内部使用映射到java.class.path的环境变量。这个类加载器是由sun.misc.Launcher$AppClassLoader类用Java实现的。

注意:除了上述讨论的三个主要类加载器之外,程序员还可以直接在代码中创建用户定义的类加载器。这通过类加载器委派模型确保了应用程序的独立性。这种方法在像Tomcat这样的Web应用程序服务器中用于使Web应用程序和企业解决方案能够独立运行。

每个类加载器都有自己的命名空间,用于存储加载的类。当类加载器加载一个类时,它根据命名空间中存储的完全限定类名(FQCN)来查找类是否已经加载。即使类具有相同的FQCN但命名空间不同,它也被视为不同的类。不同的命名空间意味着该类已由另一个类加载器加载。

 

1.2) 链接

链接涉及对已加载的类或接口及其直接超类和超接口进行验证和准备,同时遵循以下属性。

 

在链接之前,类或接口必须完全加载。

在初始化(下一步)之前,类或接口必须完全验证和准备。

如果在链接过程中发生错误,则会在程序中某个需要涉及错误的类或接口的操作点抛出该错误,该操作可能直接或间接地需要链接到该类或接口。

链接分为以下3个阶段:

验证(Verification):确保.class文件的正确性(代码是否根据Java语言规范正确编写?是否由符合JVM规范的有效编译器生成?)。这是类加载过程中最复杂的测试过程,耗时最长。尽管链接会减慢类加载过程,但它避免了在执行字节码时多次执行这些检查的需要,从而使整

 

体执行更高效和有效。如果验证失败,将抛出运行时错误(java.lang.VerifyError)。例如,执行以下检查:

- 一致且格式正确的符号表

- 未被覆盖的final方法/类

- 方法符合访问控制关键字

- 方法具有正确的参数数量和类型

- 字节码未正确操作堆栈

- 变量在读取之前被初始化

- 变量具有正确类型的值

准备(Preparation):为静态存储分配内存,并为JVM使用的任何数据结构(如方法表)分配内存。静态字段被创建并初始化为其默认值,但在此阶段不执行初始化器或代码,因为这将在初始化阶段执行。

解析(Resolution):将类型中的符号引用替换为直接引用。通过搜索方法区来定位所引用的实体。

 

1.3) 初始化

在这里,将执行每个已加载的类或接口的初始化逻辑(例如,调用类的构造函数)。由于JVM是多线程的,因此类或接口的初始化应该非常小心地进行,使用适当的同步,以避免其他线程同时尝试初始化同一个类或接口(即使使其线程安全)。

 

这是类加载的最后阶段,所有静态变量都被赋予其在代码中定义的原始值,并且静态块将被执行(如果有)。这在类中逐行执行,从上到下,在类层次结构中从父类到子类。

2) 运行时数据区域

运行时数据区域是当JVM程序在操作系统上运行时分配的内存区域。除了读取.class文件外,类加载器子系统还会生成相应的二进制数据,并将以下信息分别保存在方法区域(Method Area)中,用于每个类:

 

- 加载的类的完全限定名称及其直接父类

- .class文件是否与类/接口/枚举相关联

- 修饰符、静态变量和方法信息等

 

然后,对于每个加载的.class文件,在堆内存中创建一个Class对象来表示该文件。这个Class对象可以用于在代码中稍后读取类级别的信息(类名、父类名、方法、变量信息、静态变量等)。

 

2.1) 方法区域(线程共享)

这是一个共享资源(每个JVM只有一个方法区域)。所有JVM线程共享同一个方法区域,因此对方法数据的访问和动态链接的过程必须是线程安全的。

方法区域存储类级别的数据(包括静态变量),例如:

- 类加载器引用

- 运行时常量池:包括数字常量、字段引用、方法引用、属性等。除了每个类和接口的常量外,它还包含了所有方法和字段的引用。当引用一个方法或字段时,JVM会使用运行时常量池在内存中查找方法或字段的实际地址。

- 字段数据:每个字段包括名称、类型、修饰符和属性。

- 方法数据:每个方法包括名称、返回类型、参数类型(按顺序)、修饰符和属性。

- 方法代码:每个方法包括字节码、操作数栈大小、局部变量表大小、局部变量表和异常表。对于异常表中的每个异常处理程序,包括起始点、结束点、处理程序代码的PC偏移和捕获的异常类的常量池索引。

 

2.2) 堆区域(线程共享)

这也是一个共享资源(每个JVM只有一个堆区域)。所有对象及其对应的实例变量和数组的信息都存储在堆区域中。由于方法区域和堆区域共享内存供多个线程使用,因此存储在方法区域和堆区域的数据不是线程安全的。堆区域是垃圾回收的主要目标。

 

2.3) 栈区域(每个线程独立)

这不是一个共享资源。对于每个JVM线程,在线程启动时,会创建一个单独的运行时栈用于存储方法调用。对于每个方法调用,都会创建一个条目并添加(推入)到运行时栈的顶部,这个条目称为栈帧(Stack Frame)。

 

每个栈帧包含局部变量数组的引用、操作数栈和方法执行所属的类的运行时常量池。局部变量数组和操作数栈的大小在编译时确定。因此,栈帧的大小根据方法而定。

 

当方法正常返回或在方法调用期间抛出未捕获的异常时,将删除(弹出)该帧。还要注意,如果发生任何异常,堆栈跟踪的每一行(以printStackTrace()等方法显示为方法)表示一个栈帧。栈区域是线程安全的,因为它不是共享资源。

栈帧(Stack Frame)被分为三个子实体:

局部变量数组(Local Variable Array):它的索引从0开始。对于特定方法,涉及到的局部变量数量和相应的值存储在这里。0是方法所属类实例的引用。从1开始,保存了传递给方法的参数。在方法参数之后,保存了方法的局部变量。

操作数栈(Operand Stack):它作为运行时的工作空间,在需要时执行任何中间操作。每个方法在操作数栈和局部变量数组之间交换数据,并推送或弹出其他方法调用的结果。操作数栈空间的大小在编译时可以确定。因此,操作数栈的大小也可以在编译时确定。

帧数据(Frame Data):存储与方法相关的所有符号信息。对于异常情况,捕获块的信息也会在帧数据中维护。

由于这些是运行时的栈帧,在一个线程终止后,它的栈帧也会被JVM销毁。

栈可以是动态大小或固定大小。如果一个线程需要比允许的栈更大的空间,将抛出StackOverflowError。如果一个线程需要一个新的栈帧,但没有足够的内存来分配它,则会抛出OutOfMemoryError。

2.4) PC寄存器(每个线程)

对于每个JVM线程,在线程启动时,会创建一个单独的PC(程序计数器)寄存器,用于保存当前执行指令的地址(方法区中的内存地址)。如果当前方法是本地方法,则PC的值是未定义的。一旦执行完成,PC寄存器会更新为下一条指令的地址。

 

2.5) 本地方法栈(每个线程)

Java线程与本地操作系统线程之间存在直接映射。在为Java线程准备好所有状态之后,还会创建一个单独的本地栈,用于存储通过JNI(Java Native Interface)调用的本地方法信息(通常使用C/C++编写)。

 

一旦创建和初始化了本地线程,它会调用Java线程中的run()方法。当run()方法返回时,将处理未捕获的异常(如果有),然后本地线程确认是否需要终止JVM(即它是否是最后一个非守护线程)。当线程终止时,会释放本地线程和Java线程的所有资源。

 

一旦Java线程终止,本地线程就会被回收。因此,操作系统负责调度所有线程并将它们分派给任何可用的CPU。

3) 执行引擎

字节码的实际执行发生在这里。执行引擎按行读取上述运行时数据区域中分配的数据,逐行执行字节码中的指令。

 

3.1) 解释器

解释器解释字节码并逐个执行指令。因此,它可以快速解释一个字节码行,但执行解释结果的速度较慢。缺点是当一个方法被多次调用时,每次都需要进行新的解释和较慢的执行。

 

3.2) 即时编译器(JIT编译器)

如果只有解释器,当一个方法被多次调用时,每次都会进行解释,这是一个冗余的操作,如果能够高效处理的话。这就是JIT编译器的作用。首先,它将整个字节码编译为本机代码(机器代码)。然后对于重复的方法调用,它直接提供本机代码,使用本机代码执行比逐个解释指令快得多。本机代码存储在缓存中,因此编译后的代码可以更快地执行。

 

然而,即时编译器编译的时间比解释器解释的时间更长。对于只执行一次的代码段,最好是解释而不是编译。另外,本机代码存储在缓存中,这是一种昂贵的资源。在这些情况下,即时编译器在内部检查每个方法调用的频率,并决定仅在所选方法出现超过一定次数时才对其进行编译。这种自适应编译的思想被用于Oracle Hotspot虚拟机。

 

执行引擎成为引入JVM供应商的性能优化的关键子系统。在这些努力中,以下4个组件可以大大提高其性能。

 

中间代码生成器生成中间代码。

代码优化器负责优化上述生成的中间代码。

目标代码生成器负责生成本机代码(即机器代码)。

分析器是一个特殊组件,负责找出性能瓶颈,也称为热点(例如,一个方法被多次调用的情况)。

 

供应商对编译优化的方法

1. Oracle Hotspot VMs

Oracle有两种标准Java VM的实现,采用了一种称为Hotspot编译器的流行JIT编译器模型。通过分析,它可以识别最需要进行JIT编译的热点代码,并将这些性能关键部分编译为本机代码。随着时间的推移,如果这种编译方法不再频繁调用,它将把该方法标识为不再是热点,并迅速将本机代码从缓存中删除,然后以解释器模式运行。这种方法可以提高性能,同时避免对很少使用的代码进行不必要的编译。此外,Hotspot编译器还可以根据实际情况决定如何优化编译代码,例如内联等技术。编译器执行的运行时分析使其能够消除确定哪些优化将产生最大性能收益的猜测工作。

这些VM使用相同的运行时(解释器、内存、线程),但是采用了下面提到的定制化JIT编译器的实现。

- Oracle Java Hotspot Client VM是Oracle JDK和JRE的默认VM技术。它经过调优,以在客户端环境中运行应用程序时获得最佳性能,缩短应用程序启动时间并减小内存占用。

- Oracle Java Hotspot Server VM旨在为在服务器环境中运行的应用程序提供最高的程序执行速度。这里使用的JIT编译器称为高级动态优化编译器,采用了更复杂和多样化的性能优化技术。可以通过使用服务器命令行选项(例如java server MyApp)来调用Java HotSpot Server VM。

Oracle的Java Hotspot技术以其快速的内存分配、快速高效的垃圾收集(GC)和可伸缩的线程处理能力而闻名,适用于大型共享内存多处理器服务器。

 

2. IBM AOT(提前编译)

这里的特点是这些JVM通过共享缓存共享本机代码编译结果,因此通过AOT编译器已经编译的代码可以被另一个JVM使用而无需重新编译。此外,IBM JVM通过使用AOT编译器将代码预编译为JXE(Java可执行)文件格式,提供了一种快速执行的方法。

 

3.3)垃圾收集器(GC)

只要对象被引用,JVM就认为它是活动的。一旦对象不再被引用,也就是不再被应用程序代码访问到,垃圾收集器将移除该对象并回收未使用的内存。一般情况下,垃圾回收是在后台进行的,但我们可以通过调用System.gc()方法触发它(执行不一定被保证)。因此,可以调用Thread.sleep(1000)方法等待垃圾回收完成。

4) Java Native Interface (JNI)

这个接口用于与执行所需的本地方法库进行交互,并提供这些本地库(通常使用C/C++编写)的功能。这使得JVM能够调用C/C++库,并能够被C/C++库调用,这些库可能特定于硬件。

 

5) 本地方法库

这是一组C/C++本地方法库,用于执行引擎,并可以通过提供的本地接口进行访问。

 

JVM线程

我们讨论了Java程序如何执行,但没有具体提到执行者。实际上,为了执行我们之前讨论的每个任务,JVM会同时运行多个线程。其中一些线程承载着编程逻辑,并由程序(应用程序线程)创建,而其余的线程由JVM自身创建,以在系统中执行后台任务(系统线程)。

 

主要的应用程序线程是主线程,在调用public static void main(String[])时创建,所有其他应用程序线程都由这个主线程创建。应用程序线程执行诸如从main()方法开始执行指令、在方法逻辑中发现new关键字时在堆区创建对象等任务。

 

主要的系统线程如下:

编译器线程:这些线程负责在运行时将字节码编译为本机代码。

GC线程:所有与GC相关的活动都由这些线程执行。

定期任务线程:执行定期操作的定时器事件(即中断)由此线程执行。

信号分发器线程:该线程接收发送到JVM进程的信号,并通过调用适当的JVM方法在JVM内部处理它们。

VM线程:某些操作需要JVM达到一个安全点,不再对堆区进行修改。这些情况的示例包括“停止-世界”垃圾回收、线程堆栈转储、线程暂停和偏向锁撤销。这些操作可以在一个称为VM线程的特殊线程上执行。

 

理解的一些要点:

Java被认为是一种既解释型又编译型的语言。

由于动态链接和运行时解释,Java的设计使其变得较慢。

JIT编译器通过保留本地代码而不是字节码来弥补解释器的缺点,用于重复操作。

最新的Java版本解决了其原始架构中的性能瓶颈。

JVM只是一个规范。在实现过程中,供应商可以自由定制、创新和改进其性能。

正文到此结束
本文目录