聊一聊虚拟机类加载机制吧

引入

 虚拟机的类加载机制是怎样的?这个问题可以有很多种问法。比如:一个class的生命周期有哪些,是如何演变的?再比如:你知道类在实例化之前虚拟机都干了些什么吗?
 首先,这篇文章仅针对于类加载的过程,对于class的生命周期中的类的使用和卸载不会涉及。即便如此,因为这里面包含了太多的细枝末节,所以也不会对类加载过程作过多非常详细的说明,仅仅只是为了帮助理解这个过程。如果朋友们有这方面的疑惑,可以在评论区留言,大家可以一起来探讨。那么,对于这篇文章,我们的目标是:

  1. 了解类加载过程有哪些阶段
  2. 各个阶段干了些什么事情
  3. 简单分析下这块内容中常见的考题

 请注意,除非特别注明,否则本文篇中所涉及得到内容都是基于jdk1.8来说的。

什么是类加载

 简单的讲,类加载就是把各种各样的class变成虚拟机可以直接使用的类型。
 事实上,一个类的类加载过程是这个类生命周期的一部分。如下图所示:


类的生命周期

 如果上面的内容并没有让你对类加载有一个初步的认识,不用着急,等把下面的部分看完了再回过头来理解,就能搞明白了。

(一)编译

 举个例子来说,我在代码中定义了一个simple类。要怎么让虚拟机大哥认识我定义的这个类呢?
请注意:下面的内容全是我个人通俗的理解,可能会有不严谨的地方,我只是想把这部分表达得更加形象一些。
 如果你尝试把写好的simple.java文件直接丢给虚拟机,虚拟机并不会买单。对虚拟机大哥来说,他只认识字节码文件。这也是为什么同一套代码可以跑在不同的操作系统上的部分原因,我们的代码最终都会在虚拟机中被执行,而虚拟机会根据不同的操作系统调用不同的机器原语。所以,哪有什么平台移植,只不过有虚拟机替我们铺路前行。
 ok,虚拟机需要.class文件。这很简单,编译器就可以帮我们搞定。假设现在编译器已经生成.class文件了,我们已经把虚拟机大哥所需要的资料准备好了。

(二)加载

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

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

 为了帮助理解,还是接着刚刚的例子继续吹牛。你毕恭毕敬的给虚拟机大哥提供了资料,上面写满了一堆字节码。虚拟机拿到你提交的资料后(通过类的全限定名来获取定义此类的二进制字节流),并不会马上开始干事情。虚拟机对于所有开发人员提交的资料都有自己的一套表格,所以在工作的时候首先要把所有的内容转换为格式统一的文件(将这个字节流代表的静态存储结构转为方法区的运行时数据结构),虚拟机大哥会把这个跟你相关的统一格式文档放到方法区存档(在内存中生成一个代表此类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口)。

(三)验证

 验证很好理解,无非就是校验数据、格式之类的东西对于虚拟机来说是不是合法的。具体包括:

第一回合验证:文件格式验证
 验证字节流是否符合class文件格式的规范,是否能被当前版本的虚拟机处理。该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之中。只有通过了这个阶段的验证之后,这段字节流才被允许进入虚拟机内部的方法区进行存储。

到底有哪些验证项,这里就不在提及了。总之非常多,而且不同的虚拟机验证项并不相同(相同的虚拟机版本不同,也可能存在差异),建议根据自己的虚拟机的版本查看相应的虚拟机规范。
 这一部分验证到底有什么意义呢?上面说到虚拟机会把所有开发人员提交的资料转化为格式统一的表格,那如果有开发人员在关键栏上填错了信息(比如,性别:null)的话,虚拟机同样是不认账的。所以,在你提交的资料(.class)被转化为统一的表格的时候,就需要进行校验,看看该填的内容是否都填了,有没有填错的地方之类的。实际上文件格式验证就干了这么一件类似的事。

 只有通过了文件格式的验证之后,字节流才被允许进入Java虚拟机内存的方法区中进行存储。后面的三个验证阶段都是基于方法区的存储结构上进行的,不会再读取、操作字符流了。
 换成我们的例子再来一遍。如果文件格式验证没有问题的话,就表示你提交的资料是通过了第一步的审核。这个时候,虚拟机大哥会把你提交的资料(原材料)退还给你,因为对他来说,你的这份资料已经没用了。你提供的原材料已经被他留底了,在他的资料库里面已经填好了一份格式统一的表格。他以后处理事情,可以直接用他的表格。

