这是没啥用的知识第一篇技术文章,小编准备聊一聊Java中的类加载机制。在写Java程序的时候,你不用知道类加载机制是啥,它从哪里来,要到哪里去,
虽然这知识没啥用,但是,面试(吹水)或许能顶一顶。
每一个Java程序员在刚开始学Java的时候,第一个了解到的,一定是Java的灵魂——JVM。你写的JavaBug,I‘M sorry,Java代码最终一定是经过Java编译器编译成class文件后,交付给JVM进行逻辑执行。
那么你一定一定要疑惑(否则写不下去了),Java类是如何与JVM进行交互的呢?下面我们一起来学习一下这些学完就忘的知识。
注:文章中所述的技术,JDK版本皆为JDK1.8
小贴纸
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文件。
小贴纸
JVM在创建InstanceKlass对象时,为其申请的内存空间,远超instanceKlass本身所需要得空间,这是因为InstanceKlass还要存虚表、接口表、以及Java类中的引用类型表。
二、class类加载的过程
1. 加载阶段
- 通过类的全限定名获取java类编译后生成的class文件,加载进JVM,并解析class文件。
- 解析完成后,JVM便会在内部创建一个与Java类对等的类模板对象 instanceKlass实例(也是C++的一个类,里面保存了java类的常量池、方法、属性等信息)。
小贴纸
解析常量池、解析Java类字段、解析Java类方法这些JVM的精华部分感兴趣的同学,也可以关注微信公众号:云下凤澜。相关文章会在公众号上更新。
下面奉上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));
}
- 通过instanceKlass生成一个镜像类,放在堆区,即instanceMirrorKlass实例(对应hotspot源码中的 instanceMirrorKlass类)。instanceKlass供jvm内部使用,小编认为多生成一个instanceMirrorKlass是因为考虑到运行安全因素,不能直接把类暴露给外部使用,所以弄出了个镜像类实例提供给外部程序调用。
小贴纸:
- 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锁机制吗?
- 类加载时延迟偏向锁的原因?
- 如果实现一个类加载器,不按照类加载器机制实现可不可以?
问君能有几多愁,恰似满屏Bug+需求。