Java虚拟机--之--类加载机制篇

1. 简介

我们都知道,我们编写的Java代码,由编译器编译成.class文件后,读取到虚拟机后由类加载器对class文件进行加载。类加载器属于虚拟机的一部分,那么类加载器它是如何加载一个class文件的、如何解析以及又如何分配内存的呢?等等。本篇文章从class文件结构出发,分析类加载过程,最后讲解Java中使用到的类加载器,将对这些疑问进行一一解答。大致内容如下:

2. class 类文件结构

Java之所以能做到跨平台执行,其原因就是Java源代码并没有直接编译成机器指令,而是编译成Java虚拟机可认识和运行的字节码文件即class文件,Java虚拟机屏蔽了不同操作系统和硬件的差异性。

           那么class文件结构是怎样的呢?

  • 是一组以8位字节为基础的单位的二进制流;
  • 各个数据项目严格按照顺序紧凑排列在class文件中;
  • 中间没有任何分隔符,这使得class文件中存储的内容几乎是全部程序运行的程序,当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储;
  • Java虚拟机规范规定,Class文件格式采用类似C语言结构体的伪结构来存储数据,这种结构只有两种数据类型:无符号数和表。

何为无符号数和表?

  • 无符号数属于基本数据类型,主要可以用来描述数字、索引符号、数量值或者按照UTF-8编码构成的字符串值,大小使用u1、u2、u4、u8分别表示1字节、2字节、4字节和8字节。
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯以“_info”结尾,表主要用于描述有层次关系的复合结构的数据,比如方法、字段。

class文件格式如下:

由于class文件的结构没有任何分隔符好,因此表结构是严格按照上图的方式来存储的,那个字节代办什么含义,长度多少,先后顺序都是不允许改变的。

  • 魔数(Magic Number)
  1. 每个Class文件的头4个字节称为魔数;
  2. 唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件;
  • 次版本号与主版本号

版本主要用来控制当前class文件能够运行的最低JDK版本,JDK可编译生成的class文件版本号必须大于或等于该class文件的版本号。

  • 常量个数和常量池表
  1. 常量个数由两个字节表示,记录常量池中有多少项常量;
  2. 常量池中主要存放两大类常量:字面量(例如文本字符串、final类型常量等)和符号引用(例如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符);
  3. 常量池中每一项常量都是一个表,这个表结构中记录的有该常量的信息。

由于篇幅考虑,就不对class文件中其他类型做介绍了,在此点到为止,具体可以参考《深入理解Java虚拟机》。通过上图,细心的读者就会发现方法的个数也是u2类型,也即两个字节16位,2的16次方,也就是65536,而常量池容量计数是从1开始的,所以u2类型最多能表示65535个不同的值,这也说明方法数不能超过65535的原因。

3. 类加载过程

上面简要的分析了class文件的结构,那么class文件的加载过程是怎样的呢?

简要的说,虚拟机把描述类的数据从Class文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

像c/c++,连接工作时在编译时完成的,Java不同,它的连接是在运行时完成的。

从类被加载到虚拟机内存中开始,到卸载出内存为止,类的生命周期包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段:

验证、准备和解析三部分称为连接,加载、验证、准备、初始化和卸载这5个阶段的顺序是固定的,类的加载过程必须按照这种顺序按部就班的开始,解析过程有时候可能在初始化阶段之后开始,这主要是为了Java支持动态绑定也就是运行时绑定。

  • 加载

加载”是“类加载”中的一个阶段(两个名词不要弄混了),这个阶段通常也被称作“装载”。在这一过程中主要完成三件事:

1. 通过一个类的全限定名来获取定义此类的二进制字节流;

虚拟机规范对于通过“类全名”来获取定义此类的二进制字节流,并没有指明二进制流必须要从一个本地class文件中获取,准确地说是根本没有指明要从哪里获取及怎样获取。例如可以从如下方式获取:

  • 从Zip包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础;
  • 从网络获取,常见应用Applet;
  • 运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中;
  • ……

2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构;

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

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较 特殊,它虽然是个对象,但在存放在方法区里!!!),这个对象作为程序访问方法区中的这些类型数据的外部接口。

  • 验证

验证是连接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

  • 文件格式验证

该阶段主要验证字节流是否符合Class文件格式的规范,并且是否能被当前版本的虚拟机处理(如:是否以魔数0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内等)。

  • 元数据验证

这个阶段是对字节码描述的信息进行语义分析,以保证起描述的信息符合java语言规范要求。例如这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、这个类是否继承了不允许被继承的类(被final修饰的)等等。

  • 字节码验证

这个阶段主要是是通过数据流和控制流分析确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完全检验后,这个阶段对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。例如可以把一个子类对象赋值给父类数据类型,这是安全的,但不能把一个父类对象赋值给子类数据类型、保证跳转命令不会跳转到方法体以外的字节码命令上。

  • 符号引用验证

符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。例如符号引用中通过字符串描述的全限定名是否能找到对应的类、指定的类中是否存在符合描述符与简单名称描述的方法与字段、符号引用类中的类,字段和方法的访问性(private、protected、public、default)是否可被当前类访问等等。

  • 准备

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

需要注意的是:

  • 这个时候进行内存分配的仅包含 类变量(被Static修饰的变量),则不包括实例变量(实例变量将会在对象实例化时随着对象一起分配在Java堆中);
  • 这里所说的初始值“通常情况”下是数据类型的零值。假设一个类变量定义为: public static int value = 123;那么变量value在准备阶段过后的初始值为0而不是123;
  • 特殊情况下,例如public final static int a = 123,此时在该阶段初始值为123。
  • 解析

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

