注:我对虚拟机的学习结合了yiyang0大神的开源项目[y1yang0/yvm: yvm] low performance garbage-collectable jvm (github.com),该项目实现了很多虚拟机规范,虽然也有很多虚拟机机制没有实现,但是对我帮助很大,这里表示感谢。
使用教材:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
1. 虚拟机类加载机制
1.1. 1. 类加载的过程
1.1.1. 1.1 加载
加载阶段当然就是将一个二进制字节流转化为内存中的java.lang.Class的数据结构。这里的二进制字节流可以来自于磁盘上的Class文件,也可以来自其他途径,常见的像是java反射运行时生成的Class类对象。Class内存数据结构是位于元空间(MetaSpace)中的。
一个内存中的Class对象数据结构大致如下,可以参考上一章书中的详细介绍:
struct ClassFile {
u4 magic;
u2 minorVersion;
u2 majorVersion;
u2 constPoolCount;
ConstantPoolInfo** constPoolInfo;
u2 accessFlags;
u2 thisClass;
u2 superClass;
u2 interfacesCount;
u2* interfaces;
u2 fieldsCount;
FieldInfo* fields;
u2 methodsCount;
MethodInfo* methods;
u2 attributesCount;
AttributeInfo** attributes;
}
类加载是会有一定的缓存机制,使得减少不必要的重复加载。在加载某个类之前后先判断这个类是否已经完成加载,如果能从缓存中找到则直接缓存即可。类在加载的过程中,通过解析出来的superClass字段和interfaces可以得到该类的父类和所有实现的接口,如果父类和接口没有被加载,还会继续递归加载父类和接口类。
下面是类加载的示例代码:
bool ClassSpace::loadJavaClass(const string& jcName) {
lock_guard<recursive_mutex> lockMA(maMutex);
// TODO 这里不局限于解析路径Class文件
auto path = parseNameToPath(jcName);
if (path.length() != 0 && !findJavaClass(jcName)) {
// Load this class which specified by jcName (it's a path string)
auto* jc = new JavaClass(path);
jc->parseClassFile();
classTable.insert(make_pair(jc->getClassName(), jc));
// Load super class if it doesn't exist in the class table
if (!jc->getSuperClassName().empty() &&
!findJavaClass(jc->getSuperClassName())) {
this->loadJavaClass(jc->getSuperClassName());
}
// Load super interfaces if existed
vector<u2>&& interfacesIdx = jc->getInterfacesIndex();
if (jc->getInterfacesIndex().empty()) {
for (auto idx : interfacesIdx) {
this->loadJavaClass(jc->getString(idx));
}
}
return true;
}
return false;
}
注意为了完成反射,在这里不光要将class数据加载到内存中的数据结构中,还要在堆中创建Class对象。
1.1.2. 1.2 连接
连接阶段包括验证,准备和解析阶段
1.2.1 验证
验证阶段顾名思义,即对Class文件进行校验。主要分文件格式验证(魔数、版本号、tag范围合理等),元数据验证(final关键字规则等),字节码校验(主要针对code,是否执行不会有问题,最复杂)
使用-Xverify: none参数来关闭大部分的类校验措施
1.2.2 准备
准备阶段主要是为静态变量分配内存,并且只对final修饰的字段进行初始化,其他字段赋零值
从实现的角度来看,即对类的每个field查看其AccessFlag的值是否包含静态,然后在判断它的类别:引用类、数组类、普通字段类。对于引用类,遍历引用类的每个字段,判断其是否有ConstantValue属性(参考书中前面一章的属性表,final修饰的字段都会在attribute中增加ConstantValue参数项),这里要注意特殊的String类,因为它在堆中存在一个字符串常量池。数组类这里只是保存一个数组的placeholder,真实分配内存的地方可能是初始化阶段)。普通字段分配内存非常简单,对于没有ConstanValue的字段赋零值,否则用真实值填充。
下面示例代码展示的是类属性为静态数组的情况:
else if (IS_FIELD_REF_ARRAY(descriptor)) {
// Special handling for field whose type is array. We create a null
// JArray as a placeholder since we don't know more information
// about size of array, so we defer to allocate memory while meeting
// opcodes [newarray]/[multinewarray]
if (IS_FIELD_STATIC(
javaClass->raw.fields[fieldOffset].accessFlags)) {
JArray* uninitializedArray = nullptr;
javaClass->staticVars.insert(
make_pair(fieldOffset, uninitializedArray));
}
1.2.3 解析
解析阶段即java虚拟机把常量池的符号引用替换为直接引用的过程。这个过程可能会便随着类的加载过程(在加载到内存时,内存中javaClass的数据结构中的字段可能已经被解析成直接引用)
符号引用在我的理解看来多数指的就是ConstantPool的常量池符号,根据Tag类别(是CONSTANT_CLASS还是CONSTANT_Fieldref来对应解析成具体的内存引用),下面给出部分示例代码:
// As JVM 8 specification described, the index of constant pool
// started from 1 to constant_pool_count-1
for (int i = 1; i <= cpCount - 1; i++) {
u1 tag = reader.readget1();
ConstantPoolInfo* slot;
switch (tag) {
case TAG_Class: {
slot = new CONSTANT_Class();
dynamic_cast<CONSTANT_Class*>(slot)->nameIndex =
reader.readget2();
raw.constPoolInfo[i] = dynamic_cast<CONSTANT_Class*>(slot);
break;
}
case TAG_Fieldref: {
slot = new CONSTANT_Fieldref();
dynamic_cast<CONSTANT_Fieldref*>(slot)->classIndex =
reader.readget2();
dynamic_cast<CONSTANT_Fieldref*>(slot)->nameAndTypeIndex =
reader.readget2();
raw.constPoolInfo[i] = dynamic_cast<CONSTANT_Fieldref*>(slot);
break;
}
......
1.1.3. 1.3 初始化
很简单的过程,即直接调用javac编译器生成的clinit()方法。注意区分于对象的构造方法init()。同一个类加载器和同一个class只会调用一次clinit()方法,这是虚拟机保障的。
javac会将所有的class中定义的静态变量的赋值和静态语句块的执行按顺序放到clinit()方法中。jvm会保证先执行父类的clinit()方法。
注意接口类执行clinit()方法时父类接口不会被初始化,除非有父类接口的变量被用到。
1.2. 2. 类加载的时机
只有类的初始化阶段的时机是在《java虚拟机规范》中有明确的定义
(1)new、putstatic、getstatic、invokestatic四条指令
很好理解,new是创建对象,在对象创建前一定要先完成类的clinit()。后面三条对静态的修改,也是理所应当的。
(2)反射调用,先看有没有完成类的初始化。这里要注意在类加载的初始阶段会完成Class对象在堆中的创建,这是可以使用反射找到类的条件,但是这时候还不能确定是否完成类的初始化。
(3)在一些类加载过程中隐含的规则:非接口子类clinit()先调用父类clinit(),接口default方法等
1.3. 3. 类与类加载器
类加载器需要知道的无非就是以下两点:
1.两个类是否相等的条件首先是类加载器相同,这一点可以通过instanceof和equals()自行验证
2.双亲委派模型
这里重点说一下双亲委派模型
1.3.1. 3.1 双亲委派模型
3.1 启动类加载器
唯一属于JVM虚拟机一部分的类加载器的是Boostrap Class Loader(启动类加载器),我认为其他的类加载器都要通过调用它的本地方法完成类加载过程?该类加载器加载<JAVA_HOME>\lib目录和-Xbootclasspath参数所指定的路径中存放的类。在jdk中,null代表的类加载器就是启动类加载器。
3.2 扩展类加载器
负责加载<JAVA_HOME>\lib\ext中的类
3.3 应用程序类加载器
加载用户类路径下的所有的类库,用户自定义的类加载器一般都继承自该类加载器。
用户自定义类加载器的作用主要是实现类的隔离和重载,真实类加载器的loadClass的代码如下:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
关于如何破坏双亲委派原则(比如父类加载器需要委派子类加载器加载某些类的情况)中的利用线程的上下文类加载器可以自行查阅资料。
2. 虚拟机字节码执行引擎
主要讲解方法调用中的解析和分派
2.1. 1. 方法调用
方法调用的5种字节码指令集指令(来自《深入理解JVM虚拟机》):
(1)·invokestatic。用于调用静态方法。
(2)·invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。
(3)·invokevirtual。用于调用所有的虚方法。
(4)·invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
(5)·invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引 导方法来决定的。
2.1.1. 1.1 解析
首先要理解的是解析和分派是两个时期的动作,不是非此即彼的关系。javac编译器会调用的方法版本写到字节码文件中,后续jvm不会改变它,对于虚函数的调用亦是如此,只是虚函数在后期执行分派的时候会先找到调用方的实际类型再去调用,所以解析调用一定是个静态的过程。
2.1.2. 1.2 分派
分派分为静态分派和动态分派。
1.2.1 静态分派
有些资料会将静态分派归到解析过程上,是因为静态分派的动作就是由解析阶段(javac阶段)完全确定的动作。
这里我说一下我的个人观点:invokeSpecial和invokeStatic实现的都是静态分派,invokeVirtual实现的是接收方动态单分派。但我在网上没有查到具体的确定的说法,所以这里有待修正。
静态分派主要应用场景是方法重载,即方法参数上。下面给出一个例子:
public class Mozi {
public void ride(Horse h){
System.out.println("ride a horse");
}
public void ride (WhiteHorse wh){
System.out.println("ride a white horse");
}
public void ride(BlackHorse bh){
System.out.println("ride a black horse");
}
public static void main(String[] args) {
Horse wh = new WhiteHorse();
Horse bh = new BlackHorse();
Mozi mozi = new Mozi( );
mozi.ride(wh); //ride a horse .
mozi.ride(bh); //ride a horse .
}
}
显而易见的是,java不支持方法参数上的动态分派,所以参数类型是在编译期已经确定下来了,这里最后两行方法参数调用的wh和bh都是Horse类型。
1.2.2 动态分派
动态分派很容易想到其应用场景,即java中方法的override重写,下面也给出一个例子:
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
运行结果:
man say hello
woman say hello
woman say hello
这里涉及到的其实就是我们熟知的虚函数,所以要理解动态分派的实现,就要从invokeVitrual指令入手:
java虚拟机规范中对虚函数的解析过程有以下说明(《深入理解JVM虚拟机》):
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
当然第一步就是实现的关键,不过这里我要打一个TODO,是关于如何找到对象的实际类型的,在yiyang0大神的开源项目中存在这样一段代码:
auto *thisRef =
(JObject *)frames->top()
->stackSlots[frames->top()->stackTop - parameter.size() - 1];
不过我现在还没看懂这一步为什么就能得到调用者的实际类型~
不过好消息是,当得到调用方的实际类型之后,接下来的调用过程在书中给出了说明:使用vtable实现快速查找实际类型的调用方法
动态分派执行非常频繁,如果频繁去实际类型方法元数据中搜索合适的目标方法,性能会受很大影响(这也是java语言支持多态而性能差于C++的一个方面的原因),所以引入了vtable(虚方法表)。
简单来说,每个类有个虚方法表,存放着各个方法的实际入口地址。如果某个方法没有在子类中被重写,则父类和子类这个方法在虚方法表中指向相同的入口地址。vtable还通过父类和子类相同签名的方法存放在相同的索引号上,以方便快速查找。vtable的初始化是在类初始化之后。
当然在调用invokeInterface的时候也会引入itable进行优化,与vtable类似。
综上可以看出来,java是一个单分派语言(只支持调用方的动态分派,不支持方法参数的动态分派)。
书中的java.lang.invoke包和invokeDynamic的相关知识就不在这里写了(没理解透~)