Java虚拟机 —— 类的加载机制

我们知道class文件中存储了类的描述信息和各种细节的数据,在运行Java程序时,虚拟机需要先将类的这些数据加载到内存中,并经过校验、转换、解析和初始化过后,最终形成可以直接使用的Java类型。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。

类的生命周期

类的生命周期

 

类的加载机制实际上就是类的生命周期中加载、验证、准备、解析、初始化5个过程。

加载

加载是类的加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下3件事情:

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

通过全限定名来获取二进制流可以有很多种方式,比如从JAR、EAR、WAR文件包中读取,从网络获取,也可以由其他文件来生成(jsp文件生成对应的Servlet类),甚至还可以通过运行时动态生成(Java动态代理)。

相比类加载过程的其他阶段,加载阶段是可控性最强的。因为开发者既可以利用系统提供的启动类加载器来完成,也可以通过自定义类加载去完成(重写loadClass方法,控制字节流的获取方式)。

关于类加载器的详细介绍将放在文章最后。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。然后在内存中实例化一个java.lang.Class类的对象,这样就可以通过这个对象来访问方法区中的这些数据。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证: 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的
    3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。
  • 元数据验证: 对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
  • 字节码验证: 对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
  • 符号验证: 对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,这个阶段发生在将符号引用转化为直接引用的时候(解析阶段中发生),目的是确保解析动作能正常执行。

准备

准备阶段是正式为类变量(静态变量)分配内存并设置初始值的阶段,这些类变量所使用的内存都将在方法区中进行分配。

这里有两点需要注意:

  1. 成员变量不是在这里分配内存的,成员变量是在类实例化对象的时候在堆中分配的。
  2. 这里设置初始值是指类型的零值(比如0,null,false等),而不是代码中被显示的赋予的值。

比如:

public class Test {
    public int number = 111;
    public static int sNumber = 111; 
}

成员变量number在这个阶段就不会进行内存分配和初始化。而类变量sNunber会在方法区中分配内存,并设置为int类型的零值0而不是111,赋值为111是在初始化阶段才会执行。

Java基本数据类型和引用数据类型零值

Java基本数据类型和引用数据类型零值


但是呢,如果类变量如果是被final修饰,为静态常量,那么在准备阶段也会在方法区中分配内存,并且将其值设置为显示赋予的值。

 

比如:

public class Test {
    public static final int NUMBER = 111; 
}

此时,就会在准备阶段将NUMBER的值设置为111。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用: 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

解析动作主要就是在常量池中寻找类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符等7类符号引用,把这些符号引用替换为直接引用。下面主要介绍下类或接口、字段、类方法、接口方法的解析:

  1. 类或接口解析: 假设当前的类A通过符号X引用了类B,虚拟机会把代表类B的全限定名传递给A的类加载器去加载BB经过加载、验证、准备过程,在解析过程又可能会触发B引用的其他的类的加载过程,相当于一个类引用链的递归加载过程,整个过程只要不出现异常,B的就是一个加载成功的类或接口了,也就是可以获取到代表Bjava.lang.Class对象。在验证了A具备对B的访问权限后,就将符号引用X替换为B的直接引用。
  2. 字段解析: 解析未被解析过的字段,要先解析字段所属的类或接口的符号引用。如果类本身就包含了简单的名称和字段描述与目标字段相匹配,就直接返回这个字段引用;如果实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段;如果是继承自其他类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。
  3. 类方法解析:类方法解析和字段解析的方式类似,也是依据继承和实现关系从小到上搜索,只不过是先搜索类,后搜索接口。如果有简单名称和字段描述符都与目标相匹配的字段,就返回字段引用。
  4. 接口的方法解析: 与类方法解析类似,从小到上搜索接口(接口没有父类,只可能有父接口)。如果存在简单名称和字段描述符都与目标相匹配的字段,就返回字段引用。

初始化

类的初始化类加载过程的最后一步,在前面的过中,除了在加载阶段开发者可以自定义加载器之外,其余的动作都是完全有虚拟机主导和控制完成。到了初始化阶段,才真正开始执行类中定义的Java代码。

在准备阶段,类变量已经设置了系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中所有的类变量(static变量)和静态代码块(static{}块)中的语句合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态代码块中只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块可以赋值,但是不能访问。

