虚拟机类加载机制

目录

1、概述

2、类加载的过程

1、过程总览

2、加载

3、链接-验证

4、链接-准备

5、链接-解析

6、初始化

7、总结

3、类加载的时机

4、类加载器

1、概述

2、类与类加载器

3、三层类加载器

4、双亲委派模型

5、其他加载策略


1、概述

一个Java类会被编译成一个Class字节码文件,这个文件包含这个类的所有信息,交给虚拟机去执行。

一方面,虚拟机需要正确加载Class文件。另一方面,静态的Class文件中有一些不确定信息,比如多态,需要在运行时补充。

什么是虚拟机的类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为“虚拟机的类加载机制”。

Java语言的特点

在Java中,类型的加载、链接和初始化过程是在程序运行期间完成的

这种策略使得Java语言进行提前编译会面临额外的困难,类加载时的开销也较大,但使得Java程序具有极高的扩展性和灵活性。

比如一个面向接口的程序,在运行阶段才会得知使用的具体实现类。

或者自定义一个类加载器,让本地的程序在运行时从网络上或其他地方加载一个Class文件,作为代码的一部分。

2、类加载的过程

1、过程总览

当程序主动使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载链接初始化3个步骤来对该类进行初始化

其中链接部分包括验证、准备和解析三个步骤

其中,加载、验证、准备、初始化和卸载这五个阶段,顺序是固定的,按部就班地开始。

而解析阶段则不确定,某些情况下可以在初始化阶段后才进行,这是为了支持Java语言的运行时绑定特性。

注意,每个阶段并不一定是按顺序进行或完成,只是按顺序开始。因为这些阶段通常是互相交叉地混合进行的,会在一个阶段中激活另一个阶段。

注意,类加载过程只负责class文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定。

2、加载

加载阶段,JVM需要做三件事情

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

定义类的二进制字节流可以有不同来源

这个阶段其实相当灵活,因为只规定要获取一个类的二进制字节流,而没有规定具体从哪里获取。

比如:

  • 从ZIP压缩包中读取(JAR、WAR包)
  • 从网络中获取
  • 运行时计算生成,比如动态代理技术
  • 从其他文件生成,比如由JSP文件生成Class文件
  • 从数据库中读取
  • 从加密文件中读取
  • ...

在整个类加载过程中,非数组类型的加载阶段(获取二进制流的动作)是开发人员可控性最强的。

可以使用JVM内置的引导类加载器完成,也可以自定义类加载器。

加载数组类型

数组类本身不通过类加载器创建,而是由JVM直接在内存中动态构造出来的。

但数组类的元素类型,还是要靠类加载器来完成加载的。

加载的目的

加载阶段结束之后,JVM外部的二进制字节流就按照虚拟机设定的格式,存储在方法区之中了。方法区中的数据存储格式完全由虚拟机实现自行设计。

类型数据放在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,作为程序访问方法区中的类型数据的外部接口。

加载阶段和链接阶段的部分动作,比如一些字节码文件格式验证动作,是交叉进行的。加载阶段尚未完成,可能链接阶段已经开始了。

但这些夹在加载阶段之中进行的链接阶段动作,依然属于链接阶段的一部分。这两个阶段的开始时间依然保持着固定的先后顺序。

3、链接-验证

链接阶段的目的

当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入链接阶段,链接阶段负责把类的二进制数据合并到JRE。

验证的目的

验证的目的在于确保这个Class文件符合JVM的全部规范,且运行后不会危害虚拟机的自身安全

Java是相对安全的语言,做了一些不合理的事情就无法通过编译。但Class文件不一定是Java代码编译得来的,所以必须对其进行验证。

而且验证非常严谨,验证阶段的工作量在整个类加载过程中占了相当大的比重

主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证

文件格式验证

目的:只有通过这个阶段的验证,这段字节流才被允许进入JVM内存的方法区中进行存储。

后面的三个验证阶段都是直接从方法区操作这段数据。

  • 主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。
  • 例如:主,次版本号是否在当前虚拟机处理的范围之内。
  • 常量池中是否有不被支持的常量类型
  • 指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
  • ...

元数据验证

对字节码描述的信息进行语法的分析,分析是否符合java的语言语法的规范

字节码验证

最复杂、最重要的验证环节。通过数据流分析和控制流分析,确定语义是合法的,符合逻辑的

主要是针对方法体的验证。保证类方法在运行时不会有危害出现。

为了避免字节码验证阶段消耗过多时间,JVM的设计团队尽可能多的把校验辅助措施放在了javac编译器中进行。

