JVM笔记:Java虚拟机的字节码指令详解

1.字节码

Java能发展到如今,其“一次编译,多处运行”的功能功不可没,这里最主要的功劳就是JVM和字节码了,在不一样平台和操做系统上根据JVM规范的定制JVM能够运行相同字节码(.Class文件),并获得相同的结果。之因此被称之为字节码,是由于字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中通常是用javac命令编译源代码为字节码文件,将java文件编译后生成.class文件交由Java虚拟机去执行,在android上,class文件被包装成.dex文件交由DVM执行。html

经过学习Java字节码指令能够对代码的底层运行结构有所了解,能更深层次了解代码背后的实现原理,例如字符串的相加的实现原理就是经过StringBuilderappend进行相加。用过字节码的视角看它的执行步骤,对Java代码的也能有更深的了解,知其然,也要知其因此然。java

经过学习字节码知识还能够实现字节码插桩功能,例如用ASM 、AspectJ等工具对字节码层面的代码进行操做,实现一些Java代码很差操做的功能。android

1. 字节码的格式

下面举个简单的例子,分析其字节码的结构程序员

public class Main {
    public static void main(String[] args) {
        System.out.println("HelloWorld");
    }
}
复制代码

Main.class的字节码

上图中纯数字字母就是字节码,右边的是具体代码执行的字节码指令。web

上面看似一堆乱码,可是JVM对字节码是有规范的,下面一点一点分析其代码结构面试

1.1魔数(Magic Number)

魔数惟一的做用是肯定这个文件是否为一个能被虚拟机接收的Class文件。不少文件存储标准中都使用魔数来进行身份识别,譬如gif和jpeg文件头中都有魔数。魔数的定义能够随意,只要这个魔数尚未被普遍采用同时又不容易引发混淆便可。数组

