深入理解Java虚拟机二 虚拟机类加载机制

前言

文章是看了《深入理解Java虚拟机》书后进行的整理和总结,算是一个读书笔记吧。

  • 深入理解Java虚拟机一 虚拟机内存管理机制
  • 深入理解Java虚拟机二 虚拟机类加载机制
  • 深入理解Java虚拟机三 垃圾回收机制

一、类加载过程

在java语言里,类型的加载、连接和初始化过程都是在程序运行期完成的,赭红设计方式虽然会让类在加载时有一定的性能开销,但是能够为java提供高度的灵活性,java能够动态扩展就是依赖运行期动态加载和动态连接实现的,例如比较出名的OSGi技术也是利用类的动态加载机制来实现的,当然这里不细讲OSGi,因为我也不太懂.

  • 类的生命周期
    类从被加载到虚拟机内存中开始,到卸载出内存为止,她的整个生命周期如图所示:


    类加载过程
  • 类加载过程
    类加载过程实际上指的是上述的加载、验证、准备、解析和初始化这五个过程

    • 加载
      在加载阶段,虚拟机主要完成三件事情:
      1. 通过类的全限定名来获取定义此类的二进制字节流。
      注意这里指的是获取二进制字节流,而不是class文件,因为类可能是从一个jar包中拿到的,也有可能是直接从内存中获取到的,比如动态代理类的字节码只存储在内存中,而且也有可能是从jsp文件中获取到的。

      1. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
      2. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
    • 验证
      验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
      验证阶段大致会进行下面四个阶段的检验工作:

      1. 文件格式验证
        验证点包括以下,这部分在了解类编译机制就会更了解,后面的文章会介绍类编译机制:
        是否以魔数0xCAFEBABE开头
        主次版本号是否在当前虚拟处理范围内
        常量池中的常量是否有不被支持的常量类型
        CONSTANT_Utf8_info型常量中是否有不符合UTF8编码的数据
        Class文件中各个部分及文件本身是否被删除的或附加的其他信息
        ……
        等等其他内容。
      2. 元数据验证
        该类是否有父类,除了Object类以外,所有的类都应该有父类
        这个类的父类是否继承了不允许被继承的类
        如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
        类中国的字段、方法是否与父类产生矛盾,比如覆盖了父类final修饰的字段,或者出现不符合规则的重载方法
      3. 字节码验证
        该阶段的验证是整个过程中最复杂的阶段,主要是通过数据流和控制流的分析,确定语义是合法的,符合逻辑的。该阶段对类的方法体校验,确保被教研类的方法在运行时不会做出危害虚拟机安全的事件。
      4. 符号引用验证
        符号引用验证主要是对类自身以外的信息进行匹配行校验,该动作也在解析阶段中触发。
        符号引用中通过字符串描述的全限定名师傅能找到对应的类
        在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
        符号饮用中的类、字段、方法的访问性是否可以被当前类访问,例如当前类无法直接访问其他类的private方法
    • 准备
      准备阶段是正式为类变量(类变量不是实例变量,是指被static修饰的变量)设置初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
      实例变量的内存分配要等到类被实例化化时才会被分配在java堆中,所以实例化对象是在堆中的,会随着程序的运行被垃圾回收器回收,而常量因为是永久存在的,是放在方法区中的。
      而初始值并不是我们给static变量定义的值,而是零值,所有的基本类型的零值就是0,而引用类型的初始值都是null,所以在使用访问基本类型的时候不会出现空指针异常,但在给基本类型赋值的时候就有可能报空指针的,因为自动拆箱的问题。

    • 解析
      解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程,而这个过程可能包含类或接口的解析、字段的解析、类方法解析、接口方法的解析。

    • 初始化
      类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导,到了初始化阶段,才真正开始执行类中定义的java程序代码。
      类初始化的过程实际上也是执行类构造器()的过程。
      类构造器是编译器自动收集类中的所有类变量的赋值动作,和静态语句块中的语句合并产生的,编译器收集的顺序是语句在源文件中出现的顺序决定的。
      ()方法和类的构造函数不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的()执行前,父类的()方法已经执行完毕。
      而父类的()方法先执行,也就能保证父类的静态方法块执行顺序会优先于子类静态变量赋值操作。
      虚拟机也能保证()方法的执行是线程安全的。

二、类加载器

虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为类加载器。

类加载器是java语言的一项创新,并且现在在OSGi、热部署、代码加密等领域大方异彩,是java技术中的一个重要基石。

每个类加载器都有自己的命名空间,对于任意一个类,都需要它的类加载器和这个类本身一同确立起在java虚拟机中的唯一性。这代表说即使是同一个类,使用不同的类加载器来加载,这两个类也是不同的

public class ClassLoaderTest {

