深入理解Java虚拟机(第六章)

2021/10/12 11:14:17

本文主要是介绍深入理解Java虚拟机(第六章),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

类文件结构

为什么会出现class文件结构呢?

为了实现“一次编写,到处运行”,而实现这种特性的的基础,是通过将Java编译器把Java代码编译成字节码文件的Class文件。

Java语言中的各种语法,关键字,常量变量和运算符号的语义最终都会由,多条字节码指令组合来表达

Class类文件的结构

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

可以将Class文件看成一个表。

img

这种伪结构中只有两种数据类型,即无符号数和表。

  • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值。

  • 是由多个无符号数或者其他表作为数据项构成的符合数据类型,所有表都习惯性地以“_info”结尾,表用于描述有层次关系的复合结构的数据,整个Class文件本质就是一张表,具体的基本数据项可以看上面这个图

注意的是,Class文件中由于没有任何分隔符号,所以无论是顺序还是数量,甚至是数据储存的字节序这样的细节,都被严格限定了,因此哪个字节代表的是什么意思都是一种规定。

Magic Number

大小:4个字节

作用:唯一确定这个文件是否为一个能被虚拟机接受的Class文件,值为0xCAFEBABE

minor_version&&major_version

大小:2个字节

作用:检测是哪个版本

紧接着魔数的4个字节是,次版本号和主版本号,Java的版本号是从45开始的,高版本的JDK能向下兼容以前版本的Class文件,但不能运行之后的Class文件,这里就相当于一个校验。

次版本号,用于发布一些新特性,类似于“公测”,标识“技术预览版”,就必须把次版本号标识为65535,以便虚拟机能够在加载类文件的时候区分出来。

比如JDK1.1能支持的版本号为45.0~46.65535的Class文件,而JDK1.2能支持45.0~46.65535的Class文件

比如,主版本号的值为0x0032,换算十进制为50,该版本号说明这个是可以被JDK6或者以上版本虚拟机执行的Class文件。

常量池

紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是class文件结构中于其他项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一,也是Class文件中第一个出现表项类型的数据项,看后面那个cp_info数据格式就是表

constant_pool_count

大小:2个字节

作用:由于常量的数量不确定的,所以在常量池的入口需要放置一项u2类型的数据,constant_pool_count,值得注意的是,这个容量计数是从1开始的,而不是从0开始的。

20191219170616180

比如在这里,先是魔数,其次4字节版本号,0x0036就是常量池计数,即十进制的54,这就代表常量池中有53项常量,索引值为1-53。

constant_pool

大小:根据

作用:存放两大类常量

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

字面量:文本字符串、声明为final的常量值

符号引用:属于编译原理方面的概念,包含了类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

值得一提的是,java代码在进行javac编译的时候,是虚拟机加载class文件的时候进行动态连接的(和C不一样),所以说在class文件中不会保存各个方法、字段的最终内存布局信息。因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址。虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

img

常量池中每一项常量都是一个表(info),这些表都有一个特点,表结构的的第一个位是一个u1类型的标志位(tag),代表着当前常量属于哪种常量类型。

20191219173238978

可以看到0x0A对应的就是10,表中就是指向声明方法的类描述符

可以使用javap工具进行分析,就不用一一计算了

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志

大小:2个字节

作用:这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口?是否定义为public类型?是否定义为abstract类型?如果是类的话,是否声明为final?等等,具体的看下面的这个表,依旧是对应表去看十六进制数。

img

Access_flag中一共有16个标识位可以使用(2字节),当前只定义了9个(JDK9后9个),没有使用到的标志位一律为0

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

大小:this_classsuper_class都是两个字节,而接口索引是一组u2类型数据的集合(接口索引计数器两个字节)

作用:

  • Class文件中由着三项数据来确定这个类的继承关系
  • 类索引用于确定这个类的全限定名
  • 父类索引用于确定这个类的父类的全限定名,由于Java不允许多继承,因此父类索引只有一个,同理除了java.lang.Object 之外,所有的java类的父类索引都不为0
  • 接口索引集合就用来描述这个类实现了哪些接口

比如一个类没有实现任何接口,那么接口计数器值就为0,后面的接口索引则不占用任何字节。

字段表集合

大小:根据字段计数器

作用:字段表用于描述接口或者类中声明的变量,包括类变量以及实例变量,但不包括在方法内部声明的局部变量

那么在Java中描述一个字段,可以有哪些修饰呢?

  • 比如字段的作用域(private、public、protected)
  • 可变性(final)
  • 并发可见(volatile)
  • 是否可序列化(transient)
  • 字段的基本数据类型(基本类型、对象、数组)

img

  • 很明显能看到public、private、protected三个标志最多只能选择其一

  • final、volatile不能同时被选择

紧接着来看描述表示字符的含义

类权限名:org/lee/leecode/ListNode就是这个类的权限名,只是把类全名中的.换成了/

简单名称:指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别是"inc"和“m”,一个没有参数,一个没类型

