类加载机制

重学Java-Jvm

Posted by ALID on May 9, 2019

img

类加载步骤

什么情况下虚拟机需要开始加载一个类呢?虚拟机规范中并没有对此进行强制约束,这点可以交给虚拟机的具体实现来自由把握。

类加载的过程主要就可以分为 5 个阶段:载入、验证、准备、解析和初始化, 具体每一步做了什么接下来一步一步说

img

加载阶段

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

注意:JVM中的ClassLoader类加载器加载Class发生在此阶段; 并且这时会将Class加载到方法区

验证

JVM 会在该阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障,下面是一些主要的检查。

  • 确保二进制字节流格式符合预期(比如说是否以 cafe bene 开头)。
  • 是否所有方法都遵守访问控制关键字的限定。
  • 方法调用的参数个数和类型是否正确。
  • 确保变量在使用之前被正确初始化了。
  • 检查变量是否被赋予恰当类型的值。

准备

JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。 也就是说,假如有这样一段代码:

1
2
3
public String chenmo = “沉默”;
public static String wanger = “王二”;
public static final String cmower = “沉默王二”;

chenmo 不会被分配内存,而 wanger 会;但 wanger 的初始值不是“王二”而是 null。 需要注意的是,static final 修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 cmower 在准备阶段的值为“沉默王二”而不是 null。

解析

该阶段将常量池中的符号引用转化为直接引用。

符号引用以一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。 在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.Wanger 类引用了 com.Chenmo 类,编译时 Wanger 类并不知道 Chenmo 类的实际内存地址,因此只能使用符号 com.Chenmo。

直接引用通过对符号引用进行解析,找到引用的实际内存地址。

Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

初始化

该阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法的过程。

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。

如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。只有当初始化完成之后,类才正式成为可执行的状态。

那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况: - 当虚拟机启动时,初始化用户指定的主类(包含main()方法的那个类); - 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类; - 当遇到调用静态方法的指令时,初始化该静态方法所在的类; - 当遇到访问静态字段的指令时,初始化该静态字段所在的类; - 子类的初始化会触发父类的初始化; - 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化; - 使用反射 API 对某个类进行反射调用时,初始化这个类; - 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

1
2
3
4
5
6
7
8
9
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。 由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。

类加载器

首先我们要知道有哪几种类加载器

启动类加载器(bootstrap class loader) 启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。 而除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

扩展类加载器(extension class loader) 扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)

应用类加载器(application class loader) 应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

自定义类加载器 除了上面说的三种默认的类加载器,用户可以通过继承ClassLoader类来创建自定义的类加载器,之所以需要自定义类加载器是因为有时候我们需要通过一些特殊的途径创建类. 想要知道怎么定义自己的类加载器, 可以通过以下的代码体现

ClassLoader 源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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;
    }
}

首先查看是否改类已经加载, 如果已加载则直接返回

之后按照双亲委派模型先使用父类加载, 如果没有父类则使用 findBootstrapClass 方法 findBootstrapClass方法是一个native方法,这是我们的root loader,这个载入方法并非是由JAVA所写,而是C++写的,它会最终调用JVM中的原生findBootstrapClass方法来完成类的加载。

最后如果Bootstrap ClassLoader也无法加载该类则调用findClass(String)方法加载. 默认的实现是直接抛出ClassNotFoundException 这就是我们自定义类加载器需要实现的部分. 也就是说, 在调用自定义类加载器的时候, 使用Jdk的类加载器加载失败的时候会调用我们自己实现的方法来加载.

双亲委派模型

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。 除了加载功能之外,类加载器还提供了命名空间的作用。 在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

委托机制的意义 — 防止内存中出现多份同样的字节码

比如两个类A和类B都要加载System类: 如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码。 如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了。

Context ClassLoader

等等, 怎么又多了一种类加载器. 首先确定Java中ClassLoader就上面提到的四种 Context ClassLoader并不是一种新的类加载器,肯定是这四种的一种.

多线程环境下不同的对象可能是由不同的ClassLoader加载的,那么当一个由ClassLoaderC加载的对象A从一个线程被传到另一个线程ThreadB中,而ThreadB是由ClassLoaderD加载的,这时候如果A想获取除了自己的classpath以外的资源的话,它就可以通过Thread.currentThread().getContextClassLoader()来获取线程上下文的ClassLoader了,一般就是ClassLoaderD了,可以通过Thread.currentThread().setContextClassLoader来显示的设置。

那为什么需要Context ClassLoader

我们知道, 因为双亲委派机制, 每次只能找父类加载器进行加载. 这样就会出现一个问题

启动类加载器中的类为系统的核心类. 比如, 在系统类中,提供了一个接口, 并且该接口还提供了一个工厂方法用于创建该接口的实例. 但是该接口的实现类在应用层中, 接口和工厂方法在启动类加载器中, 就会出现工厂方法无法创建由应用类加载器加载的应用实例问题.

拥有这样问题的组件有很多,比如JDBCXml parser等.JDBC本身是java连接数据库的一个标准,是进行数据库连接的抽象层,由java编写的一组类和接口组成,接口的实现由各个数据库厂商来完成

所以就需要对双亲委派的类加载模式进行补充

在Java中,把核心类(rt.jar)中提供外部服务,可由应用层自行实现的接口,这种方式成为spi.那我们看一下,在启动类加载器中,访问由应用类加载器实现spi接口的原理

Thread类中有两个方法

1
2
public ClassLoader getContextClassLoader()//获取线程中的上下文加载器
public void setContextClassLoader(ClassLoader cl)//设置线程中的上下文加载器

通过这两个方法,可以把一个ClassLoader置于一个线程的实例之中,使该ClassLoader成为一个相对共享的实例. 这样即使是启动类加载器中的代码也可以通过这种方式访问应用类加载器中的类了.

也就是说Contex ClassLoader提供了一个突破这种机制的后门。