这里字节码中的魔数为0xCafeBabe(咖啡宝贝),这个魔数值在Java还被称做Oak语言的时候就已经肯定下来了,据原开发成员所说是为了寻找一些好玩的、容易记忆的东西,选择0xCafeBabe是由于它象征着著名咖啡品牌Peet`s Coffee中深受喜欢的Baristas咖啡,咖啡一样也是Java的logo标志。bash

1.2版本号(Version Number)

紧接着魔数的四个字节(00 00 00 33)存储的是Class文件的版本号。前两个是次版本号(Minor Version),转化为十进制为0;后两个为主版本号(Major Version),转化为十进制为52,序号52对应的主版本号为1.8,因此编译该文件的Java版本号为1.8.0。高版本的JDK能向下兼容之前的版本的Class文件,但不能运行之后版本的Class文件,及时文件格式并未发生变化,虚拟机也必须拒绝执行超过其版本号的Class文件。数据结构

1.3常量池(Constant Pool)

这部份内容前面作了一个简要的笔记,感兴趣的能够去看看。并发

紧接着版本号以后的是常量池入口,常量池能够理解为Class文件之中的资源仓库,它是Class文件结构中与其余项目关联最多的数据结构,也是占用Class文件控件最大的数据项目之一,同事也是在Class文件中第一个出现的表类型数据项目。

常量池的前两个字节(00 22)表明的是常量池容量计数器,与Java中语言习惯不同的是,这个容量计数是从1开始的,这里的22转换成十进制后为34,去除一个下标计数即表示常量池中有33个常量,这一点从字节码中的Constant pool也能够看到,最后一个是#33 = Utf8 (Ljava/lang/String;)V

容量计数器后存储的是常量池的数据。 常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为Final的常量值(例如字符串),符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符,当虚拟机运行时,须要从常量池得到对应的符号引用,再在类建立时或者运行时解析、翻译到内存地址中。以下图。

常量池的每一项常量都是一个表,在JDK71.7以前共有11中结构不一样的表结构数据,在JDK1.7以后为了更好底支持动态语言调用,又额外增长了三种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info),总计14中,表结构以下图

常量池数据表

上图中tag是标志位,用于区分常量类型,length表示这个UTF-8编码的字符串长度是多少节,它后面紧更着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。上图的u1,u2,u4,u8表示比特数量,分别为1,2,4,8个byte。

UTF-8缩略编码与普通UTF-8编码的区别是:从\u0001\u007f之间的字符(至关于1-127的ASCII码)的缩略编码使用一个字节表示,从\u0080\u07ff之间的全部字符的缩略编码用两个字节表示,从\u0800\uffff之间的全部字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示,这么作的主要目的仍是为了节省空间。

因为Class文件中方法、字段等都须要引用CONSTANT_Utf8_info型常量来描述名称,因此CONSTANT_Utf8_info型常量的最大长度就是Java中的方法、字段名的最大长度。这里的最大长度就是length的最大值,即u2类型能表达的最大值65535,因此Java程序中若是定义了超过64K英文字符的变量或发放名,将会没法编译。

回到上面那个例子,00 22后面跟着的是 0A 0006 0014,第一个字节0A转化为十进制为10,表示的常量类型为CONSTANT_Methodref_info,这从常量表中能够看到这个类型后面会两个u2来表示index,分别表示CONSTANT_Class_infoCONSTANT_NameAndType_info。因此0006和0014转化为10进制分别是6和20。这里可能不知道这些数字指代什么意思,下面展现的是编译后的字节码指令就能够清楚了。

Constant pool:
   #1 = Methodref #6.#20 // java/lang/Object."<init>":()V
   #2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String #23 // HelloWorld
   #4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class #26 // com/verzqli/snake/Main
   #6 = Class #27 // java/lang/Object
   #7 = Utf8 <init>
   #8 = Utf8 ()V
   #9 = Utf8 Code
  #10 = Utf8 LineNumberTable
  #11 = Utf8 LocalVariableTable
  #12 = Utf8 this
  #13 = Utf8 Lcom/verzqli/snake/Main;
  #14 = Utf8 main
  #15 = Utf8 ([Ljava/lang/String;)V
  #16 = Utf8 args
  #17 = Utf8 [Ljava/lang/String;
  #18 = Utf8 SourceFile
  #19 = Utf8 Main.java
  #20 = NameAndType #7:#8 // "<init>":()V
  #21 = Class #28 // java/lang/System
  #22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
  #23 = Utf8 HelloWorld
  #24 = Class #31 // java/io/PrintStream
  #25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
  #26 = Utf8 com/verzqli/snake/Main
  #27 = Utf8 java/lang/Object
  #28 = Utf8 java/lang/System
  #29 = Utf8 out
  #30 = Utf8 Ljava/io/PrintStream;
  #31 = Utf8 java/io/PrintStream
  #32 = Utf8 println
  #33 = Utf8 (Ljava/lang/String;)V
复制代码

从上面能够看到Constant pool中一共有33个常量,第一个常量类型为Methodref,他其实指代的是这个Main类,它是最基础的Object类,而后这里它有两个索引分别指向6和20,分别是Class和NameAndType类型,和上面十六进制字节码描述的同样。

1.4访问标志(Access Flags)

在常量池结束后,紧接着的两个字节表明访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类仍是接口;是否认义为public类型;是否认义为abstract类型,若是是类的话,是否被声明为final等,具体的标志位以及标志的含义见下表。

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识是否为public类型
ACC_FINAL 0x0010 标识是否被声明为final,只有类可设置
ACC_SUPER 0x0020 用于兼容早期编译器,新编译器都设置改标志,以在使用invokespecial指令时对子类方法作特殊处理
ACC_SYNTHETIC 0x1000 标识这个类并不是由用户代码产生,而是由编译器产生
ACC_INTERFACE 0x0200 标识是否为一个接口,接口默认同事设置ACC_ABSTRACT
ACC_ABSTRACT 0x0400 标识是否为一个抽象类,不可与ACC_FINAL同时设置
ACC_ANNOTATION 0x2000 标识这是不是一个注解类
ACC_ENUM 0x4000 标识这是不是一个枚举

ACCESS_FLAGS中一共有16个标志位可用,当前只定义了其中8个(上面显示了比8个多,是由于ACC_PRIVATE,ACC_PROTECTED,ACC_STATIC,ACC_VOLATILE,ACC_TRANSTENT并非修饰类的,这里写出来是让你们知道还有这么些标志符),对于没有使用到的标志位要求一概为0。Java不会穷举上面全部标志的组合,而是同|运算来组合表示,至于这些标志位是如何表示各类状态,能够看这篇文章,讲的很清楚。

咱们继续回到例子

标志
例子中只是一个简单的Main类,因此他的标志是ACC_PUBLIC和ACC_SUPER,其余标志都不存在,因此它的访问标志为0x0001|0x0020=0x0021。

1.5 类索引、父类索引、接口索引

类索引和父类索引都是一个u2类型的数据,接口索引是一组u2类型的数据的集合,Class文件中由着三项数据来肯定这个类的继承关系。这三者按顺序排列在访问标志以后,本文例子中他们分别是:0005,0006,0000,也就是类索引为5,父类索引为6,接口索引集合大小为0 ,查询上面字节码指令的常量池能够一一对应(5对应com/verzqli/snake/Main,6对应java/lang/Object)。

类索引肯定这个类的全限定名,父类索引肯定这个类的父类全限定 名,由于Java不容许多重继承,因此父类索引只有一个,除了Object外,全部的类都有其父类,也就是其父类索引不为0.接口索引便可用来描述这个类实现了哪些接口,这些被实现的接口按implements(若是这个类自己就是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

1.6 字段表集合(Field Info)

字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量。可是不包含方法内部声明的局部变量。在Java中描述一个字段可能包含一下信息:

  • 字段的做用域(public,private,protected修饰符)
  • 是实例变量仍是类变量(static修饰符)
  • 是否可变(final修饰符)
  • 并发可见 (vlolatile修饰符,是否强制从主内存中读写)
  • 是否可悲序列化(transient修饰符)
  • 字段数据基本类型(基本类型、对象、数组)
  • 字段名称 上述信息中,每一个修饰符都是bool值,要么有要么没有,很适合用和访问标志同样的标志位来表示。而字段名称,字段数据类型只能引用常量池中的常量来描述。其中字段修饰符的访问标志和含义以下表。
标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识是否为private类型
ACC_PRIVATE 0x0002 标识是否为private类型
ACC_PROTECTED 0x0004 标识是否为protectes类型
ACC_STATIC 0x0008 标识是否为静态类型
ACC_FINAL 0x0010 标识是否被声明为final,只有类可设置
ACC_VOLATILE 0x0040 标识是否被声明volatile
ACC_TRANSIENT 0x0080 标识是否被声明transient
ACC_SYNTHETIC 0x1000 标识这个类并不是由用户代码产生,而是由编译器产生
ACC_ENUM 0x4000 标识这是不是一个枚举

字段表的结构分为两部分,第一部分为两个字节,描述字段个数(fields_count);第二部分是每一个字段的详细信息(fields_info),按顺序排列分别是访问标志(access_flags)、字段名称索引(name_index)、字段的描述符索引(descriptor_index)、属性表计数器(attribute_count)和属性信息列表(attributes)。除了最后未知的属性信息,其余都是u2的数据类型。

继续看例子,这个例子选的有点尴尬,忘记往里面放一个变量,因此在类索引后面的第一个u2 数据为0000 表示字段个数为0,因此后续的数据也没有了。只能假设一组数据来看看字段表的结构

字节码 00 01 00 02 00 03 00 07 00 00
描述 字段表个数 访问标志 字段名称索引 字段的描述符索引 属性个数
内容 1 ACC_PRIVATE 3 7 0

字段表集合中不会列出从超类或者父类接口中继承而来的字段,但有可能列出本来Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java中字段是没法重载的,对于字节码来说,只有两个字段的描述符不一致,该字段才是合法的。

为了便于理解,这里对上面提到的一些名词进行一下解释

  • 全限定名:本文中的Main类的全限定名为com/verzqlisnake/Main,仅仅把包名中的.替换成/便可为了使连续的多个全限定名补偿混淆,通常在使用时最后会假如一个;,表示全限定名结束。
  • 简单名词:值得是没有类型和参数修饰的方法或字段名称,例如public void fun()private int a的简单名称就为funa
  • 方法和字段的描述符:描述符的做用是用来描述字段的数据类型或方法的参数列表(数量、类型和顺序)和返回值。描述符包含基本数据类型和无返回值的void,主要表示为下表中形式。
描述字符 含义
描述 字段表个数
I 基本类型int
S 基本类型short
J 基本类型long,这里注意不是L,L是最后一个
F 基本类型float
D 基本类型double
B 基本类型byte
C 基本类型char
Z 基本类型boolean
V 特殊类型void
L 对象类型,例如Ljava/lang/String

对于数组类型,每一位度使用一个前置的[来描述,例如String[]数组将被记录为[Ljava/lang/String,String[][]数组被记录为[[Ljava/lang/String ;int[]数组被记录为[I

用描述符来描述方法时,要先按照参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号()之中。例如方法void fun()的描述符为()V,String.toString()的描述符为()Ljava/lang/Stringpublic void multi(int i,String j,float[] c)的描述符为(ILjava/lang/String;[F)V

1.7 方法表集合(Field Info)

方法表的结构和字段表的结构几乎彻底一致,存储的格式和描述也很是类似。方法表的结构和字段表同样,包含两部分。第一部分为方法计数器,第二部分为每一个方法的详细信息,依次包含了访问标志(access_flags)、方法名称索引(name_index)、方法的描述符索引(descriptor_index)、属性表计数器(attribute_count)和属性信息列表(attributes)。这些数据的含义也和字段表很是类似,仅在访问标志和属性表集合的可选项中有所区别。

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attribute_count 1
attribute_info attribute_info attribute_count

由于volatiletransient关键字不能修饰方法,因此方法标的访问标志中也就没有这两项标志,与之对应的,synchronized、native、strictfp、abstract能够修饰方法,因此方发表的访问标志中增长了这几类标志,以下表

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识方法是否为private
ACC_PRIVATE 0x0002 标识方法是否为private
ACC_PROTECTED 0x0004 标识方法是否为protectes
ACC_STATIC 0x0008 标识方法是否为静态
ACC_FINAL 0x0010 标识方法是否被声明为final
ACC_SYNCHRONIZED 0x0020 标识方法是否被声明synchronized
ACC_BRIDGE 0x0040 标识方法是否由编译器产生的桥接方法
ACC_VARARGS 0x0080 标识这个类是否接受不定参数
ACC_NATIVE 0x0100 标识方法是否为native
ACC_ABSTRACT 0x0400 标识方法是否为abstract
ACC_STRICTFP 0x0800 标识方法是否为strictfp
ACC_SYNTHETIC 0x1000 标识方法是否由编译器自动产生的

继续分析本文例子,方法表数据在字段表以后的数据 0002 0001 0007 0008 0001 0009

字节码 00 02 00 01 00 07 00 08 00 01 0009
描述 方法表个数 访问标志 方法名称索引 方法的描述符索引 属性表计数器 属性名称索引
内容 1 ACC_PUBLIC 7 8 1 9

从上表能够看到方法表中有两个方法,分别是编译器添加的实例构造器<init>和代码中的main()方法。第一个方法的访问标志为ACC_PUBLIC,方法名称索引为7(对应<init>),方法描述符索引为8(对应()V),符合前面的常量池中的数据。

#7 = Utf8 <init>
   #8 = Utf8 ()V
   #9 = Utf8 Code
复制代码

接着属性表计数器的值为1,表示此方法的属性表集合有一箱属性,属性名称索引为9,对应常量池中为Code,说明此属性是方法的字节码描述。

方法重写 : 若是父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但一样的,有可能会出现由编译器自动添加的方法,最典型的即是类构造器<clinit>方法和实例构造器<init>方法。 方法重载:在Java中药重载(OverLoad)一个方法,除了要与原方法遇有相同的简单名词外,还须要要有一个与原方法彻底不一样的特征签名。特征签名是一个方法中各个参数在常量池中的字段符号引用的集合,返回值并不会包含在前面中,所以没法仅仅依靠返回值不一样来重载一个方法。 可是在Class文件中,特征签名的范围更大一些,只要描述符不是彻底一致的两个方法也是能够共存的。也就是说,若是两个方法有相同的名称和特征签名,但返回值不一样,那么也是能够合法共存于同一个Class文件的,也就是说Java语法不支持,可是Class文件支持。

1.8 属性表集合(attribute Info)

属性表在前面的讲解中已经出现过数次,在Class文件、字段表、方法表均可以携带本身的属性表集合,已用于描述某些场景专有的信息 与Class文件中其余的数据项目要求严格的顺序、长度和内容不一样,属性表集合的限制稍微宽松了一些,不在要求各个属性表具备严格的顺序,只要不与已有的属性名重复,任何人实现的编译器均可以想属性表中写入本身定义的属性信息:Java虚拟机运行时会忽略掉它不认识的属性,具体的预约义属性入下表。

属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量池
Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操做数有所须要的类是否匹配
Signature 类,方法表,字段表 JDK1.5中新增的属性,用于支持泛型状况下的方法签名。任何类,接口,初始化方法或成员的泛型前面若是包含了类型变量(Type Variables)或参数化类型(Parameterized Type),则signature属性会为它记录泛型前面信息,因为Java的泛型采用擦除法实现,在为了便面类型信息被擦除后致使签名混乱,须要这个属性记录泛型中的相关信息。
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 JDK1.6中新增的属性,用于存储额外的调试信息
Synthetic 类,方法表,字段表 标志方法或字段为编译器自动生成的
LocalVariableTypeTable JDK1.5中新增的属性,使用特征签名代替描述符,是为了引入泛型语法以后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类,方法表,字段表 JDK1.5中新增的属性,为动态注解提供支持 ,用于指明那些注解是运行时(运行时就是进行反射调用)可见的
RuntimeInvisibleAnnotations 表,方法表,字段表 JDK1.5中新增的属性,和上面恰好相反,用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation 方法表 JDK1.5中新增的属性,做用与RuntimeVisibleAnnotations属性相似,只不过做用对象为方法
RuntimeInvisibleParameterAnnotation 方法表 JDK1.5中新增的属性,做用与RuntimeInvisibleAnnotations属性相似,做用对象哪一个为方法参数
AnnotationDefault 方法表 JDK1.5中新增的属性,用于记录注解类元素的默认值
BootstrapMethods 类文件 JDK1.7中新增的属性,用于保存invokeddynamic指令引用的引导方式限定符

对于每一个属性,它的名称须要从常量池中应用一个CONSTANT_Utf8_info类型的常量来标书,而属性值的结构则是彻底子墩医德,只须要经过一个u4的长度属性去说明属性值作占用的位数便可,其符合规则的结构以下图。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 infoattribute_length

由于属性表中的属性包含二十多种,下面只对几个属性作一个简要描述。

  • 1.8.1 Code 属性

Java程序方法体中的代码通过Javac编译器处理后,最终变为字节码指令存储在Code属性内,Code属性出如今方法表的属性集合之中,但并未全部的方法表都必须存在这个属性:接口或者抽象类中的方法就不存在Code属性。若是方法表有Code属性,那么它的结构将以下表所示。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_length
u2 attributes_count 1
attribute_info attributes attributes_count

attribute_name_index:一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code”,他表明了该属性的名称。 attribute_length: 属性值得长度,因为属性名称索引和长度一共为6字节,因此属性值长度固定为整个属性表长度减去6个字节。 max_stack:操做数栈深度的最大值,装虚拟机运行的时候须要根据这个值来分配栈帧中的操做栈深度,没有定义好回归的递归发生的栈溢出就是超过了这个值。 max_locals:局部变量表所需的存储空间。这里的单位是Slot,Slot是虚拟机为局部变量表分配内存所使用得最小单位。对于byte、char、float、int、short、boolean、returnAddress这些长度不超过32位的整型数据,每一个局部变量占用一个Slot。像double和float两种64位的数据类型须要两个Slot来存放位置。**方法参数(实例方法中隐藏的this)、显示异常处理器的参数(就是try-catch语句中catch锁定义的异常)、放大提中定义的局部变量都须要使用局部变量表来存放。**由于Slot能够重用,因此这个最大值并非全部的Slot之和,当代码执行超过一个局部变量的做用于时,这个局部变量所占用的Slot能够被其余局部变量使用,因此该值主要根据变量的所用域来计算大小。 code_length:字节码长度。虽然是u4长度,可是虚拟机规定了一个方法中的字节码指令条数不超过u2(65535)条,超过的话编译器会拒绝编译。 code:存储编译后生成的字节码指令。每一个字节码指令是一个u1类型的单字节。当虚拟机督导一个字节码时,能够找到这个字节码代码的指令,并能够知道这个指令后面是否须要跟随参数以及参数的意思。一个u1数据的取值范围为0x00~0xff,也就是一共能够表达256条指令,目前,Java虚拟机以及定义了其中200多条编码值对应的指令含义,具体指令能够看虚拟机字节码指令表。 由于异常表对于Code属性不是必须存在的,后面几个类型也没有太大的重要性,这里就暂时略过。

  • 1.8.2 Exceptions属性

    这里的Exceptions属性是在方法表中与Code属性评级的一项属性,Exceptions属性的做用是列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键词后面列举的异常,其结构以下图。
类型 名称 数量
u2 attribute_name_index 1
u2 attribute_lrngth 1
u2 number_of_exception 1
u2 exception_index_table number_of_exceptions

number_of_exception:表示方法可能抛出此项值数值的受查异常,每一种受查异常exception_index_table表示。 exception_index_table:表示一个指向常量池中CONSTANT_Class_indo型常量的索引,因此,表明了该种受查异常的类型。

  • 1.8.3 SourceFile属性

SourceFile属性用于记录生成这个Class文件的源码文件名称。可使用Javac的-g:none和-g:source选项来关闭或者生成这项信息。对于大多数类来讲,类名和文件名是一致的,可是例如内部类等一些特殊状况就会不同。若是不生成这个属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名,其结构入下表:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 sourcefile_index 1

sourcefile_index:指向常量池中的CONSTANT_Utf8_indo型常量,常量值是源码文件的文件名。

  • 1.8.3 InnerClass属性

InnerClass属性用于记录内部类与宿主之间的关联,若是一个类中定义了内部类,那编译器将会为他以及它所包含的内部类生成InnerClasses属性,其表结构以下图:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_classes 1
inner_classes_info inner_classes number_of_classes

number_of_classes:表示内部类信息的个数。每个内部类的信息都由一inner_classes_info表进行描述,改表结果以下:

类型 名称 数量
u2 inner_class_info_index 1
u2 outer_class_info_index 1
u2 inner_name_index 1
u2 inner_class_access_flags 1

inner_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示内部类的符号引用。 outer_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示宿主类的符号引用。 inner_class_access_flags:内部类的访问标志,相似于类的access_flags

  • 1.8.4 ConstantValue属性

    ConstantValue属性的做用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可使用这项属性,例如int a=1static int a=1,虚拟机对这两种变量的赋值方式和时刻都有所不一样。对于前者的赋值是在实例构造器方法中进行的,换而言之就是一个类的构造的方法没有被执行前,该类的成员变量是还没赋值的;而对于后者,则有两种方式能够选择:在类构造器方法中或者使用ConstantValue属性。目前Javac编译器的选择是若是同时使用finalstatic来修饰一个变量,而且这个变量的数据类型是基本类型或者字符串类型时,就生成ConstantValue属性来初始化,若是这个变量没有被final修饰,或者并不是基本类型变量或字符串,则会选择在<clinit>方法中进行初始化

    <clinit>:类构造器。在jvm第一次加载class文件时调用,由于是类级别的,因此只加载一次,是编译器自动收集类中全部类变量(static修饰的变量)和静态语句块(static{}),中的语句合并产生的,编译器收集的顺序,是由程序员在写在源文件中的代码的顺序决定的。 <init>:实例构造器方法,在实例建立出来的时候调用,包括调用new操做符;调用Class或java.lang.reflect.Constructor对象的newInstance()方法;调用任何现有对象的clone()方法;经过java.io.ObjectInputStream类的getObject()方法反序列化。

<clinit>方法和类的构造函数不一样,它不须要显示调用父类的构造方法,虚拟机会保证子类的<clinit>方法执行以前,父类的此方法已经执行完毕,所以虚拟机中第一个被执行的方法的类确定是java.lang.Object。言而言之就是先须要<clinit>完成类级别的变量和代码块的加载,再进行对象级别的加载信息,因此常常看的面试题子类和父类哪一个语句先被执行就是这些决定的。

public class Main {
     static final int a=1;
}
字节码:
  static final int a;
    descriptor: I
    flags: ACC_STATIC, ACC_FINAL
    ConstantValue: int 1
未添加final
public class Main {
      static int a=1;
}
字节码:
  public com.verzqli.snake.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/verzqli/snake/Main;
  //能够看到  这里的初始化放在了Main的类构造器中
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #2 // Field a:I
         4: return
      LineNumberTable:
        line 13: 0
}

public class Main {
        int a=1;
}
字节码:
  //能够看到  这里的初始化放在了Main的实例构造器中
  public com.verzqli.snake.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2 // Field a:I
         9: return
}
复制代码

2. 字节码指令

字节码指令是一个字节长度的,表明着某种特色操做含义的数字,总数不超过256条(所有字节码指令汇编),。对于大部分与数据类型相关的字节码指令,它们的操做码助记符中都有特殊字符来代表专门为那种数据类型服务,以下表:

描述字符 含义
i 基本类型int
s 基本类型short
l 基本类型long,这里注意不是L,L是最后一个
f 基本类型float
d 基本类型double
b 基本类型byte
c 基本类型char
b 基本类型boolean
a 对象类型引用reference

这里有一个注意的点,这对于不是整数类型的byte、char、short、boolean。编译器会在编译器或运行期将byte和short类型的数据带符号扩展(Sign-extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-extend)为相应的int数据。一样在处理上诉类型的数组数据是,也会转换为使用int类型的字节码指令来处理。

2.1 加载和存储指令。

加载和存储指令用于将数据在栈帧中的局部变量表和操做数栈之间来回传输。

<类型>load_<下标>:将一个局部变量加载到操做数栈。例如iload_1,将一个int类型局部变量(下标为1,0通常为this)从局部变量表加载到操做栈,其余的也都相似,例如:dload_2,fload_3。 <类型>store_<下标>:将一个数值从操做数栈栈顶存储到局部变量表。例如istore_3,将一个int类型的数值从操做数栈栈顶存储到局部变量3中,后缀为3,证实局部变量表中已经存在了两个值。 <类型>const_<具体的值>:将一个常量加载到操做数栈。例如iconst_3,将常量3加载到操做数栈。 wide扩展:当上述的下标志超过3时,就不用下划线的方式了,而是使用istore 6,load的写法也是同样。 bipush、sipush、ldc :当上述的const指令后面的值变得很大时,该指令也会改变。

  • 当 int 取值 -1~5 时,JVM 采用 iconst 指令将常量压入栈中。
  • 当 int 取值 -128~127 时,JVM 采用 bipush 指令将常量压入栈中。
  • 当 int 取值 -32768~32767 时,JVM 采用 sipush 指令将常量压入栈中。
  • 当 int 取值 -2147483648~2147483647 时,JVM 采用 ldc 指令将常量压入栈中。

看例子:

public void save() {
       int a = 1;
       int b = 6;
       int c = 128;
       int d = 32768 ;
       float f = 2.0f;
   }
字节码:
   Code:
     stack=1, locals=6, args_size=1
        0: iconst_1               //将常量1入栈,
        1: istore_1               //将栈顶的1存入局部变量表,下标为1,由于0存储了整个类的this
        2: bipush        6       //将常量6入栈,同时也是以wide扩展的形式
        4: istore_2               //将栈顶的6存入局部变量表,下标为2
        5: sipush        128    //将常量128入栈,
        8: istore_3               //将栈顶的128存入局部变量表,下标为3 ,后面同样的意思
        9: ldc           #2 // int 32768
       11: istore        4
       13: fconst_2
       14: fstore        5
       16: return
复制代码

2.2 运算指令。

运算主要分为两种:对征信数据进行运算的指令和对浮点型数据运算的指令,和前面说的同样,对于byte、char、short、和 boolean类型的算数质量都使用int类型的指令替代。整数和浮点数的运算指令在移除和被领出的时候也有各自不一样的表现行为。具体的指令也是在运算指令前加上对应的类型便可,例如加法指令:iadd,ladd,fadd,dadd。

  • 加法指令:(i,l,f,d)add
  • 减法指令:(i,l,f,d)sub
  • 乘法法指令:(i,l,f,d)mul
  • 除法指令:(i,l,f,d)div
  • 求余指令:(i,l,f,d)rem
  • 取反指令:(i,l,f,d)neg
  • 位移指令: ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令: ixor、lxor
  • 局部变量自增: iinc(例如for循环中i++)
  • 比较指令: dcmpg、dcmpl、fcmpg、fcmpl、lcmp

上面的指令不必强记,须要的时候查找一下便可,看多了也天然就熟悉了。至于浮点数运算的精度损失之类的这里就很少作赘述了。

2.3 类型转换指令。

类型转换指令能够将两种不一样的数值类型进行相互转换,这些转换通常用于实现用户代码中的显示类型转换操做。

Java虚拟机直接支持宽化数据类型转换(小范围数据转换为大数据类型),不须要显示的转换指令,例如int转换long,float和double。举例:int a=10;long b =a

Java虚拟机转换窄化数据类型转换时,必须显示的调用转化指令。举例:long b=10;int a = (long)b

类型转换的字节码指令其实就比较简单了,<前类型>2<后类型>,例如i2l,l2i,i2f,i2d。固然这里举的都是基本数据类型,若是是对象,当相似宽化数据类型时就直接使用,当相似窄化数据类型时,须要checkcast指令。

public class Main {
    public static void main(String[] args) {
        int a = 1;
        long b = a;
        Parent Parent = new Parent();
        Son son = (Son) Parent;
    }
}
字节码:
  Code:
      stack=2, locals=6, args_size=1
         0: iconst_1
         1: istore_1
         2: iload_1
         3: i2l
         4: lstore_2
         5: new           #2 // class com/verzqli/snake/Parent
         8: dup
         9: invokespecial #3 // Method com/verzqli/snake/Parent."<init>":()V
        12: astore        4
        14: aload         4
        16: checkcast     #4 // class com/verzqli/snake/Son
        19: astore        5
        21: return
复制代码

注意上面这个转换时错误的,父类是不能转化为子类的,编译期正常,可是运行是会报错的,这就是checkcast指令的缘由。

2.4 对象建立和访问指令

虽然累实例和数组都是对象,但Java苏尼基对类实例和数组的建立与操做使用了不一样的字节码指令。对象建立后,就能够经过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令以下。

  • new:建立类实例的指令
  • newarray、anewarray、multianewarray:建立数组的指令
  • getfield、putfield、getstatic、putstatic:访问类字段(static字段,被称为类变量)和实例字段(非static字段,)。
  • (b、c、s、i、l、f、d、a)aload:很明显,就是基础数据类型加上aload,将一个数组元素加载到操做数栈。
  • (b、c、s、i、l、f、d、a)astore:同上面同样的原理,将操做数栈栈顶的值存储到数组元素中。
  • arraylength:取数组长度
  • instanceof、checkcast:检查类实例类型的指令。

2.4 操做数栈管理指令

如同操做一个普通数据结构中的堆栈那样,Java虚拟机提供了一些直接操做操做数栈的指令。

  • pop、pop2:将操做数栈栈顶的一个或两个元素出栈。
  • dup、dup二、dup_x一、dup2_x一、dup_x二、dup2_x2:服战栈顶一个或两个数值并将期值复制一份或两份后从新压入栈顶。
  • swap:将栈顶两个数互换。

2.5 方法调用和返回指令。

方法调用的指令只要包含下面这5条

  • invokespecial:用于调用一些须要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic:用于调用static方法。
  • invokeinterface:用于调用接口方法,他会在运行时搜索一个实现了这个接口方法的对象,找出合适的方法进行调用。
  • invokevirtual:用于调用对象的实例方法,根据对象的实际类型进行分派。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条指令的分派逻辑都固话在Java虚拟机内部,而此条指令的分派逻辑是由用户设定的引导方法决定的。
  • (i,l,f,d, 空)return:根据前面的类型来肯定返回的数据类型,为空时表示void

2.5 异常处理指令。

在Java程序中显示抛出异常的操做(throw语句)都由athrow指令来实现。可是处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表来完成的,以下例子。

public class Main {
   public static void main(String[] args) throws Exception{
       try {
           Main a=new Main();
       }catch (Exception e){
           e.printStackTrace();
       }
   }
}
字节码:
public static void main(java.lang.String[]) throws java.lang.Exception;
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=2, locals=2, args_size=1
        0: new           #2 // class com/verzqli/snake/Main
        3: dup
        4: invokespecial #3 // Method "<init>":()V
        7: astore_1
        8: goto          16
       11: astore_1
       12: aload_1
       13: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V
       16: return
复制代码

2.6 同步指令

Java虚拟机能够支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用Monitor 实现的。 正常状况下Java运行是同步的,无需使用字节码控制。虚拟机能够从方法常量池的方法表结构中的ACC_SYNCHRONIZE访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZE访问表示是否被设置,若是设置了,执行线程就要求先持有Monitor,而后才能执行方法,最后当方法完成时释放Monitor。在方法执行期间,执行线程持有了Monitor,其余任何一个线程都没法在获取到同一个Monitor。若是一个同步方法执行期间抛出了异常,而且在方法内部没法处理次异常,那么这个同步方法所持有的Monitor将在异常抛出到同步方法以外时自动释放。 同步一段指令集序列一般是由synchronized语句块来表示的,Java虚拟机指令集中有monitorentermonitorexit两条指令来支持synchronized关键字。以下例子

public class Main {
    public void main() {
        synchronized (Main.class) {
            System.out.println("synchronized");
        }
        function();
    }

    private void function() {
        System.out.printf("function");
    }
}

字节码:
 Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2 // class com/verzqli/snake/Main 将Main引用入栈
         2: dup                                // 复制栈顶引用 Main
         3: astore_1                        // 将栈顶应用存入到局部变量astore1中
         4: monitorenter                  // 将栈顶元素(Main)做为锁,开始同步
        5: getstatic     #3 // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4 // String synchronized ldc指令在运行时建立这个字符串
        10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1                         // 将局部变量表的astore1入栈(Main)
        14: monitorexit                    //退出同步
        15: goto          23                  // 方法正常结束,跳转到23
        18: astore_2                        //这里是出现异常走的路径,将栈顶元素存入局部变量表
        19: aload_1                          // 将局部变量表的astore1入栈(Main)
        20: monitorexit                      //退出同步
        21: aload_2                          //将前面存入局部变量的异常astore2入栈
        22: athrow                            //  把异常对象长线抛出给main方法的调用者
        23: aload_0                          // 将类this入栈,以便下面调用类的方法
        24: invokespecial #6 // Method function:()V
        27: return
复制代码

编译器必须确保不管方法经过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,不管这个方法是正常结束仍是异常结束。

3 实例

前面说了一堆,空看理论既枯燥又难懂,理论就图一乐,真懂还得看例子。

例一:

相信面试过的人基本地看过这个面试题,而后还扯过值传递仍是引用传递这个问题,下面从字节码的角度来分析这个问题。

public class Main {
    String str="newStr";
    String[] array={"newArray1","newArray2"};

  public static void main(String[] args) {
      Main main=new Main();
      main.change(main.str, main.array);
      System.out.println(main.str);
      System.out.println(Arrays.toString(main.array));
  }

  private   void change(String str, String[] array) {
      str="newStrEdit";
      array[0]="newArray1Edit";
  }
}
输出结果:
newStr
[newArray1Edit, newArray2]
字节码:

private void change(java.lang.String, java.lang.String[]);
  descriptor: (Ljava/lang/String;[Ljava/lang/String;)V
  flags: ACC_PRIVATE
  Code:
    stack=3, locals=3, args_size=3
       0: ldc           #14 // String newStrEdit
       2: astore_1
       3: aload_2
       4: iconst_0
       5: ldc           #15 // String newArray1Edit
       7: aastore
       8: return

}
复制代码

这里main方法的字节码内容能够忽略,主要看这个change方法,下面用图来表示。

图一

这是刚进入这个方法的状况,这时候尚未执行方法的内容,局部变量表存了三个值,第一个是this指代这个类,在普通方法内之因此能够拿到外部的全局变量就是由于方法内部的局部变量表的第一个就是类的this,当获取外部变量时,先将这个this入栈aload_0,而后就能够获取到这个类全部的成员变量(也就是外部全局变量)了。 由于这个方法传进来了两个值,这里局部变量表存储的是这两个对象的引用,也就是在堆上的内存地址。

图二
上面执行了 str = "newStrEdit";这条语句,先ldc指令建立了newStrEdit(0xaaa)字符串入栈,而后 astore_1指令将栈顶的值保存再局部变量1中,覆盖了原来的地址,因此这里对局部变量表的修改彻底没有影响外面的值。
图三
上面执行 array[0] = "newArrar1Edit";这条语句,将array的地址入栈,再将要修改的数组下标0入栈,最后建立newArray1Edit字符串入栈。最后调用 aastore指令将栈顶的引用型数值(newArray1Edit)、数组下标(0)、数组引用(0xfff)依次出栈,最后将数值存入对应的数组元素中,这里能够看到对这个数组的操做一直都是这个0xfff地址,这个地址和外面的array指向的是同一个数组对象,因此这里修改了,外界的那个array也就一样修改了内容。

例二:

看过前面那个例子应该对局部变量表是什么有所了解,下面这个例子就不绘制上面那个图了。这个例子也是一个常见的面试题,判断try-catch-finally-return的执行顺序。

finally是一个最终都会执行的代码块,finally里面的return会覆盖try和catch里面的return,同时在finally里面修改局部变量不会影响try和catch里面的局部变量值,除非trycatch里面返回的值是一个引用类型。

public static void main(String[] args) {
        Main a=new Main();
        System.out.println("args = [" + a.testFinally() + "]");;
    }

    public   int testFinally(){
        int i=0;
        try{
            i=2;
            return i;
        }catch(Exception e){
            i=4;
            return i;
        }finally{
            i=6;
        }
字节码:
public int testFinally();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=5, args_size=1
         0: iconst_0				// 常量0入栈
         1: istore_1				// 赋值给内存变量1(i) i=0
         2: iconst_2				// 常量2入栈
         3: istore_1				// 赋值给内存变量1(i) i=2
         4: iload_1				    // 内存变量1(i)入栈
         5: istore_2			    // 将数据存储在内存变量2 这里缘由下面说明
         6: bipush        6    		// 常量6入栈
         8: istore_1				// 保存再内存变量1
         9: iload_2					// 加载内存变量2
        10: ireturn					// 返回上一句加载的内存变量2(i) i=2
        11: astore_2				// 看最下面的异常表,若是2-6发生异常,就从11开始,下面就是发生异常后进入catch的内容
        12: iconst_4				// 常量4入栈
        13: istore_1				// 保存在局部变量1
        14: iload_1					// 加载局部变量1
        15: istore_3				// 将局部变量1内容保存到局部变量3,缘由和上面5同样
        16: bipush        6			// 常量6入栈 (进入了catch最后也会执行finally,因此这里会从新再执行一遍finally)
        18: istore_1				// 保存在局部变量1
        19: iload_3					// 加载局部变量3并返回
        20: ireturn					//上面相似的语句,不过是catch-finally的路径
        21: astore        4			// finally 生成的冗余代码,这里发生的异常会抛出去
        23: bipush        6
        25: istore_1
        26: aload         4
        28: athrow
      Exception table:
         from    to  target type
             2     6    11   Class java/lang/Exception  //若是2-6发生指定的Exception异常(try),就从11开始
             2     6    21   any 						//若是2-6发生任何其余异常(finally),就从21开始
            11    16    21   any						//若是11-16发生任何其余异常(catch),就从21开始
            21    23    21   any						//其实这里有点不太能理解为何会循环,若是有知道的大佬能够解答一下
复制代码

在Java1.4以后 Javac编译器 已经再也不为 finally 语句生成 jsr 和 ret 指令了, 当异常处理存在finally语句块时,编译器会自动在每一段可能的分支路径以后都将finally语句块的内容冗余生成一遍来实现finally语义。(21~28)。但咱们Java代码中,finally语句块是在最后的,编译器在生成字节码时候,其实将finally语句块的执行指令移到了ireturn指令以前,指令重排序了。因此,从字节码层面,咱们解释了,为何finally语句总会执行!

若是try中有return,会在return以前执行finally中的代码,可是会保存一个副本变量(第五和第十五行)。finally修改原来的变量,但tryreturn返回的是副本变量,因此若是是赋值操做,即便执行了finally中的代码,变量也不必定会改变,须要看变量是基本类型仍是引用类型。 可是若是在finally里面添加一个return,那么第9行和第19行加载的就是finally块里修改的值(iload_1),再在最后添加一个iload_1ireturn,感兴趣的能够本身去看一下字节码。

例三:

仍是上面那个相似的例子,这里作一下改变

public static void main(String[] args) {
        Main a = new Main();
        System.out.println("args = [" + a.testFinally1() + "]");
        System.out.println("args = [" + a.testFinally2() + "]");
    }

    public StringBuilder testFinally1() {
        StringBuilder a = new StringBuilder("start");
        try {
            a.append("try");
            return a;
        } catch (Exception e) {
            a.append("catch");
            return a;
        } finally {
            a.append("finally");
        }
    }

    public String testFinally2() {
        StringBuilder a = new StringBuilder("start");
        try {
            a.append("try");
            return a.toString();
        } catch (Exception e) {
            a.append("catch");
            return a.toString();
        } finally {
            a.append("finally");
        }
    }

输出结果:
args = [starttryfinally]
args = [starttry]
复制代码

这里就不列举全局字节码了,两个方法有点多,你们能够本身尝试去看一下。这里作一下说明为何第一个返回的结果没有finally。 首先这个方法的局部变量表1里面存储了一个StringBuilder地址,执行到try~finally这一部分没什么区别,都是复制了一份变量1的地址到变量3,注意,这两个地址是同样的。 那为何第二个返回方法少了finally呢,那是由于s.toString()方法这个看起来是在return后面,但其实这个方法属于这个try代码块,分为两步,先调用toString()生成了一个新的字符串starttry而后返回,因此这里的字节码逻辑就以下:

17: aload_1
      18: invokevirtual #12 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      21: astore_2
      22: aload_1
      23: ldc           #18 // String finally
      25: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: pop
      29: aload_2
      30: areturn
复制代码

能够很清楚的看到 调用append方法拼接“start”和“try”后,先调用了toString()方法而后将值存入局部变量2。这时候finally没有和上面那样复制一份变量,而是继续使用局部变量1的引用来继续append,最后的结果也存入了局部变量1中,最后返回的是局部变量2中的值starttry,可是要注意此时局部变量1中指向的StringBuilder的值倒是starttryfinally,因此这也就是方法1中返回的值。

4.如何快捷查看字节码

若是是ide的话,应该均可以,经过``Setting->Tools->External Tools进入 而后建立一个自定义的tools。

设置
调用

如上图,新建一个External Tools,第一行输入你电脑的javap.exe地址,第二行是你想要的命令符,第三行是显示位置,设置好后要对着代码右键便可一键查看字节码指令,方便快捷。

5.Tips(后续有就继续更新)

5.1 对象被new指令建立后为何会执行一个dup(将栈顶的数据复制一份并压入栈)?

对象被new以后还须要调用invokespecial <init>来初始化,这里须要拿到一份new指令分配的内存地址,而后栈中还存在的一份地址是供这个对象给其余地方调用的,不然栈中若是不存在这个引用以后,任何地方都访问不到这个类了,因此就算这个类没有被任何地方调用,栈中仍是会存在一份它的引用。

6. 总结

原本只是想写点字节码指令的笔记,结果越记越多,本文大部分理论知识来自于《深刻理解Java虚拟机--周志明》,写得多了,错误在所不免,若是有发现的还望指出,谢谢。

相关文章
相关标签/搜索