类加载子系统

文章已同步至GitHub开源项目: JVM底层解析

一。类加载子系统

代码编译的结果从本地机器指令码转化为字节码,是存储格式发展的一小步,但却是编程语言发展的一大步

​ —— 《深入理解JVM虚拟机》周志明·著

​ Java虚拟机将描述类的数据从class字节码文件加载到内存,并且对数据进行校验,转化,解析,初始化的工作,最终形成在内存中可以直接使用的数据类型。这个过程叫做虚拟机的类加载机制。

图示

类加载子系统

作用

  • 类加载子系统负责从文件系统或者网络中加载Class文件(Class文件在开头有特定标识)。
  • l类加载器(Class Loader)只负责class文件的加载,至于是否可以运行,由执行引擎(Execution Engine)决定。
  • 加载的类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

类加载器扮演的角色

image-20210617111632499

  • Car.class存放于本地硬盘中,在运行的时候,JVM将Car.class文件加载到JVM中,被称为DNA元数据模板

    存放在JVM的方法区中,之后根据元数据模板实例化出相应的对象。

  • 在 .class -> JVM -> 元数据模板 -> 实例对象 这个过程中,类加载器扮演者快递员的角色。

类加载的时机

关于类加载的时机,《Java虚拟机规范》中并没有明确规定。这点可以由虚拟机的具体实现决定。

但是类的初始化阶段,规范中明确规定当某个类没有进行初始化,只有以下6中情况才会触发其初始化过程。

  1. 遇到new,getStaticputStatic,invokeStatic,这四条字节码指令的时候,如果改类型没有进行初始化,则会触发其初始化。也就是如下情况
    1. 遇到new关键字进行创建对象的时候。
    2. 读取或者设置一个类的静态字段的时候(必须被final修饰,也就是在编译器把结果放入常量池中)。
    3. 调用一个类的静态方法的时候。
  2. 使用java.lang.reflect进行反射调用的时候。
  3. 当初始化某个类,发现其父类没有初始化的时候。
  4. 当虚拟机启动的时候,会触发其主方法所在的类进行初始化。
  5. 当使用JDK1.7中的动态语言支持时,如果一个java.lang.invoke.MethidHandle实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个句柄对应的类没有被初始化。
  6. 当一个接口实现了JDK1.8中的默认方法的时候,如果这个接口的实现类被初始化,则该接口要在其之前进行实例化。

对于以上6中触发类的初始化条件,在JVM规范中有一个很强制的词,if and only if (有且只有)。这六种行为被称为对类进行主动引用,除此之外,其他引用类的方式均不会触发类的初始化。

二。类加载的过程

类加载的过程

image-20210730191532573

类加载的过程主要分为三个阶段 加载,链接,初始化。 而链接阶段又可以细分为验证,准备,解析三个子阶段。

接下来,我们详细分析下类加载的过程。

加载过程

加载过程需要完成以下三个事情:

  • 通过一个类的全限定名获取定义此类的二进制字节流

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

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

《Java虚拟机规范 》对这三点的要求并不是特别的具体。因此,留给虚拟机实现于Java的应用的灵活度都是很大的。

在第一步通过一个类的全限定名获取字节流的时候,并没有规范一定是从字节码文件获取,更没有规定是从本地文件中获取。因此,虚拟机的实现者就可以在加载阶段就构建出一个相当开放的舞台。

  • 从ZIP压缩文件中读取,最终成为日后JAR包,WAR包的基础

  • 从网络中获取,这种情况最典型的就是Web Applet。

  • 运行时生成,从而为后来的动态代理技术奠定了理论基础。

  • 从其他文件中生成,典型的应用就是Web中的JSP技术。由JSP文件编译生成字节码文件。

  • 从数据库获取,例如中间件服务器,可以选择把程序安装到数据库中完成程序代码在集群中的分发。

    ……

加载结束之后,外部的二进制字节流就会以JVM所设定的格式存在于方法区中了。之后会在堆中实例一个java.lang.class类型的对象,这个对象作为程序访问方法区中的类型数据的入口。

链接过程

