深入理解JVM

                                                                             关于

  Java 虚拟机屏蔽了与具体操作系统平台相关的信息,使得 Java 语言编译程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行Java 虚拟机在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。本文详细的介绍了Java 语言的编译、运行、类加载机制,类文件结构、内存的分配策略、垃圾回收机制、javac 编译、JIT 编译等 JVM 相关知识。

                                                                          

                                             走进 JVM

对于 JVM 自身的物理结构,我们可以从下图了解:

什么是 JVM

JVM 是 Java 的核心和基础,在 Java 编译器和 os 平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行 Java 的字节码程序。

Java 编译器只需面向 JVM,生成 JVM 能理解的代码或字节码文件。Java 源文件经编译器,编译成字节码程序,通过 JVM 将每一条指令翻译成不同平台机器码,通过特定平台运行。

简单的说,JVM 就相当于一台柴油机,它只能用 Java (柴油)运行,JVM 就是 Java 的虚拟机,有了 JVM 才能运行 Java 程序

                                                                               

                         Java 代码编译和执行的整个过程

Java 代码编译是由 Java 源码编译器来完成,流程图如下所示:

Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示:

Java 代码编译和执行的整个过程包含了以下三个重要的机制:

  • Java 源码编译机制
  • 类加载机制
  • 类执行机制

Java 源码编译机制

Java 源码编译由以下三个过程组成:

  • 分析和输入到符号表
  • 注解处理
  • 语义分析和生成 class 文件

流程图如下所示:

最后生成的 class 文件由以下部分组成:

  • 构信息。包括 class 文件格式版本号及各部分的数量与大小的信息。
  • 元数据。对应于 Java 源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池。
  • 方法信息。对应 Java 源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。

类加载机制

JVM 的类加载是通过 ClassLoader 及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:

1)Bootstrap ClassLoader

负责加载$JAVA_HOME中jre/lib/rt.jar里所有的 class,由 C++ 实现,不是 ClassLoader 子类。

2)Extension ClassLoader

