jvm系列(一):类的加载机制和文件结构


本文首次发布于My Blog,作者@张琦(Ian),转载请保留原文链接。

如果看的过程中遇到不懂的名词或者语句,比如:“全限定名”、“数组去掉一个维度的类型”等等,请拉到本文的底部,有解释说明,大佬请忽视😅。

引言:

java在刚刚诞生之时曾经提出过一个非常著名的宣传口号:“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了软件开发人员对冲破平台界限的渴求。但是在Java发展之初,设计者就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性。除Java语言之外的比如:clojure、Groovy、JRuby、jython、Scala等也能够在Java虚拟机运行。

Java虚拟机并不关心Class的来源是何种语言

Java虚拟机不和包括Java在内的任何语言绑定它只与“class”文件这种特定的二进制文件格式所关联,class文件中包含了Java虚拟机指令集和符号表以及若干其它辅助信息。

Class类文件的结构

任何一个Class文件都对应着唯一一个类或者接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(比如类或接口也可以通过类加载器直接生成)。

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上的空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储

Class文件格式

Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数,后面解析都要以这两种数据类型为基础。

  • 无符号数属于基本数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 是由多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数获得很有“浪漫气息”,值为:0xCAFEBABE(咖啡宝贝)

根据Java开发小组最初的关键成员Patrick Naughton所说:“我们一直在寻找一些好玩的,容易记忆的东西,选择0xCAFEBABE是因为它象征着著名咖啡品牌Peet's Coffee中深受欢迎的Baristas 咖啡”。所以现在Java的商标名称就是一个☕️

紧接着魔数的4个字节存储的是Class文件的版本号:第5第6个字节是次版本号(Minor Version),第7第8个字节是主版本号(Major Version)。

常量池

紧接着主次版本号之后的就是常量池入口,常量池可以理解为Class文件之中的资源仓库。
常量池中主要存放两大类常量:字面量符号引用

  • 字面量:比较接近于Java语言层面的常量概念,如:文本字符串、声明为final的常量值等。
  • 符号引用:属于编译原理方面的概念,包括下面三类常量:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

虚拟机类加载机制

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

在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的;Java里天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。

类的加载时机

类从被加载到虚拟机内存中开始,到卸载内存为止,它的整个生命周期包括:加载验证准备解析初始化使用卸载7个阶段。其中验证、准备、解析3个部分统称为连接,下面是用在线工具processon画的图(这个工具很好用,推荐),如下图:

通常会在一个阶段执行的过程中调用、激活另外一个阶段。但是对于初始化阶段,虚拟机规范规则是严格规定类有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

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

对于上面5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用

/**
 * 被动使用类字段demo
 * 通过子类引用父类的静态字段,不会导致子类初始化
 *
**/
public class SuperClass{
    static{
        System.out.println("SuperClass init!");
    }
    
    public static int value = 123;
}

public class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init!");
    }
}


