《深入理解Java虚拟机》学习笔记之类加载

时间:2023-12-16 19:29:02

之前在学习ASM时做了一篇笔记《Java字节码操纵框架ASM小试》,笔记里对类文件结构做了简介,这里我们来回顾一下。

Class类文件结构

在Java发展之初设计者们发布规范文档时就刻意把Java的规范拆分成了Java语言规范和Java虚拟机规范。而Java可以跨平台主要是靠不同平台的虚拟机来实现的,而在不同平台的虚拟机使用了统一的程序储存格式——字节码(ByteCode)即Class文件。

由于字节码格式(Class文件)是统一的,所以在Java语言之外发展出了一大批在Java虚拟机上运行的语言,如Clojure、Groovy、JRuby、Jython、Scala等,他们都有自己的编译器(如Java的javac),通过各自的编译器编译成Java虚拟机能够识别的字节码Class文件。这称为Java虚拟机语言无关性,如下图:

《深入理解Java虚拟机》学习笔记之类加载

Class文件是一组以8位字节为基础单位的二进制流,包含多个数据项目(数据项目的顺序,占用的字节数均由规范定义),各个数据项目严格按照顺序紧凑的排列在Class文件中,不包含任何分隔符,使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙。当遇到需要占用超过8位字节以上空间的数据项目时,会按照高位在前的方式分割为多个8位字节进行存储。数据项目分为2种基本数据类型(以及由这两种基本数据类型组成的集合):

  • 无符号数,以u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数;
  • 表,以“_info”结尾,由多个无符号数或其它表构成的复合数据类型;

Class文件格式如下表所示:

类型

名称

数量

u4

magic

1

u2

minor_version

1

u2

major_version

1

u2

constant_pool_count

1

cp_info

constant_pool

constant_pool_count-1

u2

access_flags

1

u2

this_class

1

u2

super_class

1

u2

interfaces_count

1

u2

interfaces

interfaces_count

u2

fields_count

1

field_info

fields

fields_count

u2

methods_count

1

method_info

methods

methods_count

u2

attributes_count

1

attribute_info

attributes

attributes_count

由于不包含任何分隔符,故表中的数据项,无论是数量还是顺序,都是被严格按照书面协议来的。当需要描述同一类型但数量不定的多个数据项时,经常会使用一个前置的容器计数器加若干个连续的数据项的形式。

虚拟机加载机制

《深入理解Java虚拟机》学习笔记之类加载

如上图,类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分称为连接(Linking)。而加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,但解析阶段则不一定:它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称动态绑定或晚期绑定)。

对于初始化阶段,虚拟机规范严格规定了有且只有四种情况必须立即对类进行"初始化":

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

以上四种情况会触发类进行初始化,称为对一个类进行主动引用除此之外所有引用类的方式,都不会触发初始化,称为被动引用。举例如下:

  1. 通过子类引用父类的静态字段,不会导致子类初始化
    package net.oseye;
    
    public class App
    {
    public static void main( String[] args )
    {
    System.out.println(SubClass.value);
    }
    } class SuperClass{
    static{
    System.out.println("SuperClass init!");
    } public static int value=123;
    } class SubClass extends SuperClass{
    static{
    System.out.println("SubClass init!");
    }
    }

    执行只会输出

    SuperClass init!
    1000

    对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现。对于Sun HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数看到此操作会是会导致子类的加载的,执行:

    java -XX:+TraceClassLoading net.oseye.App

    输出:

    ....
    [Loaded java.security.BasicPermissionCollection from shared objects file]
    [Loaded net.oseye.App from file:/D:/workspace4jee/Temp/target/classes/]
    [Loaded java.lang.Void from shared objects file]
    [Loaded net.oseye.SuperClass from file:/D:/workspace4jee/Temp/target/classes/]
    [Loaded net.oseye.SubClass from file:/D:/workspace4jee/Temp/target/classes/]
    SuperClass init!

    123

    [Loaded java.lang.Shutdown from shared objects file]
    [Loaded java.lang.Shutdown$Lock from shared objects file]

    其实使用javap反编译后你看到getstatic的身影,执行:

    javap -c net.oseye.App

    输出中能够看到

    ....
    3: getstatic #22 // Field net/oseye/SubClass.value:I
    6: invokevirtual #28 // Method java/io/PrintStream.println:(I)V
    .....

  2. 通过数组定义来引用类,不会触发此类的初始化
    借用上面的代码,修改App为
    public class App
    {
    public static void main( String[] args )
    {
    SuperClass[] sca=new SuperClass[10];
    }
    }

    执行没有任何输出,并没有初始化SuperClass,通过javap反编译也没看到new、getstatic、putstatic或invokestatic。

  3. 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化
    package net.oseye;
    
    public class App
    {
    public static void main( String[] args )
    {
    System.out.println(ConstClass.value);
    }
    } class ConstClass{
    static{
    System.out.println("ConstClass init!");
    } public static final int value=1000;
    }

    执行也没输出“ConstClass init!”,反编译App你可以看到

    3: sipush 1000

    根本没有对ConstClass类的符号引用入口,直接储存到了调用类中。

