让类活起来——漫谈JVM类加载机制-阿里云开发者社区

开发者社区> 开发与运维> 正文

让类活起来——漫谈JVM类加载机制

简介: JVM类加载机制,点击查看原图 所谓类加载机制,就是虚拟机把描述类的数据从Class文件加载到内存中,并对其进行校验,转换,分析以及初始化,并最终形成虚拟机可以被使用java类型的过程。
JVM类加载机制,点击查看原图

所谓类加载机制,就是虚拟机把描述类的数据从Class文件加载到内存中,并对其进行校验,转换,分析以及初始化,并最终形成虚拟机可以被使用java类型的过程。

Java作为解释型语言,支持动态加载动态连接,类型的加载、连接以及初始化过程都在程序运行是完成,虽然这样会导致类加载的过程变慢,但是为Java语言提供了更好的灵活性,实现了动态的扩展。

1. 类加载概述

1.1 类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载验证准备解析初始化使用卸载七个阶段。

其中类加载的过程包括了装载验证准备解析初始化五个阶段。其中验证、准备、解析三个步骤又合称为连接

类加载的过程

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。

这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:

  • 静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有finalstaticprivate构造方法是前期绑定的。
  • 动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。

1.2 类文件从何而来

既然加载机制是虚拟机把描述类的数据从Class文件加载到内存中的过程,那Class文件从何而来?

类文件来源包括

  • 从本地文件系统加载的class文件
  • 从JAR包加载class文件
    从网络加载class文件
  • 把一个Java源文件动态编译,并执行加载

1.3 何时执行类的初始化

JVM规范中没有明确说明合适开始类的加载,但是指明一下情况下必须要对类经行初始化(加载、验证、准备等阶段自然要在这之前进行):

  1. 创建类实例。也就是new的方式;
  2. 调用某个类的类方法(静态方法,invokeStatic指令码);
  3. 访问某个类或接口的类变量(getStatic指令码),或为该类变量赋值(putStatic指令码);
  4. 使用反射方式强制创建某个类或接口对应的java.lang.Class对象;
  5. 初始化某个类的子类,则其父类也会被初始化;
  6. 直接使用java.exe命令来运行某个主类(含有Main函数);

2. 类加载的过程

2.1 装载

装载是查找并加载类的二进制数据(查找和导入Class文件)的过程。作为类加载过程的第一个阶段,在装载阶段,JVM需要完成以下三件事情:

  1. 通过一个类的全限定名来获取其定义的二进制字节流;

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

  3. Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。这部分内容在后面的章节介绍。

2.2 连接

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

  • 验证:检验被加载的类是否有正确的内部结构,并和其他类协调一致;
  • 准备:负责为类的类变量分配内存,并设置默认初始值;
  • 解析:将类的二进制数据中的符号引用替换成直接引用;

2.2.1 验证

验证的目的是确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范;验证通过之后,装载阶段获得字节流才会保存到方法区;

  • 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。

  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

  • 符号引用验证:它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

2.2.2 准备

准备:为类的静态变量分配内存,并将其初始化为默认值。

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。

  2. 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

假设一个类变量的定义为:public static int value = 3; 那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。

Java中所有基本数据类型以及reference类型的默认零值

2.2.3 解析

解析:把类中的符号引用转换为直接引用。

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

  • 符号引用就是一组符号来描述目标,可以是任何字面量;
  • 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

2.3 初始化

初始化,即对类的静态变量,静态代码块执行初始化操作。这是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码

初始化为类的静态变量赋予正确的初始值,在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值。
  • 使用静态代码块为类变量指定初始值。

类的初始化步骤 / JVM初始化步骤:

  1. 如果这个类还没有被加载和链接,那先进行加载和链接

  2. 假如这个类存在直接父类,并且这个类还没有被初始化(注意:在一个类加载器中,类只能初始化一次),那就初始化直接的父类(不适用于接口)

  3. 假如类中存在初始化语句(如static变量和static块),那就依次执行这些初始化语句。

另一方面,初始化阶段是执行类构造器<clinit>()方法的过程:

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的;
  • JVM会保证每个类的<clinit>()都只执行一遍,不会被反复加载;
  • JVM保证<clinit>()执行过程中的多线程安全;

3. 类加载器

类的加载器是Java语言的一种创新。

3.1 类与类加载器之间的关系

对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()isAssignableFrom()isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

3.2 类加载器的分类

站在Java虚拟机的角度来讲,只存在两种不同的类加载器:

  • 启动类加载器:它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分
  • 所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
类加载器分类

站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

  • 启动类加载器:Bootstrap ClassLoader,跟上面相同。它负责加载存放在$JAVA_HOME/jre/lib/rt.jar 里所有的class或-Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。
    启动类加载器是无法被Java程序直接引用的。
  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载java平台中扩展功能的一些jar包,比如$JAVA_HOME\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

3.3 双亲委派模型

应用程序都是由以上三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。

加载器之间存在着层次关系,如下所示:


加载器的层次关系

这种层次关系称为类加载器的双亲委派模型。注意这里是以组合关系复用父类加载器的父子关系,而不是以继承关系实现的。

类加载器的双亲委派加载机制:当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

以下代码可以验证类加载器之间的父子层次关系

