JVM类加载机制

来源:互联网 发布:nba2konline考辛斯数据 编辑:程序博客网 时间:2024/06/03 09:36
在介绍JVM类加载机制之前,先介绍一下JVM的两个无关性:

JVM无关性

JVM的无关性可以分为语言无关系和平台无关系,所谓语言无关性就是除了java以外的语言也可以运行在JVM上,所谓的平台无关性就是Java诞生时提出的“Write once, run anywhere”。

实现JVM的语言无关性的基础是虚拟机和字节码存储格式。JVM不和包括Java在内的任何语言绑定,只和“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了JVM指令集和符号表以及其他辅助信息,JVM对Class文件有强制性语法和结构化约束,但是任何一门功能性语言都可以通过对应编译器编译为存储字节码的Class文件,这个Class文件是一组以8位字节为基础单位的二进制流

而JVM的平台无关性是现在操作系统的应用层上:JVM可以载入和执行同一种与平台无关的字节码(ByteCode),从而实现“Write Once, Run Anywhere”。


JVM类加载机制

而对于这些Class文件如何加载入JVM,形成可以被JVM直接使用的Java类就是JVM类加载机制。

和其他一些需要在编译阶段进行连接工作的语言不同,java语言当中,类型的加载、连接、初始化过程都是在程序运行阶段完成的,虽然这样会使得类加载稍微增加一些开销,但是会使得Java应用程序更加灵活,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载动态连接两个特点实现的。比如编写一个面向接口的应用程序可以等到运行时再指定其实际的实现类;比如用户可以通过Java预定义和自定义类加载器,让一个本地的应用程序可以在运行时从网络或者其他地方加载一个二进制流作为程序代码的一部分。


类加载的时机

首先来看一下类的生命周期:


加载、验证、准备、解析、初始化、使用、卸载7个阶段,其中验证、准备、解析三个阶段被统称为连接(Linking),其中加载、验证、准备、初始化的顺序是固定的,但是解析阶段不一定,它有可能开始于初始化阶段之后,这是为了支持Java语言的运行时绑定。


JVM规范没有规定具体实现,但是JVM规定了有且只有以下5种情况必须立即对类初始化(而加载、验证、准备自然需要在这之前开始):

a、遇到new、getstatic、putstatic、invokestatic这四条字节码指令的时候,如果类没有经过初始化,则需要触发其初始化。生成这4条指令最常见的Java代码场景是使用new关键字实例化对象的时候读取或者设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候

b、使用java.lang.reflect包的方法进行反射调用的时候,如果类没有进行初始化,则需要进行初始化。

c、当初始化一个类的时候,如果发现其父类没有初始化,则需要触发其父类的初始化

d、当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法那个类),虚拟机会先初始化这个主类

e、当使用JDK 1.7的动态语言支持的时候,如果使用java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则进行初始化。

以上5种方式为对一个类进行主动引用,所有其他的引用类的方法不会触发初始化,被称为被动引用,下面举几个没有触发初始化的被动引用例子:

①、通过子类引用父类的静态字段,不会导致子类初始化

package classLoadTest;public class SuperClass {    static{        System.out.println("SuperClass init");    }    public static int value = 123;}package classLoadTest;public class SubClass extends SuperClass {    static{        System.out.println("SubClass init");    }}package classLoadTest;public class Test {    public static void main(String[] args) {        System.out.println(SubClass.value);    }}
以上代码的执行结果为

SuperClass init
123

说明对于访问静态字段,只有直接定义这个字段的类才会去进行初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化

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

package classLoadTest;public class Test{    public static void main(String[] args){        SuperClass[] sca = new SuperClass[10];    }}

