Java虚拟机翻译.class文件的理解?

想静下心来读点书,从阅读《JVM specification》[]开始吧。一直希望一探class文件的内部结构,却无端生出了各种借口成蹉跎岁月。18年岁末是个好日子,Let’s go!

Java的class文件描述了类、接口和模块。众所周知,class文件是一个字节码文件,即按照字节(8 bits)来组织和解读内容。为了表述方便,先定义以下的术语:

通过表格方式描述的项目称作“表项”。比如常量池中有14种常量,每种常量的描述方式(属性)不同,因此每种常量使用不同格式的表项来描述。以_info结尾的词即为一个表项,比如CONSTANT_Utf8_info是Utf8编码的字符串的表项,描述了一个Utf8编码的字符串在常量池中的存储格式。
类的全限定名是指包括包名的类名,比如.java.Test即为全限定名。

class文件的结构如[, p165]所示,包含了10个部分。

接口的全限定名索引列表

初看起来,class文件的内容多的吓人,但是耐心的想一下,就会释然并觉得是很自然的事情了。下面按照class文件的顺序解读其中的每一个细节(字节),并试图说明class文件为什么要这样设计:让我们一起揣测JVM的设计者当初的“小心思”,也是一件非常有意思的事情

为了更直观的理解class的文件结构,后面的解读以为例。

目前你可能对Javap的输出不甚了了,在后面的讲解中我们会逐步弄清楚其中的每一个细节。

class文件的前4个字符是固定内容的“魔数”,通过ghex观察可见如所示,class文件的魔数是0xCAFEBABE

魔数的作用是表征文件是一个合法的Java类文件,任何不以魔数开头的字节码文件都是非法的class文件,虚拟机将拒绝执行。

魔数之后的4个字节表示“版本号”,其中前两个字节是次版本号(minor version),后两个字节是主版本号(major version)。如所示,Person.class文件的版本号是0x,即主版本号是0x0037(对应的十进制数是55),次版本号是0x0000(对应的十进制数是0),即Person.class的版本号翻译成十进制为55.0。列出了JDK定义的主版本号,对照可以看出,Person.class是使用Java SE 11编译而成的。

class文件的版本号使用了4个字节来表示,可以表达的最大版本号是,可见Java的雄心壮志:当前Java11的版本号是55.0,按照目前的开发速度,Java的版本号可以用到数万年之后。

版本号的作用是表明该class文件是由哪个版本的编译器生成的,因此需要相应版本的java虚拟机来解释执行。显然,高版本的Java虚拟机可以解释执行低版本的class文件,反之则不然。

简单的开个头。解读class文件的结构需要一点点耐心,需要一点点的技巧。魔数和版本号是固定长度的,都很容易理解,接下来解读class文件中可能是内容最多但不是最复杂的部分:常量池,TBD。

0

本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和文献引用请见

Class文件是一组以8位字节为基础单位的二进制流,不同的数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有任何空隙存在。
这些数据项目由无符号数和表来存储数据,按照顺序依次是:
4. 类索引、父类索引与接口索引集合
Java代码之所以能够一直保持良好的向后兼容性,就是因为class类文件结构一直比较稳定。

可能很多人觉得了解学习 class 类文件结构对于开发 Java 代码没有什么用处,深度学习了本文,下面的问题你就能自己回答了。

  1. Java为什么能一直保持良好的向后兼容性?
  2. Java虚拟机是怎样处理异常的?
  3. Java代码抛出异常堆栈的时候是如何打印出代码行号的?
  4. Java代码抛出异常堆栈的时候是怎样识别文件名称的?
  5. IDE调试的时候Java程序是如何识别断点的?
  6. IDE调试的时候是如何识别方法参数名称的?
  7. 类变量和实例变量是什么时候赋值的?
  8. Java中泛型是如何实现的?泛型信息是如何保存的?
  9. Java模块化功能是如何实现的?