具体做法是给方法体Code属性的属性表中新增了一项名为“StackMapTable”的属性,描述了方法体中所有的基本快开始时本地变量表和操作站应有的状态。这样,字节码验证阶段只需要检查这些记录是否合法即可,将类型推导变成了类型检查,大大提高了效率。

符号引用验证

这个阶段的校验行为,发生在符号引用转换为直接引用的时候,这个转换动作将在链接的解析阶段发生

主要目的是,检验该类是否缺少或被禁止访问它依赖的外部类、方法、字段等资源,保证引用一定会被访问到,不会出现类等无法访问的问题

这个阶段并不是一定要进行。如果测试过没问题,就可以在生产环境使用如下参数,关闭大部分这个阶段的验证,加快虚拟机的类加载过程。

-Xverify:none

4、链接-准备

准备阶段负责为类的静态变量分配内存,并设置初始值

这里只包括静态变量,实例变量将会在对象实例化时随着对象一起被分配在Java堆中。

如果是普通的静态变量,在准备阶段会被赋它相应类型的初始值。

但如果是final的静态常量,在编译阶段该常量的值就会被确定。那么在准备阶段,虚拟机会直接将常量值赋给它,而不是赋默认值。

5、链接-解析

解析阶段,是虚拟机常量池内的符号引用(变量名)替换成直接引用(地址)的过程

如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)。

并未规定解析阶段发生的具体时机,可以根据虚拟机实现,选择是在类加载器加载时就解析常量池的符号引用,还是等到将要使用一个符号引用时再去解析它。

解析动作主要针对类或接口、字段、类方法、接口方法等。在解析完成之前,还需要进行访问权限验证。

6、初始化

直到初始化阶段,JVM才开始真正执行类中的Java代码,将主导权交给程序。

初始化阶段就是执行类构造器<clinit>()方法的过程。这个<clinit>()方法并不存在于Java代码中,它是javac编译器自动生成的。

<clinit>()

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的,顺序就是代码书写顺序。

    (定义在静态代码块中的内容,可以给后面书写的静态变量赋值,但不能引用它)

  • <clinit>()方法与类的构造函数(即虚拟机中的<init>()方法)不同,它不需要显示调用父类构造器

    • 因为JVM会保证子类的<clinit>()方法执行前,父类的<clinit>()已经执行完毕。
    • 正因如此,JVM中第一个执行的<clinit>()方法肯定是Object类的。
  • 由于父类的<clinit>()先执行,所以父类的静态代码块会优先于子类的变量赋值操作。

  • <clinit>()不是必须的。

    • 如果一个类中没有静态代码块,也没有对变量的赋值操作,编译器可以不为这个类生成<clinit>()方法
  • 接口中不能定义静态代码块,但可以有变量初始化的赋值操作,所以接口也会被生成<clinit>()方法。

    • 不同于类,执行接口的<clinit>()不需要先执行父接口的<clinit>(),只有当父接口中定义的变量被使用,父接口才会被初始化。
    • 同样,接口的实现类初始化时,也不会先执行接口的<clinit>()方法
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步,即类的构造方法是线程安全的。

示例

准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:

private static int a = 10

它的执行过程是这样的:

  • 首先字节码文件被加载到内存后,先进行链接-验证这一步骤,验证通过
  • 准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0
  • 然后到解析
  • 到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。

程序中定义了一个A类,有个静态整形变量m,有静态代码块,无参构造器

目的:验证类构造器<clinit>是否会将所有类变量的赋值动作和静态代码块中的语句合并

public class Demo{
    public static void main(String[] args){
        A a = new A();
        System.out.println(a.m);
    }
}

class A{
    static{
        System.out.println("A类静态代码块初始化");
        m = 300;
    }

    static int m = 100;

    public A() {
        System.out.println("A类的无参构造初始化");
    }
}

输出

A类静态代码块初始化 A类的无参构造初始化 100

这个程序的内存分析:

m的赋值历程:

  • 在链接的准备阶段,static的m被设置初始值 0
  • 随后初始化,类构造器将所有类变量的赋值动作和静态代码块中的语句合并
    • m = 300
    • m = 100
  • 最终m的值是100

7、总结

