虚拟机类加载机制

什么是类加载机制?

  类加载机制是指,虚拟机把描述类的数据从class文件(一串二进制的字节流,无论以何种形式存在)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。其中,产生class对象的时机是在加载阶段完成后,这个阶段完成后字节流就存储在方法区中,内存中也实例化了一个java.lang.Class对象,这个对象并没有明确规定是在堆中,hotspot的class对象就是存放在方法区中。在java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的,java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接的特点实现的。

 

类的生命周期

  类从被加载到虚拟机内存中开始,直到卸载出内存为止,整个生命历程可以分为7个阶段:加载、验证、准备‘解析、初始化、使用和卸载。如下图所示。

  其中,加载、验证、准备和初始化和卸载这5个阶段的开始顺序是固定的,但是进行顺序或完成顺序并不是固定的。它们之间通常都是互相交叉混合进行,通常会在一个阶段执行的过程中调用、激活另一个阶段。但是解析阶段是不定的顺序的,它在某些情况下可以在初始化阶段之后再开始,这样做是为了支持java语言的动态绑定。这些阶段中, 加载和连接和初始化阶段属于类加载过程。类的卸载阶段则是垃圾回收的过程。

 

类加载的时机

  java虚拟机规范中并没有进行强制约束什么情况下需要开始类加载的第一个阶段:加载。但是对于初始化阶段,则规定了有且只有5种情况必须立即对类进行初始化,自然而然,加载、验证、准备阶段就要在此前开始。

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行初始化,就需要触发其初始化。生成这4条指令最常见的场景就是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行调用的时候,如果类没有进行过初始化就要先初始化。
  3. 初始化一个类的时候,如果其父类没有进行过初始化,就要让其父类进行初始化。
  4. 虚拟机启动时,会先初始化一个主类(包含main()方法的那个类)
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

  总的来说,就是需要使用到这个类的某些信息,而这个类又没有被初始化的时候。上述的5中行为称为主动引用,除此以外的类引用都是被动引用,都不会触发初始化。有3种被动引用的例子。

  1. 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,取决于虚拟机的具体实现。
  2. 通过数组定义来引用类,不会触发这个类的初始化。但是会触发一维数组的初始化。
  3. 引用常量。常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此也不会触发定义常量的类的初始化。

  详细代码可参考:https://blog.csdn.net/u012834750/article/details/70834735

  

类加载过程

加载

  加载阶段需要完成3件事情。

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

  其中,数组类的加载过程比较复杂。因为数组类本身不通过类加载器创建,而是由java虚拟机直接创建。

1)如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,就是指数组元素类型)是引用类型,那就递归采用本节定义的加载过程去加载这个组件类型,数组C将在加载该组建类型的类加载器的类名称空间上被标识(一个类必须与类加载器一起确定唯一性)。 

  2)如果数组的组件类型不是引用类型(例如int[]数组),java虚拟机将会把数组C标记为与引导类加载器关联。
  3)数组类的可见性与它的组建类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

 

验证

  整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  1. 文件格式验证。该阶段验证字节流是否符合class文件格式规范。如判断是否以魔数开头,验证主次版本号。目的是保证输入的字节流能正确地解析并存储于方法区之中,格式上符合描述一个java类型信息的要求。
  2. 元数据验证。这个阶段是对字节码描述的信息进行语义分析,保证其描述得信息符合java语言规范的要求。如这个类是否有父类等。目的是保证不存在不符合java语言规范的元数据信息。
  3. 字节码验证。这个阶段将对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。如保证跳转指令不会跳转到方法体以外的字节码指令上。
  4. 符号引用验证。这个校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将发生在连接的第三阶段-解析阶段。也就是说这校验发生在解析阶段之后。需要校验的内容如符号引用中通过字符串描述得全限定名是否能找到对应的类。目的是确保解析动作能正常执行。

 

准备

  准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这里所说的类变量是指被static修饰的变量,不包括实例变量。这里所说的初始值“通常情况下”是数据类型的零值。例外情况就是被final修饰的字段,该字段的初始值取决于代码。

 

 