无符号数是基本的数据类型,u1、u2、u4、u8分别表示1个字节、2个字节、4 个字节和8个字节的无符号数。

无符号数可以用来表示数字、索引引用、数量值或者按照UTF-8编码构成字符串。

表用于描述有层次关系的复合结构的数据,由多个无符号数或者其他表构成。

所有表都习惯性地以"_info”结尾。

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用个前置的容量计数器加若干个连续的数据项的形式, 这时称这一系列连续的某一类型的数据为某一类型的集合。

每个Class文件的头4个字节称为魔数,即:OXCAFEBABE(咖啡宝贝)

确定这个文件是否是一个合法的Class文件。

因为文件扩展名可以随意地改动,所以使用魔数而不是扩展名来进行识别class文件更加的可靠。

紧接着魔数的4个字节存储的是Class文件的版本号:

5、6个字节是次版本号

7、8个字节是主版本号

Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1。

JDK12以后考虑到一些复杂的特性需要公测,重新启用了次版本号。

高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的 Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件的资源仓库

常量池是Class文件结构中与其他数据项目关联最多的数据类型,通常也是占用 Class文件空间最大的数据项目之一,同时它还是在 Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值。

与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

常量池中主要存放两大类常量:字面量和符号引用

字面量类似于Java语言里面的常量,包括文本字符串、声明为final的常量值等。

符号引用包括了下面6类常量:

  1. 被模块导出或者开放的包(JDK9+)

关于 5、6 请见我的另一篇博客——

Java代码需要在虚拟机加载Class文件的时候进行动态连接。

当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:

  1. 这个Class是类还是接口;
  2. 是否定义为public类型;
  3. 如果是类的话,是否被声明为final等

类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合。

Class文件中由这三项数据来确定这个类的继承关系

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。

接口索引集合就用来描述这个类实现了哪些接口,被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

字段表用于描述接口或者类中声明的变量。

字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。(方法内部的局部变量存储在栈帧的局部变量表内部。)

在Java中描述一个字段可以包括的信息有:

  1. 是实例变量还是类变量(static修饰符)
  2. 并发可见性(volatile修饰符,是否强制从主内存读写)
  3. 字段数据类型(基本类型、对象、数组)

上述这些信息中,每个修饰符都是布尔值,要么有某个修饰符,要么没有,所以很适合使用标志位来表示。

而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

方法表的结构类似于字段表,依次包括了

方法表存储的是方法的元数据信息,真正的代码存储在方法属性表中的 Code 属性里面。

Class 文件、字段表、方法表都可以携带自己的属性表集合,用来描述某些场景专有的信息。

《Java虚拟机规范》不再要求各个属性表具有严格顺序,只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,

Java虚拟机运行时会忽略掉它不认识的属性。

Java代码编译成的字节码指令
Java源码的行号与字节码指令的对应关系
由 final 关键字定义的常量值
用于支持泛型情况下的方法签名
用于保存 invokedynamic 指令引用的引导方法限定符
支持将方法名称编译进 Class 文件中,可以运行时获取

Java程序方法体中的代码经过 Javac 编译器处理后,最终变为字节码指令存储在 Code 属性内。

Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性, 譬如接口或者抽象类中的方法就不存在Code属性,

Code属性是 Class 文件中最重要的一个属性,