JVM的类加载分为三个阶段:分别是加载、链接、初始化。其中链接阶段有三个操作:验证、准备、解析

  • 加载:把Class文件读进内存,创建出它的Class对象

  • 验证:

    • 目的是确保Class文件符合当前虚拟机的规范
    • 首先进行文件格式验证,通过之后会把类型数据加载进方法区
    • 之后进行元数据验证(语法是否正确)、字节码验证(语义是否正确)、符号引用验证(它访问的类、方法、属性是否存在)。
  • 准备:在方法区中为类的静态变量分配内存空间,并设置类中静态变量的初始值。如果是final修饰的,会直接赋值。

  • 解析:将常量池中的符号引用替换为直接引用。

  • 初始化:执行类的构造方法

3、类加载的时机

什么时候进行类加载过程的第一个阶段“加载”,没有明确规定,由虚拟机自由实现。

主动引用会触发类加载初始化

但对于“初始化”阶段,做了严格规定,有且只有这六种情况必须对类进行初始化:

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令,如果类型没有进行过初始化,则要先进行初始化。对应的java代码:
    • 使用new关键字实例化对象
    • 读取或设置一个类的静态字段(final的除外)
    • 调用一个类的静态方法
  • 使用java.lang.reflect包的方法,对类进行反射调用。如果类型没有进行过初始化,则要先进行初始化。
  • 当虚拟机启动,先初始化main方法所在的类
  • 当初始化一个类,如果其父类没有被初始化,则会先初始化其父类
    • (一个接口初始化时,并不要求其父接口全部完成了初始化,只有真正使用到父接口时,比如引用它定义的常量,才会触发其初始化)。
  • 接口中定义了默认方法,如果有这个接口的实现类发生了初始化,则接口必须在其之前被初始化

这几种行为,都属于对一个类的主动引用行为

触发一个类的加载过程,未必也触发它的链接和初始化过程。

特殊情况:

  • 对于一个final类型的静态变量:
    • 如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。

    • 如果final类型的静态变量的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。

被动引用不会触发初始化

  • 当访问一个静态域时,只有真正声明这个域的类才会被初始化。

    • 如:当遇到子类引用父类的静态变量,不会导致子类初始化,只会让父类初始化
  • 通过数组定义类引用,不会触发此类的初始化

    • 如:Son[] array = new Son[5];
    • 虚拟机会自动生成一个Object的子类,包装了数组元素的访问,当检测到越界就可以抛出异常,避免直接造成非法内存访问。
  • 引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)

4、类加载器

1、概述

JVM的设计团队有意把类加载阶段的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作,放在了虚拟机外部去实现。

实现这个动作的代码叫做“类加载器”,可以由开发人员决定如何获取所需的类。

2、类与类加载器

类加载器只用于类加载过程的“加载”阶段,但作用很大。

对于任意一个类,必须由它的类加载器和这个类本身一起共同确定其在JVM中的唯一性。每个类加载器都有一个独立的类命名空间。

(即使两个类来源于同一个Class文件,在同一个JVM中,如果加载它们的类加载器不同,这两个类就不相等)。

这里的“相等”,包括equals()、isInstance()、isAssignableFrom()方法的返回结果。

比如自定义了一个类加载器,加载了一个类(com.zcy.User)并实例化了一个对象,比如叫user。

user.getClass(); //class com.zcy.User
user instanceof com.zcy.User //false

可以看到,它肯定是com.zcy.User类型,但与这个类做类型比较,会返回false,这是因为JVM中同时存在两个User类:

  • 一个是通过虚拟机的应用程序类加载器加载的
  • 一个是通过自定义类加载器加载的
  • 它们都来源于一个Class文件,但在虚拟机中,由于类加载器不同,所以在类的层面,是两个不同的类型。

3、三层类加载器

从JVM的角度来看,它自身是C/C++实现的,对它来说只有两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),使用C/C++实现,是虚拟机的一部分
  • 其他的所有类加载器,都是使用Java实现的,存在于虚拟机之外,都继承自抽象类java.lang.ClassLoader

(JDK 9之后,HotSpot有一个无法获取实例的,代表Bootstrap ClassLoader的Java类出现)。

但开发人员的角度,类加载器应当划分地细致一点。注意,下列三个类加载器是有层级关系的

启动(Bootstrap)类加载器

  • 也被称为根类加载器,它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader
  • 负责加载 $JAVA_HOME中jre/lib 里所有的class,按文件名识别,不符合的类库即使放在lib下也不会加载
  • 由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。如果需要在自定义类加载器中,将请求委派给启动类加载器,直接使用null代替即可。

扩展(Extension)类加载器

  • 它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。
  • 由Java语言实现,父类加载器为null。(逻辑上讲,它的父类加载器是根类加载器。但根类加载器是C++写的,所以它在Java语言中没有父类)

