购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.3 JVM系统架构

在Java虚拟机规范中,虚拟机实例的行为是根据子系统、内存区域、数据类型和指令来描述的,即定义了JVM实现所需的行为。这些组件描述了抽象Java虚拟机的内部架构。

JVM逻辑架构总共可以分为四大子模块,分别是类加载子系统、运行时数据区、执行引擎、本地方法接口。类加载子系统用于将class文件加载到JVM中。运行时数据区用来存储程序运行过程中产生的数据。执行引擎主要用来翻译与执行字节码。本地方法接口是将JVM与本地方法库连接起来,以执行本地方法的接口。例如,在Linux上运行Java程序来读取文件,JVM会调用Linux文件读写方法库来实现文件读写。

2.3.1 类加载子系统

类加载子系统主要包含类加载器、链接、初始化3个子模块,如图2-5所示。类加载是查找并获取具有特定名称的类或接口类型的class文件的过程。链接是指将创建成的类合并至JVM,使之能够执行的过程。初始化主要是完成类和接口的初始化。

图2-5 类加载子系统

类加载的整个过程如下:首先根据类的全名(路径+文件名)来读取此class文件,然后将class文件中的字节码数据结构转化为运行时数据结构,最后在内存中生成这个类的java.lang.Class对象。

1.类加载器

JVM定义了3种类型的类加载器,如图2-6所示,分别是启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用程序类加载器(Application ClassLoader)。在实际开发场景中,如果有必要也可以加入自定义的类加载器。

图2-6 类加载器

启动类加载器负责加载存放在<JAVA_HOME>\lib目录中的.jar文件,例如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载。启动类加载器是用C++语言实现的。

扩展类加载器的实现类是sun.misc.Launcher中静态内部类ExtClassLoader,它是用Java语言实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者加载由系统变量java.ext.dirs指定路径中的所有类库。

应用程序类加载器的实现类是sun.misc.Launcher中的静态内部类AppClassLoader,是用Java语言实现的。因为应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称为“系统类加载器”。AppClassLoader负责加载用户类路径(ClassPath)上的所有类库,开发者同样可以直接在代码中使用这个类加载器。在应用程序没有自定义过自己的类加载器情况下,AppClassLoader就是程序默认的类加载器。

在Java的应用程序开发过程中,类的加载几乎都是由以上3种类加载器相互配合来完成的。在一些特定场景中,开发人员也可以用自定义的类加载器来进行扩展,例如增加除了磁盘位置之外的class文件来源,从而实现用网络加载器来加载远程资源。

2.链接

第二阶段是链接,即把原始的类定义信息平滑地转化成JVM运行时状态以便执行的过程。链接分为3个阶段:验证、准备、解析。

验证阶段 是验证class文件的正确性,确保class文件内容符合Java虚拟机的规范。核心验证的内容包含版本号、类型验证(类、接口、方法、常见的类型与完整性)、继承关系验证、系统指令验证、执行方法路径验证、抽象方法与本地方法验证、普通方法验证等相关内容。如果验证失败会抛出VerifyError的异常,这样能够防止恶意或者不合规的代码危害JVM的运行。

准备阶段 是为类中定义的变量分配内存空间,创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的初始化和后面的初始化阶段是有明显区别的,准备阶段初始化侧重于分配所需要的内存空间,不会去执行进一步的JVM指令。

解析阶段 会将常量池中的符号引用(Symbolic Reference)替换为直接引用。直接引用是指向对象的指针或者内存相对偏移量。因为直接引用受JVM内存设计布局的影响,所以不同版本的JVM上解析出来的直接引用是不同的。Java虚拟机指令(anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic)的参数是常量池中的符号引用。执行这些指令时都需要将符号引用解析成直接引用,获取到每个参数的内存地址。

3.初始化