如果把一个Java程序中的信息分为代码(code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,

那么在整个 Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

在字节码指令之后的是这个方法的显式异常处理表(下文简称异常表)集合,异常表对于Code属性来说并不是必须存在的

关于异常表的详细内容请参考我的另一篇博客——

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,上面的异常表是 Code 属性里面的内容。

Exceptions属性的作用是列举出方法中可能抛出的受检异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常。

LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。

默认会生成到 Class文件之中,可以在Javac中分别使用-g:none 或-g:lines选项来取消或要求生成这项信息。

如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,

并且在调试程序的时候,也无法按照源码行来设置断点。

LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,

默认会生成到 Class文件之中,可以在 Javac 中分别使用-g.none或-g:vars选项来取消或要求生成这项信息。

如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,

这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。

SourceFile属性用于记录生成这个Class文件的源码文件名称。

可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。

在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。

如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值

只有被static关键字修饰的变量(类变量)才可以使用这项属性。

实例变量什么时候进行初始化?

对于实例变量的赋值是在实例构造器方法中进行的;

类变量什么时候初始化?

对于类变量,则有两种方式可以选择

目前Oracle实现的Java编译器的选择是

  1. 如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成 ConstantValue 属性来进行初始化,
  2. 如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在方法中进行初始化。

InnerClasses 属性用于记录内部类与宿主类之间的关联。

如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成 InnerClasses属性。

Signature属性在JDK1.5发布后增加到了 Class 文件规范之中,它是一个可选的定长属性,可以出现于类、属性表和方法表结构的属性表中。

在JDK1.5中大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types), 则Signature属性会为它记录泛型签名信息。

之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,

在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都通通被擦除掉。