public class ClassLoaderTest {
    public static void main(String[] args) {
        //获取系统/应用类加载器
        ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("系统/应用类加载器:" + appClassLoader);
        //获取系统/应用类加载器的父类加载器,得到扩展类加载器
        ClassLoader extcClassLoader = appClassLoader.getParent();
        System.out.println("扩展类加载器" + extcClassLoader);
        System.out.println("扩展类加载器的加载路径:" + System.getProperty("java.ext.dirs"));
        //获取扩展类加载器的父加载器,但因根类加载器并不是用Java实现的所以不能获取
        System.out.println("扩展类的父类加载器:" + extcClassLoader.getParent());
    }
}

输出如下:

系统/应用类加载器:sun.misc.Launcher$AppClassLoader@7f31245a
扩展类加载器sun.misc.Launcher$ExtClassLoader@45ee12a7
扩展类加载器的加载路径:/Users/Library/Java/Extensions:/Library/Java/JavaVirtualMachines/jdk1.8.0_40.jdk/Contents/Home/jre/lib/ext:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java
扩展类的父类加载器:null

为什么根类加载器为NULL?
根类加载器并不是Java实现的,而且由于程序通常须访问根加载器,因此访问扩展类加载器的父类加载器时返回NULL。

使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要,保证同一个类在不同的环境中都由同一个类加载器来加载,保证一致性。

3.4 自定义类加载器

JVM中除了根类加载器之外的所有类的加载器都是ClassLoader子类的实例,通过重写ClassLoader中的方法,实现自定义的类加载器

  • loadClass(String name,boolean resolve): 为ClassLoader的入口点,根据指定名称来加载类,系统就是调用ClassLoader的该方法来获取制定类对应的Class对象
  • findClass(String name):根据指定名称来查找类

下面是实现findClass方法的自定义类加载器的实例:

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Method;

public class MyClassLoader extends ClassLoader {
    // 读取一个文件的内容
    @SuppressWarnings("resource")
    private byte[] getBytes(String filename) throws IOException{
        File file = new File(filename);
        long len = file.length();
        byte[] raw = new byte[(int) len];
        FileInputStream fin = new FileInputStream(file);

        // 一次读取class文件的全部二进制数据
        int r = fin.read(raw);
        if (r != len)
            throw new IOException("无法读取全部文件" + r + "!=" + len);
        fin.close();
        return raw;
    }

    // 定义编译指定java文件的方法
    private boolean compile(String javaFile) throws IOException {
        System.out.println("CompileClassLoader:正在编译" + javaFile + "……..");
        // 调用系统的javac命令
        Process p = Runtime.getRuntime().exec("javac" + javaFile);
        try {
            // 其它线程都等待这个线程完成
            p.waitFor();
        } catch (InterruptedException ie) {
            System.out.println(ie);
        }

        // 获取javac 的线程的退出值
        int ret = p.exitValue();
        // 返回编译是否成功
        return ret == 0;
    }

    // 重写Classloader的findCLass方法

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        // 将包路径中的.替换成斜线/
        String fileStub = name.replace(".", "/");
        String javaFilename = fileStub + ".java";
        String classFilename = fileStub + ".class";
        File javaFile = new File(javaFilename);
        File classFile = new File(classFilename);

        // 当指定Java源文件存在,且class文件不存在,或者Java源文件的修改时间比class文件//修改时间晚时,重新编译
        if (javaFile.exists() && (!classFile.exists())
                || javaFile.lastModified() > classFile.lastModified()) {

            try {
                // 如果编译失败,或该Class文件不存在
                if (!compile(javaFilename) || !classFile.exists()) {
                    throw new ClassNotFoundException("ClassNotFoundException:"
                            + javaFilename);
                }
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }

        // 如果class文件存在,系统负责将该文件转化成class对象
        if (classFile.exists()) {
            try {
                // 将class文件的二进制数据读入数组
                byte[] raw = getBytes(classFilename);
                // 调用Classloader的defineClass方法将二进制数据转换成class对象
                clazz = defineClass(name, raw, 0, raw.length);
            } catch (IOException ie) {
                ie.printStackTrace();
            }
        }

        // 如果claszz为null,表明加载失败,则抛出异常
        if (clazz == null) {
            throw new ClassNotFoundException(name);

        }
        return clazz;
    }

    // 定义一个主方法

    public static void main(String[] args) throws Exception {
        // 如果运行该程序时没有参数,即没有目标类
        if (args.length < 1) {
            System.out.println("缺少运行的目标类,请按如下格式运行java源文件:");
            System.out.println("java CompileClassLoader ClassName");
        }

        // 第一个参数是需要运行的类
        String progClass = args[0];
        // 剩下的参数将作为运行目标类时的参数,所以将这些参数复制到一个新数组中
        String progargs[] = new String[args.length - 1];
        System.arraycopy(args, 1, progargs, 0, progargs.length);
        MyClassLoader cl = new MyClassLoader();

        // 加载需要运行的类
        Class<?> clazz = cl.loadClass(progClass);
        // 获取需要运行的类的主方法
        Method main = clazz.getMethod("main", (new String[0]).getClass());
        Object argsArray[] = { progargs };
        main.invoke(null, argsArray);

    }

}


参考文章

  1. 【深入Java虚拟机】之四:类加载机制
  2. Java类加载机制
  3. JAVA类加载机制全解析
  4. hotpot java虚拟机Class对象是放在 方法区 还是堆中 ?
  5. JVM类加载机制详解(二)类加载器与双亲委派模型

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章