Java类加载的一些知识点

1、类的加载过程

加载过程是:字节码文件 >> 加载(Loading) >>  链接(Linking)(验证(Verify)、准备(Prepare)、解析(Resolve)) >> 初始化(Initial),生命周期还有使用(using)和卸载(unloading)。

类的生命周期

类加载子系统负责从文件系统或者从网络中加载Class,Class文件在文件的开头有特定的文件标识(CAFEBABE)。

ClassLoader只负责Class文件的加载,是否可以运行,由ExcutionEngine决定。

加载的类信息存放在方法区中,称为DNA元数据模版。

加载/Loading

通过类的全限定名获取定义此类的二进制字节流。

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

在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证/Verify

确保Class文件的字节流中包含的信息符合虚拟机的要求,保证加载类的正确性,不会危害虚拟机自身的安全。

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

文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

是否以魔数0xCAFEBABE开头。

主、次版本号是否在当前虚拟机处理范围之内。

常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。

指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。

CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。

Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

        实际上,第一阶段的验证点还远不止这些,上面这些只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区之内,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段全部是基于方法区的存储结果进行的,不会再直接操作字节流。

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:

这个类是否有父类(除了java.lang.Object之外,所有的类都应当由父类)。

这个类的父类是否继承了不允许被继承的类(被final修饰的类)。

如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。

第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。

字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。

保证跳转指令不会跳转到方法体以外的字节码指令上。

保证方法体中的类型转换是有效地,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象复制给子类数据类型,甚至把对象赋值给与他毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。这里涉及了离散数学中一个很著名的问题“Halting Problem”:通俗一点的说法就是,通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确的检查出程序是否能在有限的时间之内结束运行。

        由于数据流验证的高复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶段,在JDK 1.6之后的Javac编译器和Java虚拟机中进行了一项优化,给方法体的Code属性的属性表中增加了一项名为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应用的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样将字节码验证的类型推导转变为类型检查从而节省一些时间。

        理论上StackMapTable属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验则是虚拟机设计者值得思考的问题。

        在JDK 1.6的HotSpot虚拟机中提供了-XX : -UseSplitVerifier选项来关闭这项优化,或者使用参数- XX : FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而在JDK 1.7之后,对于主版本大于50的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到类型推到的校验方式。

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

符号引用中通过字符串描述的全限定名是否能找到对应的类。

在制定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFiledError、java.lang.NoSuchMethodError等。

准备/Prepare

为类变量分配内存,并设置该变量的初始默认值,即零值。

这里不包含被final static修饰的变量(被final static修饰是常量 ),这种变量在编译时已经分配,准备阶段会显式的初始化。

不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java堆中。

解析/Resolve

将常量池中的符号引用转化为直接引用的过程。

事实上,解析操作往往伴随着JVM执行完初始化之后再执行。

符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。

初始化/Initial

初始化阶段就是执行类构造器方法()过程。

此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。

构造器的方法中指令语句按照语句在源文件中出现的顺序执行。

class Test{

static{

        number = 1; //前向引用,不会报错

        }

        private static int number = 2;

//在准备阶段被初始化为零值;初始化时先赋值为1,再赋值为2。

}

()不同于类的构造器。构造器是虚拟机视角下的()。

若该类有父类,JVM会保证子类的()执行之前,父类的()已经执行完毕。

虚拟机必须保证同一个类的()在多线程下被同步加锁。

2、类加载器的分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-defined ClassLoader)。

从概念上来讲,自定义类加载器一般是指程序中由开发人员自定义的一类类加载器,但是Java的虚拟机规范却没有这么定义,而是将派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。

启动类加载器(引导类加载器(Bootstrap ClassLoader))

这个类加载器使用C/C++语言实现,嵌套在JVM内部。

它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容)、用于提供JVM需要的类。

并不继承java.lang.ClassLoader,没有父加载器。

加载扩展类加载器和应用程序类加载器,并指定为他们的父加载器。

出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类。

虚拟机自带的类加载器

扩展类加载器(Extension ClassLoader)

Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。

派生于ClassLoader类。

父加载器为启动类加载器。

从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的Jar放在此目录下,也会由扩展类加载器加载。

应用程序类加载器(系统类加载器,AppClassLoader)

Java语言编写,有sun.misc.Launcher$AppClassLoader实现。

派生于ClassLoader类。

父加载器为扩展类加载器。

它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。

该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载的。

通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

各加载器关系

用户自定义类加载器

在Java的日常开发中,类的加载几乎都由上面三种类加载器相互配合执行的,在必要时可以自定义类加载器,来制定类的加载方式。

为什么要自定义类加载器?

隔离加载类:多个模块有同一个类,防止冲突。

修改类的加载方式

扩展加载源

防止源码泄漏

自定义类加载器的步骤:

通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求。

JDK1.2之前,继承ClassLoader重写loadClass()方法,在JDK1.2之后重写findClass()。

如果没有很复杂的需求,可以直接继承URLClassLoader类,这样可以避免去编写findClass()方法及其获取字节流的方式,使编写更加简洁。

3、双亲委派机制

工作原理

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行;

如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终达到顶层的启动类加载器。

如果父类可以完成类加载任务,就成功返回。如果父类无法完成加载,子加载器才会尝试自己去加载,这就是双亲委派机制。

父 >> 子

Bootstrap ClassLoader > Extension ClassLoader > AppClassLoader >自定义ClassLoader

优势

避免类的重复。

保护程序安全,防止核心API被篡改。

4、类的主动使用和被动使用

主动使用

创建类的实例。

访问某个类或接口的静态变量,或对该静态变量赋值。

调用类的静态方法。

反射(比如:Class.forName("com.fanshe,Test"))。

初始化一个类的子类。

Java虚拟机启动时被标明为启动类的类。

JDK7开始提供的动态语言支持:java.lang.Methodhandle实例的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial句柄对应的类型没有初始化,则初始化。

除了以上7种情况,其他使用Java类的方式都被看作是类的被动使用,不会导致类的初始化。

被动使用

通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。

class SuperClass {

    static {

        System.out.println("SuperClass init!");

    }

    public static int value = 123;

}

class SubClass extends SuperClass {

    static {

        System.out.println("SubClass init!");

    }

}

public class NotInitialization {

    public static void main(String[] args) {

        System.out.println(SubClass.value);

        // SuperClass init!

    }

}

通过数组定义来引用类,不会触发此类的初始化。

class SuperClass2 {

    static {

        System.out.println("SuperClass init!");

    }

    public static int value = 123;

}

public class NotInitialization2 {

    public static void main(String[] args) {

        SuperClass2[] superClasses = new SuperClass2[10];

    }

}

常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

class ConstClass {

    static {

        System.out.println("ConstClass init!");

    }

    public static final String HELLO_BINGO = "Hello Bingo";

}

public class NotInitialization3 {

    public static void main(String[] args) {

        System.out.println(ConstClass.HELLO_BINGO);

    }

}

编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。

5、其他

在JVM中,即使两个类的对象来源于同一个Class文件,被同一个虚拟机所加载,但只要加载他们的ClassLoader实例对象不同,这两个对象也不相等。

JVM必须知道一个类型是由启动加载器加载还是由用户类加载器加载的,如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型遇到另一个类的引用时,JVM需要保证这两个类型的类加载器是相同的。

你可能感兴趣的:(Java类加载的一些知识点)