public class Test {
    static {
        number = 111;               // 可以赋值
        System.out.println(number); // 不能读取,编辑器或报错Illegal forward reference
    }
    static int number;
}

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类的<clinit>()方法,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。所以,父类定义的静态代码块要先与子类的赋值操作。

class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

class Sub extends Parent {
    public static int B = A;
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。

类加载器

在之前的加载过程中,提到了类加载器通过一个类的全限定名来获取描述此类的二进制字节流,这个过程可以让开发中自定义类加载器来决定如何获取需要的字节流。那么,什么是类加载器呢?

对于任意一个Java类,都必须通过类加载器加载到方法区,并生成java.lang.Class对象才能使用类的各个功能,所以我们可以把类加载器理解为一个将class类文件转换为java.lang.Class对象的工具。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是说,如果两个类“相等”,那么这两个类必须是被同一个虚拟机中的同一个类加载器加载,并且来自同一个class文件。

在Java当中,已经有3个预制的类加载器,分别是BootStrapClassLoaderExtClassLoader、AppClassLoader

  • BootStrapClassLoader: 启动类加载器,它是由C++来实现的,在Java程序中不能显氏的获取到。它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下的类。
  • ExtClassLoader: 扩展类加载器,它是由sun.misc.Launcher$ExtClassLoader实现,负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库。开发者可以直接使用它。
  • AppClassLoader: 应用程序类加载器,由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器。一般来说,开发者自定义的类就是由应用程序类加载器加载的。

ExtClassLoader作为类加载器,但它也是一个Java类,是由BootStrapClassLoader来加载的,所以,ExtClassLoader的parent是BootStrapClassLoader。但是由于BootStrapClassLoaderc++实现的,我们通过ExtClassLoader.getParent获取到的是null。同样地,AppClassLoader是由ExtClassLoader加载,AppClassLoader的parent是ExtClassLoader

public class Test {
    public static void main(String[] args) {
        ClassLoader cl = Test.class.getClassLoader();
        while (cl != null) {
            System.out.println(cl);
            cl = cl.getParent();
        }
    }
}

打印结果:

sun.misc.Launcher$AppClassLoader@232204a1
sun.misc.Launcher$ExtClassLoader@74a14482

同时我们可以定义自己的类加载器CustomClassLoader,那么它的parent肯定就是AppClassLoader了。类加载器的这种层次关系称为双亲委派模型。

 

类加载器

类加载器

 

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系不是以继承的关系来实现,而是都使用递归的方式来调用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

ClassLoader的源码:

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;
    }
}

先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,依次向上递归。若父类加载器为空则说明递归到启动类加载器了。如果从父类加载器到启动类加载器的上层次的所有加载器都加载失败,则调用自己的findClass()方法进行加载。

使用双亲委派模型能使Java类随着加载器一起具备一种优先级的层次关系,保证同一个类只加载一次,避免了重复加载,同时也能阻止有人恶意替换加载系统类。

自定义类加载器

一般地,在ClassLoader方法的loadClass方法中已经给开发者实现了双亲委派模型,在自定义类加载器的时候,只需要复写findClass方法即可。

public class CustomClassLoader extends ClassLoader {

    private String root;

    public CustomClassLoader(String root) {
        this.root = root;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String name) {
        String fileName = root + File.separatorChar
                + name.replace('.', File.separatorChar)
                + ".class";
        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length;
            while ((length = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

新建一个类com.xiao.U,编译成class文件,放到桌面,来测试一下:

public class Test {
    public static void main(String[] args) {
        CustomClassLoader customClassLoader = new CustomClassLoader("C:\\Users\\PC\\Desktop");
        try {
            Class clazz = customClassLoader.loadClass("com.xiao.U");
            Object o = clazz.newInstance();
            System.out.println(o.getClass().getClassLoader());
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
    }
}

打印结果:

CustomClassLoader@1540e19d

自定义类加载器在可以实现服务端的热部署,在移动端比如android也可以实现热更新。

我有一个微信公众号,经常会分享一些Java技术相关的干货;如果你喜欢我的分享,可以用微信搜索“Java团长”或者“javatuanzhang”关注。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序员-张凯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值