解析

  解析阶段是虚拟机将常量池里的符号引用替换为直接引用的过程。这里解释一下符号引用和直接引用的意思。

  • 符号引用以一组符号来描述所引用的目标,可以是任何形式的字面量,通常是字符串。可以将符号引用看做是直接引用的占位符,java是动态连接的语言,当java中需要指向一个类或类或字段时的方法时就先用符号引用占位。符号引用可以看做是二次指针,指向某个指向字符串地址的指针。这点不理解可以参考一下我的内存管理的第一篇文章。符号引用与虚拟机实现的内存布局无关。
  • 直接引用可以是直接指向目标的指针、相对偏移量或时一个能间接定位到目标的句柄。就像计算机组成里的二次间址或一次间址。

 

  解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。这里介绍前面4种。

  1. 类或接口的解析。假设当前所处的类为d,如果要把一个从未解析过得符号引用N解析为一个类或接口c的直接引用。需要完成以下3个步骤。

    1) 如果c不是一个数组类型,那么僵代表N的全限定名传递给d的类加载器去加载这个类。

    2) 如果c是一个数组类型,并且数组的元素类型是对象。那么就去加载这个数组元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象。

    返回直接引用之后,还要进行符号引用验证,验证d是否有权限访问此类。

  2. 字段解析。首先解析字段所属的类或接口的符号引用。

    1)查找自身,看c本身是否含有这个简单名称和字段描述符都匹配的字段。有则返回直接引用。

    2)按继承关系从下往上递归查找接口。找到则返回,查找结束。

    3)按继承关系从下往上递归查找父类。直到java.lang.Object。

    4)  否则查找失败。抛java.lang.NoSuchFieldError异常。

    当查找成功,需要进行权限验证。

  3. 类方法解析。首先也是先解析方法所属的类或接口的符号引用。

    1)如果发现c是接口,抛异常。

    2) 查找自身。

    3) 在父类中查找。

    4) 在接口中查找,找到则说明类c是个抽象类,抛java.lang.AbstractMethodError异常。

    5) 查找失败,抛异常。

    查找成功则进行权限验证。

  4. 接口方法解析。同样首先解析方法所属的类或接口的符号引用。

    1) 如果c是个类则抛异常。

    2) 查找自身。

    3) 查找父接口,直到java.lang.Object类。

    4) 查找失败。

    由于接口中所有方法默认都是public的,所以不需要验证权限。

 

初始化

  初始化阶段才真正开始执行类定义中定义的java程序代码。这个阶段简单来说就是执行类构造器<clinit>()方法的过程。那么<clinit>()方法怎么来的呢?

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值的动作和静态语句块中的语句合并产生的,收集顺序是语句在源文件中出现的顺序所决定的。静态语句块只能访问到定义在它前面的变量,在之后的变量,虽然能赋值,但是不能访问。
  • 执行<clinit>()方法不需要显示调用父类构造器,虚拟机会保证在子类的类构造器执行之前,父类的类构造器会先执行。
  • <clinit>()方法不是必要的。如果类中没有静态语句块也没有对变量的赋值操作,编译器可以不生成这个方法。
  • 接口和类不一样,只有当父接口定义的变量使用的时候,父接口才会初始化,接口的实现类在初始化的时候也一样不需要执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确地加锁、同步。如果多个线程同时去初始化一个类,虚拟机保证只有一个线程去执行这个动作,其他线程阻塞等待,所以如果一个类的<clinit>()方法中有耗时的操作的话,就可能造成多个进程阻塞。

 

类加载器

  在加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”的这个动作是放到java虚拟机外部实现的,实现这个动作的代码模块称为“类加载器”。

  类加载器与类的关系:对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性。也就是说,每一个类加载器,都对应着一个独立的类命名空间。当使用class对象的equals()方法,包括instanceof 关键字做对象所属关系判定情况时,都需要注意到类加载器的影响。

 

类加载器类型

  从java虚拟机角度讲,类加载器只存在两种不同的类型。

  1. 自身的一部分的加载器:启动类加载器,这个类加载器用c++语言实现。

  2. 所有的其他的类加载器。这些类加载器由java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。

 

  从java开发人员角度看,可以分为3种类加载器。

  

  1)启动类加载器,负责加载存放在%JAVA_HOME%\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且被java虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库,即使放在指定路径中也不会被加载)类库加载到虚拟机的内存中,启动类加载器无法被java程序直接引用。用户在编写自定义类加载器时,如果需要把加载器请求委派给引导类加载器,那直接使用null代替即可。

  2)Extension ClassLoader:扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载%JAVA_HOME%\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

  3)Application ClassLoader:应用程序类加载器,由sun.misc.Launcher $App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

 

双亲委派模型

  双亲委派模型

  用我们自定义的加载器的话,会有如上图所示的层次关系。这种关系是用组合来实现的。双亲委派模型并不是说有两个层次的父类,而是parents,指有很多代的意思。

  双亲委派模型工作过程:

  (1)如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器去完成。

  (2)每一层的类加载器都把类加载请求委派给父类加载器,直到所有的类加载请求都应该传递给顶层的启动类加载器。

  (3)如果顶层的启动类加载器无法完成加载请求,子类加载器尝试去加载,如果连最初发起类加载请求的类加载器也无法完成加载请求时,将会抛出ClassNotFoundException,而不再调用其子类加载器去进行类加载。
  双亲委派 模式的类加载机制的优点是java类它的类加载器一起具备了一种带优先级的层次关系,越是基础的类,越是被上层的类加载器进行加载,保证了java程序的稳定运行。

 

双亲委派模型的两次破坏

  第一次:jdk1.2之前存在的loadclass()方法。补救措施是findClass()方法。

  第二次:当基础类想要调用回下层的用户代码时无法委派子类加载器进行类加载,例如JNDI服务。为了解决这个问题JDK引入了ThreadContext线程上下文,通过线程上下文的setContextClassLoader方法可以设置线程上下文类加载器。 

 



posted @ 2019-01-14 20:43  1码归1码  阅读(145)  评论(0编辑  收藏  举报