初始化是类加载的最后一个步骤,负责类或接口的默认属性或方法的初始化。主要完成static字段、final字段、静态方法、接口的默认方法的初始化。在Java代码中,如果要初始化静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果字段被final修饰,并且它的类型是基本类型或字符串时,那么该字段便会被Java编译器标记成常量值,会在这个阶段进行全局的初始化。如果类与接口含有静态方法,则会完成整类的解析与初始化。如果一个接口有默认实现的方法,则需要优先初始化默认方法。直接赋值操作以及所有静态代码块中的代码会被Java编译器置于同一方法中,并将其命名为clinit。初始化阶段最重要的步骤就是执行clinit方法。

每个类或接口都有一个初始化锁,在开始进行初始化之前会对其进行加锁,在初始化结束后会释放锁。JVM通过初始化锁的机制来防止多个线程同时初始化一个类或接口。

4.双亲委派模型

JVM在类加载的策略上采用的是双亲委派模型,如图2-7所示。双亲委派模型是指当类加载器接收到一个类的加载请求时,自己并不会去加载这个类,而是委托给自己的父类加载器进行加载。如果父类加载器仍然存在父类加载器,则依次向上递归传递,直至传递给顶层的启动类加载器为止。如果父类加载器成功加载该类则成功返回。如果父类加载器无法加载该类,则依次递归向下,尝试由子类进行加载。

双亲委派模型有3个特征:可见性、单一性与唯一性。 可见性 是指子类加载器可以访问父类加载器加载的内容,但父类加载器无法访问子类加载器的内容。这样设计能够很好地实现“向上可见、向下隔离”的逻辑,能够很好地保护JDK核心代码的安全。 单一性 是指由于父类加载器对子类加载器是可见的,所以父类加载器中加载过的类就不会在子类加载器中重复加载。如果有两个同一层级的类加载器可以对同一类实现多次加载,那么它们相互不可见。 唯一性 是类在系统中的标识,是由加载器+类的全路径名称来共同决定的。如果一个类被用户自定义的两个加载器加载了,那么在JVM中这是两个类。

图2-7 双亲委派模型

2.3.2 运行时数据区

JVM定义了在程序执行期间使用的各种运行时数据区。有一些数据区是在JVM启动时创建的,只有在JVM退出时才会被销毁。有一些数据区是线程创建的。每个线程的数据区在创建线程时创建,并在线程退出时销毁。

1.方法区

方法区主要是用来存储类型(class、interface、enum、annotation)、字段(Field)、方法(Method)的完整信息。方法区是线程共享的,线程执行的时候会从方法区读取代码相关信息进行执行。方法区会存储每个加载类型的以下信息:类型完整名称(包名、类名)、类型直接父类的完整名称(interface与java.lang.Object没有父类)、类型的修饰符(public、abstract和final)、类型实现接口的有序列表。方法区会存储Field成员变量的变量名称、变量类型、变量修饰符,以及方法的名称、返回类型(或void)、参数的数量和类型(按顺序)、修饰符、方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)、异常表(abstract和native方法除外)等信息。

2.常量池

运行时常量池是方法区的一部分。常量池表是class文件的一部分,用于存放编译阶段产生的各种字面量和符号引用,在类加载完成之后,就会将这部分内容存放到方法区的运行时常量池中。JVM会为每个加载的类型(class、interface、enum、annotation)分配一个常量池,池中的数据项像数组一样,可以通过索引快速访问。

3.堆内存

JVM有一个在所有线程之间共享的内存区域称之为堆。堆是为所有类实例和数组分配内存的运行时数据区。堆是在虚拟机启动时创建的,用来存放对象实例。堆又分为年轻代(Young Generation)、老年代(Old Generation)、永久代(Perm),逻辑结构如图2-8所示。

图2-8 堆内存逻辑结构

年轻代又可分为Eden、From Survivor、To Survivor这3个区域,默认按照80%、10%、10%的大小来分布。Eden区是用来存放刚创建好的对象实例的。当Eden区满了,新创建的对象会被放在Survivor区中。在垃圾回收时,会将活跃对象在From Survivor与To Survivor两者之间腾挪。同时每次垃圾回收时,都会将对象的年龄加1,当对象年龄达到一定阈值时就会被移到老年代。老年代是存放生命周期较长的对象的,而永久代在JDK 8之后已被元空间替代。元空间使用本地内存,永久代使用JVM内存,所以使用元空间的好处是,程序的内存不再受限于JVM的内存,本地内存剩余多少空间,元空间就可以有多大,解决了空间不足的问题。