    public static void main(String[] args)
        throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class loadClass(String name) throws ClassNotFoundException {
                String file = name.substring(name.lastIndexOf(".") + 1) + ".class";
                try (InputStream is = ClassLoaderTest.class.getResourceAsStream(file)) {
                    if(Objects.isNull(is)){
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[is.available()];
                    int i = is.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (Exception e) {
                    throw new ClassNotFoundException(file);
                }
            }
        };
        Object o = classLoader.loadClass(ClassLoaderTest.class.getName()).newInstance();
        ClassLoaderTest test = new ClassLoaderTest();

        System.out.println(o.getClass().getName());
        System.out.println(o instanceof ClassLoaderTest);
        System.out.println(test instanceof ClassLoaderTest);
    }

}
cn.learn.test.design.factory.classloader.ClassLoaderTest
false
true

上面这个例子是用我们自己写的类加载器去加载了一个类 然后和虚拟机自己加载的这个类进行比较,发现系统确实认为他们不相同。

实际上ClassLoaderTest这个类本身已经被另一个应用类加载器加载了,这里就要带入java的类加载器结构。

双亲委派模型

从java虚拟机角度来说,只存在两种类加载器,一种是启动类加载器,一种是其他类加载器。
启动类加载器是虚拟机内部实现的一部分。
而其他类加载器则是由java实现,独立于虚拟机外部。

类加载器结构图
  • 启动类加载器
    这个类加载器是虚拟机内部实现,他负责加载/lib目录或者被-Xbootclasspath 指定路径中,并且被虚拟机识别(按照文件名识别,比如rt.jar)的类库。
  • 扩展类加载器
    这个类加载器加载JAVA_HOME/jre/ext/*.jar 目录下的类库,由sun.misc.Launcher$ExtClassLoader实现
  • 应用程序类加载器
    这个类加载器负责加载用户类路径(Classpath)上指定的类库,开发者可以通过CLassLoader中的getSystemCLassLoader()方法获得,所以这个类加载器也叫系统类加载器。

我们的程序就是由这三类类加载器配合进行加载的。
双亲委派模型要求除了顶层类加载器,其余的类加载器都有自己的父类类加载器。
双亲委派的工作过程是:如果一个类加载器收到类加载请求,它首先不会自己去尝试加载这个类加载器,而是把请求委派给父类加载器去加载,每一个层次都是如此,因此每一个类加载请求最终都会被委派到最顶层的类加载器来处理,只有当父加载器反馈自己无法完成这个请求,子类加载器才会尝试自己去加载。
这样做的好处是,类加载器具备一种优先级的层次关系,无论哪个类被请求加载,最终都只会被一个固定的类加载器来加载,从而保证这个类在虚拟中的唯一性,前面说了,不同类加载器有不同的命名空间,这样也就保证了系统的安全性。

破坏双亲委派模型

双亲委派模型并不是一个强制约束,而是Java设计者推荐给开发者的类加载方式,在通常情况下,这是最好的选择。
但既然设计为非强制约束,就代表着这种约束有被破坏的需求,双亲委派模型有两种知名的破坏场景。

  • JNDI接口服务(SPI)
  • OSGi模块化编程

JNDI服务是由启动类加载器去加载,但JNDI是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序ClassPath下的JNDI接口提供者的代码,但启动类加载的路径下不可能会有这样的代码,为了解决这个问题,java设计团队引入一个新的设计:线程上下文类加载器。这个类加载器保存在Thread类里,打开这个类我们能够看到这个变量:

    /* The context ClassLoader for this thread */
    private ClassLoader contextClassLoader;

创建线程时可以选择手动设置一个类加载器进去,如果不设置,系统会默认从父类线程继承一个,如果全局都没继承过,这个类加载器默认就是应用程序类加载器。有了这个类加载器,JNDI服务就可以在启动类加载器需要加载JNDI接口服务(SPI)时,直接调用线程的上下文加载器来加载。
JDBC的驱动类就是这样实现的,早期我们应该还记得需要创建JDBC连接时需要手动调用Class.forName("xxxDriver"),但现在已经不用了,这是因为现在都已经实现了SPI接口服务。

还有一个OSGi模块化编程的话,我也不够了解,只了解一些基本概念,未来我也会把这个列为学习计划。
OSGi的理念就是把程序做成一个可以热插拔的组件,就像我们的鼠标一样,插上就能用,拔掉就可以换上另一个鼠标,而电脑从来都不需要关机。
OSGi的理念也是这样,让我们的程序在不停机的情况下就能实现功能的替换,只需要我们把我们应用的功能做成一个个可以热插拔的模块即可。
而要实现这样的功能就必然需要依赖类加载器来实现。
在OSGi的环境中,类加载器不再是双亲委派模型的树形结构,而是进一步发展的网状接口,刚才我们提到了一个个模块的热插拔,但是这一个个模块之间也可能存在依赖,这就导致我们需要的类可能需要由其他模块(Bundle)来加载,这种模块的依赖关系实际上也就是类加载器的依赖关系。
这里的东西也就不细讲了,实在是我也不太懂,未来学习后再来分享。

你可能感兴趣的:(深入理解Java虚拟机二 虚拟机类加载机制)