第7章 虚拟机类加载机制

时间:2021-08-08 10:18:49

7.2 类加载时机

类生命周期
第7章 虚拟机类加载机制

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

加载、验证、准备、初始化和卸载这5个阶段顺序是确定的,但解析不一定,某些情况可在初始化阶段之后开始,这是为了支持Java的运行时绑定。

什么时候开始类的加载?
虚拟机没有强制约束,但是对于初始化阶段有严格规定,有五种情况必须立即初始化,因此加载、验证、准备自然需要在此之前开始:
1. 遇到new getstatic putstatic iinvokestatic这四条字节码指令时,如果类没有初始化,就触发,最常见这四条的指令场景是用new 实例化对象的时候、读取或设置一个类的静态字段时,以及调用一个类的静态方法的时候。
2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,就要先触发初始化。
3. 初始化一个类的时候,如果其父类还没有进行初始化,就先触发父类的初始化。
4. 虚拟机启动时,用户指定一个要执行的主类,包含main方法的那个类,虚拟机会先初始化这个主类。
5. 当使用JDK1.7的动态语言支持,如果java.lang.invoke.MethodHandle实例最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,且这个方法句柄所对应类没有初始化,那么就触发初始化。

除了这五种方法会主动触发初始阿虎之外,其他引用类的方式都不会触发初始化,称为被动引用。

//被动引用例子1
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init");
    }
}

//演示
public class NotInit{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}
//演示2
public class NotInit{
    public static void main(String[] args){
        SuperClass[] sca = new SuperClass[10];
    }
}

程序只会输出“SuperClass init!”,只会触发父类初始化不会触发子类的初始化。
演示2对于该数组也是不会触发类加载的。

7.3 类加载的过程

加载阶段,需要完成三个事情:
1. 通过一个类的全限定名获得定义此类的二进制字节流
2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3. 内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

一个非数组类的加载阶段是可控性最强的,既可以用系统提供的引导类加载器,也可以用户自定义的类加载器去完成,开发员可以通过自定义的类加载器去控制字节流的获得方式,重写类加载器的loadClass()方法。

对于数组类,其本身不通过类加载器创建,由虚拟机直接创建的。但是数组类的元素类型最终是靠加载器去创建的,数组和加载器仍然有关系。

数组类创建遵循规则:
- 如果数组的组件类型,是引用类型,就递归采用本节中定义的加载过程去加载组件类型,该数组就在加载该组件类型的类加载器的类名称空间上被标识。
- 数组的组件类型不是引用类型的,如int[],虚拟机就会把该数组标记为和引导类加载器相关联
- 数组类的可见性和组件类型的可见性一直,如果组件类型不是引用类型,那数组默认public

加载阶段完成后,虚拟机外部二进制字节流存储在方法区中,方法区存储格式由虚拟机自定义。
然后在内存中实例化一个Class类的对象,虽然是对象,但是存放在方法区内,作为程序访问方法区中的类型数据的外部接口。

加载阶段和连接阶段部分是交叉进行的,加载阶段未完成,连接阶段可能已经开始了。

7.3.2 验证

验证是连接阶段第一步,目的是确保Class文件的字节流包含信息符合虚拟机要求。

验证是否严谨,决定了虚拟机承受恶意代码攻击的能力。
验证阶段大体4个阶段检验动作:
文件格式验证、元数据验证、字节码验证、符号引用验证

  1. 文件格式验证
    验证字节流是否符合Class文件格式规范,且能被当前版本虚拟机处理。
    验证点:
    i. 是否以魔数0xCAFEBABE开头
    ii. 主、次版本号是否在虚拟机处理范围内
    iii. 常量池中常量是否有不支持的常量类型,检查tag标志
    iv. 指向常量的索引值中是否有指向不存在的常量或不符合类型的常量
    v. CONSTANT_Utf8_info型的常量中是否又不符合UTF8编码的数据
    vi. Class文件各部分及文件本身是否有被删除或附加的其他信息
    只有格式符合要求,二进制字节流才会进入内存的方法区中进行存储,后面三个验证都是基于方法区的存储结构进行,不直接操作字节流。
  2. 元数据验证
    对字节码描述信息进行语义分析
    验证点:
    i. 这个类是否有父类,除了Object,其他都有父类
    ii. 这个类的父类是否继承了不允许被集成的类,final修饰的类
    iii. 如果这个类不是抽象类,是否实现其父类或接口中要求实现的所有方法
    iv. 类的字段、方法是否和父类矛盾,如覆盖父类的final字段,不符合规则的重载
  3. 字节码验证
    目的是通过数据流和控制流分析,确定程序语义合法】符合逻辑。
    例如:
    i. 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,不会出现如在栈放了一个int数据,使用时却按long来加载到本地变量表中
    ii. 保证跳转指令不会跳转到方法体意外的字节码指令上
    iii. 保证方法体中类型转换有效,例如把子类对象赋值给父类数据类型,是安全的;但是把父类对象赋值给子类数据类型,或是和子类没关系的数据类型这是不合法的。
    即便通过了字节码验证,也不一定就是安全的。
  4. 符号引用验证
    发生在虚拟机符号引用转化为直接引用的时候,这个转化在连接的第三阶段-解析阶段。
    符号引用验证可看作是对类自身以外的信息(常量池中各种符号引用)进行校验,校验内容:
    i. 符号引用中通过字符串描述的全限定名是否找到对应的类
    ii. 在指定类中是否存在符合方法的字段描述符及简单名称描述的方法和字段
    iii. 符号引用中的类、字段、方法和访问性是否可被当前类访问。

