了解JVM架构

JVM架构

JVM只是一个规范,在市面上有不同的公司进行了实现,这包含收费的以及免费的。下面这张图是JVM的整体架构图,涵盖了java代码执行的全过程,下面分别就这些过程进行说明。

类加载器子系统

如果与php相比较,当php文件执行完后Zend引擎被立即销毁,可java却不同,JVM会长驻在内存并一直工作。它在执行期间使用Class Loader子系统将类文件带到RAM,这被称为Java动态类加载。在它首次运行时(而非编译时)将加载、链接和初始化类文件(.class)。

加载

将编译的类(.class文件)加载到内存中是Class Loader的主要任务。通常类加载过程从加载主类(即带有 static main() 方法声明的类)开始的,所有后续的类加载都是根据已运行类中的类引用完成的,如以下情况所述:

  • 当字节码静态引用一个类时(例如 System.out
  • 当字节码创建一个类对象时(例如 Person person = new Person("John")

在JVM中有三个类加载器(与继承属性相关),它们工作时遵循4个主要原则。

1. 可见性原则

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

2. 唯一性原则

该原则指出,父类加载的类不应再由子类加载器加载,也就是确保不会发生重复的类加载现象。

3. 委托层次结构原则

为了满足上述2个原则,JVM遵循一个委托层次结构来为每个类加载请求选择合适的类加载器。在这里,从最低的子级别开始,Application Class Loader将接收到的类加载请求委托给Extension Class Loader,然后Extension Class Loader将该请求委托给Bootstrap Class Loader。如果在Bootstrap路径中找到了所请求的类,则将加载该类。否则,请求将再次转移回Extension Class Loader层级,以从扩展路径或自定义指定的路径中查找类。如果它也失败,则请求返回到Application Class Loader以从System类路径查找该类,并且如果Application Class Loader也未能加载所请求的类,则我们将获得运行时异常—java.lang.ClassNotFoundException

4. 无卸载原则

即使类加载器可以加载类,也不能卸载已加载的类。可以删除当前的类加载器而不是卸载,并创建新的类加载器。

  • Bootstrap Class Loader(Bootstrap类加载器)从rt.jar中加载标准JDK类,例如引导路径中存在的核心Java API类 $JAVA_HOME/jre/lib 目录(例如 java.lang.* 包类)。它以C/C++等本地语言实现,并充当Java中所有类加载器的父级。
  • Extension Class Loader(扩展类加载器)将类加载请求委托给其父类Bootstrap,如果不成功,则从扩展路径中的扩展目录($JAVA_HOME/jre/lib/extjava.ext.dirs 指定的任何其他目录)中加载类。该类加载器由 sun.misc.Launcher$ExtClassLoader java类实现。
  • System/Application Class Loader(系统/应用程序类加载器)从系统类路径加载应用程序特定的类,可以在运行程序时使用 -cp-classpath 命令行选项进行设置,这将在内部映射到java.class.path 环境变量。该类加载器由 sun.misc.Launcher$AppClassLoader java类实现。

注意:除了上面讨论的3个主要的类加载器,程序员还可以直接在代码本身上创建自定义类加载器。这通过类加载器委托模型保证了应用程序的独立性。这种在Tomcat等Web应用程序服务器中常见,它使Web应用程序和应用独立运行。

每个类加载器都有其命名空间,用于存储加载的类。当类加载器加载类时,它将基于存储在命名空间中的FQCN(类全限定名称)搜索该类,以检查该类是否已被加载。即使该类具有相同的FQCN但如果有不同的名称空间,也会被视为不同的类。不同的命名空间意味着该类由其它的类加载器加载。

链接

  • 在链接一个类或接口之前,必须将其完全加载。
  • 在初始化类或接口之前,必须对其进行完全验证和准备。
  • 如果在链接过程中发生错误,则会将其抛出到程序中的某个位置,在该位置程序将采取某些操作,这些操作可能直接或间接地需要链接到错误所涉及的类或接口。

链接分为以下三个阶段:

  • 验证:确保 .class 文件的正确性(代码是否根据Java语言规范正确编写?它是由有效的编译器根据JVM规范生成的吗?)。这是类加载过程中最复杂的测试过程,并且耗时最长。即使链接减慢了类加载过程的速度,它也避免了在执行字节码时多次执行这些检查的需要,从而使整体执行高效而有效。如果验证失败,则会引发运行时错误(java.lang.VerifyError)。例如,执行以下检查。

    1
    2
    3
    4
    5
    6
    7
    - 一致且格式正确的符号表
    - 不覆盖最终方法/类
    - 方法遵循访问控制关键字
    - 方法具有正确数量和参数类型
    - 字节码不会错误地操作堆栈
    - 变量在读取之前已初始化
    - 变量的值为正确的类型
  • 准备:为静态存储和JVM使用的任何数据结构(例如方法表)分配内存。静态字段已创建并初始化为其默认值,但是在此阶段不执行任何初始化程序或代码,因为这是初始化的一部分。

  • 解决:用直接引用替换类型中的符号引用。通过搜索方法区域以找到引用的实体来完成此操作。

初始化

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

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

运行时数据区

运行时数据区是JVM程序在OS上运行时分配的存储区。除了读取.class文件之外,Class Loader子系统还会生成相应的二进制数据,并将以下信息分别保存在每个类的Method区域中。

  • 加载的类及其直接父类的全限定名称
  • .class文件是否与Class/Interface/Enum相关
  • 修饰符,静态变量和方法信息等。

然后,对于每个已加载的.class文件,它都会按照java.lang包中的定义,恰好创建一个Class对象来表示堆内存中的文件。此Class对象可用于稍后在我们的代码中读取类级别信息(类名称,父名称,方法,变量信息,静态变量等)。

方法区域(线程间共享)

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

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

  • 类加载器引用
  • 运行时常量池-数字常量,字段引用,方法引用,属性;以及每个类和接口的常量,它包含方法和字段的所有引用。引用方法或字段时,JVM使用运行时常量池在内存中搜索该方法或字段的实际地址。
  • 字段数据-每个字段:名称,类型,修饰符,属性
  • 方法数据-每个方法:名称,返回类型,参数类型(按顺序),修饰符,属性
  • 方法代码-每个方法:字节码,操作数堆栈大小,局部变量大小,局部变量表,异常表;异常表中的每个异常处理程序:起点,终点,处理程序代码的PC偏移,捕获的异常类的常量池索引

堆区域(线程间共享)

这也是一个共享资源(每个JVM仅1个堆区域)。所有对象及其对应的实例变量和数组的信息都存储在堆区域中。由于“方法”和“堆”区域共享多个线程的内存,因此“方法和堆”区域中存储的数据不是线程安全的。堆区域是GC的重要目标。

栈区域(每个线程)

这不是共享资源。对于每个JVM线程,当线程启动时,都会创建一个单独的运行时堆栈以存储方法调用。对于每个此类方法调用,将创建一个条目并将其添加(推入)到运行时堆栈的顶部,并且该条目称为“ 堆栈框架”。

每个堆栈帧都有局部变量数组,Operand堆栈和正在执行的方法所属的类的运行时常量池的引用。局部变量数组和操作数堆栈的大小在编译时确定。因此,根据该方法固定栈帧的尺寸。

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

堆栈帧分为三个子实体:

  • 局部变量数组 (Local Variable Array)—它的索引从0开始。对于特定的方法,涉及多少个局部变量,并且相应的值存储在此处。0是该方法所属的类实例的引用。从1开始,保存发送到方法的参数。在方法参数之后,将保存方法的局部变量。
  • 操作数堆栈 (Operand Stack)—充当运行时工作空间,以在需要时执行任何中间操作。每个方法在Operand堆栈和局部变量数组之间交换数据,并推送或弹出其他方法调用结果。可以在编译期间确定操作数堆栈空间的必要大小。因此,操作数堆栈的大小也可以在编译期间确定。
  • 帧数据 (Frame Data)—与该方法有关的所有符号都存储在这里。作为例外,捕获块信息也将保留在帧数据中。

由于这些是运行时堆栈帧,因此线程终止后,JVM也会破坏其堆栈帧。

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

PC寄存器(每个线程)

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

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

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

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

Java线程终止后将回收本地线程。因此,操作系统负责调度所有线程并将其分配给任何可用的CPU。

执行引擎

字节码的实际执行在这里进行。执行引擎通过读取分配给上述运行时数据区域的数据逐行执行字节码中的指令。

解释器

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

即时(JIT)编译器

如果只有解释器可用,则当多次调用一种方法时,每次都会进行解释,这将是多余的操作。使用JIT编译器可以避免这个问题。首先,它将整个字节码编译为本地代码(机器代码)。然后,对于重复的方法调用,它直接提供了本地代码,使用本地代码的执行比单步解释指令要快得多。本地代码存储在缓存中,因此可以更快地执行编译后的代码。

但是,即使对于JIT编译器,编译所花费的时间也要比解释器所花费的时间更多。对于仅执行一次的代码段,最好对其进行解释而不是进行编译。同样,本地代码存储在高速缓存中,这是一种昂贵的资源。在这种情况下,JIT编译器会在内部检查每个方法调用的频率,并仅在所选方法发生超过特定时间级别时才决定编译每个方法。自适应编译的想法已在Oracle Hotspot VM中使用。

当JVM软件供应商引入性能优化时,执行引擎有资格成为关键子系统。在这些工作中,以下4个组件可以大大提高其性能。

  • 中间代码生成器生成中间代码。
  • 代码优化器负责优化上面生成的中间代码。
  • 目标代码生成器负责生成本地代码(即机器代码)。
  • Profiler是一个特殊的组件,负责查找性能瓶颈(也称为热点)(例如,多次调用一种方法的实例)

软件供应商的优化方法

Oracle Hotspot虚拟机

Oracle有两种流行的JIT编译器模型Hotspot Compiler来实现其标准Java VM的两种实现。通过分析,它可以确定最需要JIT编译的热点,然后将代码的那些性能关键部分编译为本地代码。随着时间的流逝,如果不再频繁调用这种已编译方法,它将把该方法标识为不再是热点,并迅速从缓存中删除本地代码并开始以解释器模式运行。这种方法可以提高性能,同时避免不必要地编译很少使用的代码。此外,Hotspot Compiler可以即时确定使用lining等技术来优化已编译代码的最佳方式。编译器执行的运行时分析使它可以消除在确定哪些优化将产生最大性能收益方面的猜测。

这些虚拟机使用相同的运行时(解释器,内存,线程),但是将自定义构建JIT编译器的实现,如下所述。

  • Oracle Java Hotspot Client VM是Oracle JDK和JRE的默认VM技术。它通过减少应用程序启动时间和内存占用量而在客户端环境中运行应用程序时进行了优化,以实现最佳性能。
  • Oracle Java Hotspot Server VM旨在为在服务器环境中运行的应用程序提供最高的程序执行速度。此处使用的JIT编译器称为“高级动态优化编译器”,它使用更复杂和多样化的性能优化技术。通过使用服务器命令行选项(例如,java服务器MyApp)来调用Java HotSpot Server VM。

Oracle的Java Hotspot技术以其快速的内存分配,快速高效的GC以及易于在大型共享内存多处理器服务器中扩展的线程处理能力而闻名。

IBM AOT(提前)编译

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

垃圾收集器(GC)

只要引用了一个对象,JVM就会认为它是活动的。一旦不再引用该对象,应用程序代码就无法访问它,并且垃圾收集器将其删除并回收未使用的内存。通常,垃圾回收是在后台进行的,但是我们可以通过调用 System.gc() 方法来触发垃圾回收(同样无法保证执行。因此,请调用 Thread.sleep(1000) 并等待GC完成)。

Java本地接口(JNI)

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

本地方法库

这是执行引擎所需的C/C++本地库的集合,可以通过提供的本地接口进行访问。

JVM线程

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

主应用程序线程是作为调用 public static void main(String[]) 的一部分而创建的主线程,而所有其他应用程序线程都是由该主线程创建的。应用程序线程执行诸如以 main() 方法开始的指令执行,在Heap区域中创建对象(如果它在任何方法逻辑中找到新关键字)之类的任务等。

主要系统线程如下:

  • 编译器线程:在运行时,这些线程将字节码编译为本地代码。
  • GC线程:所有与GC相关的活动均由这些线程执行。
  • 定期任务线程:用于调度定期操作执行的计时器事件(即中断)由该线程执行。
  • 信号调度程序线程:此线程接收发送到JVM进程的信号,并通过调用适当的JVM方法在JVM内处理它们。
  • VM线程:作为前提条件,某些操作需要JVM到达安全点,在该点不再进行对Heap区域的修改。这种情况的示例是“stop-the-world”垃圾回收,线程堆栈转储,线程挂起和有偏向的锁吊销。这些操作可以在称为VM线程的特殊线程上执行。

一些了解

  • Java被认为是解释语言和编译语言。
  • 按照设计,由于动态链接和运行时解释,Java速度很慢。
  • JIT编译器通过保留本地代码而不是字节码来弥补解释器重复操作的缺点。
  • 最新的Java版本解决了其原始体系结构中的性能瓶颈。
  • JVM只是一个规范。软件供应商可以在实施过程中自由定制,创新和改善其性能。