类加载过程

  1. 加载
    加载(Loading)是类加载的第一个阶段,主要工作:
      i. 通过一个类的全限定名来获取定义此类的二进制字节流。
      ii. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 
      iii. 在java堆中生成一个代表这个类的java.lang.Class对象,做为方法区这些数据的访问入口。
    加载阶段完成之后二进制字节流就按照虚拟机所需的格式存储在方区去中,然后在Java堆中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中这些类型数据的外部接口。
  2. 验证
    这一阶段是连接的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求。 
    i.文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。 
    ii.元数据验证:对字节码描述的信息进行语义分析,以确保其描述的信息符合java语言规范的要求。 
    iii.字节码验证:这个阶段的主要工作是进行数据流和控制流的分析。任务是确保被验证类的方法在运行时不会做出危害虚拟机安全的行为。 
    iv.符号引用验证:这一阶段发生在虚拟机将符号引用转换为直接引用的时候(解析阶段),主要是对类自身以外的信息进行匹配性的校验。目的是确保解析动作能够正常执行。 3. 准备:准备阶段是正式为变量分配内存并设置初始值,这些内存都将在方法区中进行分配,这里的变量仅包括类标量不包括实例变量。
  3. 准备
    准备阶段是正式为变量分配内存并设置初始值,这些内存都将在方法区中进行分配,这里的变量仅包括类变量不包括实例变量。
  4. 解析
    解析是虚拟机将常量池的符号引用替换为直接引用的过程。
    • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
    • 直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接饮用是与内存布局相关的。

    解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTAN_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。

  5. 初始化
    类初始化是类加载过程中最后一步,前面的加载过程总,除了在加载阶段用户可以通过自定义类加载器参与之外,其余动作全部是虚拟机主导和控制的。到了初始化阶段,才真正开始执行中定义的Java代码(更确切地说是字节码)。
    在准备阶段变量已经被赋过一次系统要求的非零值,而在初始化阶段则根据程序员的设定来初始化类变量和其他资源,从另一个角度来说,初始化阶段就是执行类结构器<clinit>()方法的过程。那么来了解下类结构器<clinit>():
    • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量。
    • <clinit>()方法与类的构造函数(或者说实例构造器()方法)不同,它不需要显式地调用父类构造器,虚拟机会在子类的<clinit>()方法执行之前完成父类<clinit>()方法的执行。
    • 由于父类的<clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
    • <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,则编译器可以不为这个类生成<clinit>()方法。
    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法,不同于类的地方是执行接口的<clinit>()方法时不坱要先执行父类的<clinit>()方法。
    • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,则只有一个线程去执行这个类的<clinit>()方法,其它线程阻塞等待,直到活动线程执行<clinit>()方法完毕。

类加载器

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

类加载器通常用于类层次划分、OSGi、热部署、代码加密等。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟中的唯一性。说通俗一些,比较两个类是否“相等”,只有在两个类是由同一个类加载器的前提之下才有意义,否则,即使这两个类来源于同一个class文件,只要加载它的类加载器不同,那这两个类必定不相等。这里所指的“相等”包括代表类的Class对象的equal方法、isAssignableFrom()、isInstance()方法及instance关键字返回的结果。

有三种Java类加载器:

  1. BootStrap ClassLoader:启动类加载器,负责加载存放在%JAVA_HOME%\lib目录中的,或者通被-Xbootclasspath参数所指定的路径中的,并且被java虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库,即使放在指定路径中也不会被加载)类库到虚拟机的内存中,启动类加载器无法被java程序直接引用。
  2. Extension ClassLoader:扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载%JAVA_HOME%\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. Application ClassLoader:应用程序类加载器,由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径classpath上所指定的类库,是类加载器ClassLoader中的getSystemClassLoader()方法的返回值,开发者可以直接使用应用程序类加载器,如果程序中没有自定义过类加载器,该加载器就是程序中默认的类加载器。

我们的应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,还可以加入我们自定义的类加载器,这些类加载器之间的关系如下:

《深入理解Java虚拟机》学习笔记之类加载

上图所示的这种层次关系称之为类加载器的双亲委派模型(Parent Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器代码。

类加载器的双亲委派模型在JDK1.2期间被引入,但它不是一个强制性的约束模型,而是Java设计者们推荐给开发者们的一种类加载器实现方式。

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

使用这种模型来组织类加载器之间的关系的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object类,无论哪个类加载器去加载该类,最终都是由启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。否则的话,如果不使用该模型的话,如果用户自定义一个java.lang.Object类且存放在classpath中,那么系统中将会出现多个Object类,应用程序也会变得很混乱。如果我们自定义一个rt.jar中已有类的同名Java类,会发现JVM可以正常编译,但该类永远无法被加载运行。

在rt.jar包中的java.lang.ClassLoader类中,我们可以查看类加载实现过程的代码,具体源码如下:

 protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
} if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name); // this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

通过上面代码可以看出,双亲委派模型是通过loadClass()方法来实现的,根据代码以及代码中的注释可以很清楚地了解整个过程其实非常简单:先检查是否已经被加载过,如果没有则调用父加载器的loadClass()方法,如果父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器加载失败,则先抛出ClassNotFoundException,然后再调用自己的findClass()方法进行加载。