龙空技术网

JAVA类加载-从一个故事认识类加载

会编程的东北虎 646

前言:

今天姐妹们对“bytejava读取”大体比较看重,姐妹们都需要分析一些“bytejava读取”的相关知识。那么小编同时在网上汇集了一些有关“bytejava读取””的相关文章,希望朋友们能喜欢,同学们快快来了解一下吧!

小咖在一家工厂拧螺丝,工厂在需要的时候会找小咖过来拧螺丝。小咖来到工厂门前需要通过门禁,刷过人脸后,小咖进入换衣间找到拧螺丝的专用衣服并换上,换好衣服去工具间拿好扳手,来到工作间开始干活。小咖干完活了,工作间陆续有其他人来干活,工厂考核部门发现小咖没事了并确认了小咖干完了,就让小咖下班。

按照JAVA类加载进行释义:

一个JAVA程序,编写的.java文件经过编译后变成.class文件,该文件就是小咖(cafe babe 是 JVM 识别 .class 文件的“魔数”),可以认为工厂就是类加载器,让小咖来干活就是Loading(载入),小咖来工厂门前刷脸认证Verification(验证),小咖去换衣间换工服时就是Preparation(准备),找自己需要的工服并换上就是Resolution(解析),然后去工具间取扳手就是Initialization(初始化),来到工作间就是使用,即java实例化。工厂考核部门就是垃圾收集,发现小咖在的工作间拥挤并且小咖无活可干,让其回家就是卸载

类加载流程如图:

类加载流程如图

说明:载入、验证、准备、初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

以上是类加载的过程,那么我们提出以下问题:

1、类(class) 怎么进行载入(loading)呢?

2、载入(loading)的类(class)存放在哪里?

3、载入(loading)的类又怎么找到它?

4、验证(Verification)哪些方面才能保证合法性呢?

5、准备(Preparation)具体内容是什么?

6、解析(Resolution)具体内容是什么?

7、初始化(Initialization)具体内容有哪些?

带着这些问题,我们慢慢探索。

载入(loading):通过IO读取字节码文件(class文件)至JVM虚拟机方法区,同时在堆中创建class对象。

通过这句话我们确认三件事:

第一、通过IO读取字节码文件(class文件)。我们知道java是按照包的全限定名标识唯一的类文件,因此可以通过全限定名获取字节码文件。并且没有限定只能从编译好的.class文件中获取,也可以是zip包,jar,war,网络流(Applet),运行时计算生成(如动态代理,通过反射在运行时动态生成代理类),其他文件(如jsp,因jsp最终会编译成class),数据库(用的场景较少)等

第二、把读取的字节码文件(class文件)放到JVM虚拟机方法区(内存)。字节码文件通过JVM虚拟机按照一定的数据格式存储在运行时数据区中的方法区。

第三、把放好的数据,在堆中创建class对象。java是个面向对象编程语言,因此字节码文件(class文件)最终会有个class对象进行表示。

说明: 加载阶段即可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证(Verification):验证一个Class的二进制内容是否合法,主要包括4个方面:

第一、文件格式验证: 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如:验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型等。

主要目的: 保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。

第二、元数据验证: 字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法等。

第三、字节码验证: 通过分析数据流和控制流,确保程序语义符合逻辑,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如:验证类型转换是合法的。

说明: 一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但一个方法体通过了字节码验证,也不能说明其一定就是安全的。

第四、符号引用验证: 发生于符号引用转换为直接引用的时候(转换发生在解析阶段)。如:验证符号引用中通过字符串描述的权限定名是否能找到对应的类、成员变量、方法(NoSuchMethodError、NoSuchFieldError)以及类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问(IllegalAccessError)等。

准备(Preparation):在准备阶段,虚拟机会在方法区中为Class分配内存,并设置static成员变量的初始值为默认值。注意这里仅仅会为static变量分配内存(static变量在方法区中),并且初始化static变量的值为其所属类型的默认值。如:int类型初始化为0,引用类型初始化为null。

例如:

public static int value = 1;

在准备阶段后,value在内存中的值仍然是0, 赋值1这个操作会在初始化阶段执行,因此在初始化阶段产生了对应的Class对象之后value的值才是1 ,用final static 修饰的变量(也就是常量)是在编译的时候就会分配内存。