这段代码没有输出SuperClass init,说明SuperClass没有初始化。但是却初始化了一个叫做[LclassLoadTest.SuperClass的类,这个类的实例就是sca,这个类是由JVM自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发,因此数组类不是通过类加载创建的,是通过JVM直接创建的这个类代表了一个元素类型为classLoadTest的一维数组,数组中应有的属性和方法(length属性和clone()方法)都是现在这个类里(这也就不难理解为什么我们创建一个数组的时候就能访问到length和clone法()方法)。检查到数组越界会报出java.lang.ArrayInexOutOfBoundsException异常。

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

package loadClassTest;public class ConstClass{    static{        System.out.println("ConstClass Init");    }    public static final String TEXT = "123";}public class Test{    public static void main(String[] args){        System.out.println(ConstClass.TEXT);    }}
这里输出结果为123,但是没有输出ConstClass Init,因为TEXT作为一个常量存入到的是Test类的常量池中,Test类对ConstClass.TEXT的引用实际上都转化成了Test类对自身常量池的引用,没有直接引用定义常量的类,所以没有初始化。
而对接口进行初始化的时候在第三点上与对类进行初始化不太一样,规则如下:

当对类进行初始化的时候,要求其父类全部都已经初始化过了,但是在一个接口初始化的时候,不要求其父接口全部完成了初始化,只有在真正用到父接口的时候才会进行初始化


类加载的过程

加载

加载是类加载阶段的一个过程,包括三件事情:

1、通过一个类的全限定名来获得这个类的二进制字节流

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

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

值得一提的是第一条,他没有明确指明需要从什么地方获取,怎样获取类的二进制字节流,于是很多有趣的技术产生了:

    a、从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础;

    b、从网络中获取,这种场景最典型的应用就是Applet;

    c、运行时计算生成,比如动态代理技术。

    d、由其他文件生成,比如JSP应用,由JSP文件生成对应的Class类。

    e、从数据库中读取,这种场景很少见,可以选择把程序安装到数据库中来完成程序代码在集群之间的分发。

JVM外部的二进制字节流会按照JVM需要的格式存储在方法区之中,存储格式由JVM自行实现。然后在内存中明确规定一个java.lang.Class类的对象(没有明确规定在Java堆中),对于HotSpot虚拟机而言,Class对象比较特殊,虽然是对象,但是存放在方法区,这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段和连接阶段的部分内容(如验证)是交叉进行的,加载尚未完成,连接可能已经开始了。

验证

验证时连接阶段的第一步,目的是为了确保Class文件中的字节流中信息复合当前JVM的要求,不会危害到JVM的安全,否则会因为载入了有害的字节流而导致系统崩溃。验证阶段大体上分为四个验证动作:

1、文件格式验证

验证字节流是否符合Class文件格式的规范,保证输入的字节流能够被正确解析并且存储于方法区内,经过这个阶段字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段全都是基于方法区的存储结构进行的,不再直接操作字节流。

2、元数据验证

对字节码描述的信息进行语义分析保证其描述的信息符合Java语言规范的要求,也就是保证不存在不符合Java语言规范的元数据信息。

3、字节码验证

主要目的是通过数据流控制流分析,确定程序语义是合法的符合逻辑的,保证被检验类的方法在运行时不会做出危害虚拟机安全的事件。

4、符号引用验证

这在JVM将符号引用转化为直接引用的时候(解析阶段)发生,保证解析动作能正常执行

准备

准备阶段正式为类变量(static修饰的变量)分配内存并设置变量初始值的阶段,这些变量的内存在方法区中进行分配。在这里进行内存分配的只有类变量,没有实例变量,实例变量在对象实例化的时候一起分配到Java堆中。而且,这里的初始值一般是数据类型的零值

举一个例子:

public static int value = 123;

value在经过准备阶段之后的初始值是0而不是123,因为准备阶段没有开始执行任何Java方法,而把value复制为123的putstatic指令是在程序被编译之后,存放于类构造器<clinit>()方法之中,所以把value复制为123的动作在初始化阶段才会执行。

另外,通常情况下是零值,什么情况下不是零值呢?

public static final int value = 123;

这样的情况下字段属性表存在ConstantValue属性,编译的时候就为将value生成ConstantValue属性,直接把value复制为123,也就是用static final修饰的时候经过准备阶段变量就直接被赋予了对应的值,否则为零值

解析

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

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任意字面量,只要使用时能无歧义地定位到目标即可。

直接引用:直接引用可以是直接指向目标地指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同JVM上翻译出来的直接引用一般不会相同。

初始化

类初始化阶段时类加载过程的最后一步,之前的类加载过程中,除了加载阶段用户可以通过自定义类加载器参与之外,其余动作完全由JVM控制和主导。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码

在准备阶段,变量一般已经被初始化为了零值,而在初始化阶段则更具程序员制定的计划去初始化类变量和其他资源,或者可以说初始化时执行类构造器<clinit>()方法的过程。

<clinit>方法

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

public class Test{

    static{

        i = 0;//给变量赋值可以正常编译通过

        System.out.println(i);//访问会报错 Cannot reference a field before it is defined大致意思是非法向前引用

    }

    public static int i = 2;

}

b、父类的<clinit>()与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式调用父类构造器,JVM会保证在子类的<clinit()>执行之前父类的<clinit>()方法已经执行完毕,因此JVM中第一个被执行<clinit>()方法的类一定是java.lang.Object

c、由于父类的<clinit>()方法会先执行,就意味着父类中定义的静态语句块要优于子类的变量赋值操作

d、<clinit>()方法不是必须的,如果一个类中没有静态语句块,也就没有对类变量的赋值操嘴,编译器也就可以不为这个类生成<clinit>()方法。

e、接口中不能使用静态语句块,但是也有变量初始化的赋值操作,因此也会生成<clinit>()方法。接口和类不同的地方在于执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有使用了父接口的变量,父接口才会被初始化,去执行<clinit>()方法。而且接口的实现类在初始化的时候也不会执行接口的<clinit>()方法

f、JVM会保证<clinit>()方法执行时的线程安全,当多个线程同时初始化一个类的时候,一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程的<clinit>()方法执行完毕。如果一个类的<clinit>()方法中有耗时很长的操作,就有可能造成多个进程阻塞。在实际应用中这种阻塞往往很隐蔽,比如下列代码:

public class TestClass{    static{        if(true){//不写if编译器会提示"Initializer does not complete normally"            System.out.println("init");            while(true){//模拟超长时间的<clinit>()            }        }    }}public static void main(String[] args){    Runnable script = new Runnable(){        public void run(){            System.out.println(Thread.currentThread()+"start");            TestClass tc = new TestClass();                    }    }}

在这样的情况下一条线程会进行超长时间的<clinit>()方法,而另外一条线程则会一直阻塞等待。


类加载器

前面提到过“通过一个类的全限定名来获取此类的二进制字节流”这个动作是JVM外部来实现的,实现这个动作的模块叫做类加载器,具有很高的自由度。

类与类加载器

比较两个类是否相等,只有在两个类是由同一个类加载器加载的前提下才算是相等,否则哪怕这两个类来源于同一个Class文件,被同一个JVM加载,只要加载他们的类加载器不同,他们就一定不相等。这里的相等指的是Class对象的equals()方法、isAssignableForm()方法、isInstance()方法以及instanceof关键字。

比如下面用自定义的类加载器来进行加载:

package classloadertest;import java.io.IOException;import java.io.InputStream;public class ClassLoaderTest {    public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {        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();                }            }        };        Object obj = myLoader.loadClass("classloadertest.ClassLoaderTest").newInstance();        System.out.println(obj.getClass());//class classloadertest.ClassLoaderTest        System.out.println(obj instanceof classloadertest.ClassLoaderTest);//false    }}
我们写了一个简单的类加载器,去加载classloadertest.ClassLoaderTest这个类,虽然通过getClass()发现这里的obj对象确实是classloadertest.ClassLoaderTest这个类加载出来的,但是用instanceof判断的时候却返回了false,这是因为JVM存在了两个ClassLoaderTest类,一个是系统应用程序类加载器加载的,另一个是我们自己写的类加载器加载的,是两个独立的类。

双亲委派模式

从JVM角度来看,JVM只存在两种不同的类加载器:

1、启动类加载器(Bootstrap ClassLoader),这个类加载器使用c++实现,是JVM的一部分

2、所有其他的类加载器,用Java实现,独立于JVM,全都继承自抽象类java.lang.CLassLoader。

三种系统提供的常用的类加载器:

1、启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数指定的路径中的,并且JVM识别的类库加载到JVM内存中。这个加载器无法被Java程序直接引用,如果想要把加载请求委派给启动类加载器,直接用null代替即可。

2、扩展类加载器(Extension ClassLoader):负责<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用。

3、应用程序类加载器(Application ClassLoader):是ClassLoader中的getSystemClassLoader()方法的返回值,一般称为系统类加载器,负责加载用户路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,也是一个默认的类加载器

看一下双亲委派机制的模型:


这种模式要求除了顶层的启动类加载器之外,其他的类加载器都有自己的类加载器,但是这里的父子关系一般是不以继承(Inheritance)的关系来实现,而是以组合(Composition)关系来复用父加载器的代码。

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

这不是一种强制的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。双亲委派模型对保证Java程序稳定运行很重要,使得Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object类,最终是委派给最顶端的启动类加载器进行加载,这也使得Object类在各种类加载器环境中都是同一个类。如果没有这种模型,用户自己编写一个java.lang.Object类,放在ClassPath下,系统会出现多个Object类,使得应用程序一片混乱。

破坏双亲委派模型

破坏双亲委派模型中的破坏的含义不是一个贬义词,主要出现过3次较大规模的被破坏情况:

1、第一次出现在双亲委派模型这个概念之前,当时用户继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为JVM在进行类加载时会调用类加载器的私有方法loadClassInternal(),这个方法的唯一逻辑就是调用自己的loadClass()。为了引入双亲委派模型,ClassLoader加入了一个新的protected方法findClass(),提倡用户把类加载逻辑写在findClass()当中,如果loadClass()方法中父加载器加载失败,就调用自己的findClass()方法来完成加载,以保证这个类加载器是符合双亲委派机制的。

2、Java设计团队引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader),以解决基础类无法调用用户代码的问题,通过java.lang.Thread类的setContextClassLoader()方法进行设置。通过这个类加载器可以让父加载器去请求子加载器。

3、因为对程序动态性的追求(说白了就是代码的可插拔)导致的。在OSGi环境下,类加载器从树状结构变成了复杂的网状结构