符号引用验证是确保解析动作能否正常执行,如果不通过符号引用验证,就抛出IncompatibleClassChangeError异常的子类

验证阶段非常重要,但不是必要的。如果全部代码已经被反复使用和验证过,那么可以考虑-Xverify:none参数关闭验证,缩短虚拟机类加载时间。

7.3.3 准备

是正式为类变量分配内存并设置类变量初始值的阶段,这些变量使用的内存都在方法区中分配。

这个时候内存分配的仅包括类变量,static修饰的,而不包括实例变量,实例变量在对象实例化时随对象一起分配在Java堆中。

这里初始值通常是数据类型的零值,比如定义:public static int value = 123;
准备阶段过后的初始值是0,而不是123,把value赋值为123将在初始化阶段才执行。

7.3.4 解析

该阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析阶段中直接引用和符号引用的关联?
1. 符号引用:以一组符号描述所引用的目标,可以是任何形式的字面量。符号引用和虚拟机内存布局无关,引用目标不一定已经加载到内存中。
2. 直接引用:是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关。如果有了直接引用,那引用目标必然在内存中。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
1. 类或接口的解析:如果代码所处的类是D,把一个未解析过的符号引用N解析为一个类或接口C的直接引用,整个解析过程3个步骤:
i. 如果C不是数组类型,虚拟机会把N的全限定名传递给D的类加载器去加载这个类C。加载过程出现异常,解析过程就失败。
ii. 如果C是数组类型,数组元素类型是对象,N的描述符是类型“Ljava/lang/Integer”形式,就按第1点规则加载数组元素类型。如果N要加载的元素类型是java.lang.Integer,由虚拟机生成一个代表数组维度和元素的数组对象。
iii. 如果上述无异常,C在虚拟机中已经是一个有效的类或接口,解析完成前进行符号引用验证,确认D是否对C有访问权限。不具备的话,抛出IllegalAccessError异常。
2. 字段解析
首先对字段表内class_index中索引进行解析,就是字段所属的类或接口的符号引用。如果在这个阶段出现异常,字段符号解析失败,如果成功,类就是C
i. 如果C本身包含简单名称和字段描述符都与目标相匹配的字段,返回这个字段直接引用
ii. 否则,如果C实现了接口,就从下往上递归搜索各个接口和它父接口,如果接口包含了简单名称和字段描述都与目标匹配的字段,就返回这个字段直接引用
iii. 否则,C不是java.lang.Object的话,就从下往上递归搜索其父类,如果父类包含了简单名称和字段描述都与目标匹配的字段,就返回这个字段直接引用
iv. 否则,查找失败,抛出NoSuchFieldError异常。
3. 类方法解析
类方法解析第一个步骤和字段解析一样,要先解析出类方法表的class_index中索引的方法所属的类或接口的符号引用,用C表示类。

  1. 接口方法解析

7.3.5 初始化

类初始化是类加载的最后一步,前面的加载过程,除了在加载阶段用户程序可通过自定义加载器参与外,其余动作全由虚拟机主导和控制。

初始化阶段,才真正开始执行类中定义的Java程序代码,或说是字节码。

准备阶段,变量已经赋值过一次系统要求的初始值;在初始化阶段,程序员通过程序制定的主观计划去初始化类变量和其他资源;初始化阶段是执行类构造器()方法的过程。

  1. () 方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{ })中的语句合并产生的,收集器收集顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
  2. ()方法与类的构造函数不同,它不需要显式调用父类构造器,虚拟机保证在子类()方法执行前,父类()方法已经执行完毕。虚拟机中第一个被执行的()方法的类肯定是java.lang.Object