/**
 * 非主动使用类字段demo
 
**/
public class NotInitialization{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

上述代码运行之后,只会输出“SuperClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

/**
 * 被动使用类字段demo
 * 通过数组定义来引用类,不会触发此类的初始化。
**/
public class NoInitialization{
    public static void main(String[] args){
        SuperClass[] sca = new SuperClass[10];
    }
}

运行之后没有输出“SuperClass init!”,说明并没有触发类SuperClass的初始化阶段。但是这段代码里面触发类另一个名为“[Lorg.fenixsoft.classloading.SuperClass” 的类的初始化阶段,对于用户代码来说,这个并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray的触发。

/**
 * 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常 *量的类的初始化。
 *
**/
public class ConstClass{
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}

/**
 * 非主动使用类字段
 *
**/
public class NotInitialization{
    public static void main(String[] args){
        System.out.println("ConstClass.HELLOWORLD");
    }
}

上述代码运行之后,也没有输出,这是因为虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际都被转化为NotInitialization自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

接口和类加载过程稍有区别

接口也有初始化过程,这点是于类是一致的。

接口与类真正有所区别的是前面讲述的5种“有且仅有”需要开始初始化场景中的第3种:当一个在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有真正使用父类的时候(如引用接口中定义的常量)才会初始化。

类加载的过程

加载

虚拟机需要完成3件事情:

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

对于数组而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。一个数组类创建过程需要遵循以下规则:

  1. 如果数组的组建类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程区加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识。
  2. 如果数组的组件类型不是引用类型(int[]数组)Java虚拟机将会把组数C为与引导类加载器关联。
  3. 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

源码:

public class ArrayTest {

    /**
     * @param args
     */
    public static void main(String[] args) {
        test1();
        test2();
        test3();
    }

    /**
     * 数组具有这种特性:
     * 如果有两个类A和B,如果B继承(extends)了A,那么A[]类型的引用就可以指向B[]类型的对象
     * 测试数组的特殊特性对参数传递的便利性
     */
    private static void test3() {
        String[] a = new String[3];
        doArray(a);
    }

    private static void doArray(Object[] objs){
        
    }
    
    private static void doArray1(Object obj){
        //不能用Object接收数组,因为这样无法对数组的元素进行访问
        // obj[1]  //错误
        
        //如果在方法内部对obj转型到数组,存在类型转换异常的风险
        // Object[] objs = (Object[]) obj;
    }
    
    private static void doArray2(String[] strs){
        //如果适用特定类型的数组,就限制了类型,失去灵活性和通用性
    }
    
    private static void doArray3(String name, int age, String id, float account){
        //如果不适用数组而是依次传递参数,会使参数列表变得冗长,难以阅读
    }
    /**
     * 测试数组的集成关系, 并且他的继承关系是否和数组中元素的类型有关
     */
    private static void test2() {
        
        //1     在test1()中已经测试得到以下结论: 数组也是对象, 数组的顶层父类是Object, 所以可以向上转型
        int[] a = new int[8];
        Object obj = a ; //数组的父类也是Object,可以将a向上转型到Object
        
        //2     那么能向下转型吗?
        int[] b = (int[])obj;  //可以进行向下转型
        
        //3     能使用instanceof关键字判定吗?
        if(obj instanceof int[]){  //可以用instanceof关键字进行类型判定
            System.out.println("obj的真实类型是int[]");
        }
        
        //4     下面代码成立吗?
        String[] s = new String[5];
        Object[] obja = s;   //成立,说明可以用Object[]的引用来接收String[]的对象
        
        //5     那么String[] 的直接父类是Object[] 还是 Object?
        System.out.println(s.getClass().getSuperclass().getName());
        //打印结果为java.lang.Object,说明String[] 的直接父类是 Object而不是Object[]
        
        //6   下面成立吗?  Father是Son的直接父类
        Son[] sons = new Son[3];
        Father[] fa = sons;  //成立
        
        //7     那么Son[] 的直接父类是Father[] 还是  Object[] 或者是Object?
        System.out.println(sons.getClass().getSuperclass().getName());
        //打印结果为java.lang.Object,说明Son[]的直接父类是Object
        
        /**
         * 做一下总结, 如果A是B的父类, 那么A[] 类型的引用可以指向 B[]类型的变量
         * 但是B[]的直接父类是Object, 所有数组的父类都是Object
         */
        
        //8     上面的结论可以扩展到二维数组
        Son[][] sonss = new Son[2][4];
        Father[][] fathers = sonss;
        //将Father[][]数组看成是一维数组, 这是个数组中的元素为Father[]
        //将Son[][]数组看成是一维数组, 这是个数组中的元素为Son[]
        //因为Father[]类型的引用可以指向Son[]类型的对象
        //所以,根据上面的结论,Father[][]的引用可以指向Son[][]类型的对象
        
        /**
         * 扩展结论:
         * 因为Object是所有引用类型的父类
         * 所以Object[]的引用可以指向任何引用数据类型的数组的对象. 如:
         * Object[] objs = new String[1];
         * Object[] objs = new Son[1];
         *
         */
        
        //9     下面的代码成立吗?
        int[] aa = new int[4];
        //Object[] objaa = aa;  //错误的,不能通过编译
        //这是错误的, 因为Object不是int的父类,在这里自动装箱不起作用
        
        //10    这样可以吗?
        Object[] objss = {"aaa", 1, 2.5};//成立
    }

    /**
     * 测试在java语言中,数组是不是对象
     * 如果是对象, 那么他的类型是什么?
     */
    private static void test1() {
        int[] a = new int[4];
        //a.length;  //对属性的引用不能当成语句
        int len = a.length;  //数组中保存一个字段, 表示数组的长度
        
        //以下方法说明数组可以调用方法,java中的数组是对象.这些方法是Object中的方法,所以可以肯定,数组的最顶层父类也是Object
        a.clone();
        a.toString();
        
        
        /**
         * java是强类型的语言,一个对象总会有一个特定的类型,例如 Person p = new Person();
         * 对象p(确切的说是引用)的类型是Person类, 这个Person类是我们自己编写的
         * 那么数组的类型是什么呢? 下面使用反射的方式进行验证
         */
        int[] a1 = {1, 2, 3, 4};
        System.out.println(a1.getClass().getName());
        //打印出的数组类的名字为[I
        
        String[] s = new String[2];
        System.out.println(s.getClass().getName());
        //打印出的数组类的名字为  [Ljava.lang.String;
        
        String[][] ss = new String[2][3];
        System.out.println(ss.getClass().getName());
        //打印出的数组类的名字为    [[Ljava.lang.String;
        
        /**
         * 所以,数组也是有类型的,只不过这个类型不是有程序员自己定义的类, 也不是jdk里面
         * 的类, 而是虚拟机在运行时专门创建的类
         * 类型的命名规则是:
         *      每一维度用一个[表示;
         *      [后面是数组中元素的类型(包括基本数据类型和引用数据类型)
         * 
         * 在java语言层面上,s是数组,也是一个对象,那么他的类型应该是String[],
         * 但是在JVM中,他的类型为[java.lang.String
         * 
         * 顺便说一句普通的类在JVM里的类型为 包名+类名, 也就是全限定名
         */
    }
    
    public static class Father {

    }
    
    public static class Son extends Father {

    }
}

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。

加载阶段与连接阶段的部分内容(一部分字节码文件格式验证动作)是交叉进行的。加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

作用

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

注: Class文件并不一定要求用Java源码编译而来,可以使用任何途径残生。

从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 1.的文件格式验证:
    • 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
      • 是否以魔数0xCAFEBABE开头。
      • 主、次版本号是否在当前虚拟机处理范围之内。
      • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
      • 指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量。
      • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
      • Class文件中各个部分及文件本身是否有被删除的活着附加的其它信息。
        ... ...

这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

  • 2.元数据验证:
    • 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息复合Java语言规范的要求,这个阶段主要验证一下几点:
      • 这个类是否有父类(除java.lang.object之外,所有的类都应当有父类)。
      • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
      • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
      • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合方法重载,例如方法参数都一致,但返回值类型却不同等)。
      • ... ...

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

