浅谈 Java 类加载

1、前述

Java 程序里,需要将 java 文件 编译为 class 文件,然后虚拟机再去加载这些 class 文件,加载到虚拟机以后,才能运行和使用,在 java 语言里,类加载都是在程序运行时完成的,也就是 runtime,

本文参考 《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》,本人自己做笔记加深理解和记忆

2、类加载源代码简述

类加载使用的是双亲委派模型:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
    	//同步加锁锁对象
        synchronized (getClassLoadingLock(name)) {
            // 检查当前这个类型是否已经被加载
            Class<?> c = findLoadedClass(name);
            //如果没被加载过
            if (c == null) {
            	//获取系统的时间
                long t0 = System.nanoTime();
                try {
                	//调用父类加载器的 loadclass 方法来加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                    	//父类加载器为空,使用根类加载器加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果没有找到类,抛出类型未发现异常
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // 如果仍然没有发现这个类,就调用自己的 findclass 方法去发现类
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
             // 解析 class 文件,符号引用替换为直接引用
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

以上就是类加载的主要流程

3、类加载流程图

在这里插入图片描述

4、类加载详解

4.1 加载

在加载阶段,虚拟机需要完成以下3件事情:

通过一个类的全限定名来获取定义此类的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

4.1.1 获取二进制流

在这里,获取二进制流,不仅仅只是从class 文件来获取,还有其他的方式来获取二进制流:

从 zip 包中读取
从网络中获取,例如 Applet
运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用 ProxyGenerator.generateProxyClass 来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流
由一些文件生成,例如 JSP 文件,编译生成了对应的 class 文件

4.1.2 说明

在加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,但是这里需要注意的是,加载和连接并不是严格的先手顺序执行,在连接部分的验证截断,需要验证文件格式,验证正确以后才允许将二进制字节流存储到方法区里面去,也就是说,两个阶段的开始没有固定的先后顺序,交叉进行

4.2 验证(连接阶段)

4.2.1 文件格式验证

文件格式验证主要是验证获取到的二进制字节流是否是正确的,是否满足 class 文件格式的规范,而且可以被当前版本的虚拟机规范处理
包括不仅限于如下的验证点:

是否是以魔数 0xCAFEBABE 开头,因为可以通过魔数来标识是不是 class 文件类型,否则的话,可以恶意修改文件的格式,假冒,从而让虚拟机崩溃
主、次版本号是否在当前虚拟机处理范围之内,每一个版本的 JDK 都有其对应的主次版本号
常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

需要注意的是,这一阶段的文件格式验证,是基于二进制流来验证的,因为只有对二进制流验证正确以后,才会将二进制流放到方法区里去存储,以便后面的三个验证,这一阶段的开始时间不固定的是在加载阶段以后

4.2.2 元数据验证

这个阶段主要对字节码进行语义分析,保证描述的类型信息符合 Java 语言规范,这个阶段的验证点包括不仅限于如下方面:

这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
这个类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)

4.2.3 字节码验证

这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
保证跳转指令不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

4.2.4 符号引用验证

这个阶段主要是发生在将符号引用转为直接引用的时候,在连接的第三个阶段 – 解析时候发生的,通常校验如下内容:

符号引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

4.3 准备

准备阶段是正式为类变量(static 修饰)分配内存并设置类变量初始值的阶段,例如:

private static int i = 1;

i 在准备阶段的时候,会被初始化值,也就是零值,1 这个值是在初始化的时候才会被赋值,这里有一点特殊的点,如果类字段
的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为 ConstantValue 属性所指定的值,例如:

 private static final  int i = 1;

这里的 i 在准备阶段就会被赋值为 1,也就是说同时使用final 、static来修饰的变量(常量),并且这个变量的数据类型是基本类型或String类型,就生成 ConstantValue 属性来进行初始化。

4.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
1)符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标就可以了
2)直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在
3)解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型

4.5 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)
1)初始化阶段是执行类构造器<clinit>() 方法的过程
2)clint 方法是由静态语句块和类变量的赋值动作合并产生的,编译器合并的顺序就是在原来文件中出现的顺序
3)静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如代码所示:

public class Test{
static{
i=0;//给变量赋值可以正常编译通过
System.out.print(i);//这句编译器会提示"非法向前引用"
}
static int i=1;
}

4)clint 方法 跟类的构造方法不同,它不需要显示的调用父类的 () 方法,虚拟机自身会保证调用子类的 clint 方法之前,父类的 clint 方法已经执行完毕了,由于这个方法又是由 类变量赋值动作以及静态语句块合并的,所以父类中定义的静态语句块要优先于子类的变量赋值擦做
5)由第三点可以得到:第一个执行 clint 方法是肯定是 java.lang.Object 基类

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值