JVM类加载器之加载过程

  • 我们知道在jvm中是通过各类形形色色的类加载器对class文件进行加载后才能进行使用的,而且jvm中能提供动态加载的特性也全是依靠类加载的机制,它已经成为了Java体系中一块重要的基石。但是类加载器究竟是如何工作的,什么时候开始加载类,又有哪些具体步骤,类的生命周期又是如何的?我觉得了解这些对我们了解jvm虚拟机是非常有帮助的。

概述


  • 类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initiallization)、使用(Using)和卸载(Unloading)这7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这七个阶段的发生顺序如下图
    在这里插入图片描述

  • 图中加载,验证,准备,初始化,和卸载五个过程的相对顺序是确定的,类的加载过程必须按这个顺序开始。但是只是开始,这些阶段不一定要等上个阶段完成后才能进行下一个阶段的,这些阶段之间没有前后顺序必要的关系,通常可以在一个阶段执行的过程中激活另一个阶段。解析阶段比较特别,它有时候可以在初始化阶段之后再开始,这是为了支持java语言中的运行时绑定,这在后面会详细说明。

什么时候开始


  • 什么时候开始加载,虚拟机规范并没有强制性的约束,对于其它大部分阶段究竟何时开始虚拟机规范也都没有进行规范,这些都是交由虚拟机的具体实现来把握。所以不同的虚拟机它们开始的时机可能是不同的。但是对于初始化却严格的规定了有且只有四种情况必须先对类进行“初始化”(加载,验证,准备自然需要在初始化之前完成):
    1. 遇到new、getstatic、putstatic和invokestatic这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。这四个指令对应到我们java代码中的场景分别是,new关键字实例化对象的时候;读取或设置一个类的静态字段(读取被final修饰,已在编译器把结果放入常量池的静态字段除外) ;调用类的静态方法时。
    2. 使用java.lang.reflect包方法时对类进行反射调用的时候。
    3. 初始化一个类的时候发现其父类还没初始化,要先初始化其父类。
    4. 当虚拟机开始启动时,用户需要指定一个主类,虚拟机会先执行这个主类的初始化。
  • 虚拟机规范用了有且只有这个非常强烈的限定词,把这四种场景称为主动引用,其它引用方式称为被动引用。这是指其它方式的引用都不允许触发初始化么?存疑。

类的加载过程


  • 类的加载过程就是指加载、验证、准备、解析和初始化五个过程。下面将会一一介绍。
加载

  • 在加载阶段,虚拟机需要完成以下三件事:
  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个二进制字节流所代表的静态存储结构导入为方法区的运行时数据结构。
  3. 在java堆中生成一个java.lang.Class对象,来代表的这个类,作为方法区这些数据的入口。
  • 一言以蔽之,加载阶段就是把类的二进制字节流加载到方法区中,并设置了一个访问它们的入口。java虚拟机规范对这三点的要求并不具体,它只说通过“类的全限定名”来获取“二进制字节流”,并没有说要如何获取,以及去哪里获取。相当于它只定义了一个接口,具体的实现可以任由虚拟机的实现来发挥。虚拟机的设计团队在加载阶段搭建了一个相当开发、广阔的舞台。java的发展历程中,许多举重轻重的java技术都是建立在这一基础上的,例如:
    • 从ZIP包中读取,从JAR,EAR,WAR格式中读取。
    • 从网络中获取,这种场景典型的引用就是Applet。
    • 运行时自动生成,这种场景运用最多的是动态代理技术,在java.lang.reflect.proxy中,就是运用了ProxyGenerator.generateProxyClass来为特定接口生成*Proxy的代理类的二进制字节流。
    • 由其它文件生成,比如jsp。
    • 从数据库中读取。
  • 相对于类加载过程的其它阶段,加载阶段(准确的说,是加载阶段中获取类的二进制字节流的过程)是开发期可控性最强的过程,因为加载阶段既可以使用系统定义的类加载器进行加载,也可以自定义类加载器来控制二进制字节流的加载方式。将会在下一章中介绍自定义类加载,做详细的说明。
  • 方法区中的数据存储格式虚拟机规范也没有具体规范,由虚拟机的实现自行定义。