7.4 类加载器

类加载阶段的“通过一个类的全限定名获取描述此类的二进制字节流”动作放到虚拟机外实现,让程序自己决定如何获得所需要的类,实现该动作代码模块叫“类加载器”。

7.4.1 类与类加载器

对于任意一个类,都要由加载它的类加载器和类本身确定虚拟机中的唯一性,每个类加载器,都有一个独立的类名称空间。

简洁:比较两个类是否“相等”,必须在这两个类由同一个类加载器加载才可以。

相等就是代表类的Class对象的equals()方法、 isAssignableFrom()方法、isInstance()方法返回的结果,包括instanceof。

//不同类加载器对instanceof运算的影响
public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if(is == null){
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name,b,0,b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj = myLoader.loadClass("com.wangt.jvmtest.ClassLoaderTest");
        System.out.println(obj.getClass());
        System.out.println(obj instanceof com.wangt.jvmtest.ClassLoaderTest);
    }
}

二者并不相等,返回的是false。因为虚拟机中存在两个ClassLoaderTest类,一个是由系统程序类加载器加载的,一个是由自定义的类加载器加载的,都来自同一个Class文件,但是两个独立的类。

7.4.2 双亲委派模型

虚拟机只存在两种不同的类加载器:一种是启动类加载器,这个用C++实现,虚拟机一部分;第二种就是所有其他的类加载器,这些类加载器由Java实现,全都继承自java.lang.ClassLoader

大部分程序用到的3种系统提供的类加载器:
1. 启动类加载器(Bootstrap ClassLoader):
负责将存放在[JAVA_HOME]\lib目录中的或-Xbootclasspath参数指定的路径中的类加载到内存中。启动类加载器无法被Java程序直接引用。
2. 扩展类加载器(Application ClassLoader):
该加载器由sun.misc.Launcher$ExtClassLoader实现,负责加载[JAVA_HOME]\lib\ext目录中的,或被java.ext.dirs系统变量所指定的路径中所有类库,开发者可直接使用扩展类加载器。
3. 应用程序类加载器(Application ClassLoader):该类加载器由sun.misc.Launcher$App-ClassLoader实现。这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,也称为系统类加载器。负责加载用户类路径上所指定的类库,开发者可直接使用这个类加载器,如果没有自定义过加载器,一般程序就用这个类加载器。

第7章 虚拟机类加载机制
这种类加载器的层次关系,称为类加载器的双亲委派模型。
该模型要求,除了顶层的启动类加载器之外,其余类加载器都应有自己的父类加载器。加载器间的父子关系不是以继承来实现,而是以组合关系来复用父加载器的代码。

工作过程:
如果一个类加载器收到了类加载的请求,首先不会自己尝试加载这个类,而是委派给父类加载器完成,每层次的类加载器都是这样,所有的加载请求最终都传送到启动类加载器中,只有父类加载器自己无法完成这个加载请求(搜索范围没有找到所需的类),子加载器才会尝试自己加载。

好处:
Java类随它的类加载器一起具备了一种带有优先级的层次关系。
如java.lang.Object,存放在rt.jar中,最终都是委派给最顶端启动类加载器加载,因此Object类在程序各种类加载器环境中是同一个类。如果,没有该模型,各个类加载器自行加载,自己编写一个java.lang.Object类,放在ClassPath中,系统会出现不同的Object类。

模型代码实现:
都放在java.lang.ClassLoader的loadClass()方法中;先检查是否已被加载过,没有加载就调用父加载器的loadClass(),若父加载器为空默认使用启动类加载器作为父加载器。父类加载失败,抛出ClassNotFoundException异常后,调用自己的findClass()方法加载

//模型的实现
protected synchronized Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException {
    //首先,检查请求的类是否已经被加载过
    Class c = findLoadedClass(name);
    if(c == null){
        try{
            if(parent != null){
                c = parent.loadClass(name,false);
            }else {
                c = findBootstrapClassOrNull(name);
            }
        }catch(ClassNotFoundException e){
            //如果父类加载器抛出ClassNotFoundException
            //说明父类加载器无法完成加载请求
        }
        if(c == null){
            //在父类加载器无法加载的时候
            //再调用本身的findClass方法进行类加载
            c = findClass(name);
        }
    }
    if(resolve){
        resolveClass(c);
    }
    return c;
}