JVM类加载机制

class类文件结构

class文件是一组以8位字节为基础单位的二进制流,采用类似于C语言结构体的伪结构来存储,只有两种数据类型:无符号数和表,无符号数包括u1、u2、u4、u8代表1、2、4、8个字节无符号数;表由多个无符号数或其他表构成的复合数据结构,习惯以”_info”结尾。通过

1
$ javap -verbose Test.class

命令可以将class文件以可读形式输出,整个class文件就是一张表,由如下数据项构成:

  • u4魔数:0xCAFEBABE
  • u2/u2:class文件minor/major version
  • 常量池
  • 类基础数据:访问标志、类索引、父类索引、接口索引集合(常量池中的索引值)
  • 字段表集合
  • 方法表集合
    • 方法、字段都使用CONSTANT_Utf8_info型常量来描述名称,而长度类型为u2,因此,方法和字段名最大长度为65535,超过64KB字符的变量或方法名将无法编译
    • 描述符:对于数组类型,每一维度将使用一个前置”[“来描述,如:String二维数组记录为:[[Ljava/lang/String;一维整形数组记录为:[I。描述方法时,按照先参数列表(按参数严格顺序放在括号内),后返回值,如:方法int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targeOffset, int targetCount, int fromIndex)描述符为:([CII[CIII)I
    • code_length长度类型为u4,理论上最大值是2^32-1,但是虚拟机规范限制了一个方法不允许超过65535条字节码指令,如果超过,将无法编译
  • 属性表集合

JVM类加载

JVM把class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是JVM的类加载机制。与编译时进行连接的语言不同,Java语言的类型加载和连接都是在运行期完成的,因此在类加载时会稍微增加性能开销,但是却为Java提供了高度的灵活性。Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态链接这个特点实现的。

加载(Loading)

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(不一定从class文件中获取,可以从zip包中读取,如:jar、ear、war;从网络中读取,如:Applet;运行时计算生成,如:动态代理技术java.lang.reflect.Proxy;从其他文件生成,如:JSP;从数据库中读取,如:某些中间件服务器(SAP Netweaver)可以把程序安装到数据库来完成代码在集群中的分发
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在堆中生成一个代表这个类的java.lang.class对象,作为方法区这些数据的访问入口

链接阶段分为3个部分:

验证(Verification)

Java编译器会检查诸如:数组越界、非法跳转之类的异常操作,代码编译得到的字节码相对安全。但是如果通过其他途径,如:直接十六进制编辑器编写的class文件,则可能会导致JVM崩溃,因此,验证是JVM对自身保护的一项重要工作。如果所运行的全部代码(包括第三方包中的代码)都已经被反复使用和验证,在实施阶段可以通过参数-Xverify:none关闭大部分的验证措施以缩短类加载的时间。

  1. 文件格式验证:验证class文件格式规范。这个阶段的验证是基于字节流进行,经过这个阶段验证后,字节流才会进入方法区存储,后面三个阶段全部都是基于方法区的存储结构进行
  2. 元数据验证:这个阶段是对字节码描述的信息进行语义分析,以保证起描述的信息符合Java语言规范要求
  3. 字节码验证:进行数据流和控制流分析,这个阶段对类的方法体进行校验分析,这个阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为
  4. 符号引用验证:符号引用中通过字符串描述的全限定名是否能找到对应的类;指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;符号引用类中的类,字段和方法的访问性(private、protected、public、default)是否可被当前类访问
准备(Preparation)

准备阶段是正式为类变量(被static修饰的变量,不包括实例变量,实例变量会在对象实例化时随着对象一起分配到堆中)分配内存并设置变量初始值(零值)的阶段,这些内存都将在方法区中进行分配。如:

1
public static int value = 123; 

在准备阶段过后初始值为0,而把value赋值为123的putstatic指令是在程序被编译后,存在于类构造器<clinit>()方法之中,所以将value赋值为123是在初始化阶段被执行的。但如果类字段字段属性表存在ConstantValue属性,如:

1
public static final int value = 123; 

编译时会为value生成ConstantValue属性,在准备阶段JVM会根据ConstantValue将value设置为123,跳过零值设置阶段。

解析(Resolution)

解析阶段是虚拟机将常量池内的符号引用(class文件中的类型常量)替换为直接引用(根据对象访问方式,可能是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄)的过程。JVM会根据需要,在类被加载器加载时或者等一个符号引用被使用前进行解析。

常见的解析有四种:

  1. 类或接口CONSTANT_Class_info的解析,如果类D中有一个类或接口C的引用N,如:

    1
    2
    3
    class D {
        public static C N;
    }
    

    (1)如果C不是数组类型,JVM把N的全限定名传递给D的类加载器去加载类C

    (2)如果C是数组类型,并且数组元素类型是对象,如N的描述符为[Ljava.lang.String,先按(1)加载数组元素类型(即java.lang.String),然后由JVM生成一个代表此数组维度和元素的数组对象

    (3)如果前两步没有异常,那么C在JVM中实际已经成为一个有效的类或接口,还要进行符号引用验证,确认C是否具备对D的访问权限

  2. 字段CONSTANT_Fieldref_info解析,首先对字段表中的class_index的CONSTANT_Class_info按1进行解析,解析成功,将字段所属的类或接口用C表示,依次从C,C的接口和父接口,C的父类递归查找与目标相匹配的字段,如果成功找到引用,还要进行访问权限验证

  3. 类方法CONSTANT_Methodref_info解析,首先对类方法表中的class_index的CONSTANT_Class_info按1进行解析,解析成功,将字段所属的类或接口用C表示,依次从C,C的父类,C的接口和父接口递归查找与目标相匹配的方法,如果在接口和父接口找找到匹配方法,说明C是一个抽象类,抛AbstractMethodError异常。如果成功找到引用,还要进行访问权限验证

  4. 接口方法CONSTANT_InterfaceMethodref_info解析,首先对接口方法表中的class_index的CONSTANT_Class_info按1进行解析,解析成功,将字段所属的类或接口用C表示,依次从C,C的父接口中递归查找与目标相匹配的方法,由于接口中所有方法默认都是public的,所以不存在访问权限问题

初始化(Initialization)

触发类初始化有且只有以下四种情况:

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令,对应Java代码场景是:使用new关键字实例化对象、读取或设置一个类的static字段(被final修饰、已在编译器放入常量池的静态字段除外)或调用一个类的静态方法的时候
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候
  3. 初始化一个类,如果发现其父类没有进行过初始化,需要先出发其父类初始化
  4. JVM启动时,用户指定一个要执行的主类(包含main()方法的那个类),JVM会先初始化这个主类

初始化阶段才真正开始执行类中定义的java程序代码,初始化阶段是执行类构造器<clinit>()方法的过程

  • <clinit>()方法由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)的语句按在源代码中出现的顺序合并产生。静态语句块只能访问定义在其之前的变量,对于定义在其之后的变量,在前面的静态语句块只可以对变量赋值,不能访问:
1
2
3
4
5
static {
    a = 11;
    int b = a; //编译无误,无法访问后面定义的变量
}
static int a;
  • <clinit>()方法无需显示调用父类构造器,JVM会确保在子类的<clinit>()方法调用之前,先执行完父类的<clinit>()方法,因此第一个执行的<clinit>()方法的类是java.lang.Object
  • 如果类或接口没有静态语句块,也没有对变量的赋值操作,可以没有<clinit>()方法
  • 接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用是,父接口才会被初始化,同样,接口的实现类初始化时也一样不会执行接口的<clinit>()方法
  • JVM会为一个类的<clinit>()方法在多线程运行环境中自动加锁和同步,如果多个线程同时初始化一个类,只有一个线程去执行这个类的<clinit>()方法,其他线程阻塞等待,知道活动线程执行完毕。如果一个类的<clinit>方法有耗时很长的操作,会造成多个线程阻塞,应当尽量避免

类加载器

类加载阶段中“通过类的全限定名来获取描述此类的二进制字节流”可以让应用程序自己决定如何获取所需要的类,实现这一动作的代码模块称为“类加载器”。

对于任何一个类,由加载它的类加载器和这个类本身一同确定其在JVM中的唯一性,即使两个类来源于同一个class文件,如果加载它们的类加载器不相同,这两个类也不相等(equals(),isAssignableFrom(),isInstance(),instanceOf的结果不相等)

类加载器的主要工作流程如下:

loadClass(Java方法,确认由哪个加载器进行加载)->findClass(Java方法,寻找class文件)-> defineClass(native方法,通过class文件在内存中生成Class类供使用)

双亲委派模型(Parents Delegation Model)

JVM的类加载器有多个,不同的类加载器用于搜索不同位置的class文件,满足如下层次关系的类加载器称为双亲委派模型:

  • 启动类加载器(Bootstrap ClassLoader):是用本地代码实现的类装入器,它负责将$Java_Runtime_Home/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。该类加载器代码在JVM内核中,由native代码实现
  • 扩展类加载器(Extension ClassLoader):是由sun.misc.Launcher$ExtClassLoader实现的。它负责将$Java_Runtime_Home/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。该类加载器由Java代码实现
  • 应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径ClassPath上所指定的类库,是类加载器ClassLoader中的getSystemClassLoader()方法的返回值,开发者可以直接使用应用程序类加载器,如果程序中没有自定义过类加载器,该加载器就是程序中默认的类加载器该类加载器由Java代码实现
  • 自定义类加载器:继承自抽象类java.lang.ClassLoader。直接覆盖loadClass()方法会破坏双亲委派模型,因此不提倡。而应该覆盖findClass(),如果父类加载失败,会调用当前的findClass()方法,保证自定义的类加载器符合双亲委派模型。

除了顶层的启动类加载器外,其余类加载器都有自己的父类加载器。上述三个JDK提供的类加载器虽然是父子类加载器关系,但是没有使用继承(Inheritance),而是使用了组合(Composition)关系

从JDK1.2开始,JVM规范推荐开发者使用双亲委派模式进行类加载,其加载过程如下:

  1. 如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器去完成
  2. 每一层的类加载器都把类加载请求委派给父类加载器,直到所有的类加载请求都传递给顶层的启动类加载器
  3. 只有当父类加载器无法完成加载请求(它的搜索范围内没有找到所需要的类),子类加载器才会尝试去加载,如果连最初发起类加载请求的类加载器也无法完成加载请求时,将会抛出ClassNotFoundException,而不再调用其子类加载器去进行类加载

采用双亲委派模型的好处在于:使得Java类随着它的类加载器一起具备了一种带有优先级的层次关系,越是基础的类,越是被上层的类加载器进行加载,保证了基础类被同一个基础加载器加载,使得不同加载器加载得到的是同一基础类(判断类相同的标准是类加载器和类本身),从而保证了Java程序的稳定运行。

不符合双亲委派模型的情况:

  • 基础类需要调用回用户的代码,如JNDI需要调用由独立厂商实现并部署在ClassPath下的JNDI接口提供者(SPI,Service Provider Interface),引入线程上下文类加载器(Thread Context ClassLoader),如果未设置,从父线程继承,如果全局范围未设置,默认是应用程序类加载器。JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作。所有涉及SPI的加载动作基本上都采用这种方式,如:JNDI、JDBC、JCE、JAXB和JBI等
  • 追求程序动态性,如:代码热替换(HotSwap)、模块热部署(Hot Deployment)等。OSGi(Open Service Gateway Initiative)是当前业界的Java模块化事实标准,OSGi在Java中最著名的应用案例就是Eclipse IDE。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连通类加载器一起换掉以实现代码的热替换。因此,不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。除了以java.*开头的类和委派列表名单中的类,委派给父类加载器加载,其余的类查找都是在平级的类加载器中进行。这种网状结构带来灵活性的同时,也引入了额外的复杂度,带来了线程死锁和内存泄露的风险

类加载器实例:Tomcat的类加载器架构

Web服务器需要实现以下基本功能,因此主流的Java Web服务器都事先定义了不止一个自己的类加载器:

  • 同一个服务器两个Web应用所使用的类库可以相互隔离
  • 同一个服务器两个Web应用所使用的类库可以相互共享
  • 服务器要保证自身安全不受Web应用影响,因此,一般而言,服务器使用的类库应该与Web应用隔离
  • 支持JSP应用的Web服务器,需要支持HotSwap功能,因为JSP文件最终被编译为Java的Servlet来运行,当修改JSP文件时,不需要重启服务器就可以实现热部署

由于存在上述需求,在部署Web应用时,单独的一个ClassPath无法满足需求,因此各种Web服务器都提供好几个ClassPath路径供用户存放第三方类库,被放置在不同路径的类库,具备不同的访问范围和服务对象,通常,每个目录都会有一个相应的自定义类加载器去加载该目录下的Java类库。

Tomcat5.x的类库结构如下:

  • /common目录:类库可以被Tomcat服务器本身和所有的Web应用程序共同使用
  • /server目录:类库可以被Tomcat服务器本身使用,对所有的Web应用程序不可见
  • /shared目录:类库可以被所有的Web应用程序共同使用,对Tomcat服务器本身不可见
  • /WebApp/WEB-INF目录:类库仅可以被当前Web应用程序使用,对其他的应用程序和Tomcat服务器不可见

对应的类加载器符合双亲委派模式:

CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader分别加载上述对应目录,其中WebApp类加载器和Jsp类加载器通常存在多个实例,每个Web应用程序对应一个WebAppClassLoader,每个JSP文件对应一个Jsp类加载器JasperLoader(不是JspClassLoader?)。

根据委派关系:CommonClassLoader加载的类都可以被CatalinaClassLoader、SharedClassLoader使用,而后两者自己加载的类相互隔离。WebappClassLoader可以使用SharedClassLoader加载到的类,但各个WebappClassLoader实例之间相互隔离。而JasperLoader加载范围仅仅是当前JSP文件编译出来的class,当检测到JSP文件被修改,会替换当前的JasperLoader实例重新加载,以实现JSP的HotSwap功能。

Tomcat6.x默认把/common、/server和/shared三个目录合并成一个/lib目录 ,类库结构简化为:

  • /lib目录:类库可以被Tomcat服务器本身和所有的Web应用程序共同使用,相当于5.x时的/common目录
  • /WebApp/WEB-INF目录:类库仅可以被应用程序使用,对其他的应用程序和Tomcat服务器不可见

对应的类加载器也简化为:

如果默认设置不能满足需求,可以通过修改配置文件指定server.loader和share.loader建立CatalinaClassLoader和SharedClassLoader,重新启用5.x的加载器架构。

根据上文“不符合双亲委派模型的情况”,如果Spring放到/lib目录让各个Web应用共享,Spring又需要对用户程序的类库进行管理,那么被CommonClassLoader加载的Spring需要引入线程上下文类加载器(Thread Context ClassLoader)访问Web应用在/WebApp/WEB-INF目录的类库。