验证

  • 验证是连接这个大阶段的第一个阶段。这一阶段中的主要目的是保证二进制的字节流所包含的信息符号虚拟机的要求,并且不会危害到虚拟机自身的安全。
  • Java语言本身是相对安全的语言(相对于C、C++),使用纯粹的java代码无法做到诸如数组越界,使用未定义的类等等一些错误,这在编译器就不会被编译通过。但是class文件并不一定是由编译器编译的,即使是我们也可以手动的修改class文件,所以对字节流的验证是非常具有必要性的。
  • 但究竟要验证哪些东西能保证安全呢?虚拟机规范对这个阶段的限制和指导显得非常笼统,而且是不是一定能验证完全保证二进制字节流的绝对安全?在验证上所花费的时间至多多少合适?
  • 不同的虚拟机对类的验证的实现可能会不同,但是大致上都会完成下面这四个阶段的验证:
  1. 文件格式验证
  • 是否以魔数0xCAFFBABE开头,主次版本号是否在当前虚拟机的处理范围之内,常量池是否有被支持的常量类型等等。该阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区内。后面三个阶段完全是基于方法区的存储结构的数据进行的。
  1. 元数据验证
  • 主要是对字节码所描述的信息进行语义分析,保证其描述的信息符合java语言的规范要求。如这个类是否有父类,这个类的父类是否继承了不被允许继承的类,比如被final修饰的类。类如果不是抽象类,是否实现了所有的方法。类中的字段,方法是否与父类发生矛盾等等。
  1. 字节码验证
  • 这个阶段的验证是整个验证阶段最为复杂的一个阶段,主要工作是进行数据流和控制流的分析,保证被校验的类的方法在运行时期不会做出危害虚拟机安全的动作。比如任意时刻操作数栈的数据类型能与指令代码匹配,不会出现例如iadd指令加载却是double类型的数据。保证跳转指令不会跳转到方法体以外的字节码指令上等等。即使通过了字节码校验并不能一定能保证安全性,这涉及到离散数学中很著名的问题“halting problem”。简单说就是通过程序校验程序逻辑是无法做到绝对准确的——不能通过程序准确的检查出程序是否能够在有限时间之内结束运行。
  1. 符号引用的验证
  • 最后一个阶段发生在虚拟机将类内的符号引用转化为直接引用时发生,这个转化的动作将在“解析”阶段中发生。例如校验符号引用中字符串描述的全限定名是否能找到对应的类,在指定的类中是否存储符号方法的字段描述符及简单名称所描述的方法和字段。符号引用中的类、字段、方法的访问性是否可以被当前类访问等等。符号引用的目的是保证解析阶段能正常的执行。

  • 验证阶段对应类加载机制来说是一个非常重要但不一定必要的过程,如果所运行的全部代码被反复使用与验证过,在实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施。

准备

  • 准备阶段正式为类变量分配内存,并设置类变量的初始零值,这些内存都将在方法区内分配内存。两个需要注意的地方,一个是类变量指的是被static修饰的变量,实例变量是在初始化时随对象一起在堆中分配内存的。初始零值,虽然在代码中可以为类变量设置初始值,但是在准备阶段,赋给该类变量的值是该数据类型的零值,而不是初始值。比如下面这个语句:

public static int value=123;

  • 准备阶段value值为0,而不是123。赋值给value为123的指令putstatic是在程序被编译后存放于构造器()方法之中,在初始化阶段才会被执行。但是类字段的字段属性表中存在ConstantValue属性时,那么准备阶段就会被指定为ConstantValue属性所指定的类型。具体ConstantValue属性是啥,怎么设置我不太了解,感觉不是很重要。
  • 再提供一下基本数据类型的零值
