JVM系列之:关于JVM类加载的那些事

在上一篇文章中我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。比如 Java 的基本类型,它们是由 Java 虚拟机预先定义好的。

Java 引用类型主要分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。Java 使用编译器将源码文件编译得到 class 文件时,会严格按照 Java 虚拟机规范进行校验,关于这部分内容在《Java虚拟机规范》一书中的第四章节做了详细介绍,这里就不介绍了。

无论是直接生成的数组类,还是非数组的类或接口,都要经过一系列步骤后才能被 JVM 直接使用,其中包括如下步骤:对数据进行校验、转换解析、初始化等等,这个说来简单但实际复杂的过程叫做 JVM 的类加载机制

Class 文件中的“类”从加载到 JVM 内存中,到卸载出内存过程有七个生命周期阶段。类加载机制包括了前五个阶段。

如下图所示:

其中,加载、验证、准备、初始化、卸载的开始顺序是确定的,注意,只是按顺序开始,进行与结束的顺序并不一定。解析阶段可能在初始化之后开始。

另外,类加载无需等到程序中“首次使用”的时候才开始,JVM预先加载某些类也是被允许的。(类加载的时机)

加载

我们平常说的加载大多不是指的类加载机制,只是类加载机制中的第一步加载。加载是指查找字节流,并且据此创建类的过程。在这个阶段,JVM 主要完成三件事:

1、通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过 jar包、war 包、网络中获取、JSP 文件生成等方式。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息,常量,静态变量,编译后的代码的运行时内存区域)

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。

对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。

Java 虚拟机支持两种类加载器:Java 虚拟机提供的引导类加载器和用户自定义的类加载器。每个用户自定义的类加载器应该是抽象类 ClassLoader 的某个子类的实例。

关于 JVM 提供的类加载器,介绍如下:

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader,其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:

  • BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
  • ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  • SystemClassLoader(应用程序类加载器) :面向我们用户的加载器,它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

这里涉及到一个新的概念——双亲委派模型

双亲委派模型

概念介绍

如上图所示的类加载器之间的这种层次关系,被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

每个类加载都有一个父类加载器,我们通过下面的程序来验证。

public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
        System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
        System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
    }
}
复制代码

执行结果为:

ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@60e53b93
The GrandParent of ClassLodarDemo's ClassLoader is null
复制代码

AppClassLoader的父类加载器为ExtClassLoader ExtClassLoader的父类加载器为null,null并不代表ExtClassLoader没有父类加载器,而是 BootstrapClassLoader

双亲委派模型有什么好处?

比如位于 rt.jar 包中的类 java.lang.Object,无论哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,确保了 Object 类在各种加载器环境中都是同一个类。

连接

类的加载过程后生成了类的 java.lang.Class 对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段。

1、验证:验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。验证内容:文件格式验证(魔数、版本、常量池....)、元数据验证(字节码的语义验证)、字节码验证(类的方法)、符号引用验证

2、准备:为类的静态字段在方法区分配内存,并赋默认初值(0值或null值)。

如 static int a = 100; 类变量 a 就会在准备阶段被赋默认值 0。

对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。

另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如 static final int a = 666; 静态常量a就会在准备阶段被直接赋值为666,对于静态变量,这个操作是在初始化阶段进行的。

除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。

3、解析:将类的二进制数据中的符号引用换为直接引用。

符号引用和直接引用有什么不同?

  • 符号引用使用一组符号来描述所引用的目标,可以是任何形式的字面常量,定义在Class文件格式中。
  • 直接引用可以是直接指向目标的指针、相对偏移量或则能间接定位到目标的句柄。

举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

这个符号引用可能指向一个未被加载的类,那么解析将会触发这个类的加载。

初始化

初始化阶段是执行类构造器方法的过程,主要为静态变量赋程序设定的初值。

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

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

是 instance 实例构造器,对非静态变量解析初始化,而 是 class 类构造器对静态变量,静态代码块进行初始化。

Java 虚拟机中严格规定了有且只有5种情况必须对类进行初始化。

  • 执行 new、getstatic(读取静态字段)、putstatic(设置静态字段值) 和 invokestatic 指令(调用一个静态方法);

  • 使用 reflect 对类进行反射调用;

  • 初始化一个类的时候,父类还没有初始化,会事先初始化父类;

  • 启动虚拟机时,需要初始化包含 main 方法的类;

  • 在 JDK1.7 中,如果 java.lang.invoke.MethodHandler 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行初始化;

以下几种情况,不会触发类初始化

1、通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

class Parent {
    static int a = 100;
    static {
        System.out.println("parent init!");
    }
}

class Child extends Parent {
    static {
        System.out.println("child init!");
    }
}

public class Init{  
    public static void main(String[] args){  
        System.out.println(Child.a);  
    }  
} 
//输出结果
parent init!
100
复制代码

2、定义对象数组,不会触发该类的初始化。

public class Init{  
    public static void main(String[] args){  
        Parent[] parents = new Parent[10];
    }  
}
//无输出内容
复制代码

3、常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。

class Const {
    static final int A = 100;
    static {
        System.out.println("Const init");
    }
}

public class Init{  
    public static void main(String[] args){  
        System.out.println(Const.A);  
    }  
} 
//输出
100
复制代码

4、通过类名获取 Class 对象,不会触发类的初始化。

public class test {
   public static void main(String[] args) throws ClassNotFoundException {
        Class c_dog = Dog.class;
        Class clazz = Class.forName("zzzzzz.Cat");
    }
}

class Cat {
    private String name;
    private int age;
    static {
        System.out.println("Cat is load");
    }
}

class Dog {
    private String name;
    private int age;
    static {
        System.out.println("Dog is load");
    }
}
//输出
Cat is load
复制代码

5、通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。

Class clazz = Class.forName("com.msdn.domain.Cat", false, Cat.class.getClassLoader());
复制代码

6、通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

new ClassLoader(){}.loadClass("com.msdn.domain.Cat");
复制代码

扩展

我们之前都接触过单例模式,接下来看一个延迟单例初始化的案例:

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 的初始化(对应执行invokestatic指令),继而新建一个 Singleton 的实例。

根据上文我们得知静态内部类实现单例模式的背后原理:懒加载模式内部类通过JVM的特性(类初始化是线程安全的)来实现。类是先加载到JVM中,但是只有在具体使用的时候会进行初始化,而这个初始化是JVM自身实现并且是线程安全的因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。

这里引申出另一个案例:

public class Singleton {

  private Singleton() {
  }

  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
    static {
      System.out.println("LazyHolder.<clinit>");
    }
  }

  public static Object getInstance(boolean flag) {
    if (flag) {
      return new LazyHolder[2];
    }
    return LazyHolder.INSTANCE;
  }

  public static void main(String[] args) {
    getInstance(true);
    System.out.println("----");
    getInstance(false);
  }
}
复制代码

执行结果为:

----
LazyHolder.<clinit>
复制代码

先说结论:如果 flag 为 true,则会新建数组,执行该语句只会加载 LazyHolder,但是并不会初始化该类。在getInstance(false)时才真正连接和初始化 LazyHolder

可以通过下述命令查看区别:

$ javac Singleton.java
$ java -verbose:class Singleton
复制代码

参考文献

JVM类加载过程

JVM类加载的那些事

《Java虚拟机规范》

猜你喜欢

转载自juejin.im/post/7066261471381946382
今日推荐