记录于 2022-01-02 17:25:12 ©GhostFace
1. 什么是JVM?
概念
来自百度百科
- JVM是一种规范的、跨平台的软件,一种新的网络计算平台,本质上就是一个程序,通过仿真模拟各种计算机功能实现。
- JVM可以让Java语言在不同平台上运行时不需要重新编译 —— Java语言的特点:跨平台、可移植性
- (.java) → Java语言编译程序 → 在Java虚拟机上运行的目标代码 (.class字节码文件) —— “一次编译,多次运行”
- Java虚拟机有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统。
总结: JVM是一种规范,它实现了Java的跨平台,是软件、网络计算平台或者说是程序,一个通过模拟各种计算机功能实现的程序。因为是程序,所以它有完善的架构,如处理器、堆栈、指令库等等。
跨平台:*.java 源代码文件经过编译器生成 *.class 字节码文件,通过这个 *.class 字节码文件拿到各种装有JDK或者JRE的操作系统中执行就能运行了。
思考问题?
- *.java 源代码文件 如何编译成 *.class 字节码文件?什么是字节码文件 (.class)?
- JVM 如何将 .class 字节码文件变成不同操作系统可以直接运行的机器语言?
- JVM 能干嘛?在实际开发中的运用?
- JVM 的体系结构?位置?
接下来就带着这些问题进一步探究吧~
1. *.java 源代码文件 → *.class 字节码文件的过程?什么是字节码文件?
java源文件转换为字节码文件的过程
*.java 源代码文件 经过 javac编译 成 *.class 字节码文件,这里给出流程图(图1),详细分析下次再细说,或者可以看看《深入理解Java虚拟机》原文第10章 (P456)
编译过程大致可以分为3个过程(以Sun Javac为例)
1、解析与填充符号表过程;
解析主要包括词法分析和语法分析两个过程
2、插入式注解处理器的注解处理过程;
3、语义分析与字节码的生成过程。 ©GhostFace
图1 .java源代码文件到.class字节码文件的过程
.java源代码文件
通过javac编译器先进行解析,即对词法和语法的分析,词法分析会将源代码转换为字符流,生成标记集合(标记集合包括:关键字、变量名、字面量和运算符等),而语法分析是将标记集合生成语法树的过程,(如果源代码中存在注解,则通过注解处理器进行处理)再进行下一步语义分析对源代码上下文有关性质的审查和数据及控制流分析,最后由字节码生成器生成.class字节码文件
什么是字节码文件?
来自CSDN社区 理解
.class 字节码文件 由javac编译产生,是8位字节的二进制流文件。
特点:文件数据项紧凑,体积轻巧,占用内存空间小,便于网络传输
Class文件的结构属性
在理解之前先从整体看下java字节码文件包含了哪些类型的数据:
从一个例子开始
下面以一个简单的例子来逐步讲解字节码。
//Main.java
public class Main {
private int m;
public int inc() {
return m + 1;
}
}
通过以下命令, 可以在当前所在路径下生成一个Main.class文件。
javac Main.java
以文本的形式打开生成的class文件,内容如下:
- 文件开头的4个字节("cafe babe")称之为
魔数
,唯有以"cafe babe"开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。 - 0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一。也就是说,编译生成该class文件的jdk版本为1.8.0。
通过java -version命令稍加验证, 可得结果。
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
继续往下是常量池... 知道是这么分析的就可以了,然后我们通过工具反编译字节码文件继续去看。
反编译字节码文件
其中<options>
选项包括:
输入命令javap -verbose -p Main.class
查看输出内容:
字节码文件信息
开头的7行信息包括:Class文件当前所在位置,最后修改时间,文件大小,MD5值,编译自哪个文件,类的全限定名,jdk次版本号,主版本号。
然后紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER,访问标志的含义如下:
常量池
Constant pool
意为常量池。
常量池可以理解成Class文件中的资源仓库。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量类似于java中的常量概念,如文本字符串,final常量等,而符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
不同于C/C++, JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。 直接通过反编译文件来查看字节码内容:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#4 = Class #21 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#18 = NameAndType #7:#8 // "<init>":()V
#21 = Utf8 java/lang/Object
第一个常量是一个方法定义,指向了第4和第18个常量。以此类推查看第4和第18个常量。最后可以拼接成第一个常量右侧的注释内容:
java/lang/Object."<init>":()V
这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。
第二个常量同理可得:
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#5 = Utf8 m
#6 = Utf8 I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
复制代码此处声明了一个字段m,类型为I, I即是int类型。关于字节码的类型对应如下:
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的16进制文件内容如何,我们直接看反编译后的内容。
private int m;
descriptor: I
flags: ACC_PRIVATE
此处声明了一个私有变量m,类型为int,返回值为int
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
这里是构造方法:Main(),返回值为void, 公开方法。
code内的主要属性为:
stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
attribute_info: 方法体内容,0,1,4为字节码"行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的"java/lang/Object.""😦)V", 然后执行返回语句,结束方法。
LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。
同理可以分析Main类中的另一个方法"inc()":
方法体内的内容是:将this入栈,获取字段#2并置于栈顶, 将int类型的1入栈,将栈内顶部的两个数值相加,返回一个int类型的值。
类名
最后很显然是源码文件:
SourceFile: "Main.java"
2. JVM 如何将 .class 字节码文件变成不同操作系统可以直接运行的机器语言?
从.java源代码经过javac编译器编译成.class字节码文件我们已经有大致印象了,接下来就需要了解class字节码文件如何经过JVM变成不同操作系统可以直接运行的机器语言了。直接上图
.class文件加载到内存(JVM整个过程都在内存中执行),对数据进行 加载(Loading) → 连接(Linking) [ 验证(Verification) → 准备(Preparation) → 解析(Resolution) ]→ 初始化(Initialization) → 使用(Using) → 卸载(Unloading) 7个阶段(生命周期) ,其中 验证、准备、解析 统称 连接,这个过程就是类加载过程。
注意:类加载过程 ≠ 加载(Loading) ,加载是一个类加载的子环节。
类加载机制
类的生命周期
加载
、验证
、准备
、解析
、初始化
五个阶段。在这五个阶段中,加载
、验证
、准备
和初始化
这四个阶段发生的顺序是确定的,而解析
阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。类的加载: 查找并加载类的二进制数据
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class
类的对象,这样便可以通过该对象访问方法区中的这些数据。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有数据库中提取.class文件
- 将Java源文件动态编译为.class文件
连接
验证: 确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
文件格式验证
: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE
开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。元数据验证
: 对字节码描述的信息进行语义分析(注意: 对比javac
编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类(除了java.lang.Object
之外)、是否是抽象类,是否实现了父类或接口的方法等。字节码验证
: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。对类方法体进行校验分析,保证被校验类的方法在运行时不会做危害虚拟机安全的事件符号引用验证
: 确保解析动作能正确执行。对类自身以外的信息进行匹配性校验
准备: 为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为: public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。 - 对于引用数据类型
reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
。 - 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
- 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中
解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符7类符号引用进行。
符号引用
就是一组符号来描述目标,可以是任何字面量。直接引用
就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName("com.pdai.jvm.Test"))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
使用
类访问方法区内的数据结构的接口, 对象是Heap区的数据。
卸载
Java虚拟机将结束生命周期的几种情况
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类加载器, JVM类加载机制
类加载器的层次
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类 :
启动类加载器
: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
扩展类加载器
: Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器
: Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
- 在执行非置信代码之前,自动验证数字签名。
- 动态地创建符合用户特定需要的定制化构建类。
- 从特定的场所取得java class,例如数据库中和网络中。
寻找类加载器
寻找类加载器小例子如下:
package com.pdai.jvm.classloader;
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
}
}
结果如下:
sun.misc.Launcher$AppClassLoader@64fef26a
sun.misc.Launcher$ExtClassLoader@1ddd40f3
null
从上面的结果可以看出,并没有获取到ExtClassLoader
的父Loader,原因是BootstrapLoader
(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null
。
类的加载
类加载有三种方式:
1、命令行启动应用时候由JVM初始化加载
2、通过Class.forName()方法动态加载
3、通过ClassLoader.loadClass()方法动态加载
分别切换加载方式,会有不同的输出结果。
- Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
- ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
- Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
JVM类加载机制
全盘负责
,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入父类委托
,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类缓存机制
,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效双亲委派机制
, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派机制过程?
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
双亲委派代码实现
双亲委派优势
- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。下面我们通过一个示例来演示自定义类加载器的流程:
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。
这里有几点需要注意 :
1、这里传递的文件名需要是类的全限定性名称,即com.pdai.jvm.classloader.Test2
格式的,因为 defineClass 方法是按这种格式进行处理的。
2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把com/pdai/jvm/classloader/Test2.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
好了,现在我们知道java源文件到字节码文件的过程,字节码文件的具体组成,字节码文件由JVM加载至内存的过程。问题来了!
问题
1. 类加载过程中,数据的存储?
2. JVM的内存模型?
参考文章
面试题
请你谈谈你对JVM的理解? java8虚拟机和之前的变化更新?
JDK、JRE、JVM的关系是什么?
JVM的常用调优参数有哪些?
内存快照如何抓取,怎么分析Dump文件?知道吗?
谈谈JVM中,类加载器你的认识?
引用
书籍:作者:周志明 —— 以JDK7为技术平台的 提取码:gfxl
文章:Java源代码编译成字节码文件的过程——第10章(P456) [1]
什么是字节码文件? [2]