public class TestClass{
  private int m;
  public int inc(){
    return m+1;
  }
}

方法和字段的描述符:大写+V,看上面的表6-10

值得注意的是,数组类型,每一维度将使用一个前置的“[”字符来描述

  • 如一个定义为“java.lang.String[][]”类型的二维数组,将被记录为“[[Ljava/lang/String”
  • 一个整型数组“int[]”将被标记为“[I”

规定:描述方法时,按照先参数列表,后返回值的顺序描述,参数列表的参数严格顺序放在一组小括号“()”之内

  • 如方法void inc()的描述符为“()V”
  • 方法java.lang.String toString()的描述符为“()Ljava/lang/String”
  • 方法int indexOf(char[]source, int sourceOffset, int sourceCount, char[]target, int tarOffset, int targetCount, int fromIndex)的描述为“([CII[CIII)I”

方法表集合

大小:根据方法表计数器计算

作用:对方法的描述和字段的描述采用了几乎一致的方式,不赘述了。

img

而这里看到,方法的定义可以通过访问标志,名称索引,描述符索引来表达,但是方法里面的代码没有展示,方法里的Java代码其实经过Javac编译成字节码指令后,存放在方法属性表集合中一个名为Code的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目(最后一个attribute_info)

在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求一个与原方法不同的签名特征

签名特征就是:指一个方法中各个参数载常量池中的字段符号引用的集合,注意这里是参数的并不是返回值的,也正是因为返回值没有包含在签名特征中,所以Java语言里面是无法依靠返回值的不同来对一个已有的方法进行重载。

属性表

作用:在class文件中,属性表集合包括Java虚拟机预先规范定义的属性以及用户自定义的属性,对于用户自定义的属性,虚拟机加载的时候会自动忽略掉

属性表的结构attribute_info

attribute_info{
    attribute_name_index//属性的名称索引(指向常量池)2个字节
    attribute_length//属性长度 4个字节
    info//有attribute_length个字节属性值
}

大小:因此由于属性名称索引和属性长度一共6个字节,所以属性值的长度固定为整个属性表长度减去6个字节

img

因此就是,每个不同的属性都有自己的表,这个属性的名称又是从常量池中引用一个CONSTANT_utf8_info类型的常量类表示,下面就来讲讲方法表中不同的属性表。

Code属性

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

Code属性是Class文件中最重要的一个属性,把一个Java程序中的信息分为两部分

  • 代码(Code,方法体里面的Java代码):描述代码
  • 元数据(MetaData,包括类、字段、方法定义等):描述元数据

Code属性出现在方法表的属性集合中,但是并非所有的方法表都有这个属性.例如接口或类中的方法就不存在Code属性了

Code属性的整体结构:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u2max_stack1
u2max_locals1
u4code_length1
u1codecode_length
u2exception_table_length1
exception_infoexception_tableexception_length
u2attributes_count1
attribute_infoattributesattributes_count

说几个重要的

attribute_name_index指向的就是常量池中固定的"Code"(因为是code属性表)

max_stack代表了操作数栈深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度,而虚拟机用这个数值来分配栈帧中的操作栈深度

max_locals代表了局部变量表所需的存储空间。局部变量表是以slot为分配局部变量内存的最小单位,对于double和long分配两个slot,其余6种为1个slot,而且Java虚拟机会将局部变量表的slot重用,也就是这个槽会根据变量的生命周期来分配给各个变量,而虚拟机根据同时生存的最大局部变量数量和类型计算出max_locals。

code_length&code用来存储Java源程序编译后生成的字节码指令。code,是一个u1,那么就一共可以表达256条之林,目前的Java虚拟机规范已经定义了200条编码对应的指令含义,而code_length虽然是一个u4,但是实际只用了u2的长度,如果超过这个限制,javac编译器就会拒绝编译

EXCEPTIONS属性

这里的EXCEPTIONS属性是在方法表中与Code属性平级的一项属性

作用:Exception属性的作用是列出方法中能抛出的受查异常Check Exceptions,也就是方法描述时在throws关键字之后列举的异常

EXCEPTIONS属性结构:

类型名称数量
u2attribute_name_index1
u2attribute_length1
u2attribute_of_exception1
u2exception_index_tablenumber_of_exceptions

number_of_exceptions:表示方法可能抛出的number_of_exceptions种受查异常,也就是这个方法会抛出多少种异常,并且每一种受查异常使用一个exception_index_table项表示

exception_index_table:代表了该受查异常的类型,是一个指向常量池中CONSTANT_Class_info型常量的索引

LINENUMBERTABLE属性

作用:描述Java源码行号和字节码行号之间的对应关系

它并不是运行时必须的,但是会默认生成到Class文件中,如果不生成的话,对程序最直接的影响就是当抛出异常时,堆栈将不会显示出错的行号,在调试的时候也无法按照源码行来设置断点

LINENUMBERTABLE结构:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u2line_number_table_length1
line_number_infoline_number_tableline_number_table_length

属性有很多,有20多种,就不一一说了。

其实一般程序员只需要了解一下class文件的构成和原理就行了,解析class文件的工具有很多,我们可以直接看解析出来的文件就可以了

字节码指令简介

这部分参考:JVM字节码指令集大全及其介绍

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode),以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。

由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码

由于限制了Java虚拟机操作码的长度为一个字节,这意味着指令集的操作码总数不可能超过256条。

又由于class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构。

例如,如果要将一个16位长度的无符号整数使用两个无符号字节存储起来(将它们命名为byte1和byte2),那这个16位无符号整数的值就是:(byte1<<8)|byte2

字节码与数据类型

对于大部分与数据类型相关的字节码指令来说,它们的操作码助记符中的首字母都跟操作的数据类型相关:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。

因为Java虚拟机的操作码长度只有一个字节,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令,有一些单独的指令可以再必要的时候用来将一些不支持的类型转换为可支持的类型(理解这句话)

对照着下面这个表

img

可以看到,大部分指令都没有支持byte、char、short,甚至boolean没有任何指令。

那么根据上面那句话,将不支持的类型转化为支持的,也就是编译器会在编译期或运行期将byte、short、char、boolean扩展为int类型,相同的,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。

因此,操作数的实际类型为boolean、byte、char及short的大多数操作,都可以用操作数的运算类型(computationaltype)为int的指令来完成。

加载和存储指令

加载和存储指令用于将数据从栈帧的本地变量表和操作数栈之间来回传递:

  • 将一个本地变量加载到操作数栈的指令::iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。这里load后面的代表的是当前栈帧中局部变量表的索引值,执行load操作后会把位于索引n位置的数据入栈到操作数栈顶。

  • 将一个数值从操作数栈存储到局部变量表的指令:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。这里store后面的代表的是当前栈帧中局部变量表的索引值,执行store操作后会把操作数栈顶的数据出栈,然后保存到位于索引n位置的局部变量表中。

  • 将一个常量加载到操作数栈的指令:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。const操作就是把对应类型的常量数据入栈到操作数栈的栈顶。例如iconst_10则表示把int类型的常量10入栈到操作数栈顶。

  • 扩充局部变量表的访问索引的指令:wide

如果是实例方法(非static的方法),那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用"this"。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot(比如方法method(int a1,inta2),参数表为a1和a2,则局部变量表索引0、1、2则分别存储了this指针、a1、a2,如果方法内部有其他内部变量,则在局部变量表中存在a2之后的位置)。

算术指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。

大体上,分为

  • 对整型数据进行运算的指令
  • 对浮点类型数据进行运算的指令

类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换。这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令的不完备的问题(上面说的byte、short、char和boolean)。

Java虚拟机支持宽化类型转换(小范围类型向大范围类型的转换)、窄化类型转换(大范围类型向小范围类型的转换)两种:

宽化类型转换

  • int --> long、float、double
  • long --> float、double
  • float --> double

类型转换指令有:i2l、i2f,i2d、l2f、l2d、f2d。"2"表示to的意思,比如i2l表示int转换成long。

宽化类型转换是不会导致Java虚拟机抛出运行时异常的

窄化类型转换:

  • int --> byte、short、char
  • long --> int
  • float --> int、long
  • double --> int、long、float

窄化类型转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度

窄化类型转换是不会导致Java虚拟机抛出运行时异常的。

同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用同步锁(monitor)来支持的。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义

package com.wkp.clone;
 
public class TestLock {
 
	public void onlyMe(Object f) {
		synchronized (f) {
			doSomething();
		}
	}
 
	private void doSomething() {
		System.out.println("执行方法");
	}
}

上面代码通过 javap -c TestLock.class > TestLock.txt 将class文件进行反汇编,得到如下指令代码

Compiled from "TestLock.java"
public class com.wkp.clone.TestLock {
  public com.wkp.clone.TestLock();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: return
 
  public void onlyMe(java.lang.Object);
    Code:
       0: aload_1				//将对象f推送至操作数栈顶
       1: dup					//复制栈顶元素(对象f的引用)
       2: astore_2				//将栈顶元素复制到本地变量表Slot 2(第三个变量)
       3: monitorenter			//以栈顶元素对象f作为锁,开始同步
       4: aload_0				//将局部变量Slot 0(this指针)的元素入栈
       5: invokespecial #16     //调用doSomething()方法
       8: aload_2				//将本地变量表Slot 2元素(f)入栈
       9: monitorexit			//释放锁退出同步
      10: goto          16		//方法正常返回,跳转到16
      13: aload_2				//将本地变量表Slot 2元素(f)入栈
      14: monitorexit			//退出同步
      15: athrow				//将栈顶的异常对象抛给onlyMe的调用者
      16: return				//方法返回
    Exception table:
       from    to  target type
           4    10    13   any
          13    15    13   any
}

方法中调用过的每条momtor指令都必须执行其对应的momtorexlt指令

从上面的指令代码中可以看到,为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行momtorexit指令。

还有很多指令…



这篇关于深入理解Java虚拟机(第六章)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程