应用程序类加载器(Application)/ 系统(System)类加载器

  • 有两个名字,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。
  • 由于它是ClassLoader类中的getSystemClassLoader()的返回值,所以也被称为“系统类加载器”。
  • 程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。
  • 如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。
  • 如果没有自定义类加载器,则这是程序中默认的类加载器。

获取示例

public static void main(String[] args) throws ClassNotFoundException {
    //获取系统类加载器
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println(systemClassLoader);  //sun.misc.Launcher$AppClassLoader@18b4aac2

    //获取系统类加载器的父类加载器:扩展类加载器
    ClassLoader extensionClassLoader = systemClassLoader.getParent();
    System.out.println(extensionClassLoader);   //sun.misc.Launcher$ExtClassLoader@1b6d3586

    //获取扩展类加载器的父类加载器:根类加载器
    ClassLoader BootstrapClassLoader = extensionClassLoader.getParent();    //无法直接获取,会返回null
    System.out.println(BootstrapClassLoader);

    //测试当前类(用户类)是哪个加载器加载的
    //获取当前类的Class对象
    Class name = Class.forName("com.zcy.Test.Demo");
    //调用Class的getClassLoader()方法,获取这个类的构造器对象
    ClassLoader classLoader = name.getClassLoader();
    System.out.println(classLoader);    //sun.misc.Launcher$AppClassLoader@18b4aac2

    //测试JDK内置的类是哪个加载器加载的
    Class name2 = Class.forName("java.lang.Integer");
    ClassLoader classLoader1 = name2.getClassLoader();
    System.out.println(classLoader1);   //null
}

4、双亲委派模型

类加载器之间的关系(双亲委派模型):

双亲委派模型要求,除了顶层的启动类加载器之外,每个类加载器都要有自己的父类加载器。

不过父子关系不是用继承实现的,而是一种逻辑上的优先级关系,每个类加载器都有一个名义上的“father”。

1、工作过程

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
  • 如果父类加载器可以完成类加载任务,就成功返回
  • 倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

2、源码中的实现

  • 逻辑是,检查自己的父类加载器是否为null。
  • 如果不为null,就让父类加载器去尝试加载。
  • 如果为null,说明父类加载器是启动类加载器,就主动调用启动类加载器去尝试加载。

3、工作逻辑

先检查请求加载的类型是否已经加载过,如果没有,就调用父类加载器的loadClass()方法。如果父类加载器为null,使用启动类加载器加载。

如果父类加载器加载失败,就调用此加载器自身的loadClass()方法尝试加载。

4、意义

  • 采用双亲委派模式的是好处是,Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载。当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。
  • 其次是考虑到安全因素,java核心API中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

5、破坏双亲委派模型

双亲委派模型并不是一个具有强制约束力的模型,只是Java设计者推荐的最佳实践。

Java世界中的大多数类加载器都遵循这个模型,但也有例外。直到Java模块化出现为止,双亲委派模型出现过3次较大规模的“被破坏”情况。

线程上下文类加载器

这是双亲委派模型的第二次被破坏,第一次是为了兼容用户自定义的老旧类加载器。

双亲委派模型自身也有缺陷,它解决了各个类加载器协作时,基础类型的一致性问题。但如果有基础类型需要调用高层的用户代码,如何实现?

比如JNDI服务,它由启动类加载器加载,但它需要调用一些用户代码,启动类加载器无法直接加载它们。(启动类加载器没有双亲,它自己又无法加载,因为用户代码只能由应用程序类加载器加载。)

为了解决这个问题,Java团队只能引入一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。

线程上下文类加载器可以通过Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,就从父线程继承一个。如果全局范围内都没有设置,那就是用应用程序类加载器。

有了线程上下文类加载器,程序就可以做一些“舞弊”的事情,即可以通过父类加载器去请求子类加载器,违背了双亲委派模型的一般性原则

重写方式

重写一个类继承ClassLoader,并重写loadClass方法,也可以破坏双亲委派机制。

正常情况下自定义类加载器,如果不想破坏双亲委派模型,只需要重写findClass()方法。

5、其他加载策略

JVM中除了双亲委派,还有两种辅助性的加载策略:

  • 缓存机制:

    • 缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。
    • 这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
    • JVM的垃圾回收机制可以回收这些缓存中的Class对象
  • 全盘负责:

    • 当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器尝试载入,除非显式使用另外一个类加载器来载入(这个过程双亲委派也参与了)

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值