第二回合验证:元数据验证
 主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。
 可能包括的验证点有:这个类是否有父类、这个类的父类是否继承了被final修饰的类、这个类(前提是这个类不是抽象类)是否实现了其父类或接口中要求实现的所有方法、类中的字段以及方法是否与父类产生了矛盾等等。

 这部分的验证很好理解,就不啰嗦了,看下一个。

第三回合验证:字节码验证
 这部分是整个验证过程中最复杂的一个阶段。删繁就简,粗略的看一下大概干了什么。
 这阶段的主要目标是方法体。目的是保证校验类的方法在运行时不会做出危害虚拟机安全的行为。
 例如:在方法中定义了一个int类型的数据,使用时却按long类型来加载入本地变量表中。这样的操作将导致这一个回合的验证不会通过。

 这部分的验证同样好理解,说白了,就是防止方法体中出现一些明显的bug而导致虚拟机的正常运转。

 扩展一下,如果有一个方法体没有通过字节码验证,那么这个方法肯定是有问题的;那么通过了,就能说这个方法一定没有问题吗?

 答案是很明显的,并不能说明这个方法没有问题。换个说法,就算一个方法通过了字节码验证,也不保证这段代码一定是安全的。
 事实上,我们根本就无法通过一段程序去检测另外一段程序是否存在bug。甚至无法通过人为的方式去发现所有bug,虽然我也不愿意相信,但就存在这样的一个事实:bug是永无止境的。

 但是实际上,不能因为无法定位所有bug就干脆不做bug的校验,虚拟机只能在这一步中进行大量且严密的检查过程。在JKD 6之后的Javac编译器和虚拟机进行了一项联合优化,把尽可能多的辅助措施都搬到了编译器里进行,这样可以避免大量的bug在字节码校验的时候才被发现。

第四回合验证:符号引用验证
 通俗来说,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
 比如:符号引用中通过字符串描述的全限定名是否能找到对应的类;当前类对于符号引用中的类的字段、方法是否有可访问权等

 这个回合的验证主要是保证后面的解析行为能正常执行。如果没有通过符号引用验证,虚拟机将会抛出一个java.lang.IncompatibleClassChangeError类型的子异常。比如NoSuchMethodError、NoSuchFieldError、IllegalAccessError等等。

(四)准备

 准备阶段是正式为类中所定义的变量(静态变量)分配内存并设置类变量的初始值的阶段。
从概念上讲,静态变量所使用的内存都应在方法区中进行分配,但方法区本身是一个逻辑上的区域。在JDK 7及之前,HotSpot使用永久代来实现方法区;在JDK 8及之后,类变量会随着Class对象一起存放在堆中。

 准备阶段仅给类变量分配内存并设置初始值。这里所指的类变量,就是静态变量(被static修饰的变量);初始值通常情况下(特殊情况见下面的代码)是数据类型的零值。例如下面的语句:

    // 类变量,初始值为int类型的零值:0
    public static int value_1 = 111;
    /** 
     * 类变量,初始值为ConstantValue属性的值
     * 注意这里不是把int类型的零值改为了0。
     * 只要类变量加了final修饰,在编译阶段的时候就会给类字段的字段属性表中加了一个ConstantValue属性
     * 这里虚拟机只需要取出ConstantValue属性的值,并赋值给类变量
    */
    public static final int value_2 = 222;
    // 实例变量,不会在准备阶段分配内存
    public int value_3 = 333;

 关于基本数据类型的零值,见:传送门