JAVA的基本数据类型默认值

类型

默认值

byte

(byte)0

short

(short)0

int

0

long

0L

float

0.0f

double

0.0d

char

'\u0000'

boolean

false

解析(Resolution): 虚拟机将常量池中的符号引用替换为直接引用,解析主要针对的是类或接口、字段、类方法、接口方法、方法属性、方法句柄、调用点限定符这7类符号引用。在转换成直接引用后,会触发校验阶段的符号引用验证,验证转换之后的直接引用是否能找到对应的类、方法、成员变量等。这里也可见类加载的各个阶段在实际过程中,可能是交错执行。

说明:符号引用:用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可(前面验证阶段,也提到了符号引用,它存在于常量池中,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

直接引用:是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

方法属性、方法句柄、调用点限定符为了让Java语言支持动态语言特性而在Java 7 版本中新增的三个常量池项,只会在极其特别的情况能用到它,在class文件中几乎不会生成这三个常量池项。所以接下来看下前4类符号解析过程:

第一、类或接口的解析

如果类C不是数组类型,那么虚拟机会把类C直接传给类加载器。如果类C是数组类型并且元素类型是对象(如String[]),那么先用类加载器加载元素类型(String类型),再由虚拟机创建代表此数组维度和元素的数组对象。判断调用类是否有权限访问被加载类,如果不允许的话,就抛出IllegalAccessError异常。

第二、字段的解析

首先解析字段所属的类或接口的符号引用。如果类中有字段的符号引用(字段的名称和描述符)和目标字段相匹配,则返回这个字段的直接引用。如果没有,则自下而上查找其实现的接口和父接口,若匹配到,则返回这个字段的直接引用。如果还没有,就自下而上查找其继承的父类,若匹配到,则返回这个字段的直接引用。否则,查找失败,抛出NoSuchFieldError异常。最后如果查找成功的话,会判断字段访问权限,如果该字段不允许访问,则抛出 IllegalAccessError异常。

第三、类方法解析

类方法解析第一步同字段解析一样,也需要先解析方法所属的类或接口的符号引用。类方法和接口方法符号引用的常量类型是分开的。如果,在类方法中解析出来的是一个接口,则会抛出 IncompatibleClassChangeError 异常。如果在类中有方法的符号引用(方法的名称和描述符)和目标方法相匹配,则返回这个方法的直接引用,查找结束。否则,在类的父类中递归查找,若找到则返回,查找结束。否则,查找它实现的接口和父接口,如果找到,说明此类是一个抽象类,抛出 AbstractMethodError异常。若都找不到,就抛出NoSuchMethodError 异常。最后,如果查找成功,会判断此方法是否有访问权限,若没有,则抛出 IllegalAccessError异常。

第四、接口方法的解析

首先解析方法所属的类或接口的符号引用,和类方法解析同理,如果发现解析出来是一个类方法,则会抛出 IncompatibleClassChangeError 异常。如果所属接口中匹配到目标方法,则返回此方法的直接引用。否则,在父接口中查找,若找到,则返回。否则,查找失败,抛出 NoSuchMethodError 异常。由于接口的方法都是public的,所以不存在访问权限的问题。

初始化(Initialization): 这个阶段主要是对类变量初始化,是执行类构造器的过程,到此才是真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则可以根据需要来赋值了。可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。换句话说,只对static修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

说明:类构造器 < clinit > 方法和实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。

类构造器方法有如下特点:

第一、保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。所以父类的静态代码块也优于子类执行。

第二、类中没有静态代码块,也没有为变量赋值,则可以不生成 < clinit > 方法。

第三、执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。

第四、接口的实现类在初始化时也不执行接口的 < clinit > 方法。虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。

第五、类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景如下:使用new关键词创建对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。反射调用时,会触发类的初始化(如Class.forName())初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。虚拟机启动时,会先初始化主类(即包含main方法的类)。有些场景并不会触发类的初始化:通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。通过数组来创建对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化。

使用和卸载就不属于类加载器,就不在此处讲解。目前我们应该可以很好地回答之前的7个问题了。

类加载器和双亲委派可以阅读:JAVA类加载-从"爸,我妈呢"认识双亲委派模型

标签: #bytejava读取