加载示例

  1. 验证(Verify)

    1. 目的:

    在于确保Class文件的字节流中包含信息符合当前JVM规范要求,保证被加载类的正确性,不会危害虚拟机自身安全。

    2. 主要包括四种验证
    • 文件格式验证

      • 字节码是否以十六进制的CAFEBABE开头
      • 主,次版本号是否在当前虚拟机可接受的范围之内。
      • 常量池的常量中是否有不被支持的类型
      • Class文件中是否有被添加的其他恶意信息。

      文件格式验证不止以上,上面所列举的只是从HotSpot虚拟机源码中摘抄的一部分。只有通过这个阶段的验证之后,这一段字节流才会进入虚拟机内存中进行存储,之后的过程都是基于方法区中的存储结构进行的。不会直接读取字节流了。

    • 源数据验证

      用于保证字节码中的代码符合《Java语言规范》

      • 此类的父类是否是不可继承的类(Final修饰的)
      • 如果此类不是抽象类,它是否实现了全部需要实现的方法。
      • 类中的字段,方法是否和父类冲突。
      • ……
    • 字节码验证

      此过程保证代码是符合逻辑的,对代码的流程进行判断,保证不会出现危害虚拟机安全的情况。

      • 保证任意时刻操作数栈中的类型和指令代码序列可以正常工作,比如执行到iadd字节码指令,但是操作数栈顶有一位是Long类型的。
      • 保证代码中的类型转换是有效的。

      如果一个类型中的方法体没有通过次阶段,那它一定是有问题的。但是,不可以认为只要通过此阶段验证,一定没有问题。通过程序去校验程序的逻辑是无法做到绝对准确的。

    • 符号引用验证

      此阶段验证符号引用是否合法,主要用于解析阶段的前置任务。

      主要用于判断 该类中是否存在缺少后者被禁止访问它依赖的某些外部类,字段,方法等资源。

  2. 准备(Prepare)

    • 为类变量(static)分配内存并且设置初始值。

    • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;

    • 不会为实例变量分配初始化,类变量会分配在方法去中,而实例变量是会随着对象一起分配到java堆中。

  3. 解析(Resolve)

    • 将常量池内的符号引用转换为直接引用的过程。

    • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

    • 符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_infoCONSTANT_Methodref_info等。

初始化过程

  • 初始化阶段就是执行类构造器方法clInit()的过程。 clInit是ClassInit缩写。此方法并不是程序员定义的构造方法。

  • 是javac编译器自动收集类中的所有类变量(Static)的赋值动作和静态代码块中的语句合并而来。

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • 若该类具有父类,jvm会保证子类的clinit()执行前,父类的clinit()已经执行完毕

    比如如下代码

    /**
     * @作者: 写Bug的小杜 【[email protected]】
     * @时间: 2021/07/30
     * @描述:
     */
    class A{
        public static int a = 10;
        static {
            a = 20;
        }
    }
    class B extends A{
        public static int b = a;
    }
    public class CInitTestMain {
    
        public static void main(String[] args) {
            System.out.println(B.b);
        }
    }
    

    通过执行,发现B类中b的值为20 由于是父类的CInit方法先执行,也就是说父类的静态代码块中的内容优于子类的赋值操作先执行。

  • 虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁。

    验证

    /**
     * @作者: 写Bug的小杜 【[email protected]】
     * @时间: 2021/07/30
     * @描述: 测试一个类的CInit方法是否被加锁
     */
    class TestClass {
        static{
            // 如果不加这个判断 编译器会报死循环的错误
            if(true){
                System.out.println(Thread.currentThread().getName() + "线程正在执行CInit方法");
                while (true){
                }
            }
        }
    }
    public class DeadLoopClass{
        public static void main(String[] args) {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "启动");
                    TestClass testClass = new TestClass(); //触发加载TestClass类
                    System.out.println(Thread.currentThread().getName() + "结束");
                }
            };
            new Thread(runnable).start();
            new Thread(runnable).start();
        }
    }
    

    执行结果如下: 当一条线程死循环在CInit处,别的线程也会阻塞。

    image-20210730195642762

三。类加载器的分类

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有三个,如下所示:

image-20210730200638388

  • 启动类加载器

    • 负责加载JAVA_HOME/lib目录下的可以被虚拟机识别(通过文件名称,比如rt.jar``tools.jar)的字节码文件。
    • 与之对应的是java.lang.ClassLoader
  • 扩展类加载器

    • 负责加载JAVA_HOME/lib/ext目录下的的字节码文件。
    • 对应sun.misc.Launcher类 此类继承于启动类加载器ClassLoader
  • 应用程序类加载器

    • 负责加载ClassPath路径下的字节码 也就是用户自己写的类。
    • 对应于sun.misc.Launcher.AppClassLoader类 此类继承于扩展类加载器Launcher
  • 用户自定义加载器

    • 需要继承系统类加载器ClassLoader,并重写findClass方法。

    • 负责加载指定位置的字节码文件。通过类中的path变量指定。

    • 如下为用户重写的自定义加载器

      package cn.shaoxiongdu;
      
      import java.io.ByteArrayOutputStream;
      import java.io.File;
      import java.io.FileInputStream;
      import java.io.FileNotFoundException;
      import java.io.IOException;
      import java.io.InputStream;
      
      /**
       * @作者: 写Bug的小杜 【[email protected]】
       * @时间: 2021/07/30
       * @描述: 用户自定义类加载器
       */
      public class MyClassLoader extends ClassLoader {
          
          private String path="/home/lib/";    //默认加载路径
          
          private String name;                    //类加载器名称
          
          private final String  filetype=".class"; //文件类型
          
          
          public MyClassLoader(String name) {
              // TODO Auto-generated constructor stub
              super();
              this.name=name;
          }
          
          public MyClassLoader(ClassLoader parent,String name){
              super(parent);
              this.name=name;
          }
          
          @Override
          public Class<?> findClass(String name) throws ClassNotFoundException {
              // TODO Auto-generated method stub
              byte[] b=loadClassData(name);
              return defineClass(name, b, 0, b.length);
          }
          
          private byte[] loadClassData(String name) {
              byte[] data=null;
              InputStream in=null;
              name=name.replace('.', '/');
              ByteArrayOutputStream out=new ByteArrayOutputStream();
              try {
                  in=new FileInputStream(new File(path+name+filetype));
                  int len=0;
                  while(-1!=(len=in.read())){
                      out.write(len);
                  }
                  data=out.toByteArray();
              } catch (FileNotFoundException e) {
                  // TODO Auto-generated catch block
                  e.printStackTrace();
              } catch (IOException e) {
                  // TODO Auto-generated catch block
                  e.printStackTrace();
              }finally{
                  try {
                      in.close();
                      out.close();
                  } catch (IOException e) {
                      // TODO Auto-generated catch block
                      e.printStackTrace();
                  }
              }
              return data;
          }
          
          public String getPath() {
              return path;
          }
      
          public void setPath(String path) {
              this.path = path;
          }
          
          @Override
          public String toString() {
              // TODO Auto-generated method stub
              return this.name;
          }
      
      }
      

四。 双亲委派机制

介绍

​ Java虚拟机对class文件采用的是按需加载的方式,

​ 也就是说当需要使用该类时才会将它的class文件加载到内存生成的class对象。

​ 而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式。

​ 即把请求交由父类处理,它是一种任务委派模式

工作原理

image-20210617212921462

  1. 如果一个类加载器收到了类加载的请求,它并不会自己加载,而是先把请求委托给父类的加载器执行
  2. 如果父类加载器还有父类,则进一步向上委托,依次递归,请求到达最顶层的引导类加载器。
  3. 如果顶层类的加载器加载成功,则成功返回。如果失败,则子加载器会尝试加载。直到加载成功。

代码验证

通过查看最顶层父类ClassLoader的loaderClass方法,我们可以验证双亲委派机制。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查此类是否被加载过了 
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 调用父类的加载器方法
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        // 此时是最顶级的启动类加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 抛出异常说明父类无法加载
                }

                if (c == null) {
                    //父类无法加载的时候,由子类进行加载。
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    //记录加载时间已经加载耗时
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委派机制优势

  • 避免类的重复加载

    当自己程序中定义了一个和Java.lang包同名的类,此时,由于使用的是双亲委派机制,会由启动类加载器去加载JAVA_HOME/lib中的类,而不是加载用户自定义的类。此时,程序可以正常编译,但是自己定义的类无法被加载运行。

  • 保护程序安全,防止核心API被随意篡改

五。 沙箱安全机制

1、定义:

自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,
而引导类加载器在加载过程中会先加载jdk自带的文件(rt.jar包中的java\lang\String.class),
报错信息说没有main方法就是因为加载的是rt.jar包中的String类。
这样可以保证对java核心源代码的保护,这就是沙箱安全机制.

2、类比举例

我们在读写U盘信息时可以用360沙箱,防止U盘内的病毒等对沙箱外的系统构成污染

六。 补充

1、在jvm中表示两个class对象是否为同一个类存在的两个必要条件

  • 类的完整类名必须一致,包括包名

  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

    换句话说,在jvm中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的.

2、对类加载器的引用

JVM必须知道一个类型是有启动类加载器加载的还是由用户类加载器加载的。如果一个类型由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的会议部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证两个类型的加载器是相同的。

文章已同步至GitHub开源项目: JVM底层解析

最后更新于:
2021.08.04