(五)解析

 这一阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
 这部分内容相对复杂,我们大概有个认识就可以了。验证阶段有一个符号引用验证的过程,其实就是在为这里作准备。
 结合上面我们定义的场景,举一个形象但不太严谨的例子来说明一下。虚拟机大哥已经有一份格式统一的表格,假设表格中你填了一个监护人的栏目,内容写的是:妹妹的妈妈的哥哥。

  1. 虚拟机大哥会先判断这个人是不是真实的,你有妹妹吗?你妹妹有妈妈吗?你妹妹的妈妈有哥哥吗?
  2. 然后虚拟机大哥会先根据人物关系判断这个人是不是非法的,通过查户口之类的操作,判定这个人到底有没有法律资格作为你的监护人。

 符号引用验证就干了类似这样的一份工作,而解析干的工作就更容易形容了,你妹妹的妈妈的哥哥不就是你舅舅吗?ok,监护人那一栏直接贴上一个备注标签:易根聪(你舅舅的名字)。
上面的例子并不严谨,不能简单的以为虚拟机在这个阶段就干了这么点事情。我只是为了帮助理解才描述了这个例子,这只是为了对这块内容没什么概念的朋友能更加快速的有一个初步的认识。

(六)初始化

 废了九牛二虎之力,虚拟机大哥终于来到最后一步了,我们也迎来了春天:终于可以执行自己写的代码了。
 在整个类加载的过程中,我们有两个地方可以参与进去:一个是加载阶段,一个是初始化。加载阶段可以局部参与的原因是用户可以自定义类加载器,初始化可以参与的原因是我们可以控制构造器。

请注意,类构造器<clinit>()方法并不等同于构造方法<init>(),它是编译器的自动生成物:是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。

 类构造器<clinit>()方法遵循了以下几点:

 第一点:保证子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。

 根据这一点,我们可以得知,在虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object的<clinit>()方法。
 这一个考点也常常出现在各种笔试题中,例如下面的代码段:

    // 父类
    public class Parent {
        public static int A = 10;
        static {
            A = 20;
        }
    }
    // 子类
    public class Sub extends Parent {
        public static int B = A;
    
        public static void main(String[] args){
            /*
             * 根据上面的描述,Sub的<clinit>()方法执行前,会执行Parent的<clinit>()方法
             * Parent的<clinit>()方法会先执行A=10,再执行static块中的A=20
             * Parent的<clinit>()方法执行完后,才会执行Sub的<clinit>()方法,所以A等于20,而不是10
             */
            System.out.println(Sub.B);
        }
    }

 第二点:接口也会生成<clinit>()方法,与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也不会执行接口的<clinit>()方法。
 第三点:如果使用的仅仅是一个类的常量(被final修饰),则不会导致其初始化。

 对于第三点,看下面的代码片段:

    public class FinalTest {
        static {
            System.out.println("FinalTest被初始化");
        }
    
        public final static int value = 50;
    
    }

    public class ClinitTest {
        public static void main(String[] args){
            System.out.println(FinalTest.value);
        }
    }
    // 输出:50
    // 为什么说FinalTest类没有被初始化?
    // (1). 初始化是把类变量和静态代码块打包一起执行的,
    // (2). 上面的输出结果中没有执行静态代码块,所以可以肯定没有被初始化.
    // (3). 在准备阶段中也提到了相关内容,加了final修饰的静态变量会把值放到ConstantValue属性中去,
    // (4). 所以虚拟机不用对类进行初始化就能拿到值。

理解类加载机制时应注意的点

