在说类加载之前,我先说一说类的文件结构,我们知道当编译Java代码的时候会生成.class文件,这种字节码方式保证了Java的可移植性,同时字节码又存储了整个类的信息,用字节码存储,所以字节码中每个字节都有它的意义,所以了解类文件的结构还是比较重要的
Class文件结构详解
它的文件结构就是根据ClassFile
来构建的,接下来我们根据下面这个结构体一个一个属性来解析,来了解一下类文件结构
cpp
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
我们也可以通过一些插件来看这个信息,比如idea
中的jclasslib
插件,如下图
magic魔法数
每一个class
文件的前四个字节称之为魔法数,它是一个标识,固定值是0xCAFEBABE
,作用就是确定class
文件能否虚拟机接收,所以这是Java的一个规范,如果魔法数不匹配,虚拟机将拒绝加载它
minor_version和major_version,class的次版本号和主版本号
紧跟着魔法数四个字节的就是这两个属性,第5和6个字节存储次版本号,第7和8个字节存储主版本号。表示的就是编译Java文件的Java版本,这个是由官方决定的,而版本号的作用是什么呢?每当Java进行升级的时候,可能虚拟机也会升级,也就是完成版本对应,高版本的虚拟机可以兼容低版本的Class
文件,但是低版本的虚拟机可能无法加载高版本的Class
文件了,作用就是用于比对版本。
constant_pool_count常量池中常量数目,constant_pool[constant_pool_count-1]常量池数据
我们发现个小东西,常量池的数组的长度是数目-1,这是没问题的,数目是从1开始计算的,而所以是从0开始计算的,所以constant_pool[constant_pool_count-1]
就代表着最后一个常量的值,如果计算的索引=0,那么就说明没有任何常量引用。
constant_pool_count
是class
文件的常量,也就是final
修饰的,常量包括两种类型,一种是字面量,一种是符号引用。字面量就是我们常见的final int a =14
;或者字符串字面量,final String s = "str";
而符号引用属于编译原理方面的知识了它包括了类和接口的全限定名,字段的名称和描述符,方法的名称和描述符,如下图 常量池中每一项常量都一个表,一共有14种类型,每个表的开始的第一位是一个标识来标识是哪种类型,比如整型字面量,长整形字面量等等。
access_flags访问标志
这个标志来表示Class
是类还是接口,public
还是private
等等,和Class
的访问有关系的标志。
this_class当前类,super_class父类,interfaces_count实现的接口数,interfaces[interfaces_count];存储实现接口的索引地址
Java是单继承的机制,所以没有数组存储,同时如果一个类没有继承任何类,那么它的父类就是Object
,所以父类的索引永远都不会为0,然后接口数组里面就是按照编写的顺序进行存储的,最后都是要根据值去找到对应的全限定类名。那去哪里找呢?就是上面的那个常量池中!
fields_count字段数量和fields[fields_count]字段
这个就是存储类中字段的信息,比如字段的作用域,名字和信息的索引(常量池中存储数据)等等。
methods_count方法数量和methods[methods_count]方法集合
和字段类似都是存储类中方法的信息
attributes_count属性数和attributes[attributes_count];属性集合
这个和字段的区别是,字段是存储字段的信息,比如作用于,名字之类的,属性代表的是字段的能力,比如get方法
,set方法
等等。
类的加载过程是啥样的?
在知道我们类的结构之后,那么我们开始说说类加载的过程。不多说,先上个图。 类的生命周期也就是上面的七步:加载->验证->准备->解析->初始化->使用->销毁,接下来我一个一个说。而我要说的是加载过程,也就是五步:加载->验证->准备->解析->初始化
类加载过程---加载
记载这一步主要是由类加载器完成的,而使用哪种类加载器是由双亲委派模型 决定的,这个部分我会在下面的类加载器模块中详细讲解。每一个类都有一个引用指向加载它的ClassLoader
,数组是个例外,而是在JVM需要的时候通过getClassLoader
根据数组元素的类型来获取的。
在加载阶段虚拟机根据全类名获取类的内容,也就是我们上面说的ClassFile
,然后将二进制的内容所代表的静态结构转换为方法区(到时候说内存模型的时候讲)的运行时数据结构,然后在内存中生成一个代表该类的对象实例。
当然进行加载的过程也会完成一部分验证的步骤,比如字节码验证,从静态结构转为运行时数据结构的时候就是对字节码的解析,所以要同时验证字节码
类加载过程---验证
验证是链接阶段的第一步,这一步是非常重要的,就是确保Class
文件的字节流包含的信息符合《Java虚拟机规范》的全部约束。主要进行四个步骤的校验。
1. 文件格式验证: 比如我们上面说的魔法书验证,主次版本号验证,常量池中的常量类型有没有超出范围等。
2. 元数据验证: 主要对字节码秒描述的信息进行分析,看是否符合规范,比如是否继承了不能继承的类等。
3. 字节码验证: 主要通过数据流和控制流分析判定语义是否合法,类型转换合不合理,参数类型对不对之类 的。
4. 符号引用验证: 验证类的正确性,比如引用其他类的方法是否存在,有没有权限访问等。
当然了这四个验证步骤不是串行的,而是分布在整个链接阶段的,比如符号引用验证就是在解析的阶段验证的,比如你访问了一个类的私有属性这种。所以验证阶段就是验证类的合法性和正确性。
类加载过程---准备
准备阶段就是为类变量分配内存和设置初始值的阶段,这些内存将在方法区中分配。而类变量指的是在类中用static
修饰的变量,而且设置初始值不是开发人员指定的值,比如int
类型的默认值是0,float
类型的默认值为0.0f等。而且是在堆中分配的(1.8中的变化,讲内存模型的时候会介绍)。
类加载过程---解析
解析就是虚拟机将常量池中的符号引用替换为直接引用的过程 ,主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄(直接调用目标方法的一种底层表示,不同于反射,更加的高效),调用限定符(和方法句柄组合使用,实现更灵活的的分派机制)。怎么理解这个符号引用和直接引用呢。我在上面写了在ClassFile
的常量池中存储了字面量和符号引用,这时候就体现出来了。我们调用一个方法的时候需要知道这个方法所在的位置,所以在上面的ClassFile
中我们可以看到methods
那个属性,而这个属性存储了方法在内存,也就是方法区的指针或者方法句柄,然后转换,这个过程就是直接引用。在准备阶段的时候就是将ClassFile
的静态存储结构转换为方法区的动态结构,这个时候就已经在内存中了。所以符号引用很关键,在静态文件中标志着和其他类的各种关系,用它来转换成可执行的动态结构!所以转换过程就是将ClassFile中的常量池中的符号引用全都转换为直接引用,这就是解析阶段干的事。
类加载过程---初始化
这也是假加载类的最后一个阶段,完成对静态变量,实例变量的赋值,调用类的静态方法,new对象实例等,如果一个类的父类没有初始化,那么会先初始化父类。
到这里类的初始化就已经完成了,类的属性的赋值,对于方法的引用全都完成了,之后就是调用的事了,接下来说一说类加载这里的最后一块,类加载器。
类加载器
上面我在类加载阶段的加载中说到了,会使用ClassLoader
去生成类对象,其实也就是生成一个Class
对象放在方法区中,它也是所有这个类对象的一个参照。ClassLoader
就是干这个的,它的主要工作就是将Java的类字节码加载到JVM中,在内存中生成一个代表该类的Class
的对象。
类加载器的加载规则
类加载不是一次性全部加载的,而是动态的加载,大部分的类只有在使用的时候才会去加载,比如main
方法所在的类和一些Java核心的库的类都是要加载的,这样对内存和性能更加的友好。在ClassLoader
类中有这样一个属性。
java
private final Vector<Class<?>> classes = new Vector<>();
里面存储的就是该ClassLoader
加载过的类,如果加载过了就不再加载了,而判定条件就是类的全限定类名+加载它的类加载器是不是一样的。 JVM中内置了三种类加载器,也是三层类加载器,分别是:
- BootStrapClassLoader: 启动类加载器,C++实现,没有父类,主要用于加载Java的核心类库,比如
rt.jar
,resources.jar等包含了我们常见的不如java.lang,java.util,java.io。
等等 - ExtensionClassLoader: 扩展类加载器,主要加载
lib/ext
下的jar包和类。 - AppClassLoader: 应用程序类加载器,主要面向我们的应用程序,加载
classPath
下所有的jar和类。
除了这三个之外,用户也可以自己定义类加载器,用于实现独特的功能,比如想给字节码加密这种。如果自己实现ClassLoader
,那么就继承ClassLoader
类,然后去实现它的方法,它的方法有两个需要我们注意的,一个是findClass
,一个是loadClass
,这是有区别的。这里有一个概念叫双亲委派模型,这个模式是用于确定我们加载的类是由哪种类加载器加载的,如果重写了findClass
那么就不打破双亲委派模型,如果重写了loadClass
那么就打破双亲委派模型。说了半天双亲委派模型是啥东西?接着说。
双亲委派模型
直接上图,更加清晰。 实际上就是当加载类的时候先看自己这个类的ClassLoader
是不是加载了本类,如果没有在去类加载器的父级类加载器中找加没加载过,一直到最顶层的BootStrapClassLoader
,如果都没有,就尝试用最顶层的类加载器去加载,如果加载不了,那么就往下委托去加载。简而言之就是,自下而上查找,自上而下加载。
双亲委派模型的好处
既然我们明白了双亲委派模型的过程,那么我们说说它的好处。第一个好处就是避免了类的重复加载,第二个好处就是避免了Java的核心类库被覆盖。第二个很重要,如果我们没有双亲委派模型的机制,那么我们甚至可以更改java.Object
下的类,这很危险。而我们引用第三方包的时候有时候会修改它的源码,所以通常做法就是在classpath
下创建一个相同的全限定类名的类,第三方包也属于classpath
下,属于AppClassLoader
加载,就能完成覆盖的效果了。
Tomcat打破双亲委派模型
我上面也说了,打破双亲委派模型的方式,而Tomcat
就需要打破,这是有原因的。在Tomcat
中可能需要部署多个web应用,也就是我们常见的webapp
,而不同的web应用可能存在相同名字的类,但是功能不同,版本不同,但是还是需要加载的。所以在Tomcat
中每一个webApp
都有它自己的一个WebappClassLoader
,web应用自己的类要首先由自己的WebappClassLoader
加载,否则交给ExtensionClassloader
加载,这就打破了双亲委派模型了。
SPI中打破双亲委派模型
SPI
的接口比如java.sql.Driver
是由Java的核心库提供的,需要BootStrapClassLoader
加载,而它的实现是由第三方包提供的,比如com.mysql.cj.jdbc.Driver
,这个需要应用程序类加载器或者自定义加载器加载。但是吧,BootStrapClassLoader
是没办法找到SPI的实现类的,也就没法委托子类加载器去尝试加载。所以为了解决这个问题,Java的设计团队设计了一个线程上下文类加载器 ,通过java.lang.Thread
中的getContextClassLoader
或者setContextClassLoader
进行获取和设置,比如JNDI
在使用这个来获取类加载器,以此来达到父加载器请求子加载器完成加载的动作了,但是这明显的违背了双亲委派模型,但也没啥办法。
那破坏双亲委派模型之后,能不能重写String类?
即便是破坏了双亲委派模型,你重写了loadClass
方法,但是还是需要调用父类的defineClass
来将字节流转为JVM可是别的class
,而defineClass
其中有个判断就是全限定类名不能以java.
开头,所以就控制了这个。