那么什么是符号引用什么是直接引用呢?

  • 符号引用

符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因此符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

  • 直接引用

直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

  • 初始化

类初始化阶段是“类加载过程”中最后一步,在之前的阶段,除了加载阶段用户应用程序可以通过自定义类加载器参与,其它阶段完全由虚拟机主导和控制,直到初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

在准备阶段,变量已经赋过一次初始值,在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其它资源,简单说,初始化阶段即虚拟机执行类构造器<clinit>()方法的过程,下面详细介绍下<clinit>方法:

  • <clinit>由编译器自动收集类中所有类变量的赋值动作静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定,需要注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如如下Demo:
public class TestClinit {
    public static int i = 1;
    static{
        i += 1;
        System.out.println("i = " + i );
        j = 2; // 可以赋值,但不能访问
        //System.out.println( j );  // 会报错,不能访问到j
    }
    public static int j = 1;
    public static void main(String[] args) {
        System.out.println("j = " + Test.j ); // 输出为 j = 1
    }
}
  • 类构造器<clinit>()方法与类的构造函数(实例构造函数<init>()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕(这也意味着 父类中定义的静态语句块要优于子类的变量赋值操作)。因此在虚拟机中的第一个执行的< clinit>()方法的类肯定是java.lang.Object。
  • < clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句,也没有类变量赋值的操作,那么编译器可以不为这个类生成< clinit>()方法
  • 虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么 只会有一个线程执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕(生成单例时可以用到)。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

4. 类加载器

       何为类加载器?

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

在加载阶段,Java虚拟机需要完成三件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流;
  • 将定义类的二进制字节流所代表的静态存储结构转换为方法区的运行时数据结构;
  • 在java堆中生成一个代表该类的java.lang.Class对象,作为方法区数据的访问入口。

第一件事就是由类加载器来完成。

比较两个类是否相等,只有这两个类是由同一个类加载器加载才有意义。否则,即使这两个类是来源于同一个Class文件,只要加载它们的类加载器不同,那么这两个类必定不相等。下面用一个例子来说明:

public class ClassLoaderTest {

    public static void main(String[] args){

        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {

                String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
                InputStream is = getClass().getResourceAsStream(fileName);
                if(is == null){
                    return super.loadClass(name);
                }
                try {
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    e.printStackTrace();
                }

                return super.loadClass(name);
            }
        };

        try {
            Class c = myLoader.loadClass("gc.ClassLoaderTest");
            Object o = null;
            try {
                o = c.newInstance();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
            System.out.println(o.getClass());     // class gc.ClassLoaderTest
            System.out.println(o instanceof ClassLoaderTest);        // false
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

        try {
            Class c = Class.forName("gc.ClassLoaderTest");
            Object o = null;
            try {
                o = c.newInstance();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
            System.out.println(o.getClass());  // class gc.ClassLoaderTest
            System.out.println(o instanceof ClassLoaderTest);    // true
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

    }


}

从输出可以看到,这两个类确实都是ClassLoaderTest实例化出来的,但并非是同一个类,虚拟机中存在两个ClassLoaderTest类,一个是系统应用程序类加载器加载的,另一个是myLoader类加载器加载的,虽然来自同一个class文件,但实际在虚拟机中是独立的。

从Java虚拟机的角度来看,类加载器其实只有两种,即:启动类加载器(由C++实现,是虚拟机自身一部分)和其他类加载器(由Java语言实现,独立与虚拟机)。

但是从开发人员的角度来看,可以分为三种类加载器。

  • 启动类加载器(Bootstarp ClassLoader)

这个ClassLoader由JVM自己控制。主要加载JVM自身工作需要的类:将%JAVA_HOME%\lib路径下或-Xbootclasspath参数指定路径下的、能被虚拟机识别的类库(仅按照文件名识别,如:rt.jar,名字不符合的类库不会被加载)加载至虚拟机内存中,启动类加载器无法被Java程序直接引用。

  • 扩展类加载器(Extension ClassLoader)

该类加载器由sun.misc.Launcher类的静态内部类ExtClassLoader实现,负责加载%JAVA_HOME%\jre\lib\ext的所有类库,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader)

该类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,也称为系统类加载器。负责加载用户类路径(Classpath)上所指定的类库,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

当然,除了如上三种,我们也可以自定义类加载器,上面已有Demo展示(上例Demo实现是为了突出上面的说明,我们一般在自定义类加载器的时候,并不建议复写loadClass方法),我们的自定义类加载器需要继承java.lang.ClassLoader类并重写该类的findClass方法来实现。

总结

类加载过程,我们所讲的是在Java虚拟机中类的加载全过程,主要分为加载、验证、准备、解析和初始化五个阶段,而类加载器它所起到的作用并不是类加载的全过程,仅仅只是加载过程中的第一步:通过一个类的全限定名来获取定义此类的二进制字节流,这很容易让人造成混淆。

参考文献

《深入理解Java虚拟机》

https://blog.csdn.net/gaitiangai/article/details/50930849

http://liucw.cn/2017/12/24/jvm/JVM%E8%99%9A%E6%8B%9F%E6%9C%BA%E7%B1%BB%E5%8A%A0%E8%BD%BD%E6%9C%BA%E5%88%B6/

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值