关于各个阶段的执行顺序

 让我们回到最开始的类生命周期中来,通过上面的内容我们已经大概了解了类加载分为五个部分:加载、验证、准备、解析、初始化。事实上,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,解析则要看情况,在某些情况下,解析可以在初始化阶段之后才开始(与Java的后期绑定特性有关)。
 而即便是加载、验证、准备和初始化这几个阶段也不是严格的顺序执行的,如果你看过了上面的部分应该就能看出来,有一些阶段事实上是交叉进行的,所以这里面没有一个强的先后顺序执行的关系。

关于类加载和对象实例化

 有很多朋友在初学的时候(包括我自己)都会把这两个概念弄混,事实上,这两个工作流程的理念有点类似,都是进行初始化的。但他们的目标对象完全不同,不可以一概而论。关于对象实例化,我会在后面的章节中进行补充。
 关于对象实例化参见:传送门
 我们可以尝试这样去区分他们:

  1. 如果对Java有一些了解,你就应该知道类可以分为两个大的部分:类本身和类的实例。类加载机制加载的是类本身,而类的实例化是针对的类的实例。
  2. 一个类只会被加载一次,所以在方法区中只有一个java.lang.Class对象能代表当前类;而一个类的实例可以有多个,实例对象基本上都是在堆中分配的内存。
  3. 类的实例化必须依赖于类加载,在实例化时必须是被jvm认可的类型,而被jvm认可就是类的加载。类的实例化干的第一件事就是检查当前类是不是已经被加载过了,如果没有加载,则应先加载类。

类加载器与双亲委派模型

类加载器是什么?

 如果你有细心看初始化那一节的时候,应该已经注意到了上面我提到:有两个阶段给我们提供了参与到类加载的过程中的机会。一个是类构造器,上面已经讲过这部分的内容了;另外一个在加载阶段,我们可以自己定义类加载器。
 在加载阶段中,第一步的内容为:通过类的全限定名来获取定义此类的二进制字节流。Java虚拟机的设计团队有意把这一步放到虚拟机外部去实现,目的就是为了让应用程序自己决定如何获取所需的类。而实现这个动作的代码被称为:类加载器(ClassLoader)。

为什么要提出双亲委派模型?

 理解双亲委派模型之前,我们按照类加载器的定义,来考虑一下下面这些场景:

 问:既然虚拟机允许我们自己实现类加载器,那么我对于同一个类,可以写两个类加载器吗?
 答:当然可以,我们看到在虚拟机类加载机制规范的所有阶段中,并没有任何一个阶段会去检测我的类是不是已经被其他类加载器加载了。
 问:那么我的两个类加载器加载的java.lang.Class对象是同一个吗?
 答:不是。在虚拟机规范中指明了,对于任意一个类,都必须由加载它的类加载器和这个类本身来标识类在虚拟机中的唯一性。换一个通俗一点的表达就是:如果一个类被一个虚拟机加载,但加载他们的类加载器不是同一个,那么他们就是不同的类的定义(java.lang.Class对象),只不过他们代表的是同一个类。

 通过上面的分析,我们可以知道如果我们用不同的类加载器加载同一个类,会在方法区中生成两个不同的类定义。到这里,我们就知道问题是什么了——如果我们要使用一个类,那么虚拟机该给我们哪个类的定义呢?这便是双亲委派模型的产生原因。
 理解了上面的问题,对于双亲委派模型的理解就变得更加简单了。简单来说,双亲委派模型就是为了保证一个类能且只能被虚拟机加载一次。

双亲委派模型干了什么?

 双亲委派模型的解题思路可以概括为这样的一句话:当一个类加载器收到加载一个类的任务时,它不会立即尝试开始加载这个类,而是把这个任务委托给父类类加载器,这个往上层类加载器移交任务的过程会一直进行,直到一个类没有父类类加载器了(顶层的启动类加载器)。只有当父类类加载器反馈自己无法加载这个类(这个类的不在它的加载范围)时,才会尝试由自己去进行加载任务。


