美文网首页
JVM虚拟机系列----(四)类加载机制

JVM虚拟机系列----(四)类加载机制

作者: 梦飞成2012 | 来源:发表于2017-10-26 20:01 被阅读64次

    很多做应用层的同学感觉不理解,我们做应用层的,管底层类加载干什么? 它爱什么时候加载什么时候加载? 以前的很多时候我也这么认为,但是后面慢慢的发现,了解类的加载流程,对我们开发有很大帮助,尤其当一些疑难问题百思不得解的时候,不信看看下面的代码:

    public class SuperClass {
          static {
              System.out.println("SuperClass Init");
          }
    
          public static int value = 123;
    }
    
    
    public class SubClass extends SuperClass {
            static {
                System.out.println("SubClass Init");
            }
    }
    
    
    public class Test {
          public static void main(String[] args) {
                  System.out.println(SubClass.Value);
          }
    }
    
    

    这段代码的输出结果是:
    SuperClass Init
    123

    是不是感觉有点惊讶!
    原因在于:对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态变量,只会出发父类的初始化,而不会引起子类的初始化动作。

    下面讲一下虚拟机中类的加载流程:

    一、什么是类加载?

    类的加载,是指将类的class文件对应的二进制数据读取到内存中,将其放在运行时的方法区中,然后在堆区中创建一个java.lang.Class的对象,用来封装类在方法区的数据结构。类加载的最终产品是位于堆区的Class对象,class对象封装了类在方法区的数据结构,并且向Java程序员提供了访问方法区数据结构的接口。
    如下图所示:

    类加载结果示意图

    类加载并不是某个类“首次主动使用”才进行加载,JVM规范允许类加载器在预料到某个类将要被使用的时候加载它。如果在加载.class 的时候遇到文件缺失或者错误,类加载器必须在程序主动使用该类的时候才报告错误(LinkAgeError错误)。如果这个类一直未被使用,则不会报告这个错误。

    加载.class的方式有以下几种:

    • 从本地系统中直接加载
    • 从网络中下载.class 文件
    • 从zip、jar等归档文件中加载
    • 从转悠数据库中提取.class
    • 从Java源文件动态编译为.class
      简言之,任何能获得.class 二进制方式都可以。

    二、类的生命周期

    2.1类的加载步骤:

    类从加载到虚拟机内存开始,到被卸载出内存为止。它整个生命周期包括 :加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)
    如下图所示:

    类加载步骤

    加载、验证、准备、初始化和卸载这几个步骤的开始顺序是确定的,类加载的步骤也必须按照这个顺序按部就班的开始,而解析步骤不一定,某些情况下,它可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也叫动态绑定或晚期绑定)。
    另外前几个步骤的顺序确定,是指开始顺序确定,而不是按顺序完成或者进行,因为这些阶段通常交叉混合进行的。通常在一个阶段的过程中会激活或者调用另一个阶段。

    下面我们分阶段介绍各个步骤:

    (1)加载:查找并加载类的二进制数据。

    加载是类加载过程的第一个阶段。在加载阶段,需要完成三件事:

    • 1、通过一个类的全限定名获取其定义的二进制数据流。
    • 2、将这个字节流所代表的静态数据结构转换成方法区运行时的数据结构。
    • 3、在Java堆内存区域生成一个代表这个类的java.lang.class 对象,作为对方法区中这些数据结构的访问入口。
      相对于类加载的其他阶段,加载阶段(准确的说是,获取类的二进制字节流动作)是可控性最强的阶段。因为开发人员既可以用系统提供的类加载器完成,也可以用自定的类加载器完成该动作。
      加载动作完成后,虚拟机外部的二进制流,就按照虚拟机所需要的格式存储在方法区中而且在Java堆中也创建了一个java.lang.class 的对象,这样就可以通过该对象方法方法区的数据了。

    连接动作:连接包括 验证、准备、解析三个动作。

    (2)验证:确保被加载类的正确性。

    验证是连接的第一步,这一步的目的是为了确保class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。
    验证阶段大概会分为4个阶段 的检验动作:

    • 1、文件格式的检验:
      验证字节流是否符合class文件格式的规范,例如: 是否以oxCAFEBABE 开头,主版本号是否在当前虚拟机的处理范围之内、常量池总的常量是否有不被支持的类型。
    • 2、元数据验证:
      对字节码描述的信息进行语义分析,以保证它描述的信息符合Java语言规范的要求, 例如这个类是否有父类,Java.lang.Object除外。
    • 3、字节码验证:
      通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
    • 4、符号引用验证:
      确保解析动作正确执行。

    验证阶段非常重要,但不是必须的。它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone来关闭大部分类的验证,以缩短虚拟机加载的时间。

    (3)准备:为类的静态变量分配内存,并初始化其默认值。

    准备阶段是正式为类的静态变量分配内存并设置类变量的初始值的阶段,这些内存都将分配在方法区中。
    对于该阶段,有几点需要注意的地方:
    (i)这时候进行内存分配的仅包括类变量(static)而不包括实例变量,实例变量会在对象实例化时随对象一起分配在Java堆内存区域。
    (ii)这里所设置的默认值,通常情况下是数据类型的默认零值(int的0
    ,boolean的false等,long的0L,对象的Null),而不是Java代码中显示被赋予的值。例如下面代码:
    private static int value = 3;
    那么,value变量在准备阶段过后,value的值为0,而不是3,因为这时候尚未执行任何Java方法,而把value赋值为3的指令putstatic指令,是在程序编译后,存放于类构造器<cinit>()方法中的,所以将value赋值为3的操作将在初始化阶段采取执行。

    这里需要注意几点:

    • 对于基本类型来说,对于类变量(static)和全局变量来说,如果不显示的赋值而直接使用,则系统将会使用其默认值。
    • 对于局部变量来说,在使用前必须显示的赋值,否则编译不通过。(局部变量不会在类准备阶段初始化内存空间,也就是不会赋值,使用必须自己赋值,申请内存空间。)
    • 对于同时被static和final修饰的常量,必须在声明的同时就显示的赋值,否则编译不通过。
    • 而只被final修饰的,可以在声明的时候显示赋值,也可以在类初始化的时候赋值,总之必须在使用前赋值,否则系统不会给它赋初始值。
    • 对于引用类型的reference来说,如数组和对象引用,如果没有显示赋值,则系统会赋默认值,即null
    • 如果数组的元素没有初始化,那么其中的元素会根据对应的类型,赋初始值。

    对于常量,即同时被static和final修饰的变量,则会在准备阶段赋自定义的值。
    比如,final static int value = 3;
    在准备阶段,value的值就变成了3了,这个值是放在方法区的常量池。

    (4)解析:把类中的符号引用转变为直接引用

    解析阶段是将常量池中的符号引用转变为直接应用的过程。解析动作主要针对类或接口、类方法、字段、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
    符号引用就是用一组符号描述目标,可以是任何字面量。
    直接引用是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

    (5)初始化:初始化阶段为类的静态变量赋予正确的初始值,JVM负责对类变量进行初始化。

    在Java中对类变量进行初始化值设置有两种方式:
    ①声明类变量时指定初始值。
    ②使用静态代码块为类变量进行初始值设定。

    JVM的初始化步骤:

    • 1、假如这个类还没有进行加载和连接,则先进行加载和连接步骤。
    • 2、假如这个类的直接父类还没有初始化,则先初始化该类的直接父类。
    • 3、假如类中有初始化语句,则依次执行这些初始化语句。

    类的初始化时机:
    只有当类被主动使用的时候,才会导致类的初始化。
    类的主动使用包括以下六种:

    • 1、创建类的实例,也就是new一个类的对象。
    • 2、访问某个类或接口的静态变量、或者对该类的静态变量赋值
    • 3、调用类的静态方法
    • 4、反射,例如:Class.forName("com.beibei.test");
    • 5、初始化某个类的子类,则其子类的父类也会被初始化。
    • 6、Java虚拟机启动时,被标为启动类的类(如java.test),或者直接使用java.exe运行的某个类。

    (6)使用:使用步骤就是普通的代码运行。

    (7)卸载:当JVM虚拟机结束自己生命周期,即卸载类,有以下几种方式:

    • 1、执行了System.exit()方法;
    • 2、程序正常运行结束;
    • 3、程序在运行时候,遇到异常或错误而异常终止
    • 4、由于操作系统出现错误,导致JVM虚拟机进程异常终止。

    三、类加载器简介:

    先来一段类加载器的代码:

    public class ClassLoader {
        public static void main(String[] args) {
                ClassLoader loader = Thread.currentThread().getContextClassLoader();
                System.out.println(loader);
                System.out.println(loader.getParent());
                System.out.println(loader.getParent().getParent());
        }
    }
    

    运行后输出的结果是:

    sun.misc.Launcher$AppClassLoader@643df239
    sun.misc.Launcher$ExtClassLoader@92dfa992
    null
    

    从上面的输出结果来看,并没有找到ExtClassLoader的父loader,原因是BootStrapClassLoader(引导类加载器)是C语言实现的,找不到一个确定的返回父loader 的方式,所以返回null。

    JVM中类加载器的层次结构如下图:

    JVM 类加载器结构图

    注:这里的父类加载器不是使用继承,而是使用组合方式实现的。

    站在Java虚拟机的角度来言,只有两种加载器:启动类加载器和非启动类加载器。
    启动类加载器:它使用C++语言实现,是所有其他类是的加载器, 是虚拟机的一部分。
    所有其他类的加载器: 这些加载器都独立于虚拟机之外,有Java语言实现,全部继承于java.class.ClassLoader, 这些类加载器需要由启动类加载器加载到内存中,才能加载其他类。

    站在Java开发人员的角度,类加载器大致可以分为三类:
    启动类加载器:Bootsrap Classloader,负责加载并识别虚拟机的类库(如rt.jar,所有已Java.开头的类,均被其加载),启动类加载器无法被Java程序直接引用。
    扩展类加载器:由sun.misc.Launcher$ExtClassLoader实现,负责加载系统变量指定的类库,或者以javax.
    开头的类。
    应用程序类加载器:由sun.misc.Launcher$AppClassLoader 实现, 负责加载用户类路径下的类, 开发者可以直接使用该类加载器,如果程序员没有自定义,一般使用系统默认的。

    应用程序都是由这三个类加载器配合使用的, 如果有必要,我们还可以自定义类加载器,因为JVM自带的类加载器,只懂得从本地文件系统,读取标准的class 文件,自己编写的ClassLoader可以做到以下几点:

    • 1)在执行非置信代码之前,自动验证数字签名
    • 2)动态创建符合用户特定需求的结构化类
    • 3)从特定场景,如网络和数据库取得Java class

    3.1JVM加载机制的特点:

    (1) 全盘负责:当一个类加载器加载某个class时,该class所依赖的和所引用的class,也由该类加载器负责载入,除非显示的使用另一个类加载器载入
    (2) 父类委托:先让父类加载器试图加载该类,只有当父类加载器无法加载该类的时候,才尝试从自己的路径加载该类
    (3)缓存机制:缓存机制,将保证所有被加载过的类都将被缓存,当程序中需要使用某个class时候,类加载器首先从缓存区寻找该class,只有缓存区不存在的时候,系统才会读取该类对应的二进制数据,并将其转换为class对象,存入缓存区,这就是修改了class之后,为什么要重启JVM,程序的修改才会生效。(Android程序的热修复也是这个原理,必须重新加载才行。)

    3.2类的加载:

    类的加载有三种方式:

    • 1.命令行启动应用时,由JVM初始化加载
    • 2.通过class.forName方法动态加载
    • 3.通过ClassLoader.loadclass方法动态加载。
      不同的加载方式,触发的加载步骤不一样,如下面代码:
    public class Test {
            static {
                    System.out.println("Test 类开始初始化了!");
            }
    }
    
    

    测试代码:

    public class ClassLoader {
        public static void main(String[] args) {
                ClassLoader loader = Thread.currentThread().getContextClassLoader();
                System.out.println(loader);
                loader.loadclass("Test"); // 不会输出"Test 类开始初始化了!" 语句,因loadclass不会执行初始化步骤
              Class.forName("Test"); // 输出"Test 类开始初始化了!"语句,因为使用class.forName 会调用初始化语句
        }
    }
    

    Class.forName 和ClassLoader.loadclass的区别:
    Class.forName: 将class加载到JVM中,并进行解释,执行初始化步骤
    ClassLoader.loadclass:只干了一件事,将class文件加载到JVM中,不会执行static内容块,只有执行了newInstance之后,才会进行类的初始化步骤。

    3.3双亲委派模型:

    双亲委派模型
    如上图所示,这就是类加载器双亲委派模型的示意图。
    双亲委派模型要求,除了顶层的启动类加载器之外,其余的类加载器都应有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承关系实现,而是以组合的关系来复用父类的代码。

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

    双亲委派模型的好处,显而易见:

    • 1.Java类随着它的加载器一起举杯了一种带有优先级的层次关系,例如,java.lang.Object 类,它存放在rt.jar中,无论哪个类加载器要加载这个类, 最终都会委派给处于顶端的启动类加载器,因为Object类在程序的各种类加载器中,都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行加载,如果用户编写了一个Java.lang.Object 类,并放在程序的classPath中,那么系统将会出现多个不同的Object类,Java体系最基础的行为都无法保证,需也将会一片混乱。
    • 2.双亲委派模型对于Java程序的稳定运行非常重要。但是双亲委派模型的实现却很简单。实现双亲委派模型的代码都在ClassLoader的loadclass())方法中,逻辑如下:先检查类是否已经被加载过,若没有加载,则调用父加载器的loadclass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器加载失败,抛出classNotFountException异常后,在调用自己的findclass()方法。
      核心代码如下:
    
        protected  Class<?>  loadClass(String name,  boolean resolve) {
            Class c = findLoadedClass(name);    // 首先,检查请求的类是否被加载过
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, resolve);
                    } else {
                        c = findBootStrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果抛出异常,说明父类加载器无法完成请求
                }
    
                if (c == null) {
                    // 在父类加载器无法完成请求的时候,调用本身的findClass方法进行类加载
                    c = findClass(name);
                }
            }
            
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    

    3.4自定义类加载器:

    步骤:

    • 1、继承ClassLoader类,重新findClass()方法,在该方法里面写加载class二进制数据的逻辑(包括可能的解密等逻辑)
    • 2、最好不要重写 loadClass方法,因为这样会破坏双亲委派模型

    参考资料:

    java类的加载机制
    Java类加载器(一)——类加载器层次与模型
    Java虚拟机类加载机制
    http://blog.csdn.net/ns_code/article/details/17881581
    https://segmentfault.com/a/1190000005608960
    http://www.importnew.com/18548.html
    http://zyjustin9.iteye.com/blog/2092131
    http://www.codeceo.com/article/java-class-loader-learn.html

    相关文章

      网友评论

          本文标题:JVM虚拟机系列----(四)类加载机制

          本文链接:https://www.haomeiwen.com/subject/plhpuxtx.html