JVM
JVM(Java虚拟机)是Java编程语言的核心组成部分之一,它是一个在物理计算机上运行Java字节码的虚拟机。JVM的主要作用是将Java源代码文件编译成字节码文件,然后在不同的操作系统和硬件平台上执行这些字节码。
JVM具有跨平台的特性,即“一次编写,到处运行”,这意味着编写的Java程序可以在任何安装了JVM的平台上运行,而不需要针对不同操作系统和硬件进行修改。
字节码文件
Java源代码(.java
文件)通过Java编译器(如javac
)编译生成字节码文件(.class
文件)。
字节码文件不能直接由硬件执行,需要由JVM解释执行。JVM在运行时将字节码转换为特定平台的机器码。
一个.class
文件包含了Java虚拟机(JVM)用于执行Java程序的所有信息。字节码文件包含JVM指令集,这些指令对应于源代码中的各种操作,如加载、存储、算术运算、类型转换等。同时还包括符号表和其他辅助信息,如变量和方法的声明。
字节码文件的结构
字节码文件的结构是固定的,它由一系列严格定义的项组成,每项都有明确的长度和格式,以便JVM能够准确地解析和执行。由于字节码文件的这种结构化特性,它可以通过各种工具(如反编译器、字节码分析工具等)进行查看和分析。
魔数
文件类型检测通常是基于文件的开头几个字节(称为魔数或文件签名)来确定的,因为这些字节通常包含了特定于文件格式的标识信息。每个文件格式都有其独特的魔数,这是一种快速识别文件类型的方法。
这些文件头是文件格式设计时有意放入的,目的是为了让软件能够快速识别文件格式,无需打开文件即可进行初步的类型判断。
用户可以修改文件的扩展名,但这不会改变文件格式本身,因此软件在打开文件时仍然会根据文件头来确定文件类型。
如果软件没有识别到正确的文件头,可能会无法打开文件,或者提示文件格式不正确。
下面常见的几种文件头。
文件类型 | 文件头 | 文件头长度 | 备注 |
---|---|---|---|
JPEG (jpg) | FFD8FF | 3 bytes | 通常表示JPEG图像文件的开始 |
PNG (png) | 89504E47 | 4 bytes | 表示PNG图像文件的开始,基于PNG发明者PNGigo的姓名首字母缩写 |
BMP | 424D | 2 bytes | 表示Windows位图图像文件的开始 |
XML (xml) | 3C3F786D6C | 5 bytes | 表示XML文档,3C3F 是XML声明的开始字符,786D6C 是"xml"的ASCII编码 |
AVI (avi) | 41564920 | 4 bytes | 表示音频视频交错(AVI)文件的开始 |
Java字节码 | CAFEBABE | 4 bytes | Java虚拟机用于识别Java class文件的魔数 |
Java字节码的魔数是文件的前4个字节,值为0xCAFEBABE
,用于标识这是一个Java class文件。
版本信息
紧接着魔数之后的是2个字节的版本号,包括minor version(次版本号)和major version(主版本号),用于判断当前字节码的版本和运行时的JDK是否兼容。
常量池
常量池计数器(constant_pool_count):2个字节,表示常量池中的条目数(constant_pool[constant_pool_count-1]是有效的)。
常量池表(constant_pool[]):一个数组,每个条目都是一个常量池项,包括类和接口名称、字段和方法名称、字符串常量等。
每个常量池项都是一个表,它们的类型和结构由一个1字节的标记(tag)决定。不同的标记代表不同的常量类型。以下是一些常见的常量类型:
CONSTANT_Integer_info:
- 表示一个int类型的字面量。它包含一个4字节的整数值。
CONSTANT_Methodref_info:
- 表示一个类中方法的符号引用。它包含两个索引,一个指向CONSTANT_Class_info条目(表示方法所属的类),另一个指向CONSTANT_NameAndType_info条目(包含方法的名称和描述符)。
CONSTANT_InterfaceMethodref_info:
- 表示一个接口中方法的符号引用。它包含两个索引,一个指向CONSTANT_Class_info条目(表示方法所属的接口),另一个指向CONSTANT_NameAndType_info条目(包含方法的名称和描述符)。
常量池的作用
- 避免重复:常量池允许在字节码文件中重复使用相同的字符串字面量和符号引用,避免重复定义,从而节省空间。
- 快速访问:常量池中的每个条目都有一个唯一的索引,字节码指令可以通过这个索引快速地引用到常量池中的数据。
- 动态链接:在Java程序运行时,JVM会使用常量池中的符号引用来定位对应的类、字段、方法和接口等,实现动态链接。
访问标志
(Access Flags)2个字节,用于表示类或接口的访问级别(如public、final、abstract等)和其他属性。
类索引
(This Class)2个字节,指向常量池中的一个UTF-8字符串,表示这个类的全限定名。
父类索引
(Super Class)2个字节,指向常量池中的一个UTF-8字符串,表示这个类的直接超类的全限定名。对于接口,这个值通常是java/lang/Object
;对于对象,如果没有直接超类,这个值为0。
接口集合
接口集合和常量池的结构相同,都是前面是数量后面紧跟着具体条目。
接口计数器(Interfaces Count):2个字节,表示这个类或接口直接实现的接口数量。
接口表(Interfaces[]):一个数组,每个条目都是2个字节,是索引,指向常量池中的一个UTF-8字符串,表示一个接口的全限定名。
字段表
字段计数器(Fields Count):2个字节,表示类中字段的数量。
字段表(Fields[]):一个数组,包含了类的所有字段(成员变量)的信息,每个条目都表示一个字段的信息,包括访问标志、名称索引、描述符索引和属性表。
方法表
方法计数器(Methods Count):2个字节,表示类中方法的数量。
方法表(Methods[]):一个数组,包含了类的所有方法的信息,每个条目都表示一个方法的信息,包括访问标志、名称索引、描述符索引和属性表。方法的代码在Code属性中。
属性表
属性计数器(Attributes Count):2个字节,表示类中属性的数量。
属性表(Attributes[]):一个数组,每个条目都表示一个属性的信息,包括属性的名称索引、长度和具体内容。常见的属性包括Code、LineNumberTable、SourceFile等。Code属性包含了方法的字节码指令、异常表、局部变量表和操作数栈的大小等信息。
类的生命周期
Java类的生命周期是指从类被加载到Java虚拟机(JVM)开始,直到被卸载出JVM的整个过程。
加载
类的加载(Loading)是类生命周期的第一个阶段,JVM在这个阶段会完成以下工作:
- 加载二进制数据:类加载器根据类的全限定名找到对应的
.class
文件,然后读取该文件,将字节码数据加载到JVM中。 - 生成
Class
对象:JVM会在堆内存中生成一个java.lang.Class
对象,这个对象作为方法区中类的数据的访问入口。 - 数据存储:加载的字节码数据会被存储在方法区中,包括类的结构信息、字段、方法、接口等。对于开发者来说,只需要访问堆中的Class对象而不需要访问方法区中所有信息。
类加载器会遵循双亲委派模型,首先尝试让父类加载器加载类,只有在父类加载器无法加载时才由自己加载。
链接
连接阶段(Linking)是类的第二个阶段,这个阶段又分为三个小阶段:验证、准备和解析。
验证
验证阶段(Verification)确保加载的类的正确性,主要验证以下几个方面:
- 文件格式验证:验证字节码文件是否符合JVM规范,比如魔数、版本号等。
- 元数据验证:验证类的元数据是否符合JVM规范,比如是否有父类、是否继承了final类等。
- 字节码验证:验证字节码中的指令是否符合JVM规范,比如指令的参数类型是否正确、跳转指令的目标是否有效等。
- 符号引用验证:验证符号引用是否能够解析为正确的直接引用,比如类、字段、方法的访问权限是否允许等,比如是否访问了其他类中private的方法等。
准备
准备阶段(Preparation)为类的静态字段分配内存,并设置默认初始值。这些初始值通常是数据类型的默认值,比如int
类型的字段默认值为0,引用类型字段的默认值为null
。
解析
解析阶段(Resolution)是将类、接口、字段和方法的符号引用转换为直接引用。符号引用是基于字符串的,而直接引用是直接指向内存中的地址。
初始化
初始化阶段(Initialization)是类生命周期的第三个阶段,这个阶段执行类的初始化代码,包括静态代码块和对静态变量的赋值。这个阶段会为静态变量赋予正确的初始值,默认初始值将被覆盖。
类的初始化是懒惰的,只有在以下几种情况下才会触发:
- 主动使用:第一次访问类的静态变量或静态方法时。注意变量是final修饰的并且等号右边是常量不会触发初始化。
- 反射使用:通过
Class.forName(String className)
等方式反射地创建类对象时。 - 初始化子类:当初始化一个类的时候,如果其父类还没有进行过初始化,则会先触发其父类的初始化。
- main方法:启动程序时,执行
main
方法的类会被初始化。
使用
(Using)类被初始化后,就可以使用类的成员变量、方法等资源了。
创建对象、调用方法、访问类变量等都是使用阶段的操作。
卸载
(Unloading)当类不再被使用,并且垃圾回收器确定该类的Class
对象不再被引用时,JVM会卸载该类,以释放方法区和堆内存中的空间。
类的卸载条件比较苛刻,通常需要满足以下条件:
- 类的所有实例都被回收,也就是JVM中不存在该类的任何实例。
- 加载该类的类加载器被回收。
- 该类的
java.lang.Class
对象没有被引用。
类加载器
Java的类加载器(ClassLoader)是JVM的一部分,负责将类的字节码文件(.class
文件)加载到JVM内存中,并生成对应的java.lang.Class
对象。类加载器在Java中扮演着非常重要的角色,它们不仅负责类的加载,还负责验证、准备和解析类。
类加载器的类型
启动类加载器(Bootstrap ClassLoader)
这个加载器是JVM实现的一部分,通常用C/C++编写,负责加载Java的核心库(如
rt.jar
)。它是所有类加载器的父加载器,但不是一个Java类,因此无法通过Java代码直接获取。
扩展类加载器(Extension ClassLoader)
这个加载器负责加载JVM的扩展目录(如
jre/lib/ext
或由系统属性java.ext.dirs
指定的目录)中的类库。系统类加载器(System ClassLoader)
这个加载器负责加载classpath上的类库。
自定义类加载器(User-Defined ClassLoader)
开发者可以通过继承
java.lang.ClassLoader
类并重写其findClass
方法来自定义类加载器。在自定义类加载器中,可以定义自己的加载策略,比如从网络、数据库、文件系统或其他特殊的地方加载类。
类加载机制
标准机制
Java类加载器遵循以下机制:
双亲委托模型(Delegation Model):
每个Java实现的类加载器中保存了一个成员变量叫“父”(Parent)类加载器,可以理解为它的上级,并不是继承关系。
类加载器在尝试自己加载类之前,首先委托给其父加载器进行加载。
只有当父加载器无法加载该类时,才由自己来加载。
这种模型避免了类的重复加载,并且保证了Java核心库的类型安全。
可见性限制(Visibility Limitation):
一个类加载器加载的类对其他类加载器是不可见的,除非它们之间有特定的关系。
例如,系统类加载器加载的类对扩展类加载器和引导类加载器是不可见的。
唯一性(Uniqueness):
同一个类标识(全限定名)的类,在同一时间内,只能由一个类加载器加载。
如果一个类已经被加载,那么后续的加载请求都会被定向到已经加载的类。
双亲委托模型的破例
Tomcat 服务器为了支持多个 Web 应用程序的独立性和避免类库冲突,采用了特殊的类加载器架构来打破标准的双亲委派模型。
Tomcat 的类加载器架构
Tomcat 设计了一套复杂的类加载器架构,主要包括以下几个类加载器:
Common ClassLoader (Com)
- 用于加载 Tomcat 服务器自身需要的类库。
- 这个类加载器继承自 Extension ClassLoader。
Catalina ClassLoader (Ct)
- 用于加载 Tomcat 核心容器相关的类库。
- 这个类加载器继承自 Common ClassLoader。
Shared ClassLoader (Sh)
- 用于加载所有 Web 应用程序都可以共享的类库。
- 这个类加载器继承自 Catalina ClassLoader。
Webapp ClassLoader (Wa)
- 为每个 Web 应用程序创建一个实例,用于加载该应用自身的类库。
- 这个类加载器继承自 Shared ClassLoader。
打破双亲委派模型
Tomcat 通过自定义类加载器的实现,允许 Web 应用程序在加载类时打破双亲委派模型。这种机制允许 Web 应用程序优先加载自己的类和库,而不是依赖父加载器。这种行为主要通过 WebappClassLoader
来实现。
具体实现
WebappClassLoader
重写了loadClass
方法,以改变类加载的行为。- 在
loadClass
方法中,WebappClassLoader
首先尝试自己加载类,如果找不到再委托给父类加载器。 - 这意味着对于每个 Web 应用程序,如果类存在于该应用的 CLASSPATH 中,那么
WebappClassLoader
将优先加载该类,即使父加载器也可以加载同名类。
Tomcat类加载器初始化过程
- 初始化流程:在Tomcat启动时,org.apache.catalina.startup.Bootstrap类中的initClassLoaders()方法负责初始化类加载器。这个方法会创建CommonClassLoader、CatalinaClassLoader和SharedClassLoader。
- 配置读取:创建过程中,从
catalina.properties
文件中读取相关的配置信息,用于决定哪些类或JAR由哪个加载器来加载。
实现细节
- 如果
WebappClassLoader
发现一个类在它的 CLASSPATH 中,它将直接加载这个类,而不委派给父加载器。 - 如果
WebappClassLoader
无法找到类,它会按照正常的双亲委派模型递归地委托给父加载器进行查找。 - 这样做可以确保每个 Web 应用程序可以拥有自己的类和库版本,避免了类库冲突问题。
通过这种方式,Tomcat 保证了每个 Web 应用程序的独立性和隔离性,同时仍然保留了类加载器体系的基本优势,例如避免重复加载类和保护核心类库不被篡改。
类加载器的应用
类加载器在热部署中的应用
类加载器在Java热部署中起着关键作用。通过自定义类加载器,可以实现在不重启JVM的情况下,替换掉已经加载的类。这是通过创建一个新的类加载器实例,并用它来加载新的类版本,然后使用反射机制来替换旧版本的引用。
类加载器与JDBC驱动加载
当使用JDBC连接数据库时,JVM会使用系统类加载器来加载JDBC驱动类。这是因为JDBC驱动通常是以jar文件的形式放在classpath上的。
运行时数据区
Java虚拟机(JVM)运行时数据区是JVM在运行Java程序时所使用的内存区域,用于存储各种数据和信息。这些数据区包括方法区、Java堆、Java栈、本地方法栈、程序计数器等。
程序计数器
程序计数器(Program Counter Register)是JVM运行时数据区的一部分,用于存储当前线程执行的字节码指令地址。
程序计数器是一个较小的内存空间,可以看做当前线程所执行字节码的行号指示器。
程序计数器是线程私有的,即每个线程都有一个独立的程序计数器。
程序计数器用于跟踪当前线程的执行流程,以及跳转和返回指令。
方法区
在Java 8及之前,方法区(Method Area)通常使用永久代(PermGen)实现,它位于JVM堆的一部分,并且与Java堆分开管理。永久代的大小是固定的,且在JVM启动时预先分配。
在Java 9及之后,方法区被替换为元空间(Metaspace),元空间使用的是直接内存,而不是JVM堆的一部分。元空间通过与本地操作系统的内存管理器交互,实现对方法区数据的存储和管理。
方法区是线程共享的,即多个线程可以访问同一个方法区。
方法区的组成
方法区主要包括以下几个部分:
- 类信息:存储类的基本信息,如类的全限定名、类的直接父类、接口列表、字段信息、方法信息等。
- 常量池:存储编译期生成的各种字面量,如字符串、整数、浮点数、方法类型、类名等。
- 静态变量:存储类的静态变量,包括静态字段和静态方法。
- 即时编译后的代码:存储编译器为类生成的即时编译(JIT)代码。
- 运行时常量:存储运行时生成的常量,如类的静态字段值、静态方法结果等。
Java堆
Java堆是各个线程共享的内存区域,几乎所有的对象实例都在这里分配内存。堆的大小可以固定,也可以在运行时按需扩展,并且可以通过-Xmx和-Xms参数来调整。如果堆中没有足够的空间为新对象分配,并且堆无法扩展,就会报OutOfMemoryError异常。
它是Java内存模型中最大的一块内存区域,通常也是垃圾收集器管理的主要区域。
JVM的堆内存通常被划分为三个主要区域:新生代 (Young Generation)、老年代 (Old Generation) 和永久代 (PermGen)。
年轻代又分为Eden区、From Survivor区和To Survivor区。
在 JDK 8 及更高版本中,永久代已被元数据区 (Metaspace) 取代。
年轻代
年轻代是Java堆中最活跃的区域,用于存储新创建的对象。
年轻代的内存分配策略如下:
- Eden区:新创建的对象首先被分配到Eden区。
- From Survivor区:当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。Minor GC会将Eden区和From Survivor区中的存活对象复制到To Survivor区,然后清空Eden区和From Survivor区。
- To Survivor区:Minor GC完成后,From Survivor区变成了To Survivor区,而To Survivor区变成了From Survivor区。
老年代
老年代是Java堆中另一个区域,用于存储那些在年轻代中存活了足够长时间的对象。当对象在年轻代中存活了一定次数(如15次)后,就会被晋升到老年代。
老年代的内存空间比新生代大,对象在老年代中存活时间更长。
当老年代的内存空间满时,会触发一次 Full GC(完全垃圾回收),对整个堆进行垃圾回收。
元数据区
元数据区主要用于存储类的元数据信息,如类的结构信息、方法信息、字段信息、常量池、注解等。这些信息在程序运行期间会被频繁访问,因此需要快速、高效地存储和访问。
元数据区的设计目的是解决永久代带来的一些问题,比如内存溢出(OutOfMemoryError)、内存碎片化等。
在Java 8之前,类的元数据是存储在永久代中的,永久代的大小是固定的,容易发生内存溢出,尤其是在大型应用程序中,动态生成类的情况更为明显。
元数据区使用本地内存,而不是JVM堆内存。这意味着它的最大可用内存只受系统内存限制,它会根据应用程序的需要动态调整大小。而元数据区的动态大小特性可以更好地适应应用程序的需求,减少了出现内存溢出的可能性。
在元数据区中,采用基于本地内存的垃圾回收方式,通常是通过 JVM 内部的元数据垃圾回收器来管理。这种方式不同于堆内存中的标记-清除或复制算法,而是依赖于操作系统和 JVM 对本地内存的管理。
Java栈
Java栈是Java虚拟机中的一个关键组件,用于存储和管理方法调用过程中的数据。
每个线程都有自己的Java栈,当线程创建时,Java栈也随之创建,并在线程销毁时进行回收。
Java栈的结构和功能可以通过栈帧(Stack Frame)来理解,栈帧是Java栈的基本组成单位。
每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口。
局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。
这些局部变量可以是基本数据类型(如int、float、long、double等)和引用类型(如对象引用)。
局部变量表是一个数组,每个槽(slot)用于存储一个局部变量。
long和double类型的变量占两个槽,其他类型占一个槽。
方法参数和方法内部定义的局部变量都会保存在局部变量表中。它们的顺序与方法定义时的顺序一致。
为了节省空间,局部变量表中的槽是可以复用的。一旦某个局部变量不再生效,当前槽就可以再次被使用。这种优化有助于提高内存的使用效率。
操作数栈
操作数栈用于存放方法执行过程中的中间数据。
操作数栈是一种栈式数据结构,它允许Java虚拟机在执行字节码指令时将数据压入栈顶(push)或从栈顶弹出(pop)数据。
栈的大小在编译期就已经确定,并分配相应的内存空间。在Java虚拟机规范中,操作数栈的最大深度是固定的,通常是1024个slot(槽)。
操作数栈在Java虚拟机执行方法时扮演着重要的角色,主要用于以下几个方面:
- 执行算术运算:Java虚拟机中的算术运算指令,如
add
、sub
、mul
等,会操作操作数栈。 - 传递参数:方法调用时,参数会通过操作数栈传递给被调用的方法。
- 返回结果:方法执行完成后,返回值会通过操作数栈传递给调用者。
- 处理异常:当方法执行过程中遇到异常时,异常信息也会通过操作数栈传递。
动态链接
动态链接用于处理类和方法的符号引用(symbolic references),并将它们转换为实际的直接引用(direct references),从而确保程序能够正确地调用其他类和方法。
在Java程序中,类和方法的引用通常以符号的形式存在,这些符号引用是指向类和方法的名称和位置的引用。符号引用包括类名、方法名、字段名等。当程序需要调用一个类或方法时,它需要知道这个类或方法的确切位置。
直接引用是指向类和方法的实际内存地址的引用。当符号引用被转换为直接引用后,程序就可以直接通过这些地址来访问类和方法。
主要体现为以下:
- 类加载:当Java虚拟机加载一个类时,它会将类的符号引用转换为直接引用,并存储在方法区中。
- 方法调用:当程序调用一个方法时,它会使用方法的符号引用。动态链接会将这个符号引用转换为直接引用,并确保程序能够正确地找到并调用这个方法。
- 字段访问:当程序访问一个字段时,它会使用字段的符号引用。动态链接会将这个符号引用转换为直接引用,并确保程序能够正确地找到并访问这个字段。
在Java虚拟机中,动态链接的实现主要依赖于方法区(Method Area)。方法区中存储了所有已加载类的信息,包括类的符号引用和直接引用。当程序需要调用一个类或方法时,Java虚拟机会从方法区中查找这个类或方法的直接引用,并使用它来执行调用。
方法出口
方法出口用于标识方法在正确或异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。在当前栈帧中,需要存储此方法出口的地址。
方法出口的作用是确保方法执行完成后,程序能够正确地返回到调用处。
在Java程序的执行过程中,方法出口的主要作用包括:
- 正常结束:当方法执行完成后,当前栈帧会被弹出,同时程序计数器会指向上一个栈帧中的下一条指令的地址。这样,程序就可以继续执行上一次调用该方法的地方。
- 异常结束:当方法执行过程中遇到异常时,异常信息会通过操作数栈传递。当前栈帧会被弹出,同时程序计数器会指向上一个栈帧中的下一条指令的地址。这样,程序就可以继续执行上一次调用该方法的地方,并处理异常。
本地方法栈
本地方法栈(Native Method Stack)用于支持Java程序中本地方法(Native Method)的执行。
本地方法是Java代码调用非Java代码的方法,通常用于与Java平台之外的系统进行交互,如操作系统、硬件设备等。
本地方法栈的作用主要包括以下几个方面:
- 存储本地方法调用:当Java代码调用本地方法时,本地方法栈会存储本地方法的调用信息,包括本地方法的名称、参数和返回值等。
- 执行本地方法:本地方法栈会执行本地方法,这些本地方法通常由非Java语言编写,如C、C++等。
- 处理本地方法调用:当本地方法执行完成后,本地方法栈会处理本地方法的返回值,并将结果传递给Java代码。
本地方法栈与虚拟机栈的作用类似,不同之处在于,虚拟机栈处理Java方法,而本地方法栈则处理Native方法。在某些虚拟机实现中,比如HotSpot,本地方法栈和虚拟机栈是合二为一的。