类加载机制的原理

了解面试者对JVM的理解,属于面试八股文系列。

在回答这个问题之前,我们需要先了解一下什么是加载机制?

类加载机制简述

什么是加载机制?

简单来说:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

经过加载这个过程后,我们才能在程序中构建这个类的实例对象,并完成对象的方法调用和操作。

基本的工作原理下图所示。

我们编写的.java后缀的原始代码,通过JVM编译之后得到.class文件。

类加载机制,就是把.class文件加载到JVM中,我们知道JVM的运行时数据区又分为堆内存、虚拟机栈、元空间、本地方法栈、程序计数器等空间,当类被加载后,会根据JVM内存规则,把数据保存到对应区域内。

了解类加载器

大家想想,在实际开发中,运行一个程序,有哪些地方的需要被加载?

  • 从本地系统直接加载,如JRE、CLASSPATH。
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件(服务器)

由于加载器是负责这些和系统运行有关的所有类的加载行为,而针对不同位置的类,JVM提供了三种类加载器:

  1. 启动类加载器,BootStrapClassLoader,最顶层的加载类,主要加载核心类库,也就是我们环境变量下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等,还可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。
  2. 扩展类加载器,ExtClassLoader,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录
  3. 应用类加载器,AppClassLoader,也称为SystemAppClass。 加载当前应用的classpath的所有类和jar包

从上述三个加载器的描述来看,不同的加载器代表了不同的加载职能。当我们自己定义的一个类,要被加载到内存中时,类加载器的工作原理如下图所示。

从Java2开始,加载过程采取了双亲委派模型(Parents Delegation Model【PDM】),PDM 更好的保证了 Java 平台的安全性。在该机制中,JVM 自带的 BootStrapClassLoader 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。

PDM 只是 Java 推荐的机制,并不是强制的。可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持 PDM,就重写 findClass(name);如果想破坏 PDM,就重写 loadClass(name)。JDBC使用线程上下文加载器打破了 PDM,原因是 JDBC 只提供了接口,并没有提供实现。

类加载器的演示

通过下面这段代码演示一下所使用的加载器。

public class ClassLoaderExample {
    public static void main(String[] args) {
        ClassLoader loader=ClassLoaderExample.class.getClassLoader();
        System.out.println(loader);  //case1
        System.out.println(loader.getParent()); //case2
        System.out.println(loader.getParent().getParent()); //case3
    }
}
  • Case1 所示的代码,表示ClassLoaderExample这个类是被那个类加载器加载的。
  • Case2 所示的代码,表示ClassLoaderExample的父加载器
  • Case2 所示的代码,表示ClassLoaderExample的祖父加载器

运行结果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@29453f44
null

证明了,ClassLoaderExample是被AppClassLoader加载。

最后一个应该是Bootstrap类加载器,但是这里输出为null,原因是BootStrapClassLoader是一个使用 C/C++ 编写的类加载器,它已经嵌入到了 JVM 的内核之中。当 JVM 启动时,BootStrapClassLoader 也会随之启动并加载核心类库。当核心类库加载完成后,BootStrapClassLoader 会创建 ExtClassLoader 和 AppClassLoader 的实例,两个 Java 实现的类加载器将会加载自己负责路径下的类库,这个过程可以在sun.misc.Launcher中看到。

为什么要设计PDM

Java中为什么要采用PDM方式来实现加载呢?有几个目的

  1. 防止内存中出现多份同样的字节码。如果没有 PDM 而是由各个类加载器自行加载的话,用户编写了一个java.lang.Object的同名类并放在ClassPath中,多个类加载器都能加载这个类到内存中,系统中将会出现多个不同的Object类,那么类之间的比较结果及类的唯一性将无法保证,同时,也会给虚拟机的安全带来隐患。
  2. 双亲委派机制能够保证多加载器加载某个类时,最终都是由一个加载器加载,确保最终加载结果相同。
  3. 这样可以保证系统库优先加载,即便是自己重写,也总是使用Java系统提供的System,自己写的System类根本没有机会得到加载,从而保证安全性。

类的加载原理

一个类在加载过程中,到底做了什么?它的实现原理是什么呢?

从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们的顺序如下图所示:

其中加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

每个阶段的所执行的工作,如下图所示。

下面详细分析一下加载器在每个阶段的详细工作流程。

加载

”加载“ 是 ”加机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:

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

验证

验证的主要作用就是确保被加载的的正确性。也是连接阶段的第一步。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:

  1. 文件格式的验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息、在这里可以不用理解)。
  2. 元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。
  3. 字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出威海虚拟机安全的事。
  4. 符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

对整个加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

准备

准备阶段主要为变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词:

  1. 类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到Java堆中,
  2. 这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。比如public static int value = 1;,在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。

在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。

解析

解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。什么是符号应用和直接引用呢?

符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是能无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。解析动作主要针对或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

初始化

一个在以下情况下,会被初始化。

  1. 创建类的实例,也就是new一个对象
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(Class.forName("com.gupao.Example"))
  5. 初始化一个类的子类(会首先初始化子类的父类)
  6. JVM启动时标明的启动类,即文件名和类名相同的那个类

