美文网首页
虚拟机类加载机制

虚拟机类加载机制

作者: jqdywolf | 来源:发表于2018-03-30 19:55 被阅读0次

    虚拟机把Class文件加载到内存,并对数据进行校验、转换、初始化,最终形成可以被虚拟机直接使用的Java类型。这就是虚拟机的类加载机制。
    Java语言的加载、连接、初始化都是在运行期完成的,这也让Java天生有了动态灵活性

    类加载的时机

    简述类加载过程

    类从被加载到用完之后被卸载,生命周期存在7个阶段:

    类加载过程
    其中加载--验证--准备--初始化--卸载,这5个过程的开始顺序是固定的。
    这里说的开始顺序是指:5个过程开始的次序固定,可能前一个没结束,后一个开始,即通常情况下,这几个都是交叉进行的。
    类加载的时机

    加载的时机Java虚拟机规范并没有明确规定(各个虚拟机实现版本可以自行决定)。但初始化的时机确有很严格的规定。__有且只有__5种情况必须立刻对类进行初始化:

    1. 遇到new/putstatic/getstatic/invokestatic四种指令时,如果类还没初始化,则先触发其初始化。四种指令对应的Java代码是:使用new关键字实例化对象、修改/读取类的静态变量、调用类的静态方法。
    2. 使用java.lang.reflect对类进行反射调用的时候。
    3. 当初始化一个类,发现其父类还没初始化,则先触发父类初始化。
    4. 当虚拟机启动时,指定运行的主类(包含main方法,程序的人口),要先初始化。


      image.png

    第5点先放着,回头再细看一下。

    • 上面说“有且仅有”的意思是除了上述的5种情况,其他情况都不会要求初始化。下面说几种不会引起初始化的情况:
      • 引用子类中父类的静态变量,只会引起父类的初始化,不会引起子类的初始化。
      • 初始化数组,并不会初始化数组中的每个对象。比如
        String[] a = new String[10];
      • 引用类中的常量(final static修饰),不会引起类的初始化。引用类的常量在编译阶段就会把该常量放入引用类的常量池。即在运行阶段引用类和被引用类之间没有任何关系。
        特别注意:引用类的常量必须是编译期可确定的,如果不是编译期可确定,则还是会引起类的初始化。
    • 接口的初始化时机。我们知道接口也有默认的构造器的--用于初始化接口内定义的变量。
      接口的初始化时机是和类初始化类似,除了第三点:当一个类初始化时,要求其父类必须初始化。但一个接口初始化时,并不要求其父类初始化。只有在真正用到的时候(如引用接口中常量)才会初始化。
      注意一下:我们知道接口中定义的变量都会在编译期默认加上final static。但并不意味着变量都是在编译期可确定的,所以这样说的是引用接口中(编译期不可确定的)常量才会初始化。

    类加载的过程

    分别对加载、验证、准备、解析、初始化五个过程做详细介绍。

    加载

    简单地说,就是将class文件读到内存中方法区的过程。
    主要完成了3件事:

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

    然后分别对这三点做出说明:

    • 为何说定义此类的二进制字节流,不说class文件?
      因为我们可以指定任意字节码的来源,比如从zip包(jar/war)读取、从网络中获取(applet)、运行时计算生成(代理技术)等。
    • Class类的对象存在哪里?
      虚拟机规范并没有明确指明,HotSpot是存在方法区中的。

    注意两点:

    1. 对于加载阶段,当是非数组类时,我们可以使用系统提供的导入类加载器,也可以自己指定自己的类加载器。
      当是数组类时,数组类本身不通过类加载器完成,而由虚拟机自己创建。
      关于类加载器的东西,我们后面细说。
    2. 在加载阶段还没结束时,验证已经开始了。
    验证

    验证阶段的目的:保证class文件流是符合当前虚拟机要求的,并且不会危害虚拟机本身。
    简单地说就是保证输入class文件流的合法性。
    主要包括4个阶段的验证工作:

    1. 文件格式的验证
      验证字节流是否符合Class文件格式规范。
      比如:magic、minor/major version是否符合当前虚拟机版本
    2. 元数据的验证
      主要是对字节流的元数据进行语义校验。
      比如:这个类是否有父类,父类是否是可被继承的,是否是抽象类,是否实现了抽象类中的所有抽象方法等。
    3. 字节码的验证
      对类的方法体做校验,根据数据流和控制流分析,确保方法体不会做出危害虚拟机的行为。
      比如:保证跳转指令不会跳到方法体外面的字节码上。
    4. 符号引用的验证
      发生在虚拟机将符号引用转化为直接引用的时候。--这个转化动作是在解析阶段进行。
      主要是对类外信息匹配性校验。
      比如:符号引用中的全限定名是否可以找到,指定类中是否存在指定的方法等。
    准备

    准备阶段是为类变量分配内存(方法区中)并赋初始值。
    注意三点:

    1. 是类变量,即static修饰的变量,并不是实例变量。
    2. 这里的赋初始值为0值,并不是程序里定义的值。
    private static int value  = 3;
    

    准备阶段value的值为0,在初始化阶段才被赋值为3。

    1. 这里的类变量不包括常值类变量。
    private final static int value  = 3;
    

    此时的value在编译阶段为value生成ConstValue属性,在准备阶段会被赋值为3。

    插入一点知识总结:

    private final static int value1 = Test.VALUE;
    private final static int value2 = 12;
    private final static String value3 = new String("123");
    

    编译阶段和准备阶段都干了什么?
    编译阶段:
    1和2都为value生成ConstValue属性。
    1是把在本类中的常量池中将Test.VALUE的具体值给替换掉。
    准备阶段:
    1和2都被赋值为12
    3被赋值为0 3在初始化阶段才被赋值为123.

    解析

    解析阶段就是虚拟机将常量池中符号引用转换为直接引用的过程。

    • 符号引用
      以一组符号来描述所引用的目标,可以是任意字符串。与具体内存无关,只是一个表示而已,引用的目标并不一定已经加载到内存中。
    • 直接引用
      直接引用可以是指向目标的地址、偏移量或句柄。它和内存有关,即这里的地址、偏移量等都是指的内存中的实际地址。如果有了直接引用,则目标必定在内存中已经存在。
    初始化

    初始化阶段是执行类构造器clinit方法的过程。

    • clinit方法是由编译器将类变量的赋值操作和static语句块联合在一起产生的。编译器收集的顺序是和源文件中出现的顺序相关。

    非法向前引用问题:static语句块只能访问此块之前的变量。此块之后定义的变量可以赋值,不可使用。

    static {
       j = 10;
    //  int j; 如果把下面定义j的地方移到这里也直接报错。
    //  System.out.println(j); 加上这行报错非法前置引用。注释这行可以正常运行。
    }
    static int j;
    public static void main(String[] args){
     System.out.println(j);
    }
    
    • clinit函数无需显示调用父类的clinit,虚拟机会保证执行子类clinit函数之前,父类的clinit函数已经执行完毕。也就意味着父类中的static语句块要优先执行于子类中的static语句块。
    • 接口中虽然不允许有static语句块,但是允许定义常量,所以编译器也会为接口生成clinit方法。和类不同的是,子类clinit函数执行不需要先执行父类的clinit方法。接口的实现类在初始化时,也不会执行接口的clinit方法。
    • clinit对于类与接口并不是一定会生成的,如果类中没有static语句块编译器就不会生成clinit方法。
    • clinit是线程安全的,多个线程同时去初始化一个类,只有一个线程会去执行clinit方法。其他线程会阻塞。被唤醒之后,其他线程也不会再执行clinit方法了。JVM保证一个类只会初始化一次。

    类加载器

    我们上面说过在类加载中的加载阶段,JVM允许我们自己指定类加载器。这里我们就说说类加载器。
    类加载器的功能:通过一个全限定名来获取描述此类的二进制字节流。

    类与加载器

    对于任意一个类,都需要加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。因为每一个类加载器都拥有一个独有的类名称空间。
    换句话说,同一个类被不同的类加载器加载,这两个类一定不相等。事实上,这两个类完全不相干。相等包括equals方法、instanceof方法等。

      public static void main(String[] args) throws Exception{
          ClassLoader classLoader = 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 (null == is) {
                          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 obj = classLoader.loadClass("ruleEngine.Test");
          System.out.println(obj.getClass());//ruleEngine.Test
          System.out.println(obj instanceof ruleEngine.Test);//false
      }
    

    上面例子中,obj是通过我们自定义的类加载器加载出来的类定义的实例。而ruleEngine.Test是系统应用程序类加载器加载的,所有返回的是false。

    双亲委派模型

    系统提供的类加载器分为三种

    • 启动类加载器
      该类加载器负责加载<JAVA_HOME>/lib下的类库到内存中。无法被程序直接引用。
    • 扩展类加载器
      该类加载器负责加载<JAVA_HOME>/lib/ext下的类库。可以被开发者直接使用。
    • 应用程序加载器
      负责加载用户类路径(ClassPath)上指定的类库。是ClassLoader中getSystemClassLoader方法的返回值。

    我们的应用程序都是由这3种类加载器互相配合进行加载,如果有必要,还可以加入自定义类加载器。关系如图:


    类加载器双亲委派模型
    • 工作过程
      如果一个类加载器收到类加载请求,它首先自己不会尝试加载这个类,而是把这个请求交给父类加载器,每一层都是如果,只有当父类加载器无法完成加载时(在它负责的搜索范围内没有找到),子加载器才会自己加载。
    • 好处
      Java类有了优先级的层次关系。比如java.lang.Object类(存在<JAVA_HOME>/lib下),无论哪个类加载器加载,都会使用启动类加载器加载这个类,这样Object无论在哪种类加载器环境中都是同一个类。就算用户自己写了一个同名的java.lang.Object类也永远无法被加载。
    • 注意
      这种模型并不是Java虚拟机规范的,这只是一直推荐方式。
    破坏双亲委派模型
    • JNDI的出现就破坏了这种模型。
    • JNDI
      Java命名和目录接口(Java naming and directory interface)是一组在Java应用中访问命名服务和目录服务的API。它是J2EE的规范之一,标准的J2EE容器都提供了对JNDI规范的实现。
      命名服务:将名称和对象联系起来,使得我们可以使用名称来访问对象。
    • 比如一个经典的例子:
      没有JNDI我们要配置一个JDBC时需要这么做:
    Class.forName("com.mysql.jdbc.Driver");  
    Connection conn=DriverManager.getConnection("jdbc:mysql://DBName?user=xxx&password=xxx");
    

    这么做的缺点是将具体配置和代码写在一起,如果替换还要改代码。而有了JNDI之后,我们就可以将代码和配置解耦。
    如下:

    import javax.naming.Context;
    
    Context ctx=new InitialContext();
    Object datasourceRef=ctx.lookup("java:MySqlDS");
    DataSource ds=(Datasource)datasourceRef;
    Connection c=ds.getConnection();  
    

    然后我们在配置文件中定义java:MySqlDS这个对象的构造参数就可以了。

    • 知道了JNDI的原理之后,我们就知道JNDI只是定义了接口,具体实现要看具体J2EE厂商。而JNDI是由启动类加载器加载的,显然启动类无法知道实现的代码。
      为了满足这种需求出现了线程上下文类加载器这种不太优雅的设计。
    • 线程上下文类加载器
      每个线程可以设置自己的线程类加载器(java.lang.Thread.setContextClassLoader),如果没有设置则继承父类的,如果一直向上都没找到,就使用应用程序类加载器。这个加载器就打破了双亲模型,父类加载器请求了子类加载器去完成类加载的动作。

    相关文章

      网友评论

          本文标题:虚拟机类加载机制

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