类加载机制

类的生命周期

  一个java文件的整个生命周期,总共要经历加载-验证-准备-解析-初始化-使用-卸载这几个阶段,有的人把验证准备解析归纳为一个阶段称为链接,所有有的说5个阶段的,也有说7个阶段的,两种说法。

什么时候开始加载?

  1.用new实例化对象的时候。

  2.读取或者设置一个类的静态字段的时候。

  3.调用一个类的静态方法的时候。

  4.使用java.lang.reflect包的方法对类进行反射的时候,如果类没有进行过初始化,则需要先触发其初始化。

  5.当初始化一个类的时候,如果发现这个类的父类还没有进行过初始化,则需要先触发其父类的初始化。

  6.当虚拟机启动的时候,如果java程序中包含main()主函数的类,则该类的加载由JVM自动触发。

加载

反射机制的原理

  所谓加载,就是将java类的字节码文件加载到机器内存中,并在内存中构建出java类的原型-类模板对象。

  所谓类模板对象,其实就是java类在JVM内存中的一个快照,JVM将从字节码文件中解析出常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对java类的成员变量进行遍历,也能进行java方法的调用,这就是反射机制背后的原理,如果JVM没有将java类的声明信息保存起来,则JVM在运行期也无法对类进行反射。

要完成的三件事

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

  1.通过一个类的全限定名(完整包名、URL地址、数据库生成、等等)来获取定义此类的二进制字节流。

  2.将整个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  3.在内存中生成一个代表整个类的java.lang.Class对象,作为方法区整个类的各种数据的访问入口。

加载阶段完成之后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,然后在内存中实例化一个java.lang.Class类的对象。

交叉进行

  需要注意的是,加载阶段与后面的验证准备解析阶段并非是阻塞式进行,可能加载阶段尚未完成,后面的阶段就已经开始了。

验证

验证的重要性

  验证这一阶段的目的是为了确保class文件的字节流中包含的信息是否符合虚拟机的要求。这个很好理解,随便一个程序,你少写一个标点符号看看还能不能进行编译。

  虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要操作。

文件格式验证

  首先需要验证字节流是否符合class文件格式的规范,并且可以被当前虚拟机处理。

验证内容列举几项:

  1.版本号是否在当前虚拟机处理范围之内。

  2.常量池的唱两种是否有不被支持的常量类型。

  3.指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。

  4.class文件中各个部分以及文件本身是否有被删除的或附加的其他信息。

元数据验证

  接下来是对字节码描述的信息进行语义分析,以保证符合java语言规范的要求。

验证内容列举几项:

  1.这个类有没有父类,因为除了java.lang.Object之外,所有的类都应该有父类 。

  2.这个类的父类是否继承了不允许被继承的类(比如被final修饰的类)。

  3.如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

  4.类中的字段、方法是否与父类产生矛盾。

字节码验证

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

验证内容举例几项:

  1.保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个Int类型的数据,使用时却按照long类型来加载入本地变量表中。

  2.保证跳转指令不会跳转到方法体以外的字节码指令上。

  3.保证方法体中的类型转换是有效合法的。

符号引用验证

  符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

验证内容举例几项:

  1.符号引用中通过字符串描述的全限定名是否能找到对应的类。

  2.在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

  3.符号引用中类、字段、方法的访问性(private/protected/public/default)是否可被当前类访问。

准备

  准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

  这个阶段有两个概念容易产生混淆。首先,准备阶段进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。

  其次,这里所说的初始值通常情况下是数据类型的零值,假设一个类变量定义为:

public static int value = 123;

  那变量value在准备阶段过后的初始值为0,而不是123,因为这时候还没有开始执行任何java方法。

  但是,如果是final修饰的变量,如:

public static final int value = 123;

  那么在准备阶段变量value就会被初始化为123。

解析

  解析的过程就是JVM将常量池中的符号引用替换为直接引用的过程。

  比如说一个变量的类型是某个对象,那么解析的时候需要把这个变量类型替换成直接指向该对象的指针。

  对同一个符号引用进行多次解析是很常见的事情,虚拟机会对第一次解析的结果进行缓存,从而避免解析动作重复执行。

初始化

  完成上面几个阶段后,便会进入类的初始化阶段。

  所谓初始化,说白了就是调用java类的<clinit>()方法,该方法是编译器在编译期间自动生成的,当java类中出现静态字段或者包含static{}块时,编译出来的java字节码文件中就会自动包含一个名为<clinit>的方法,该方法不能由程序员在java程序中调用,只能由JVM在运行期调用,这个调用的过程就是java类的初始化。

注意:<clinit>()方法并非类的构造函数。

类加载器

  要想再JVM内部创建一个与java类完全对等的结构模型,必须经过类加载器。