负责加载Java平台中扩展功能的一些 jar 包,包括$JAVA_HOME中jre/lib/*.jar-Djava.ext.dirs指定目录下的 jar 包。

3)App ClassLoader

负责记载 classpath 中指定的 jar 包及目录中 class。

4)Custom ClassLoader

属于应用程序根据自身需要自定义的 ClassLoader,如 Tomcat、jboss 都会根据 J2EE 规范自行实现 ClassLoader。

加载过程中会先检查类是否被已加载,检查顺序是自底向上,从 Custom ClassLoader 到 BootStrap ClassLoader 逐层检查,只要某个 Classloader 已加载就视为已加载此类,保证此类只所有 ClassLoade r加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。

类执行机制

JVM 是基于栈的体系结构来执行 class 字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。栈的结构如下图所示:


              

                               Java 内存区域与内存溢出

内存区域

Java 虚拟机在执行 Java 程序的过程中会把他所管理的内存划分为若干个不同的数据区域。Java 虚拟机规范将 JVM 所管理的内存分为以下几个运行时数据区:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。下面详细阐述各数据区所存储的数据类型。

程序计数器

一块较小的内存空间,它是当前线程所执行的字节码的行号指示器字节码解释器工作时通过改变该计数器的值来选择下一条需要执行的字节码指令,分支、跳转、循环等基础功能都要依赖它来实现。每条线程都有一个独立的的程序计数器,各线程间的计数器互不影响,因此该区域是线程私有的。

当线程在执行一个 Java 方法时,该计数器记录的是正在执行的虚拟机字节码指令的地址,当线程在执行的是 Native 方法(调用本地操作系统方法)时,该计数器的值为空。另外,该内存区域是唯一一个在 Java 虚拟机规范中么有规定任何 OOM(内存溢出:OutOfMemoryError)情况的区域。

Java 虚拟机栈

该区域也是线程私有的,它的生命周期也与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,栈它是用于支持续虚拟机进行方法调用和方法执行的数据结构。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入了方法表的 Code 属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

在 Java 虚拟机规范中,对这个区域规定了两种异常情况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
  • 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这两种情况存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。在单线程的操作中,无论是由于栈帧太大,还是虚拟机栈空间太小,当栈空间无法分配时,虚拟机抛出的都是 StackOverflowError 异常,而不会得到 OutOfMemoryError 异常。而在多线程环境下,则会抛出 OutOfMemoryError 异常。

下面详细说明栈帧中所存放的各部分信息的作用和数据结构。

1、局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种基本数据类型、对象引用(reference)和 returnAddress 类型(它指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,即在 Java 程序被编译成 Class 文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个 Slot 应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个 Slot 可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference 和 returnAddresss。reference 是对象的引用类型,returnAddress 是为字节指令服务的,它执行了一条字节码指令的地址。对于 64 位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的 Slot 空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始到局部变量表最大的 Slot 数量,对于 32 位数据类型的变量,索引 n 代表第 n 个 Slot,对于 64 位的,索引 n 代表第 n 和第 n+1 两个 Slot。

在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。

局部变量表中的 Slot 是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的 Slot 就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下 Slot 的复用会直接影响到系统的而垃圾收集行为。

2、操作数栈

操作数栈又常被称为操作栈,操作数栈的最大深度也是在编译的时候就确定了。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。当一个方法开始执行时,它的操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值元算等)向操作栈中写入和提取内容,也就是入栈和出栈操作。

Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称 Java 虚拟机是基于栈的,这点不同于 Android 虚拟机,Android 虚拟机是基于寄存器的。

基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。

3、动态连接

每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 final、static 域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出站,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令。

本地方法栈

该区域与虚拟机栈所发挥的作用非常相似,只是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为使用到的本地操作系统(Native)方法服务。

Java 堆

Java Heap 是 Java 虚拟机所管理的内存中最大的一块,它是所有线程共享的一块内存区域。几乎所有的对象实例和数组都在这类分配内存。Java Heap 是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。

根据 Java 虚拟机规范的规定,Java 堆可以处在物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存可分配时,并且堆也无法扩展时,将会抛出 OutOfMemoryError 异常。

方法区

方法区也是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区域又被称为“永久代”,但这仅仅对于 Sun HotSpot 来讲,JRockit 和 IBM J9 虚拟机中并不存在永久代的概念。Java 虚拟机规范把方法区描述为 Java 堆的一个逻辑部分,而且它和 Java Heap 一样不需要连续的内存,可以选择固定大小或可扩展,另外,虚拟机规范允许该区域可以选择不实现垃圾回收。相对而言,垃圾收集行为在这个区域比较少出现。该区域的内存回收目标主要针是对废弃常量的和无用类的回收。运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Class文件常量池),用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于 Class 文件常量池的另一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入 Class 文件中的常量池的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的是 String 类的 intern()方法。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,它直接从操作系统中分配,因此不受 Java 堆大小的限制,但是会受到本机总内存的大小及处理器寻址空间的限制,因此它也可能导致 OutOfMemoryError 异常出现。在 JDK1.4 中新引入了 NIO 机制,它是一种基于通道与缓冲区的新 I/O 方式,可以直接从操作系统中分配直接内存,即在堆外分配内存,这样能在一些场景中提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据

内存溢出

下面给出个内存区域内存溢出的简单测试方法。

这里有一点要重点说明,在多线程情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。操作系统为每个进程分配的内存是有限制的,虚拟机提供了参数来控制 Java 堆和方法区这两部分内存的最大值,忽略掉程序计数器消耗的内存(很小),以及进程本身消耗的内存,剩下的内存便给了虚拟机栈和本地方法栈,每个线程分配到的栈容量越大,可以建立的线程数量自然就越少。因此,如果是建立过多的线程导致的内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和每个线程的栈容量来换取更多的线程。

另外,由于 Java 堆内也可能发生内存泄露(Memory Leak),这里简要说明一下内存泄露和内存溢出的区别:

内存泄露是指分配出去的内存没有被回收回来,由于失去了对该内存区域的控制,因而造成了资源的浪费。Java 中一般不会产生内存泄露,因为有垃圾回收器自动回收垃圾,但这也不绝对,当我们 new 了对象,并保存了其引用,但是后面一直没用它,而垃圾回收器又不会去回收它,这边会造成内存泄露,

内存溢出是指程序所需要的内存超出了系统所能分配的内存(包括动态扩展)的上限。

对象实例化分析

对内存分配情况分析最常见的示例便是对象实例化:

Object obj = new Object();

这段代码的执行会涉及 Java 栈、Java 堆、方法区三个最重要的内存区域。假设该语句出现在方法体中,及时对 JVM 虚拟机不了解的 Java 使用这,应该也知道 obj 会作为引用类型(reference)的数据保存在 Java 栈的本地变量表中,而会在 Java 堆中保存该引用的实例化对象,但可能并不知道,Java 堆中还必须包含能查找到此对象类型数据的地址信息(如对象类型、父类、实现的接口、方法等),这些类型数据则保存在方法区中。

另外,由于 reference 类型在 Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到 Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄池和直接使用指针

通过句柄池访问的方式如下:

通过直接指针访问的方式如下:

这两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是 reference 中存放的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式的最大好处是速度快,它节省了一次指针定位的时间开销。目前 Java 默认使用的 HotSpot 虚拟机采用的便是是第二种方式进行对象访问的。

                                                    

                                    Class 类文件结构

平台无关性

Java 是与平台无关的语言,这得益于 Java 源代码编译后生成的存储字节码的文件,即 Class 文件,以及 Java 虚拟机的实现。不仅使用 Java 编译器可以把 Java 代码编译成存储字节码的 Class 文件,使用 JRuby 等其他语言的编译器也可以把程序代码编译成 Class 文件,虚拟机并不关心 Class 的来源是什么语言,只要它符合一定的结构,就可以在 Java 中运行。Java 语言中的各种变量、关键字和运算符的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更强大,这便为其他语言实现一些有别于 Java 的语言特性提供了基础,而且这也正是在类加载时要进行安全验证的原因。

类文件结构

Class 文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部都是程序运行的必要数据。根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储,这种伪结构中只有两种数据类型:无符号数和表。无符号数属于基本数据类型,以 u1、u2、u4、u8 来分别代表 1、2、4、8 个字节的无符号数。表是由多个无符号数或其他表作为数据项构成的符合数据类型,所有的表都习惯性地以“_info”结尾。

整个 Class 文件本质上就是一张表,它由如下所示的数据项构成。

从表中可以看出,无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的该数据项的形式,称这一系列连续的摸一个类型的数据为某一类型的集合,比如,fields_count 个 field_info 表数据构成了字段表集合。这里需要说明的是:Class 文件中的数据项,都是严格按照上表中的顺序和数量被严格限定的,每个字节代表的含义,长度,先后顺序等都不允许改变。

下表列出了 Class 文件中各个数据项的具体含义:

从表中可以看出,无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会在其前面使用一个前置的容量计数器来记录其数量,而便跟着若干个连续的数据项,称这一系列连续的某一类型的数据为某一类型的集合,如:fields_count 个 field_info 表数据便组成了方法表集合。这里需要注意的是:Class 文件中各数据项是按照上表的顺序和数量被严格限定的,每个字节代表的含义、长度、先后顺序都不允许改变。

magic 与 version

每个 Class 文件的头 4 个字节称为魔数(magic),它的唯一作用是判断该文件是否为一个能被虚拟机接受的 Class 文件。它的值固定为 0xCAFEBABE。紧接着 magic 的 4 个字节存储的是 Class 文件的次版本号和主版本号,高版本的 JDK 能向下兼容低版本的 Class 文件,但不能运行更高版本的 Class 文件。

constant_pool

major_version 之后是常量池(constant_pool)的入口,它是 Class 文件中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一。

常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于 Java 层面的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用总结起来则包括了下面三类常量:

  • 类和接口的全限定名(即带有包名的 Class 名,如:org.lxh.test.TestClass)
  • 字段的名称和描述符(private、static 等描述符)
  • 方法的名称和描述符(private、static 等描述符)

虚拟机在加载 Class 文件时才会进行动态连接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。

这里说明下符号引用和直接引用的区别与关联:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。

常量池中的每一项常量都是一个表,共有 11 种(JDK1.7 之前)结构各不相同的表结构数据,没中表开始的第一位是一个 u1 类型的标志位(1-12,缺少 2),代表当前这个常量属于的常量类型。11 种常量类型所代表的具体含义如下表所示:

这 11 种常量类型各自均有自己的结构。在 CONSTANT_Class_info 型常量的结构中有一项 name_index 属性,该常属性中存放一个索引值,指向常量池中一个 CONSTANT_Utf8_info 类型的常量,该常量中即保存了该类的全限定名字符串。而 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 型常量的结构中都有一项index属性,存放该字段或方法所属的类或接口的描述符 CONSTANT_Class_info 的索引项。另外,最终保存的诸如 Class 名、字段名、方法名、修饰符等字符串都是一个 CONSTANT_Utf8_info 类型的常量,也因此,Java 中方法和字段名的最大长度也即是CONSTANT_Utf8_info 型常量的最大长度,在 CONSTANT_Utf8_info 型常量的结构中有一项 length 属性,它是 u2 类型的,即占用 2 个字节,那么它的最大的 length 即为 65535。因此,Java 程序中如果定义了超过 64KB 英文字符的变量或方法名,将会无法编译。

下表给出了常量池中 11 种数据类型的结构:

    常量

项目

  类型  

描述

 

CONSTANT_Utf8_info

tag

u1

值为1

length

u2

UF-8编码的字符串占用的字节数

bytes

u1

长度为lengthUTF-8编码的字符串

 

CONSTANT_Integer_info

tag

u1

值为3

bytes

u4

按照高位在前存储的int

 

CONSTANT_Float_info

tag

u1

值为4

bytes

u4

按照高位在前存储的float

 

CONSTANT_Long_info

tag

u1

值为5

bytes

u8

按照高位在前存储的long

 

CONSTANT_Double_info

tag

u1

值为6

bytes

u8

按照高位在前存储的double

 

CONSTANT_Class_info

tag

u1

值为7

index

u2

指向全限定名常量项的索引

 

CONSTANT_String_info

tag

u1

值为8

index

u2

指向字符串字面量的索引

 

CONSTANT_Fieldref_info

tag

u1

值为9

index

u2

指向声明字段的类或接口描述符CONSTANT_Class_info的索引项

index

u2

指向字段名称及类型描述符CONSTANT_NameAndType_info的索引项

 

CONSTANT_Methodref_info

tag

u1

值为10

index

u2

指向声明方法的类描述符CONSTANT_Class_info的索引项

index

u2

指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项

 

CONSTANT_InrerfaceMethodref_info

tag

u1

值为11

index

u2

指向声明方法的接口描述符CONSTANT_Class_info的索引项

index

u2

指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项

 

CONSTANT_NameAndType_info

tag

u1

值为12

index

u2

指向字段或方法名称常量项目的索引

index

u2

指向该字段或方法描述符常量项的索引

access_flag

在常量池结束之后,紧接着的 2 个字节代表访问标志(access_flag),这个标志用于识别一些类或接口层次的访问信息,包括:这个 Class 是类还是接口,是否定义为 public 类型,abstract 类型,如果是类的话,是否声明为 final,等等。每种访问信息都由一个十六进制的标志值表示,如果同时具有多种访问信息,则得到的标志值为这几种访问信息的标志值的逻辑或。

this_class、super_class、interfaces

类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)则是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。类索引、父类索引和接口索引集合都按照顺序排列在访问标志之后,类索引和父类索引两个 u2 类型的索引值表示,它们各自指向一个类型为 COMNSTANT_Class_info 的类描述符常量,通过该常量中的索引值找到定义在 COMNSTANT_Utf8_info 类型的常量中的全限定名字符串。而接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是个接口,则应当是 extend 语句)后的接口顺序从左到右排列在接口的索引集合中。

fields

字段表(field_info)用于描述接口或类中声明的变量。字段包括了类级变量或实例级变量,但不包括在方法内声明的变量。字段的名字、数据类型、修饰符等都是无法固定的,只能引用常量池中的常量来描述。下面是字段表的最种格式:

其中的 access_flags 与类中的 access_flagsfei 类似,是表示数据类型的修饰符,如 public、static、volatile 等。后面的 name_index 和 descriptor_index 都是对常量池的引用,分别代表字段的简单名称及字段和方法的描述符。这里简单解释下“简单名称”、“描述符”和“全限定名”这三种特殊字符串的概念。

前面有所提及,全限定名即指一个事物的完整的名称,如在 org.lxh.test 包下的 TestClass 类的全限定名为:org/lxh/test/TestClass,即把包名中的“.”改为“/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“,”来表示全限定名结束。简单名称则是指没有类型或参数修饰的方法或字段名称,如果一个类中有这样一个方法 boolean get(int name)和一个变量 private final static int m,则他们的简单名称则分别为 get()和 m。

而描述符的作用则是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序等)和返回值的。根据描述符规则,详细的描述符标示字的含义如下表所示:

对于数组类型,每一维度将使用一个前置的 “[” 字符来描述,如一个整数数组 “int [][]” 将为记录为 “[[I” ,而一个 String 类型的数组 “String[]” 将被记录为 “[Ljava/lang/String” 。

用方法描述符描述方法时,按照先参数后返回值的顺序描述,参数要按照严格的顺序放在一组小括号内,如方法 int getIndex(String name,char[] tgc,int start,int end,char target) 的描述符为 “(Ljava/lang/String[CIIC)I”。

字段表包含的固定数据项目到 descriptor_index 为止就结束了,但是在它之后还紧跟着一个属性表集合用于存储一些额外的信息。比如,如果在类中有如下字段的声明:staticfinalint m = 2;那就可能会存在一项名为ConstantValue 的属性,它指向常量 2。关于 attribute_info 的详细内容,在后面关于属性表的项目中会有详细介绍。

最后需要注意一点:字段表集合中不会列出从父类或接口中继承而来的字段,但有可能列出原本 Java 代码中不存在的字段。比如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

methods

方法表(method_info)的结构与属性表的结构相同,不过多赘述。方法里的 Java 代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里,关于属性表的项目,同样会在后面详细介绍。

与字段表集合相对应,如果父类方法在子类中没有被覆写,方法表集合中就不会出现来自父类的方法信息。但同样,有可能会出现由编译器自动添加的方法,最典型的便是类构造器 “” 方法和实例构造器 “” 方法。

在 Java 语言中,要重载一个方法,除了要与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。

ttributes

属性表(attribute_info)在前面已经出现过多系,在 Class 文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性。Java 虚拟机规范中预定义了 9 项虚拟机应当能识别的属性(JDK1.5 后又增加了一些新的特性,因此不止下面 9 项,但下面 9 项是最基本也是必要,出现频率最高的),如下表所示:

对于每个属性,它的名称都需要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,每个属性值的结构是完全可以自定义的,只需说明属性值所占用的位数长度即可。一个符合规则的属性表至少应具有 “attribute_name_info”、“attribute_length” 和至少一项信息属性。

Code 属性

前面已经说过,Java 程序方法体中的代码讲过 javac 编译后,生成的字节码指令便会存储在 Code 属性中,但并非所有的方法表都必须存在这个属性,比如接口或抽象类中的方法就不存在 Code 属性。如果方法表有 Code 属性存在,那么它的结构将如下表所示:

attribute_name_index 是一项指向 CONSTANT_Utf8_info 型常量的索引,常量值固定为 “Code”,它代表了该属性的名称。attribute_length 指示了属性值的长度,由于属性名称索引与属性长度一共是 6 个字节,所以属性值的长度固定为整个属性表的长度减去 6 个字节。

max_stack 代表了操作数栈深度的最大值,max_locals 代表了局部变量表所需的存储空间,它的单位是Slot,并不是在方法中用到了多少个局部变量,就把这些局部变量所占 Slot 之和作为 max_locals 的值,原因是局部变量表中的 Slot 可以重用。

code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令。code 用于存储字节码指令的一系列字节流,它是 u1 类型的单字节,因此取值范围为 0x00 到 0xFF,那么一共可以表达 256 条指令,目前,Java 虚拟机规范已经定义了其中 200 条编码值对应的指令含义。code_length 虽然是一个 u4 类型的长度值,理论上可以达到 2^32-1,但是虚拟机规范中限制了一个方法不允许超过 65535 条字节码指令,如果超过了这个限制,Javac 编译器将会拒绝编译。

字节码指令之后是这个方法的显式异常处理表集合(exception_table),它对于 Code 属性来说并不是必须存在的。它的格式如下表所示:

它包含四个字段,这些字段的含义为:如果字节码从第 start_pc 行到第 end_pc 行之间(不含 end_pc 行)出现了类型为 catch_type 或其子类的异常(catch_type为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc 行继续处理,当 catch_pc 的值为 0 时,代表人和的异常情况都要转到 handler_pc 处进行处理。异常表实际上是 Java 代码的一部分,编译器使用异常表而不是简单的跳转命令来实现 Java 异常即 finally 处理机制,也因此,finally 中的内容会在 try 或 catch 中的 return 语句之前执行,并且在 try 或 catch 跳转到 finally 之前,会将其内部需要返回的变量的值复制一份副本到最后一个本地表量表的 Slot中,也因此便有了http://blog.csdn.net/ns_code/article/details/17485221这篇文章中出现的情况。

Code 属性是 Class 文件中最重要的一个属性,如果把一个 Java 程序中的信息分为代码和元数据两部分,那么在整个 Class 文件里,Code 属性用于描述代码,所有的其他数据项目都用于描述元数据。

Exception 属性

这里的 Exception 属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在 throws 关键字后面列举的异常。它的结构很简单,只有 attribute_name_index、attribute_length、number_of_exceptions、exception_index_table 四项,从字面上便很容易理解,这里不再详述。

LineNumberTable 属性

它用于描述 Java 源码行号与字节码行号之间的对应关系。

LocalVariableTable 属性

它用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的对应关系。

SourceFile 属性

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

ConstantValue 属性

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被 static 修饰的变量才可以使用这项属性。在 Java 中,对非 static 类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;而对于类变量(static 变量),则有两种方式可以选择:在类构造其中赋值,或使用 ConstantValue 属性赋值。

目前 Sun Javac 编译器的选择是:如果同时使用 final 和 static 修饰一个变量(即全局常量),并且这个变量的数据类型是基本类型或 String 的话,就生成 ConstantValue 属性来进行初始化(编译时 Javac 将会为该常量生成 ConstantValue 属性,在类加载的准备阶段虚拟机便会根据 ConstantValue 为常量设置相应的值),如果该变量没有被 final 修饰,或者并非基本类型及字符串,则选择在方法中进行初始化。

虽然有 final 关键字才更符合”ConstantValue“的含义,但在虚拟机规范中并没有强制要求字段必须用 final 修饰,只要求了字段必须用 static 修饰,对 final 关键字的要求是 Javac 编译器自己加入的限制。因此,在实际的程序中,只有同时被 final 和 static 修饰的字段才有 ConstantValue 属性。而且 ConstantValue 的属性值只限于基本类型和 String,很明显这是因为它从常量池中也只能够引用到基本类型和 String 类型的字面量。

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被 static 修饰的变量才可以使用这项属性。在 Java 中,对非 static 类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;而对于类变量(static 变量),则有两种方式可以选择:在类构造其中赋值,或使用 ConstantValue 属性赋值。

目前 Sun Javac 编译器的选择是:如果同时使用 final 和 static 修饰一个变量(即全局常量),并且这个变量的数据类型是基本类型或 String 的话,就生成 ConstantValue 属性来进行初始化(编译时 Javac 将会为该常量生成 ConstantValue 属性,在类加载的准备阶段虚拟机便会根据 ConstantValue 为常量设置相应的值),如果该变量没有被 final 修饰,或者并非基本类型及字符串,则选择在方法中进行初始化。

虽然有 final 关键字才更符合”ConstantValue“的含义,但在虚拟机规范中并没有强制要求字段必须用 final 修饰,只要求了字段必须用 static 修饰,对 final 关键字的要求是 Javac 编译器自己加入的限制。因此,在实际的程序中,只有同时被 final 和 static 修饰的字段才有 ConstantValue 属性。而且 ConstantValue 的属性值只限于基本类型和 String,很明显这是因为它从常量池中也只能够引用到基本类型和 String 类型的字面量。

下面简要说明下 final、static、static final 修饰的字段赋值的区别:

  • static 修饰的字段在类加载过程中的准备阶段被初始化为 0 或 null 等默认值,而后在初始化阶段(触发类构造器)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。
  • final 修饰的字段在运行时被初始化(可以直接赋值,也可以在实例构造器中赋值),一旦赋值便不可更改;
  • static final 修饰的字段在 Javac 时生成 ConstantValue 属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则 Javac 时会报错。可以理解为在编译期即把结果放入了常量池中。

InnerClasses 属性

该属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那么编译器将会为它及它所包含的内部类生成 InnerClasses 属性。

Deprecated 属性和 Synthetic 属性

该属性用于表示某个类、字段和方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用 @Deprecated 注释进行设置。

Synthetic 属性

该属性代表此字段或方法并不是 Java 源代码直接生成的,而是由编译器自行添加的,如 this 字段和实例构造器、类构造器等。



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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值