4.PC寄存器

JVM可以支持多个线程同时执行,每个JVM线程都有自己的PC(程序计数器)寄存器。在任何时候,每个JVM线程都在执行单个方法的代码。如果该方法不是本地方法,则PC寄存器包含当前正在执行的JVM指令的地址;如果当前线程正在执行的方法是本地方法,则JVM的PC寄存器的值是未定义的。JVM的PC寄存器的宽度足以容纳各种平台上的本地指针或原生指针。

5.线程栈

每个JVM线程都有一个私有的JVM线程栈,与线程同时创建,并在线程结束的时候销毁。JVM线程栈类似于C语言的堆栈,用来保存方法的局部变量、部分结果,并参与方法的调用和返回。JVM对线程栈的操作只有两个:对栈帧的压栈和出栈,遵循“后进先出”原则。线程在任何一个时刻只会操作一个栈帧,即当前执行的方法的栈帧(栈顶栈帧),这个栈帧被称为当前栈帧,与当前栈帧对应的方法就是当前方法。线程栈逻辑结构如图2-9所示。

执行引擎只针对当前栈帧的字节指令进行操作,当执行调用新方法时会创建出新的栈帧并放在栈的顶端。如果当前栈帧的方法执行结束,会将当前方法执行的结果返回给前一个栈帧,JVM会丢弃当前栈帧,释放内存资源。

图2-9 线程栈逻辑结构

线程栈的内存可以是固定大小、不连续的存储空间。JVM在创建线程时可以指定线程栈的大小,如果没有指定,则采用-Xss指定的默认大小。

本地方法栈与Java栈的作用和原理基本相同,都是用来执行方法。不同点在于Java栈执行的是Java方法,本地方法栈执行的是本地方法(C语言方法)。本地方法栈有固定大小,可以在创建线程的时候设定本地方法栈大小。

2.3.3 执行引擎

执行引擎包含解释器、JIT(即时)编译器和垃圾收集器三个部分。

1.解释器

解释器主要承担的是翻译者的角色,相当于国际会议中的同声翻译,将字节码文件中的内容翻译为JVM的本地机器码指令。当一条字节码指令被解释、执行后,接着对PC寄存器中记录的下一条需要被执行的字节码指令进行解释执行操作。JVM解释器有两种类型:字节码解释器、模板解释器。字节码解释器是JVM早期设计的,年代比较久远,在执行过程中会一行行地解析字节码指令,然后调用对应的C/C++函数进行处理,执行的效率非常低。而模板解释器将每一条字节码和一个模板函数相关联,模板函数中将直接产生这条字节码要执行的机器码,从而很大程度上提高了解释器的性能。

2.JIT编译器

JIT编译器在运行时将字节码编译为机器码,以提高Java应用程序的性能。JIT编译确实需要消耗大量的CPU与内存资源。JVM会维护每个方法的调用计数,每次调用该方法时,该计数都会递增。当调用计数超过JIT编译阈值时,会直接异步提交JIT编译请求。JIT编译器会启动线程对字节码进行编译,并将编译的机器码放入到Code Cache(机器码缓存)中。下次方法执行的时候会直接从Code Cache中获取对应的机器码来执行,这样能够极大地提高执行的效率。

JIT动态编译流程如图2-10所示。

图2-10 JIT动态编译

3.垃圾收集器

JVM的垃圾收集器负责从堆中清除对象(未使用的对象),以回收堆空间。像CMS、G1这些垃圾回收的调度算法,我们都非常熟悉了,本章不做过多的讲解。 X+TxrT/Eqx6adfOn6d3PAQhtO1L03y2ys8VEHqsgzKYNqLFoO8LvE6WDjDCqvj5o

点击中间区域
呼出菜单
上一章
目录
下一章
×