类加载器的定义

  java体系中定义了3种类加载器,分别如下:

 

  1.Bootstrap ClassLoader,引导类加载器,也被称作启动类加载器,加载指定的JDK核心类库,该加载器是由C++语言定义的,是虚拟机自身的一部分,无法由java应用程序直接引用,负责加载下列三种情况下所指定的核心类库:

①、%JAVA_HOME%/jre/lib目录
②、-Xbootclasspath参数所指定的目录
③、系统属性sun.boot.class.path指定的目录中特定名称的jar包

 

  2.Extension ClassLoader,扩展类加载器,加载扩展类,扩展JVM的类库,该加载器加载下列两种情况下所指定的类库:

①、%JAVA_HOME%/jre/lib/ext目录
②、系统属性java.ext.dirs所指定的目录中的所有类库

 

  3.App ClassLoader,系统类加载器,也被称作应用程序类加载器,加载java应用程序类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

 

类与类是否相等

  对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

  这句话反过来说就是,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下,比较才有意义,否则,就算这两个类都来源于同一个class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必定不相等。

  这里所指的“相等”,包括equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字对所属关系判定情况。

  以下演示不同类加载器加载出的类比较结果:

public class Test {
    public static void main(String[] args) throws Exception{
        ClassLoader loader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try{
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if(is == null){
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name,b,0,b.length);
                }catch (IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj1 = new Test();
        System.out.println("obj1:" + obj1.toString());
        Object obj2 = loader.loadClass("Test").newInstance();
        System.out.println("obj2:" + obj2.toString());
        System.out.println(obj1.equals(obj2));
    }
}

输出结果:

obj1:Test@88ff2c
obj2:Test@c0663d
false

  该示例中,obj1对象是由系统类加载器加载的,obj2对象是由我们自定义的类加载器加载的,虽然都来自于同一个class文件,但依然是两个独立的类。

 

  这里有的人会遇到一个问题,把obj2转成Test类型,如下:

Test obj2 = (Test)loader.loadClass("Test").newInstance();

  一运行会发现抛出这样一个错:

Exception in thread "main" java.lang.ClassCastException: Test cannot be cast to Test

  原因在于等号左边所声明的Test类型并没有明确为其指定类加载器,所以JVM会使用系统类加载器加载Test类,而等号右边则明确使用了自定义的类加载器加载Test类,所以等号左右两边的两个Test类型的加载器并不是同一个。

  这种异常在使用第三方框架比如Spring的时候会比较常见,究其原因,是因为很多中间件内部都有自定义的类加载器,因此被内存加载器所加载的类型,是没有办法直接转换为使用默认加载器加载的类型。

双亲委派模型

  JVM加载一个类的逻辑为以下三步:

第一步:在当前加载器的缓存中查找有没有这个类,如果有,直接返回,否则走下一步。

第二步:跳到父加载器,重复第一步内容,直到跳到最顶级的引导类加载器为止,如果缓存中还没有这个类,则继续下一步。

第三步:引导类加载器进行加载,如果加载不到,则让子加载器一级一级进行加载,直到加载成功。

 

  假设当前加载的是java.lang.Object这个类,当JVM准备加载时,JVM默认会使用系统类加载器去加载,按照上面三步的逻辑,第一步走过,因为系统类加载器和扩展类加载器的缓存中都不会有该类,走到第二步 到了引导类加载器,如果加载过,则取缓存,如果没加载过,则由引导类加载器进行加载,如果引导类加载器的搜索范围内找不到该类,那么会下发到扩展类加载器进行加载。

  这就是双亲委派机制。

  这种机制保证核心类库一定是由引导类加载器进行加载,而不会被多种加载器加载,否则每个加载器都会加载一遍核心类库,这个世界就乱了,同时也会存在安全隐患。

 

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

如图:

 

  简而言之,双亲委派从本质上而言,其实规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。

"new"做了什么事情?

  类的生命周期分为7个阶段,加载完成之后需要进行链接(验证、准备、解析)和初始化,在链接阶段,字节码指令会被重写,将其所引用的常量池的索引号转换为直接引用。

  比如说,在实例化一个类的时候,编译后生成的字节码指令为:new #2。后面这个#2表示常量池中索引为2的元素,该元素指向某个java类的全限定名。

  如果是实例化Long,常量池中2号索引里存在的是字符串:java.lang.Long。重写后的new字节码指令,后面跟着的就不是#2了,就是指向"java.lang.Long"这个字符串的内存地址。

  当JVM真正运行到new这条指令的时候,它要根据java类的全限定名称,在内存metaspace区定位到这个java类在内存中的类模板对象-instanceKlass。类模板对象包含了原始java类中的一切信息,JVM会根据这个模板创建出java类的实例对象。

注意:为了避免每次new都要进行一次定位,JVM会在第一次执行new指令时,就会将定位到的类模板对象缓存起来,这样子后续需要再次实例化同样的java类对象时,便会直接从缓存中读取模板。

 

posted @ 2019-03-14 14:43  不该相遇在秋天  阅读(777)  评论(0编辑  收藏  举报