数据类型零值
int0
long0L
float0.0f
double0.0d
char‘\u0000’
byte(byte)0
short(short)0
booleanfalse
referencenull
解析

  • 解析阶段就是虚拟机将常量池中的符号引用转化为直接引用的过程。虚拟机并未规定何时需要开始进行解析,只是要求在执行anewarray、checkcast、getfield 、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic这13个操作符号引用的字节码指令之前,先要对所操作的符号引用进行解析。

  • 同一个符号引用可能被进行多次解析,虚拟机实现可能会对第一次解析进行一次缓存,并把常量标识为已解析状态,从而避免多次解析。如果同一个符号在一个实体中被多次解析,如果第一次解析正确,那么后续的解析就应当一直成功,如果第一次失败,再次解析也应当收到相同的异常。

  • 解析主要对类、接口、字段、类方法和接口方法四类符号引用进行。下面一一介绍显示的过程。

    1.类或接口的解析(CONSTANT_Class_info)

    1. 要将一个未解析过的符号引用(命名N)解析为类或接口的直接引用, 首先先判断是否为数组类型。
    2. 如果不是数组类型,则直接将N的全限定名传递给当前所在类的类加载器去加载。由于加载过程中可能还会触发其它相关类等等的加载,如果它们加载失败了,则N也解析失败。
    3. 如果N是数组类型,并且数组元素类型是对象,如果该对象加载过了,则虚拟机直接通过该对象的直接引用与N生成一个代表此数组维度和元素的数组对象。如果元素类型没加载过,则依照第二步先解析元素类型的符号引用为直接引用,再生成该数组对象。
    4. 如果解析一切正常,再解析完成后还要判断N生成的类或接口是否对当前类有访问权限(不应该是当前类对生成的类或接口是否具有访问权限么?,书本写错了还是我理解错了???!),如果没有则抛出java.lang.IllegalAccessError异常。

    2.字段解析(CONSTANT_Fieldref_info)

    1. 解析一个字段符号引用时,首先会对CONSTANT_Fieldref_info中class_index中所指的(该字段的所属的类)类的符号引用进行解析,也就是上文的类或接口的解析。如果类解析是失败则该字段解析也是失败。
    2. 如果该类(命名为C)解析是成功的,则直接先在类C中寻找是否有与目标相匹配的字段(根据简单名称和字段描述符匹配),如果有则返回该字段的直接引用。
    3. 如果在C类中匹配不到字段,且C类实现了接口,则会按照继承关系,循环递归查找父接口,如果在父接口中有与目标相匹配的字段(根据简单名称和字段描述符匹配),则返回该字段的直接引用。
    4. 如果C类没有继承接口,或父接口中无法找到匹配的字段,则会循环递归查找父对象,如果在父对象中有与目标相匹配的字段(根据简单名称和字段描述符匹配),则返回该字段的直接引用。
    5. 如果都查找不到,则抛出java.lang.NoSuchFieldError异常。
    6. 如果查找到了,一样要对该字段进行权限验证,判断当前类是否对该字段拥有访问权限,如果没有则抛出java.lang.IllegalAccessError异常。

    3.类方法解析(CONSTANT_Methodref_info)

    1. 首先一样是CONSTANT_Methodref_info中class_index中所指的(该方法的所属的类)类的符号引用进行解析。如果类解析是失败则该类方法解析也是失败。
    2. 如果发现class_index指向的是一个接口,则抛出java.lang.IncompatibleClassChangeError异常。因为类方法和接口方法是不一样的,类方法的class_index只能指向类。
    3. 如果发现class_index指向的是一个类(命名为C),在C中寻找是否有与目标相匹配的方法(根据简单名称和方法描述符匹配),如果有则返回该方法的直接引用。
    4. 否则递归查找C的父类,父类中是否有与目标相匹配的方法(根据简单名称和方法描述符匹配),如果有则返回该方法的直接引用。
    5. 否则递归查找C的父接口,接口中是否有与目标相匹配的方法(根据简单名称和方法描述符匹配),如果匹配成功说明这是一个抽象方法,或者C类或其父类都是抽象类,第3,4步中匹配的方法如果都是抽象方法,则抛出java.lang.AbstractMethodError异常。
    6. 如果都查找不到,则抛出java.lang.NoSuchMethodError异常。
    7. 如果查找到了,一样要进行当前类的权限验证。如果没有权限则抛出java.lang.IllegalAccessError异常。

    4.接口方法解析(CONSTANT_InterfaceMethodref_info)

    1. 首先一样是CONSTANT_InterfaceMethodref_info中class_index中所指的(该方法的所属的接口)接口的符号引用进行解析。如果接口解析是失败则该接口方法解析也是失败。
    2. 如果class_index指向的是一个类,则抛出java.lang.IncompatibleClassChangeError异常。理由同类方法解析。
    3. 如果发现class_index指向的是一个方法(命名为C),在C中寻找是否有与目标相匹配的方法(根据简单名称和方法描述符匹配),如果有则返回该方法的直接引用。
    4. 否则递归查找C的父接口,直到java.lang.Object类,接口中是否有与目标相匹配的方法(根据简单名称和方法描述符匹配),如果匹配成功则返回该方法的直接引用。
    5. 如果都查找不到,则抛出java.lang.NoSuchMethodError异常。
    6. 接口的方法默认都是public的权限,不存在权限验证的问题。
初始化

  • 妈呀,终于到最后一步了。类初始化是类加载的最后一步,到了初始化阶段,才真正的开始执行类中定义的java代码了(字节码)。
  • 变量赋值(以下所说的变量都是类静态变量,不包括实例变量或方法内变量)已经在准备阶段赋了一个零值,而初始化阶段开始设置java代码为变量设置的初始值。这些工作都放在类构造器<client>()方法中执行,使用初始化阶段结束执行<client>()方法的过程。
  • 什么是类构造器<client>() ?类构造器是由编译器自动收集类的赋值动作与静态语句块(static{}块)中的语句合并产生的方法。方法内部赋值动作与语句的顺序由源文件中的顺序决定。静态语句块只能访问在语句块前定义的变量,在语句块之后的变量,静态语句块中可以赋值但是不可以访问。
  • 虚拟机能保证类构造器被执行前,一定先执行了父类构造器。
  • 类构造器并不是必须的,如果类没变量赋值语句与静态语句块,可以不生成类构造器。
  • 接口虽然没有静态方法,但是一样可以有变量赋值语句。与类构造器不同的是,执行接口的构造器<client>() 不一定需要先执行父接口的构造器<client>() ,除非先用到了父接口中的变量。
  • 虚拟机能保证类构造器<client>() 在多线程的环境下被正确的加锁与同步。如果多个线程去执行同一个类构造器,那么只有一个线程能执行该类构造器,其它线程需要阻塞等待。如果类构造器方法内部耗时过长,在这种情况下会造成多个进程阻塞。
  • 2
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值