的初始化步骤:

  • 如果这个类还没有被加载和链接,那先进行加载和链接
  • 假如这个类存在直接父类,并且这个类还没有被初始化(注意:在一个类加载器中,类只能初始化一次),那就初始化直接的父类(不适用于接口)
  • 加入类中存在初始化语句(如static变量和static块),那就依次执行这些初始化语句。

类加载的扩展知识点

加载机制中,还有很多可以扩展的知识,我们通过三个扩展变体来进行巩固分析

  1. 为什么静态方法不能调用非静态方法和变量
  2. 静态类和费静态类程序的初始化顺序

为什么静态方法不能调用非静态方法和变量

我想大家应该都知道,在静态方法中时无法直接调用非静态方法和变量的,为什么呢?

理解了类的加载原理之后,不难发现,静态方法的内存分配时间与实例方法不同。

  1. 静态方法属于类,在类加载的时候就会分配内存,有了入口地址,可以通过“类名.方法名”直接调用。
  2. 非静态成员(变量和方法)属于类的对象,所以只有该对象初始化之后才会分配内存,然后通过类的对象去访问。

意味着,也就是说在静态方法中调用非静态成员变量,该变量可能还未初始化。因此编译器会报错。

另外,除此之外,还有其他的变体。比如静态块.

public class ClassLoaderExample {
  
    static {
        //dosomething()
    }
}

静态块是在什么时候执行呢?

中的静态块会在整个类加载过程中的初始化 阶段执行,而不是在类加载过程中的加载阶段执行。

初始化 阶段是加载过程中的最后一个阶段,该阶段就是执行类构造器方法的过程,方法由编译器自动收集类中所有类变量(静态变量)的赋值动作和静态语句块中的语句合并生成,一个类一旦进入初始化阶段,必然会执行静态语句块。所以说,静态块一定会在类加载过程中被执行,但不会在加载阶段被执行。

clinit是类构造器方法,也就是在jvm进行类加载—–验证—-解析—–初始化 ,中的初始化阶段jvm会调用clinit方法。

clinit是class类构造器对静态变量,静态代码块进行初始化

class Example {
   static Log log = LogFactory.getLog(); // <clinit>
   private int x = 1;   // <init>
   Example(){
      // <init>
   }
   static {
      // <clinit>
   }
}

Java程序的初始化顺序

有以下代码,请说出它们的加载顺序.

class Base {
    public Base() {
        System.out.println("父类构造方法");
    }
  
    String b = "父类非静态变量";
  
    {
        System.out.println(b);
        System.out.println("父类非静态代码块");
    }
    static String a = "父类静态变量";
    static {
        System.out.println(a);
        System.out.println("父类静态代码块");
    }
    public static void A() {
        System.out.println("父类普通静态方法");
    }
}
class Derived extends Base {
    public Derived() {
        System.out.println("子类构造器");
    }
    String b = "子类非静态变量";
    {
        System.out.println(b);
        System.out.println("子类非静态代码块");
    }
    static String a = "子类静态变量";
    static {
        System.out.println(a);
        System.out.println("子类静态块");
    }
    public static void A() {
        System.out.println("子类普通静态方法");
    }
    public static void main(String[] args) {
        Base.A();
        Derived.A();
        new Derived();
    }
}

这个问题,需要理解的加载顺序,初始化规则如下。

  • 父类静态变量
  • 父类静态代码块
  • 子类静态变量
  • 子类静态代码块
  • 父类非静态变量
  • 父类非静态代码块
  • 父类构造函数
  • 子类非静态变量
  • 子类非静态代码块
  • 子类构造函数

总的来说,父类需要优先加载,然后在是子类,接着是父类的静态方法加载优先,其次是子类。

自定义类加载器

除了系统自带的三种加载器以外,我们还可以定义自己的类加载器。

需要继承java.lang.ClassLoader这个类来实现自定义类加载器,并且重写findClass方法或者loadClass方法。

1、如果不想打破双亲委派模型,那么只需要重写findClass方法。

protected Class<?> findClass(String name) throws ClassNotFoundException {
  throw new ClassNotFoundException(name);
}

这个方法并没有实现,它直接返回ClassNotFoundException。因此,自定义加载器必须重写findClass方法。

2、如果想打破双亲委派模型,那么就重写loadClass方法。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        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) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

ClassLoader中的loadClass方法,大致流程如下:

  1. 检查类是否已加载,如果是则不用再重新加载了;
  2. 如果未加载,则通过父类加载(依次递归)或者启动类加载器(bootstrap)加载;
  3. 如果还未找到,则调用本加载器的findClass方法;

不破坏双亲委派自定义类加载器实战

实现自定义加载器的实现,主要分三个步骤

  • 创建一个类继承ClassLoader抽象类
  • 重写findClass()方法
  • 在findClass()方法中调用defineClass()

/tmp目录下创建一个PrintClass.java类,代码如下。