使用擦除法的好处是实现简单(主要修改Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport(即将一个软件的补丁应用到比此补丁所对应的版本更老的版本的行为),运行期也能够节省一些类型所占的内存空间。

但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。

Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。

BootstrapMethods属性在JDK1.7发布后增加到了 Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。

这个属性用于保存 invokedynamic 指令引用的引导方法限定符。

作用是记录方法的各个形参名称和信息。

编译器可以(编译时加上-parameters 参数)将方法名称写入 Class 文件。

LocalVariableTable是Code属性的子属性(抽象方法和接口方法没有方法体就没有对应的 Code属性)。

JDK8 以前要获取方法名称(比如 IDE 的代码提示)只能通过 JavaDoc 得到。

JDK9的最重要的功能是提供 Java 的模块化功能,因为模块描述文件(module-info.java)最终要编译成一个独立的 Class 文件来存储的。

  咱们知道计算机是由晶体管、电路板等组装而成的电子设备,而这些电子设备其实只能识别0与1的信号。html

  那么问题来了,咱们在操做系统上编写的Java代码(由字母、数字等各类符号组成),打包后部署到服务器上,是如何被计算机所识别并运行的呢?另外,操做系统有不少种,包括Windows系统,Linux系统,Mac OS系统等,而咱们一样的Java代码,却能够不作任何处理在不一样的系统上正常运行,这又是为啥呢?java

  带着这些疑问,你将会在下面的介绍中获得答案!!!数组

一、Java虚拟机的两个特性

  在此系列博客中,咱们介绍到Java虚拟机的两个特性。安全

  对于Java语言,咱们经过编辑器编写的Java代码,后缀通常是.java。经过javac编译器编译后,会变成.class结尾的字节码文件,只有编译后的.class文件,才能在Java虚拟机上运行。(解压部署在服务器上的jar包,全是编译后的class文件)ruby

  再好比对于 JRuby 语言,经过编辑器编写的代码后缀是.rb。经过jrubyc 编译器编译后,也会变成 .class 结尾的字节码文件,而后也能在Java虚拟机上运行。服务器

  在好比已正式成为Android官方支持开发语言的Kotlin。也能够编译成.class字节码文件,而后在虚拟机上运行。并发

  咱们能够用下面这幅图来表示:oracle

   也就是说,无论你是什么语言,只要能经过某种手段生成合乎规范的.class字节码文件,其实就能够在Java虚拟机上运行,这就是语言无关性。jvm

  Write once, run everywhere(一次编写,处处运行)这是Java语言诞生之处就宣传的一个口号。Java语言之因此可以跨平台运行,其实就是由于Java虚拟机对各个平台的适配,在不一样的系统下安装不一样的Java虚拟机,咱们程序固然可以在不一样的系统上运行。编辑器

   对于文章开头提出的问题,一样的程序可以在不一样的系统上正常运行的缘由,就是由于咱们在不一样的系统上安装了不一样的Java虚拟机。

二、class 字节码文件介绍

  搞清楚了Java代码的跨平台原理,咱们接着来介绍为何编写的Java代码可以被计算机所识别。

  这实际上是上面所说的语言无关性这个特性重要文件——class字节码文件的功劳。

  Java全部的指令大概有 200 个左右,一个字节(8位)能够存储 256 种不一样的信息,咱们将一个这样的字节称为字节码(ByteCode)。

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

  好比,对于以下这段代码:

  咱们将生成的class 文件,经过十六进制编辑器打开(在IDEA中,能够下载HexView插件,安装完成后,选择这个class文件,右键 HexView)

   打开后的文件以下:(下面的介绍也都是以这张图为例)

   下面咱们会介绍这些十六进制分别表明什么意思。

  另外,为了更好的查看 Class 文件字节码结构,JDK 还为咱们提供了一个命令行工具 javap。使用语法以下:

  经过 javap -help 命令,能够查看相关参数做用:

   这些内容下面也会详细介绍。

  在介绍这些十六进制以前,咱们先介绍 Class 文件的数据类型。

  Class 文件采用一种相似于 C 语言结构体的伪结构来存储,这种伪结构只有两种数据类型:无符号数和表

  这是一种基本数据类型,以 u1,u2,u4,u8 来分别表明 1个字节、2个字节、4个字节、8个字节的无符号数,无符号数能够用来描述数字、索引引用、数量值或按照 UTF-8 编码构成的字符串值。

  表是由多个无符号数或其它表做为数据项所构成的复合数据类型,全部表都习惯行的以“_info”结尾。表用于描述有层次关系的复合结构数据。

  整个 Class 文件本质上就是一张表,结构以下:

   PS:须要说明的是,因为 Class 文件结构没有任何分隔符,因此不管是每一个数据项的的顺序仍是数量,都是严格限定的,哪一个字节表明什么含义,长度多少,前后顺序如何,都是不容许改变的。

  下面,咱们就来分别介绍这些数据项表明什么含义。  

  每一个 class 文件的头 4 个字节称为魔数(Magic Number),它的惟一做用是:标识该文件是一个Java类文件。若是没有识别到该标志,则说明该文件不是Java类文件或者文件已受损。

  其实不少文件存储标准中都使用魔数进行身份识别,好比图片gif或者jpeg,使用魔数而不是使用扩展名来进行识别主要是基于安全考虑,由于文件扩展名能够任意的改动。

五、Class 文件的版本号

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

  Java的版本号是从 45 开始的,JDK1.1 以后的每一个 JDK 大版本发布主版本号向上加1(JDK1.0~JDK1.1使用了45.0~45.3的版本号),高版本的 JDK 能向下兼容之前版本的 Class 文件,但不能运行之后版本的 Class 文件,即便文件格式未发生变化。

  上图第五、六、七、8个字节为 00 00 00 34。其十进制值为 52,是JDK8的内部版本号。

  紧随主版本号的是常量池入口,是class文件中第一个出现的表类型数据项目,也是占用Class文件空间最大的项目之一,更是Class文件结构中与其它项目关联最多的数据类型。

  由于常量池中常量的数量是不固定的,因此在常量池的入口要放置一项 u2 类型的数据,表明常量池容量计数值(constant_pool_count)。

  PS:注意,常量池容量计数值是从 1 开始的,而不是从 0 开始。将 0 空出来,是为了知足后面某些指向常量池的索引值的数据在特定状况下须要表达“不引用任何一个常量池项目”的意思。

  Class 文件结构中,只有常量池的容量是从 1 开始的,其它的集合类型,都是从 0 开始的。

  看上图的十六进制文件,常量池容量计数值为:0x0025,即十进制 37。这就表示常量池中有 36 项常量,索引值分别为 1~36(经过上面javap命令生成字节码文件能够很明显看出来有36个)

  常量池主要存放两大类常量:

  一、字面量(Literal):字面量比较接近于 Java 语言层面的常量概念,好比 文本字符串、被声明为 final 的常量值等。

  二、符号引用(Symbolic References):符号引用属于编译原理方面的概念,包括下面三类常量:

    字段的名称和描述符(Descriptor)

    方法的名称和描述符。

  须要说明的是,Java代码在进行javac 编译的时候,并不像 C 和 C++ 那样有“链接”这一步骤,而是在虚拟机加载 Class 文件的时候进行动态链接。

  也就是说,在 Class 文件中不会保存各个方法和字段的最终内存布局信息,所以这些字段和方法的符号引用不通过转换的话是没法被虚拟机使用的。当虚拟机运行时,须要从常量池得到对应的符号引用,再在类建立时或运行时解析并翻译到具体的内存地址之中。关于类的建立和动态链接的内容,下篇博客会详细介绍。

  常量池中的每一项内容都是一个表,在JDK1.8中共有 14 种结构各不相同的表结构数据,每一个表结构第一位是一个 u1 类型的标志位(tag,取值为1 到 18,缺乏标志为 二、1三、1四、17 的数据类型)。表明当前这个常量属于哪一种常量类型。

  14 种常量类型所表明的具体含义以下:

   接着看十六进制文件,紧跟常量池数量的十六进制是0x0a,这是一个标志位,0x0a的十进制数是10,查看常量池的项目表接口,表示的类型是 CONSTANT_Methodref_info。

   也就是说,接下来的u2类型0x0006,其十进制值为6,紧跟后面的u2类型十六进制为0x0017,其十进制值为23,这都是两个索引值,分别指向第索引值为6的常量和索引值为23的常量。

  整个十六进制字节码就不一一进行推导了,下面是各个数据类型的结构:

   常量池结束后的两个字节表示访问标志(access_flags),这个标识用于识别一些类或接口层次的访问信息。

  包括:这个 Class 是类仍是接口;是否认义为 public 类型,是否认义为 abstract 类型;若是是类的话,是否被声明为 final 等。

  具体的标志位及标志含义以下:

   上表定义了 8 个标志位,可是咱们说访问标志是一个 u2 类型,一共有 32 个标志位可使用,没有定义的标志位一概为 0 。

八、类索引、父类索引和接口索引集合

  类索引、父类索引和接口索引按顺序排列在访问标志以后。

  类索引:用于肯定这个类的全限类名 ,是一个 u2 类型的数据。

  父类索引:用于肯定这个类的父类全限类名,也是一个 u2 类型的数据。由于Java是单继承的,除了 java.lang.Object 类之外,全部的类都有父类。因此,除了Object 类之外,全部Java类的父类索引都不为0.

  接口索引:用于描述这个类实现了哪些接口,是一组 u2 类型的数据集合,第一项为 u2 类型的接口计数器,表示实现接口的个数。若是没有实现任何接口,则为0。

   字段表(field_info):描述接口或类中声明的变量。(不包括方法内部声明的变量)

  ②、是类级变量仍是实例级变量(static修饰)

  ③、是否可变(final修饰)

  ④、并发可见性(volatile修饰,是否强制从主从读写)

  ⑤、是否可序列化(transient修饰)

  ⑥、字段数据类型(8种基本数据类型,对象,数组等引用类型)

  前面5个修饰符,都是布尔值,用标志位来表示;后面两个字段名称和类型,是没法固定的,只能引用常量池中的常量来表示。

  Class 文件存储格式中对方法的描述和字段的描述基本上是一致的。也是依次包括:

  方法访问标志以下(access_flags):

  在前面介绍的字段表集合、方法表集合中都包括了属性表集合(attributes),其实就是引用的这里。

  根据《Java虚拟机规范第二版》中,预约义了 9 项虚拟机实现应当可以识别的属性。

   对于每个属性,它的名称要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,其属性值的结构则是彻底自定义的,只须要说明属性值所占用的位数长度便可。

我要回帖

更多关于 怎么向虚拟机里面导入文件 的文章

 

随机推荐