• 第6章 类文件结构


    6.1 概述

      传统的语言编译后的结果是native code,直接交给计算机去执行,Java编译后是class文件是交给虚拟机执行,编译后的结果是平台无关的中立的格式。

    6.2 无关性的基石

      各种平台下Java编译后的格式都是一样的,所以称为平台中立的,这个格式就是字节码(Byte Code)。Java虚拟机只认识字节码即class文件,所以虚拟机也可以执行别的语言,只要这些语言编译后的代码是class文件。

    6.3 Class类文件的结构

      主要介绍class文件的结构,以jdk1.4为主线。

      class文件是什么?是一组二进制字节流,其格式有着严格的要求必须以8字节为单位进行排列。如果需要占有的字节大于8字节就需要按照大端的方式进行分割,最高位字节在最低位。这个与x86处理器相反,x86采用最低位字节在地址最低位。这个字节码对格式的要求非常严格,中间没有分隔符。没有分隔符怎么能确定一个字段哪里开头哪里结尾呢?所以class的结构对顺序、数量都是被严格规定的。

      class文件的结构类似于c语言里的结构体,由两种数据结构组成,一种是无符号数,一种是表。无符号数用来存储基本的数据类型的内容。表是一种复合的数据结构,由多个无符号数和别的表组成。

      

    6.3.1 魔数与class文件版本

      class文件开头是魔数,是指用来判断这个二进制文件是不是class文件的。我们平时看一个文件的类型是从扩展名来判断,这其实不太安全,因为扩展名是可以改的。java虚拟机根据一个二进制文件的头4个字节的内容是不是指定的内容来判断是不是class文件。

      跟着魔数后面是class文件的版本的判断,用来判断class文件的版本与虚拟机的版本是不是兼容。

    6.3.2 常量池

      紧接着魔数和class文件版本的就是常量池了。为什么要有常量池呢?把所有的常量放在一个池子里,如果一段java里出现了两个同样的字符串,那么就没有必要加载两次,直接再次引用即可,因为是常量,不会改变。

      常量池第一个字段是保存了常量池里有多少的个常量,如果值为0x0016,10进制的22,代表这个常量池里有22个常量,索引的范围为0-21。class文件里其他的内容想引用常量池里的常量的时候就直接使用索引来表示,但是索引为0的不指向任何常量,当一个地方使用了索引为0的常量代表这个地方不使用常量。因而常量池的索引是从1开始

      具体而言常量池里有两大类的数据:

      1、字面量(Literal),符合我们对常量池的一般认知,放的是java里的字符串、final修饰的变量等。这部分内容肯定是常量。

      2、符号引用。这部分比较特殊,Java在编译的时候不连接,所以Java编译完成以后里面的符号引用还是符号引用,符号引用只有在class文件被加载的时候才会转换成真正的入口地址,因而这一部分符号引用也要以常量的形式保存起来留待后续翻译。有哪些东西需要再执行的时候“翻译”呢?我第一个想到的是类,A类里使用了B类,编译的时候肯定不知道B类是啥,所以B类的信息需要保存在常量池里留待破译,一个类的信息就是一个类的全限定名。按照这个逻辑,所有A类使用B类的地方都需要以符号引用的形式保存在常量池里,那么这常量池里内容应该还包括方法和属性,具体的在常量池里保存的是方法的名称和描述符以及属性的名称和描述符。

      常量池里每一种类型都是一个表结构,不同的数据类型对应的表结构有一点是相同的,那就是表的开头有一个标志位,代表这个表结构到底是一个什么样的常量。比如tag值取1,代表这个表结构是一个UTF-8编码的字符串。

      

      使用javap -verbose可以反编译一个class文件,进而可以看到常量池里的内容。索引为1的表代表一个class,然后后面又有一个#2,代表这个class的全限定名在索引为2的表里,索引为2的表的内容就是其全限定名。从这里也能看到常量池对空间使用的高效性,比如索引为3的class其全限定名在索引为4的表里,同样的索引为10的方法里,也需要用到索引为4的字符串,使用常量池这种相互引用的方式,只需要存一次。

      

    6.3.3 访问标志

      用来标记这段class对应的java代码是不是一个类,是不是一个接口,是不是抽象的等等信息。 

    6.3.4 类索引、父类索引与接口索引集合

      这个部分存储的是该class文件的类信息、父类信息和其实现的接口信息。可以看出都是索引,并不是存的真实的值,索引指向的是常量池里的内容。类索引、父类索引都是一个u2类型的无符号数,而接口索引是一个u2类型是数的集合,从这里可以看出java是单继承多接口实现。

    6.3.5 字段表集合

      从这里开始存储的内容比较符合我们对一个类应该有的内容的认知,从不严谨的角度来说,一个类最就是包含属性和方法,字段表集合存储的就是属性。一个类会有很多的属性,所以对应到这里用了一个集合来存储,一个字段表对应一个属性。前面提到过,class文件里的表结构,本身就是一种集合。

      一个字段表对应一个属性,描述一个属性需要哪些东西呢?首先是一些乱七八糟的访问限定符,比如作用域,是否是静态的,是否可变,是否并发可见,是否可以序列化。其次是数据类型,包括基本数据类型、引用数据类型。再次是属性的名称。最后是如果属性被在类里赋予了值,那么还需要保存这个默认的值。所以一个字段表应该至少包含上述的这些信息。

       

      最一开始是access_flags,用来描述乱七八糟的访问限定符。

      其次是name_index和descriptor_index是命名索引和描述符索引。既然是索引一定对应一个集合,name_index对应着常量池里的字段名称。descriptor_index对应字段描述,所谓字段描述符就是描述这个字段的类型int long等等。

      最后两项是用来描述attribute的,对一个字段而言,其attribute就是在声明这个字段的时候为其赋的默认值

      值得一提的是,整个字段表里并没有出现从父类继承来的属性的内容,我猜测或许是通过父类索引向上递归寻找的方式来实现访问父类属性。

      字段表里也会出现一些java代码里不存在的内容,比如定义了一个内部类,为了实现内部类对外部类的访问,会保存一个指向外部类实例的字段。

    6.3.6 方法表集合

      用来描述一个方法的表与用来描述一个属性用的表的结构是类似的。access_flags name_index与属性表想对应其意义也是相同。descriptor_index是描述符的索引,描述一个方法所用到的描述符与属性不同,属性是各种基本类型和引用类型等等,方法的描述符包括两大部分,其一是返回值,void 基本类型 引用类型等等,其二是入参的列表,也是重载的时候使用到的特征签名。

      方法里最终要的是里面的代码,这部分内容竟然没有单独列出一个部分,而是方法attributes属性集合里。属性集合里除了有程序员写的代码之外,也会有一些编译器自动生成的一些基本的方法对应的代码,典型的有<cinit>,收集所有初始化static属性的语句生成的用来初始化类的方法,还有<init>构造器方法,初始化对象的方法。如果一个重写其父类的方法,这里面也会有来自父类的方法信息。

        

    6.3.7 属性表集合

      属性表的格式要求没那么严格,可以由不同的虚拟机提供商自行实现相自定义属性信息,Java虚拟机会忽略掉他不认识的属性,但是有一些属性是常用的。

    1、Code属性

      顾名思义这个属性里放的是代码,为什么要把代码这么重要的属性放在属性表里呢?我觉得应该直接在方法表里开辟一段专门用来存放代码,我觉得这个考虑是因为在java设计中需要大量的使用接口、抽象类这些“没有代码的类”,把代码放到属性里可能会加速吧?总而言之,如果一个方法里有Code属性,他就要按照某种要求把自己代码的相关信息都存进去,除了代码内容外,还要包括栈的深度等其余信息。

      

      attribute_index,一下子就猜出来了,属性名的索引。在Code属性里这个值固定是Code。

      attribute_length,属性表的长度。

      max_stack,方法的执行需要栈,虚拟机在运行的时候回根据这个值为一个方法分配栈,任何时刻都不能超过这个栈的深度。

      max_locals,一个方法里有一个局部变量表,这个值代表局部变量所需要的空间,以slot为单位。slot是JVM为局部变量分配内存所使用的最小的单位,32字节的1slot,64字节的2slot。局部变量表里放着的有方法的参数、方法里面定义的局部变量、异常处理的参数。

      code code_length,code里存的是字节码,赫赫有名的字节码,我对class最初的印象以为class里只有字节码,没想到字节码的地位如此的底下。字节码,就是以字节为单位的存储方式,虚拟机一次读取一个字节,并且虚拟机能够知道该字节码是否有操作数,如果有操作数的话有几个操作数以及如何理解这些操作数。既然字节码的长度是8个字节,就是256个,目前定义了200多个。

      接下里的都是和异常相关的内容    

    6.4 字节码指令

      java虚拟机的字节码是面向操作数栈实现的,因而很多指令是没有操作数的。

    6.4.2 加载和存储指令

      一个栈帧里有局部变量表和操作数栈。从局部变量表传输到操作数栈叫做load,从操作数栈到局部变量表叫store。

    6.4.3 运算指令

      对操作数栈里的数据进行运算,加减乘除,位移,取余等等

    6.4.4 类型转换指令

      不同的类型转换,有宽和窄两种转换

    6.4.5 对象创建与访问

      创建一个对象或者数组并访问其中的数据。创建对象和创建数组使用的是不同的虚拟机指令。

    6.4.6 操作数栈管理指令

      操作数栈也是一个栈,出栈啊,交换位置啊

    6.4.7 控制转移指令

      修改PC寄存器的值的指令

    6.4.8 方法调用和返回指令

      后面会讲,java里的继承和多态的特性与这里有很大的关系

    6.4.9 异常处理

    6.4.10 同步指令

      Monitor,管程机制,synchronized关键字的底层实现。synchronized是方法级别的同步,是一种隐式的同步,所谓隐式的是指的没有想AQS那一堆的lock获得与释放,具体的lock与释放是通过编译器在方法执行前后增加的monitorenter和monitorexit实现的。一个线程获得了锁之后只有在其执行完毕或者抛出异常的时候才会释放锁。

      

    6.5 公有设计和私有实现

      不同的虚拟机都要遵循某种规范,这种规范就是公有设计,比如都要支持上述的字节码指令,都要能够解析class文件。

      但是虚拟机如何实现就是自由的行为了,只要你能够读取标准格式的class文件并且能够提供字节码指令的执行接口,你就上一个虚拟机。字节码如何执行不做规定,你可以把字节码翻译成另一种虚拟机的字节码,或者生成宿主CPU的本地指令集(JIT代码生成技术)

      

     

  • 相关阅读:
    CPP STL学习笔记
    CPP 设计模式学习
    blackarch 安装指南
    通过 Http 请求获取 GitHub 文件内容
    实践
    升级
    部署-MySql 之Linux篇
    数据库
    RxJs
    Vue
  • 原文地址:https://www.cnblogs.com/AshOfTime/p/10291674.html
Copyright © 2020-2023  润新知