public class PrintClass {
  public PrintClass(){
     System.out.println("PrintClass:"+getClass().getClassLoader());
     System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
  }
  public String print(){
    System.out.println("PrintClass method for print");
    return "PrintClass.print()";
  }
}

使用javac PrintClass对源文件进行编译,得到PrintClass.class文件

接在,下Java项目中创建一个自定义加载器,代码如下。

public class MyClassLoader extends ClassLoader {
    private String classPath;
    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] bytes = getClassBytes(name);
            Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
            return c;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return super.findClass(name);
    }
    private byte[] getClassBytes(String name) throws Exception {
        name = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
        int len = fis.available();
        byte[] data = new byte[len];
        fis.read(data);
        fis.close();
        return data;
    }
}

MyClassLoader继承了ClassLoader并且重写了findClass方法。该方法中是从指定路径下加载.class文件。

编写测试代码.

public class ClassLoaderMain {
    public static void main(String[] args) throws Exception {
        MyClassLoader mc=new MyClassLoader("/tmp");
        Class clazz=mc.loadClass("PrintClass");
        Object o=clazz.newInstance();
        Method print=clazz.getDeclaredMethod("print",null);
        print.invoke(o,null);
    }
}

运行结果如下:

PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print

可以看到,PrintClass.class这个类,它的类加载器是MyClassLoader

破坏双亲委派自定义类加载器实战

原本ClassLoader类中的loadClass方法,是基于双亲委派机制来实现。破坏双亲委派,只需要重写loadClass方法即可。

在MyClassLoader类中,重写loadClass方法,代码如下。

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
  synchronized (getClassLoadingLock(name)) {
    // First, check if the class has already been loaded
    Class<?> c = findLoadedClass(name);
    if (c == null) {
      // If still not found, then invoke findClass in order
      // to find the class.
      long t1 = System.nanoTime();
      //非自定义的类还是走双亲委派加载
      if (!name.equals("PrintClass")) { 
        c = this.getParent().loadClass(name);
      } else { //自己写的类,走自己的类加载器。
        c = findClass(name);
      }
      // this is the defining class loader; record the stats
      sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
      sun.misc.PerfCounter.getFindClasses().increment();
    }
    if (resolve) {
      resolveClass(c);
    }
    return c;
  }
}

PrintClass.java复制到/tmp/cl目录下,并修改print方法。

public class PrintClass {
  public PrintClass(){
     System.out.println("PrintClass:"+getClass().getClassLoader());
     System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
  }
  public String print(){
    System.out.println("PrintClass method for print NEW");  //修改了打印语句,用来区分被加载的类
    return "PrintClass.print()";
  }
}

编写测试代码

public class ClassLoaderMain {
    public static void main(String[] args) throws Exception {
        MyClassLoader mc=new MyClassLoader("/tmp");
        Class clazz=mc.loadClass("PrintClass");
        System.out.println(clazz.getClassLoader());
        System.out.println();
        //在另外一个目录下创建相同的PrintClass.class文件
        MyClassLoader mc1=new MyClassLoader("/tmp/cl");
        Class clazz1=mc1.loadClass("PrintClass");
        System.out.println(clazz1.getClassLoader());
        System.out.println();
    }
}

上述代码中,分别加载tmptmp/cl目录下的PrintClass.class文件,打印结果如下。

PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print
PrintClass:org.example.cl.MyClassLoader@610455d6
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print NEW

结论:通过重写loadClass方法,使得自己创建的类,让第一个加载器直接加载,不委托父加载器寻找,从而实现双亲委派的破坏

Tomcat是如何实现应用jar包的隔离的?

相信不小小伙伴在面试的时候遇到过这个问题。在思考这个问题之前,我们先来想想Tomcat作为一个JSP/Servlet容器,它应该要解决什么问题?

  1. 一个Web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个Web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,必然会带来内存消耗过高的问题。
  3. Web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。

为了达到这些目的,Tomcat一定不能使用默认的加载机制。

原因:如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加载器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份

所以Tomcat实现了自己的加载器,同样也打破了双亲委派这一机制,下图表示Tomcat的类加载机制。

image-20211031140814884

我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载${TOMCAT_HOME}/lib/WebApp/WEB-INF/*中的Java类库。

其中WebApp加载器和JSP类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个JSP类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp(Web应用)访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;

从图中的委派关系中可以看出:

CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了实现JSP的HotSwap功能。

很显然,Tomcat为了实现隔离性,打破了双亲委派,每个webappClassLoader加载自己的目录下的class文件。

问题解答

面试题:类加载机制的原理

回答: 类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

的加载机制包括加载、验证、准备、解析、初始化这5个过程,其中

  • 加载:将.class文件加载到内存中
  • 验证:确保加载的类符合JVM规范
  • 准备:正式为类变量分配内存并设置初始值
  • 解析:JVM常量池的符号引用转换为直接引用
  • 初始化:执行类的构造方法。

问题总结

一个小小的面试题,涉及到背后的技术知识非常庞大。

面试的时候,遇到这问题,如果自己不具备体系化的知识,那么回答时很容易找不到切入点。特别是这种比较泛的问题,切入点太多时,回答起来会比较混乱。