本节主要介绍JVM核心体系中的七大核心成员,主要包括类加载子系统、运行堆内存区域(Runtime Heap Area)、方法区(Method Area)、虚拟机栈(包含PC计数器)、本地方法栈(Native Stack)、内存管理子系统(Garbage Collector,垃圾回收器)和执行引擎子系统。这7个子系统相互协作,实现了JVM的核心功能。
类加载子系统是JVM执行流程的起始点,如同输入管道一样控制着外界数据的接收和转换等功能。另外,从某个角度而言,它也是JVM开放给Java开发者操作最接近底层的工具入口。
类加载子系统是外界系统与JVM之间的传输纽带,负责从文件系统或网络资源中加载Class字节码文件,通过检查和解析之后,生成对应的运行时数据区的数据对象。
类是从被我们装入的所有虚拟机和存储器中开始启动,直到我们卸载存储器中的文件为止,它将一个完整的生命周期划分为加载(自动)、验证、准备、解析、初始化、应用及卸载(自动)。
其中,需要重点注意的几个阶段为加载、验证、准备、解析及初始化。另外验证、准备、解析这3个阶段可以归属为链接(Linking)阶段,具体内容后面会介绍。
加载是类加载过程的第一个阶段。在这一阶段,JVM的主要工作目的就是从不同的传输渠道或来源读取Class字节码,将Class字节码转换为二进制格式字节流并加载到内存中。
加载Class字节码的渠道场景主要有以下几种。
(1)本地系统中直接加载、通过网络资源获取。
(2)压缩文件中读取,如项目中经常采用jar、war等格式运行。
(3)运行时动态生成,如利用动态代理技术cglib、JDK Proxy等实现。
(4)其他文件生成,如JSP应用文件生成的Servlet Class类字节码。
(5)专有数据库存储和提取相关Class字节码文件,此种场景比较少见。
(6)从加密文件中获取,典型的场景是防止Class文件被反编译的保护措施。
当应用程序主动添加并使用某一个Class类时,假设类从未被添加到JVM内存中,那么JVM首先会进行内存加载、链接和对象初始化这3个基本过程,主动对类进行初始化。
从类被缓存添加至JVM开始,直到从内存中进行卸载,整个类的生命周期如图3.1所示。
完成加载和链接之后,开发者就可以直接使用该对象进行相应的操作。最后,当GCRoot无法引用该对象,该对象会被回收销毁。至此整个对象的生命周期完全结束,后面的章节会对类加载器进行详细的介绍和说明。
图3.1 类的生命周期
运行堆内存区域是Java程序运行中最核心的部分,每个类实例和数组等对象都要存放在其中。但随着逃逸分析技术的出现,可以采用在栈上分配、标量替换等优化手段,从而一部分对象能够直接分配到栈内存中。
对于每个JVM而言,只会存在一个堆内存区域,不同的Java程序之间相互隔离且互不干扰。但是,对于其本身属于线程共享的区域,在多线程的情况下,就需要考虑多线程访问的安全性问题。
堆内存又称作GC堆,从内存的管理角度而言,开发者无须过多地管理和考虑内存的分配和回收,因为这些工作主要依靠GC回收系统进行处理。从结构上划分为新生代和老年代,采用分代的回收机制。新生代又划分为Eden区、Survivor to区(S1)、Survivor from区(S2)等,采用标记复制算法进行内存的分配和回收。
图3.2 堆内存的分布结构
必须特别注意的一点是,JVM在新生代的Eden区设置一小块线程私有的内存块,称为TLAB (Thread-local Allocation Buffer)。在一个Java应用程序中,大多数分配对象属于小型对象,不一定存在对线程间的共享,所以很适合被快速分配GC。因此,对于小型对象,一般会按照优先顺序分配到每个线程私有的TLAB中。另外,每个TLAB中的对象都是私有的,因此不会有任何锁开销。
堆内存在物理空间上不必是连续区域,在运行过程中只要能够支持动态扩展和收缩即可。当无法进行扩展时,则会抛出内存溢出异常,即OutOfMemoryError。
方法区与堆内存一样,也是线程共享的内存区域。方法区也称为非堆,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
使用Java 8之前版本的系统开发人员,更习惯将整个方法区称为“永久代”,而其实二者并不一样,只是因为之前JVM分代管理的设计思路选择了使用“永久代”来直接实现整个方法区(方法区属于约定或者协议规范)的分区收集功能罢了,又或者只是使用永久代方法来直接实现整个方法区的分区收集功能罢了。
因此整个Java虚拟机的内存管理系统也就能像管理堆中的内存那样,自动管理这部分方法区的内存,可以大大省去由技术人员为各部分内存进行管理的烦琐工作。
移除永久代的工作早在Java 7时就已开展。在Java 7中,存储到永久代的大部分数据已经迁移到了堆内存中,甚至是直接内存(Pirect Memory)。但永久代仍存在于Java 7之中,并没有彻底删除,如将符号(Symbols)引用转到了本地内存、字符串常量池转到了堆内存。
Java 8引入的元空间的规范本质上与永久代空间类似,均归属于对应的JVM规范中各种方法区的实现。不过,元空间和永久代间的主要差异之一是元空间并不由JVM进行管理和分配,而是完全使用本地化的内存。
因此,默认情况下元空间的内存大小受本地物理内存大小的影响,其也可以通过参数进行控制。另外,元空间的实际物理内存空间和堆内存一样,都可以是不连续的。同样,当方法区无法扩展进行内存分配时,也会抛出OutOfMemoryError。
相比于基于堆栈的内存和基于方法区这类线程的公共区,虚拟机栈和基于PC栈的计数器则都属于线程的私有区。PC计数器本身就是一个比较小的线程内存空间,可以把它看成当前线程上可以被运行的每一行代码的地址坐标指示器。PC计数器在线程初始创建时也第一时间完成初始化,大小为一个字长,所以其可以存储一个本地指针,或者是return Address的值。在执行方法时PC计数器存储值一般为下一条指令的地址或者是相对于方法首地址的偏移量。有一种特定的应用场景,即当系统执行本地方法时,PC计数器的存储值是undefined。此外,PC计数器不存在内存溢出的场景。
虚拟机栈随着线程的建立而建立,在线程销毁时也被销毁。虚拟机栈由栈帧组成,以栈帧为单元执行任务并存储工作状态。虚拟机中对栈帧的操作方式主要为进栈与出栈。当前线程执行栈帧作为栈顶的栈帧。一般在调用方法时进行入栈,当抛出异常或者返回(return)时进行出栈。虚拟机栈会存在StackOverflowError和OutOfMemoryError。
注意:PC计数器和虚拟机栈属于线程私有区,堆内存和方法区属于线程子公共区,创建对象的生命周期不同。两者相互协作,共同实现了程序的执行和内存的分配等。
本地方法栈和虚拟机栈非常类似,虚拟机主要用本地方法栈运行本地方法(Native方法),虚拟机栈运行方法时,会进行入栈操作,而在本地方法栈运行本地方法时,不会进行入栈操作。它直接使用当前栈帧的动态链接调用本地方法,并进入本地方法栈,在返回给Java方法时,跳转回虚拟机栈。与虚拟机栈方式相比,本地方法栈中同样可能会出现StackOverflowError和OutOfMemoryError。
注意:在虚拟机栈与本地方法栈中出现的StackOverflowError和OutOfMemoryError的区别,及触发条件,读者必须认真理解与分析。对于Hotspot虚拟机,将虚拟机栈与本地方法栈合二为一将更方便管理。
Java之所以非常容易上手,主要是因为JVM的GC管理子系统接管了复杂的内存管理,使得开发者只需专心进行业务或者功能开发即可。
垃圾回收的核心机制是通过分代划分策略,结合不同类型的垃圾回收器及垃圾回收算法对不再使用的对象进行标记后处理清除。
在不同的分代区域里有着不同的垃圾处理器,它属于垃圾算法的执行者,执行垃圾的回收处理。当然,开发者也需要根据不同的需求和业务场景进行定制化的调整和配置。垃圾回收类型分为Minor GC、Major GC、Full GC、Mixed GC等,后面的章节会详细介绍相关的原理和运行机制。
执行引擎在整个JVM系统中处于最核心的地位,在JVM标准定义中,执行引擎主要负责执行相关的指令集,但其并不属于JVM执行时数据区的组成部分,也不是JVM标准中所定义的数据存储区。
JVM规范定义了执行引擎的逻辑模型,无论是哪种虚拟机的实现,都必须遵循约定和标准。虚拟机栈的栈帧是执行引擎的数据结构之一,而栈帧的结构包含操作数栈、局部变量表、返回地址和动态链接等,它们之间协同合作,完成指令的执行。
从外观上来说,大部分JVM中的执行引擎及其输入、输出方式都完全相同。输入数据是以字节为单位的二进制数据流,之后用每个字节的编码方式解析数据执行代码,结果都是等效代码流程;而代码输出的结果就是实际数据执行时的结果。
执行引擎在执行过程中需要执行的字节码指令完全依赖于PC寄存器。每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
当然,在整个执行的过程中,执行引擎非常有可能将一个对象变量通过局部对象变量表(栈)区域中的实例引用准确地定位,Java堆区域栈中的对象作为其实例引用。
通过目标对象头(堆)中的元数据指针,定位到目标对象的类型信息(方法区)。堆区和方法区之间实际上还有类型指针,由堆区内对象实例指向方法区内的元数据。
此外,执行程序的方式主要包含解析执行和编译执行两种,其中编译执行主要依靠JIT编译器或者AOT编译器、自适应优化机制及将热点代码转为机器码运行等,具体内容将在后面章节详细介绍。