  • 3.字节码验证
    • 第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是否是合法的、复合逻辑的。在第二个阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
      • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,类如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时确按long类型来加载如本地变量表中。
      • 保证跳转指令不会跳转到方法体意外的字节码指令上。
      • 保证方法体中的类型转换是有效的,例如:可以把一个子类对象赋值给父类数据类型,这个是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给它毫无继承关系、完全不相干的数据类型,则是危险和不合法的。
  • 4.符号引用验证
    • 最后一个阶段的校验发生在虚拟机将符号引用过转化为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种各样符号引用)的信息进行匹配性校验,通常校验如下:
      - 符号引用中通过字符串描述的全限定名是否能找到对应的类。
      - 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
      - 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

目的

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

准备

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

解析

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

  • 符号引用:用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用是能无歧义地定位到目标即可。
  • 直接引用:同一个符号引用在不同虚拟机实例上翻译出来的直接应用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

解析动作主要针对类或接口字段类方法接口方法方法类型方法句柄调用点限定符7类符号引用进行,分别对应于常量池CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7中常量类型。

初始化

类的初始化阶段是类加载过程的最后一步。到了初始化阶段,才真正开始执行类中定义的 Java程序代码(或者说是字节码。

注:静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句快可以赋值,但是不能访问。

类加载器

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

类与类加载器

对于任意一个类,都需要由加载他的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

双亲委派模型

在Java虚拟机的角度来讲,只存在两种不同类加载器:

  1. 一种启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;
  2. 另一种就是所有其它的类加载器,这些类加载器都有Java语言实现,独立于虚拟机外部,并且都继承自抽象类java.lang.ClassLoader。

解释说明:

tag是标志位,它用于区分常量类型。

全限定名:

  • 有路径的意思。
  • 在Java源码中全限定名有类所属的包的名称加一个“.” ,在加上类名组成。例如:类Object的所属包为java.lang 那么它的全限定名就是java.lang.Object。 但是在class文件里,所有的“.” 都被 “/”代替,这样就成了java/lang/object。至于全限定名在方法区中的表示,则因不同的设计者有不同的选择而不同,可以用任何形式的好数据结构来表示。
  • 也可以理解成包名+类名

数组去掉一个维度的类型:

  • 每一个维度用一个"["表示;开头两个“[”,就代表而二维数组;“[” 后面是数组中元素类型(包括基本数据类型和引用类型)

大家如果有兴趣的还可以研究下面的:

微信订阅号回复我“jvm”会有一份转载纯洁的微笑的ppt给大家。

优质内容筛选与推荐>>
1、window.opener 的用法
2、原!!自动备份 发布脚本
3、linux命令大全
4、高利润的十大行业
5、[CSP-S模拟测试]:异或(数位DP)


长按二维码向我转账

受苹果公司新规定影响,微信 iOS 版的赞赏功能被关闭,可通过二维码转账支持公众号。

    阅读
    好看
    已推荐到看一看
    你的朋友可以在“发现”-“看一看”看到你认为好看的文章。
    已取消,“好看”想法已同步删除
    已推荐到看一看 和朋友分享想法
    最多200字,当前共 发送

    已发送

    朋友将在看一看看到

    确定
    分享你的想法...
    取消

    分享想法到看一看

    确定
    最多200字,当前共

    发送中

    网络异常,请稍后重试

    微信扫一扫
    关注该公众号