双亲委派模型

 双亲委派模型的思想其实相当简单,代码实现也很简单,代码只有短短十多行,全部在java.lang.ClassLoader的loadClass()方法中,有兴趣的朋友可以去看看源码。

怎么自定义类加载器?

 双亲委派模型中的最底层是用户自定义的类加载器,那么我们该怎么去实现自己的类加载器呢?其实,实现类加载器有两种方式:

遵守双亲委派模型

 继承ClassLoader抽象类,重写findClass()方法。

破坏双亲委派模型

 继承ClassLoader抽象类,重写loadClass()方法。

 笔者这里以遵守双亲委派模型为基础写一个简陋的类加载器,如下代码片段:

    public class MyClassLoader extends ClassLoader {
    
        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException{
            final String fileName = name + ".class";
            // 获取class文件的字节码,注意此处只是演示自定义类加载器,并未实现获取字节码
            byte[] classData = getClassData(fileName);
            if(classData != null){
                // 字节码转换为java.lang.Class对象
                return defineClass(name,classData,0,classData.length);
            }
            return null;
        }
    
        private byte[] getClassData(String fileName){
            return null;
        }
    }

 扩展,在讲String那一章的时候曾提出过这样一个问题:

我可以自己写一个和java.lang.String同名的class,并用在程序里吗?
 答:可以正常编译,永远不能被加载运行。按照双亲委派模型的规范,java.lang.String这个类最终都会被给到最顶层的类加载器进行加载,而一个类加载器不能加载两个全限定名相同的类。
 即便你自定义了类加载器,强行用defineClass()方法去加载你自己定义的java.lang.String类也不会成功,你只会收到一个虚拟机抛给你的异常:java.lang.SecurityException:Porhibited package name:java.lang

常见面试题

(一)为什么在把非静态成员赋值给静态成员是非法的?

答:类的静态成员属于类本身,在类加载的时候就会分配内存;非静态成员属于类的实例,只有在类实例化的时候才会分配内存。而实例化类的前提是这个类已经被加载过。

(二)下面的代码将输出什么?
    public class Parent {
        public static int parentValue = 10;
        static {
            parentValue = 20;
            System.out.println("Parent类中输出的Parent.parentValue = " + parentValue);
        }
        static {
            System.out.println("Parent被初始化");
        }
    }

    public class Sub extends Parent {
        public static int value = parentValue;
        static {
            System.out.println("Sub被初始化");
        }
    }

    public class ClinitTest {
        static {
            System.out.println("ClinitTest被初始化");
        }
        public static void main(String[] args){
            System.out.println("main方法中输出的Sub.value = " + Sub.value);
        }
    }
    /********************结果********************/
    ClinitTest初始化
    Parent类中输出的Parent.parentValue = 20
    Parent被初始化
    Sub被初始化
    main方法中输出的Sub.value = 20
    /********************解析********************/
    /**
     * 1.ClinitTest类是main方法的载体,会先被jvm初始化
     * 2.main方法被调用,里面用到了Sub类的类变量
     * 3.Sub类继承了Parent类,所以先初始化Parent(Parent继承了Object,这一层就不说了)
     * 4.顺序初始化Parent的一个静态变量和两个静态代码块
     * 5.顺序初始化Sub的静态变量和代码块
     * 6.返回到main方法中继续执行
     */

参考资料列表

1. 《深入理解Java虚拟机》第三版,周志明著

 本章节中大部分内容参考了此书上的虚拟机加载机制一章,在此特别感谢作者。也强烈向大家推荐这本书,如果有空可以买来看一看,周志明先生在书中把这部分讲的很透彻。


扩展区域

扩展区域主体

这是一个没有实现的扩展。


上一篇:你知道Java中基本类型和包装类的区别吗
下一篇:HotSpot虚拟机对象的创建

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,012评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,628评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,653评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,485评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,574评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,590评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,596评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,340评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,794评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,102评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,276评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,940评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,583评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,201评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,441评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,173评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,136评论 2 352