Java类加载之Class对象到Klass模型详解
作者:云下牧羊人
前言
JVM只认识Class文件,也就是说,任何一门计算机语言,只要它最后将代码编译成class文件,都能被JVM所执行。
Kotlin、Groovy、JRuby、Jython、Scala等语言就是如此。
一、什么是Class文件
1. class文件是Java代码经过Javac编译后生成的字节码文件,如下图所示。
2. class文件主要包含了魔数、JVM版本号、常量池、常量池计数器、访问标识等信息,如下图所示(截取自 Java虚拟机规范)
- magic:魔数,占4字节。作用是判断这个文件是否为一个虚拟机所能接受的class文件
- minor_version::副版本号,占2字节,最小支持版本号。
- major_version:主版本号,占2字节,最大支持版本号。
- constant_pool_count:常量池计数器,记录常量池表中的成员数,它的值为常量池表中的成员数+1,常量池表的索引值只有在大于0并且小于constant_pool_count时才认为是有效。
- constant_pool:常量池,包含class文件结构及其子结构中所引用的所有字符串常量、类或接口、字段名和其他常量。
- access_flag:访问标志,用于表示类或接口的访问权限及属性。
- this_class:类索引。
- super_class:父类索引。
- interfaces_count:接口计数器。
- interfaces_count[]:接口表。
- fields_count:字段计数器。
- fields:字段表。
- methods_count:方法计数器。
- methods[]:方法表。
- attributes_count:属性计数器。
- attributes[]:属性表。
3. 每一个Java类在JVM中都会对应创建一个C++类实例,我们称这个C++类为Klass实例(对应hotspot源码中的instanceKlass类)。
Klass实例里面存储了java类中所描述的方法、字段、属性等。
如下图所示,instanceKlass的字段皆为存储java类文件中的数据所设计,详见hotspot源码中 instanceKlass.hpp文件。
ps:JVM在创建InstanceKlass对象时,为其申请的内存空间,远超instanceKlass本身所需要得空间,这是因为InstanceKlass还要存虚表、接口表、以及Java类中的引用类型表。
二、class类加载的过程
1. 加载阶段
- 通过类的全限定名获取java类编译后生成的class文件,加载进JVM,并解析class文件。
- 解析完成后,JVM便会在内部创建一个与Java类对等的类模板对象 instanceKlass实例(也是C++的一个类,里面保存了java类的常量池、方法、属性等信息)。
下面奉上hotspot源码解析常量池、字段、方法并创建对应的Klass对象部分,代码皆在ClassFileParser.cpp的 parseClassFile方法中,有兴趣的同学可以自己看一看。
以下仅截取部分主要代码
instanceKlassHandle ClassFileParser::parseClassFile(Symbol* name, ClassLoaderData* loader_data, Handle protection_domain, KlassHandle host_klass, GrowableArray<Handle>* cp_patches, TempNewSymbol& parsed_name, bool verify, TRAPS) { // Constant pool 解析常量池 constantPoolHandle cp = parse_constant_pool(CHECK_(nullHandle)); //...... //解析Java类字段 Array<u2>* fields = parse_fields(class_name, access_flags.is_interface(), &fac, &java_fields_count, CHECK_(nullHandle)); //...... //解析Java类方法 Array<Method*>* methods = parse_methods(access_flags.is_interface(), &promoted_flags, &has_final_method, &declares_default_methods, CHECK_(nullHandle)); //.... // 开始创建与Java对等的Klass对象 _klass = InstanceKlass::allocate_instance_klass(loader_data, vtable_size, itable_size, info.static_field_size, total_oop_map_size2, rt, access_flags, name, super_klass(), !host_klass.is_null(), CHECK_(nullHandle)); }
4.通过instanceKlass生成一个镜像类,放在堆区,即instanceMirrorKlass实例(对应hotspot源码中的 instanceMirrorKlass类)。
instanceKlass供jvm内部使用,小编认为多生成一个instanceMirrorKlass是因为考虑到运行安全因素,不能直接把类暴露给外部使用,所以弄出了个镜像类实例提供给外部程序调用。
小贴纸:
1.java类中的静态变量会存储在instanceMirrorKlass类中,instanceMirrorKlass类里面比instanceKlass类多定义了一个静态字段偏移量的属性,可以通过该属性获取静态变量。
2.Java中的数组类在运行时数据区的生成的实例为
方法区:ArrayKlass。堆区:基本类型数组 TypeArrayKlass,引用类型数组ObjArrayKlass 分别对应hotspot源码里的TypeArrayKlass.cpp 与 ObjArrayKlass.cpp类
3.类加载器是什么时候加载的,如下图,hotspot源码java.c中有一个javaMain方法,javaMain 里面调用了LoadMainClass 方法,你的一切疑惑都在LoadMainClass里面,它的执行逻辑是通过启动类加载器加载类sun.launcher.LauncherHelper,执行该类的方法checkAndLoadMain,加载main函数所在的类,启动扩展类加载器、应用类加载器也是在这个时候完成的。
2. 验证
验证主要就是对Java虚拟机定义的一些约束进行校验,如果校验不通过就抛出异常。
- 静态约束:
- 结构化约束
3. 准备
创建类或接口的静态字段,并用默认值初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令。
数据类型 | 默认值 |
byte | (byte)0 |
shot | (shot)0 |
long | 0L |
char | ‘\u0000’ |
int | 0 |
float | 0.0f |
double | 0.0d |
boolean | 0(false) |
final修饰类的变量,已经不会再 发生变化,所以在准备阶段就进行赋值了,就没有赋初值这个操作了。
4. 解析
我们从各种某度的文章里总会看到,解析的主要过程就是将符号引用替换为直接引用。
那么什么是符号引用呢? 如下图,下图采用了jclasslib插件展示一个Java类文件编译后产生的class文件信息。
我们可以看到方法里面字节码指令后面的 #1等符号就是我们常说的符号引用。
这个符号引用其实就是常量池中的索引(例如#1指向的就是常量池中的第一个类或方法) 如下图所示
JVM会在准备阶段将这些索引符号替换为直接内存地址。以供后续JVM指令进行调用。
都有那些虚拟机指令需要进行符号引用的解析呢?
anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic
执行上述任何一条指令都需要对它的符号引用进行解析。
如果在某个符号引用解析过程中发生错误,那么应该在使用该符号引用的程序处抛出IncompatibleClassChangeError或者其子类的异常
5. 初始化
初始化就是 执行类的静态代码块,并且完成静态变量的赋值,我们可以看到
如下图,如果我们的代码里要是有静态变量,并且对静态变量进行赋值了,那么生成的字节码文件中就会有clinit方法。
这个clinit就是执行静态变量赋值的指令,而且方法中语句的先后顺序与代码的编写顺序相关
既然初始化的时候可以直接对变量进行赋值,那我们是否可以跳过准备阶段,直接在初始化阶段进行赋值
。因为准备阶段主要是赋初值,那我们可以直接要我们写的值,不要初始值。 答案当然是不行,原因如下
初始化阶段主要是依靠clinit方法生成的指令进行赋值,但是如果我们定义一个空的静态变量,那clinit方法中就不会生成这个静态变量相关的赋值代码。
如下图,所以这时就需要准备阶段给这个静态变量初始化、赋初值,否则这个变量就丢掉了。
初始化之后就由JVM的执行引擎进行取指执行了,执行引擎有些过于复杂,以后有机会再分析吧。
总结
- JVM能执行的就是Class文件,所有计算机语言只要最后生成了Class文件,都可以交给JVM执行。Kotlin、Groovy、JRuby、Jython、Scala等语言就是如此。
- 由于JVM是由C/C++编写的,所以每一个Java类加载到JVM时都会生成一个对应的C++类,即instanceKlass,存放在方法区(元空间)。同时生成一个instanceKlass的实例对象,即instanceMirrorKlass,放在堆区。
- JVM类加载机制分为,加载、验证、准备、解析、初始化五个阶段。
- 加载阶段:
通过类的全限定名获取存储该类的class文件,并对其进行解析
解析后生成对应的C++模板类,即instanceKlass实例,存放在元空间,用于JVM内部使用
在堆区生成该类的Class对象实例,即instanceMirrorKlass,用于其他系统或程序进行调用。 - 验证阶段主要有静态约束校验,结构化约束校验两种。
- 准备阶段主要是对静态变量赋初值的操作
- 解析阶段就是将符号引用(常量池索引)替换为直接引用(方法内存地址)
- 初始化就是 执行类的静态代码块,并且完成静态变量的赋值。赋值的指令会生成在clinit方法中,当在Java代码中对静态变量赋值了,clinit中才会生成对应的指令。
扩展问题
- 类的初始化阶段会不会有线程安全问题?
- 类加载阶段会使用synchronized锁机制吗?
- 类加载时延迟偏向锁的原因?
- 如果实现一个类加载器,不按照类加载器机制实现可不可以?
到此这篇关于Java类加载之Class对象到Klass模型详解的文章就介绍到这了,更多相关Java的Class对象到Klass模型内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!