字节码
字节码
1、字节码文件概述
1.1、字节码文件是跨平台的吗?
Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联。
无论使用何种语言进行软件开发,只要能将源文件编译为正确的Class文件,那么这种语言就可以在Java虚拟机上执行。可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁。
想要让一个Java程序正确地运行在JVM中,Java源码就必须要被编译为符合JVM规范的字节码。所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM上运行。
从Java虚拟机的角度看,通过Class文件,可以让更多的计算机语言支持Java虚拟机平台。因此,Class文件结构不仅仅是Java虚拟机的执行入口,更是Java生态圈的基础和核心。
class文件里是什么?
源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成机器码。
随着Java平台的不断发展,在将来,Class文件的内容也一定会做进一步的扩充,但是其基本的格式与结构不会做重大调整。
生成class文件的编译器
前端编译器的种类:
Java源代码的编译结果是字节码,那么肯定需要有一种编译器能够将Java源码编译为字节码,承担这个重要责任的就是配置在path环境变量中的javac编译器。javac是一种能够将Java源码编译为字节码的前端编译器。
HotSpot VM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别。
除了javac编译字节码,还有内置在Eclipse中的ECJ(Eclipse Compiler for Java)编译器。跟javac的全量编译不同,ECJ是一种增量编译器。
- 在Eclipse中,当开发人员编写完代码后,使用“Ctrl+S”快捷键时,ECJ编译器所釆取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此ECJ的编译效率会比javac更加迅速和高效,当然编译质量和javac相比大致还是一样的。
- ECJ不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是釆用GPLv2的开源协议进行源代码公开,所以,我们可以登录eclipse官网下载ECJ编译器的源码进行二次开发。
- 默认情况下,IntelliJ IDEA 使用 javac 编译器。(还可以自己设置为AspectJ编译器 ajc)
前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件
1.2、哪些类型对应有Class的对象?
(1)class:
外部类,成员(成员内部类,静态内部类),局部内部类,匿名内部类
(2)interface:接口
(3)[]:数组
(4)enum:枚举
(5)annotation:注解@interface
(6)primitive type:基本数据类型
(7)void;
int[] a = new int[10];
int[] b = new int[100];
Class c10 = a.getClass();
Class c11 = b.getClass();
// 只要元素类型与维度一样,就是同一个Class
System.out.println(c10 == c11);
这里的输出结果就是true了。
1.3、字节码指令
什么是字节码指令?
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
i++和++i有什么区别?
- i++是先返回i的值再++;
- ++i是先++再返回新值;
//面试题: i++和++i有什么区别?
@Test
public void test1(){
int i = 10;
i++;//直接不进行赋值就会出来++之后的结果也就是11
//++i;//先++再赋值结果还是11
System.out.println(i);//这里输出的都是11
}
查看一下字节码
0 bipush 10 初始化变量入操作数栈到栈底
2 istore_1 从栈中出来放到局部变量表中角标为1的位置,角标伟0的位置放的是arg当前对象this
3 iinc 1 by 1 局部变量表中角标为1的位置的数值加1也就是11了
6 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
9 iload_1 局部变量表中角标为1的数拿出来到操作数栈再出栈
先赋值再++所以此时输出i就是10
@Test
public void test2(){
int i = 10;
i = i++;
System.out.println(i);//这个输出就是10
}
字节码具体过程:
0 bipush 10 初始化变量入操作数入栈
2 istore_1 从栈中出来放到局部变量表中角标为1的位置
3 iload_1 然后再从局部变量表中加载到栈中
4 iinc 1 by 1 角标为1的局部变量表增加1此时是11
7 istore_1 将栈中的10取出来放到角标为1的位置,所以说11被覆盖掉了
8 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;> 输出操作
11 iload_1 出栈输出
再来一种情况,相乘
@Test
public void test3(){
int i = 2;
i *= i++;//i = i*i++;
System.out.println(i);
}
对应的字节码细节
0 iconst_2 将2加载到栈顶
1 istore_1 将栈顶的2存到局部变量表中角标为1的位置
2 iload_1再将局部变量表中角标为1的数也就是2再加载到栈顶
3 iload_1重复上面一步,所以现在栈顶上有两个2
4 iinc 1 by 1局部变量表中角标为1的位置数值加1,所以现在它是3
7 imul将栈顶的两个数相乘,2*2=4
8 istore_1将4加载到局部变量表中角标为1的位置覆盖掉原来的3
9 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
12 iload_1再把4加载到栈顶
13 invokevirtual #3 <java/io/PrintStream.println : (I)V> 输出
16 return
连加情况
@Test
public void test4(){
int k = 10;
k = k + (k++) + (++k);
System.out.println(k);
}
字节码详情:
0 bipush 10将10加载到栈顶
2 istore_1将10放到局部变量表中角标为1的位置
3 iload_1再加载到栈顶1次
4 iload_1又加载到栈顶1次,现在栈顶上有两个10
5 iinc 1 by 1局部变量表角标1位置自增1现在是11
8 iadd栈顶俩10相加是20
9 iinc 1 by 1局部变量表角标1位置再自增1现在是12了
12 iload_1角标1加载到栈顶,现在栈顶有20和12了
13 iadd栈顶数相加,也就是20+12=32
14 istore_1再放到局部变量表角标1上
15 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
18 iload_1角标1再加载到栈顶,栈顶是32
19 invokevirtual #3 <java/io/PrintStream.println : (I)V>输出32
22 return
包装类缓存问题
//包装类对象的缓存问题
Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);//true
对应字节码细节:
0 bipush 10操作数i1=10入栈
2 invokestatic #4 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
掉用Integer.valueOf 方法将操作数10装箱到Integer对象,并将该对象的引用推到栈顶
5 astore_1将对象引用存到局部变量表中角标为1的位置
6 bipush 10操作数i2=10入栈
8 invokestatic #4 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
再次掉用Integer.valueOf 方法将操作数10装箱到Integer对象,并将该对象的引用推到栈顶
11 astore_2将这个i2的对象引用存入局部变量表中角标为2的位置
12 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>将输出操作的引用推到栈顶
15 aload_1局部变量表角标为1的也就是i1的引用对象推到栈顶
16 aload_2同理局部变量表角标为2的也就是i2的引用对象推到栈顶
17 if_acmpne 24 (+7)进行引用比较(即判断两个Integer对象引用是否指向同一个对象)
20 iconst_1 如果判断为真则把整数1推到栈顶
21 goto 25 (+4)无条件跳转
24 iconst_0 如果判断为假则把整数0推到栈顶
25 invokevirtual #5 <java/io/PrintStream.println : (Z)V>输出操作数栈顶的布尔值
valueOf方法
Java 的 Integer
类内部使用了一个叫做 IntegerCache
的缓存。这个缓存实际上是一个数组,它存储了一定范围内的 Integer
对象。默认情况下,这个范围是从 -128 到 127,但这个范围可以通过 JVM 参数 -XX:AutoBoxCacheMax=<size>
来调整。所以,如果数值在这个区间内的,就直接使用默认的缓存对象,如果不在这个范围就新new一个Integer对象。
public static Integer valueOf(int i) {//10进来
if (i >= IntegerCache.low && i <= IntegerCache.high)//缓存在-128到127之间
return IntegerCache.cache[i + (-IntegerCache.low)];//从缓存中返回对应的 `Integer` 对象。这里使用了一个数组索引计算技巧。由于缓存数组可能不是从 0 开始存储的(例如,它可能从 -128 开始),因此我们需要调整索引以正确地获取对象。
return new Integer(i);//如果不在缓存范围之内就新创建一个Integer对象
}
那么现在超过127,使用128测试就是false
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);
布尔值的字节码细节
Boolean b1 = true;
Boolean b2 = true;
System.out.println(b1 == b2);//结果也是true
字节码:
60 iconst_1定义的b1为true在字节码中就是1代表true
61 invokestatic #6 <java/lang/Boolean.valueOf : (Z)Ljava/lang/Boolean;>
调用Boolean.valueOf将1传入代表true,并将结果true的引用对象入栈
64 astore 5将栈顶的布尔对象引用存到局部变量表中的角标为5的位置
66 iconst_1
67 invokestatic #6 <java/lang/Boolean.valueOf : (Z)Ljava/lang/Boolean;>
70 astore 6重复上面的步骤将定义的b2布尔对象引用暂存索引为6的位置
72 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>输出操作入栈
75 aload 5b1的布尔对象引用入栈
77 aload 6b2的布尔对象引用入栈
79 if_acmpne 86 (+7)判断是否为一个引用对象
82 iconst_1是同一个引用对象就是1代表true
83 goto 87 (+4)
86 iconst_0不是就是0代表false
87 invokevirtual #5 <java/io/PrintStream.println : (Z)V>
90 return
当调用Boolean.valueOf方法时,此方法就是一个三元运算,传入的是1那就是TRUE,0就是FALSE,在Boolean.java中TRUE或FALSE都是定义的全局常量,所以是true就是同一个引用对象引用地址是一样的,同理false也是一样。
public static final Boolean TRUE = new Boolean(true);
/**
* The {@code Boolean} object corresponding to the primitive
* value {@code false}.
*/
public static final Boolean FALSE = new Boolean(false);
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
字符拼接
@Test
public void test6(){
String str = new String("hello") + new String("world");
String str1 = "helloworld";
System.out.println(str == str1);
}
字符串的创建分两种情况,一个是new字符串对象这一种会放在堆栈中。另一种则是直接使用双引号创建的字符串,这种会被放在字符串常量池中。
str中new了两个字符串对象,一个是“hello”一个是“world”,将它们连接起来成一个新的字符串对象“helloworld”的引用被存放在栈顶,而str1是直接用双引号创建的被存放在常量池中,将这两个对象的引用进行比较显然不是同一个引用对象所以结果为false。
子类父类之间的继承和多态
class Father {
int x = 10;
public Father() {
this.print();
x = 20;
}
public void print() {
System.out.println("Father.x = " + x);
}
}
class Son extends Father {
int x = 30;
public Son() {
this.print();
x = 40;
}
public void print() {
System.out.println("Son.x = " + x);
}
}
public class SonTest {
public static void main(String[] args) {
Father f = new Son();
System.out.println(f.x);
}
}
main方法中,new了一个son对象,此时son的构造方法被调用,到构造方法中看到上来一个this.print,此时son中的属性X值还没有初始化,所以他会输出Son.x = 0,然后接着往下走,X设置成40。然后在子类的构造方法完成之前需要完成父类的构造方法,那么进入父类的构造方法中,this.print这个this就不是父类了,它是子类的,所以子类的print就又到了son中的print方法,这个时候因为在调用父类构造方法之前,子类的字段已经被初始化,所以就会输出Son.x = 30。因为我们f的类型它是父类的,所以要走父类构造方法里x=20这里,所以会打印出20。
那么如果把对象类型换成Son,则打印出来的就是40.
2、Class文件结构细节
2.1、文件结构有几个部分
Class文件的结构并不是一成不变的,随着Java虚拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。
Class文件的总体结构如下:
- 魔数
- Class文件版本
- 常量池
- 访问标识(或标志)
- 类索引,父类索引,接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
字节码总的结构表:
2.2、魔数
每个 Class 文件开头的4个字节的无符号整数称为魔数,魔数是class文件的标志,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。
魔数值固定为0xCAFEBABE。不会改变。
使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。
紧接着魔数的 4 个字节存储的是 Class 文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_version,而第7个和第8个字节就是编译的主版本号major_version。主版本号减去44就是编译器的版本,比如52是jdk1.8的,52-44就是8
2.3、class文件的基石常量池
常量池是Class文件中内容最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用。
常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。
常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
2.3.1、**常量池计数器constant_pool_count **
由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。常量池容量计数值(u2类型):从1开始,表示常量池中有多少项常量。即constant_pool_count=1表示常量池中有0个常量项。
为啥是从1开始不是从0开始呢?
通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。
2.3.2、字面量和符号引用
常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
常量 | 具体的常量 |
---|---|
字面量 | 文本字符串 |
声明为final的常量值 | |
符号引用 | 类和接口的全限定名 |
字段的名称和描述符 | |
方法的名称和描述符 |
2.3.3、符号引用和直接引用
Java代码在进行Javac编译的时候,并不会像C或C++一样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态的连接,也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟运行时,需要从常量池获得对应的符号引用,再在类加载过程中的解析阶段将其替换成直接引用,并翻译到具体的内存地址中。
符号引用和直接引用的区别与联系:
- 符号引用:符号引用以一组符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用地目标并不一定已经加载到了内存中。
- 直接引用:直接引用可以是直接指向目标地指针、相对偏移量或是一个能简介定位到目标的句柄。直接引用是与虚拟机实现地内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一半不会相同。如果有了直接引用,那说明引用地目标必定已经存在于内存之中了。
2.3.3、常量池表constant_pool [](常量池)
- constant_pool是一种表结构,以 1 ~ constant_pool_count - 1为索引。表明了后面有多少个常量项。
- 常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
- 它包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte (标记字节、标签字节)。
类型 | 标志或标识 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_lnteger_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_lnterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标志方法类型 |
CONSTANT_lnvokeDynamic_info | 18 | 表示一个动态方法调用点 |
2.4、访问标识(access_flag)
在常量池后,紧接着会跟着访问标记。这个标记使用两个字节表示,用于识别一些类或接口层次的访问信息,包含:这个Class是类还是接口,是否定义为public类型。是否定义为abstract类型。如果是类的话,是否被声明为final等。各种访问标记如下:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 标志为public类型 |
ACC_FINAL | 0x0010 | 标志被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法) |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志此类并非由用户代码产生(就是由编译器产生的没有源码对应) |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是个枚举 |
- 类的访问权限通常为 ACC_ 开头的常量。
- 每一种类型的表示都是通过设置访问标记的32位中的特定位来实现的。比如,若是public final的类,则该标记为ACC_PUBLIC | ACC_FINAL。
- 使用ACC_SUPER可以让类更准确地定位到父类的方法super.method(),现代编译器都会设置并且使用这个标记。
2.5、类索引、父类索引、接口索引集合
在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:
长度 | 含义 |
---|---|
u2 | this_class(类索引) |
u2 | super_class(父类索引) |
u2 | interfaces_count |
u2 | interface[interfaces_count] |
-
this_class(类索引): 2字节无符号整数,指向常量池的索引。它提供了类的全限定名,如com/abc/java1/Demo。this_class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。
-
super_class (父类索引): 2字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是 java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。super_class指向的父类不能是final。
-
interfaces: 指向常量池索引集合,它提供了一个符号引用到所有已实现的接口。由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class (当然这里就必须是接口,而不是类)。
-
interfaces_count (接口计数器) :nterfaces_count项的值表示当前类或接口的直接超接口数量。
-
interfaces_count项的值表示当前类或接口的直接超接口数量。
interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为 interfaces_count。 每个成员 interfaces[i]必须为 CONSTANT_Class_info结构,其中 0 <= i < interfaces_count。在 interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即 interfaces[0]对应的是源代码中第一个实现的接口。
2.6、字段表集合
fields
- 用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。(local variables)
- 字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
- 它指向常量池索引集合,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰符)等。
**fields_count字段计数器:**fields_count的值表示当前class文件fields表的成员个数。使用两个字节来表示。fields表中每个成员都是一个field_info结构,用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段。
字段表:
fields表中的每个成员都必须是一个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。
一个字段的信息包括如下这些信息。这些信息中,各个修饰符都是布尔值,要么有,要么没有。
- 作用域(public、private、protected修饰符)
- 是实例变量还是类变量(static修饰符)
- 可变性(final)
- 并发可见性(volatile修饰符,是否强制从主内存读写)
- 可否序列化(transient修饰符)
- 字段数据类型(基本数据类型、对象、数组)
- 字段名称
字段表作为一个表也有他自己的结构:
类型 | 名称 | 含义 | 数量 |
---|---|---|---|
u2 | access_flags | 访问标志 | 1 |
u2 | name_index | 字段名索引 | 1 |
u2 | descriptor_index | 描述符索引 | 1 |
u2 | attributer_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attributer_count |
2.7、方法表集合
methods:指向常量池索引集合,它完整描述了每个方法的签名。
- 在字节码文件中,每一个method_info项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等。
- 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。
- 一方面,methods表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法()和实例初始化方法())。
3、字节码指令集与解析概述
Java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本执行指令。Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于 Java 虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。
3.1、字节码与数据类型
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:
- i代表对int类型的数据操作
- l代表long类型的数据操作
- s代表short类型的数据操作
- b代表byte类型的数据操作
- c代表char类型的数据操作
- f代表float类型的数据操作
- d代表double类型的数据操作
常用的指令:
-
局部变量压栈指令load:
将一个局部变量加载到操作数栈:xload、xload_(其中x为i、l、f、d、a;n 为 0 到 3)
-
常量入栈指令const、push、ldc:
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_(这里的i就是常量的值)
- 指令push系列:主要包括bipush和sipush。它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。
- 指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。
类似的还有ldc_w,它接收两个8位参数,能支持的索引范围大于ldc。
-
**出栈装入局部变量表中的指令store **:
将一个数值从操作数栈存储到局部变量表:xstore、xstore_(其中x为i、l、f、d、a;n为 0 到 3)
**一般说来,类似像store这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。**但是,为了尽可能压缩指令大小,使用专门的istore_1指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有istore_0、istore_2、istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置。
-
扩充局部变量表的访问索引的指令:wide
举些例子:
- iload_1
- 作用:将局部变量表中索引为 1 位置上的整数值压入操作数栈中。
- 场景:假设在方法中有一个局部变量
int b
,并且它是方法的第二个参数(第一个参数是this
引用对于实例方法,或者没有this
对于静态方法,但这里我们假设是非静态方法且b
是第二个参数),那么iload_1
就会将b
的值加载到操作数栈上。
- iload_2
- 作用:将局部变量表中索引为 2 位置上的整数值压入操作数栈中。
- 场景:假设在方法中有一个局部变量
int c
,它是方法的第三个参数(考虑到可能的this
引用),那么iload_2
就会将c
的值加载到操作数栈上。
- iload 4(注意:超过3了,下划线也不要了)
- 作用:将局部变量表中索引为 4 位置上的整数值压入操作数栈中。
- 注意:这里使用的是
iload
而不是iload_<n>
,因为索引 4 超出了iload_<n>
指令直接支持的索引范围(0-3)。iload
指令后面会跟随一个字节码参数来指定索引。 - 场景:假设在方法中有一个局部变量
int d
,它是方法的第五个参数(对于实例方法)或者是在方法内部定义的且索引为 4 的局部变量,那么iload 4
就会将d
的值加载到操作数栈上。这里的4
是作为iload
指令的一个参数出现的,而不是指令名的一部分。
也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。
大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
3.2、指令的分类
大致分为9类
- 加载与存储指令
- 算术指令
- 类型转换指令
- 对象的创建与访问指令
- 方法调用与返回指令
- 操作数栈管理指令
- 控制转移指令
- 异常处理指令
- 同步控制指令
在做值的相关操作时,一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值,可能是对象的引用)被压入操作数栈。一个指令,也可以从操作数栈中取出一到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等等操作。
4、字节码指令
4.1、加载与存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
操作数栈(Operand Stacks)
我们知道,Java字节码是Java虚拟机所使用的指令集。因此,它与Java虚拟机基于栈的计算模型是密不可分的。在解释执行过程中,每当为Java方法分配栈桢时,Java虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。
具体来说便是:执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机执行引擎会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。
局部变量表(Local Variables)
Java 方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中。实际上,Java 虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。和操作数栈一样,long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元。
在栈帧中,与性能调优关系最为密切的部分就是局部变量表。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。在方法执行时,虚拟机使用局部变量表完成方法的传递。
演示两数相加方法操作数栈和局部变量表交互过程:
public class test {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}
对应的字节码:
0 iconst_1 将整数常量1压入操作数栈
1 istore_1 再把操作数栈顶的整数(1)弹出,并存到局部变量表的第1个槽位(索引为1)对应的变量就是a
2 iconst_2 再把整数常量2压入操作数栈
3 istore_2 把操作数栈顶的整数(2)弹出,并加载到局部变量表的第2个槽位(索引为2)对应的变量就是b
4 iload_1 把局部变量表索引为1的整数加载到操作数栈
5 iload_2 把局部变量表索引为2的整数加载到操作数栈
6 iadd 将栈顶的两个整数相加也就是1+2
7 istore_3 再把操作数栈中的计算结果整数3给弹出,存到局部变量表中的第3个槽位(索引为3)对应的变量是c
//下面就是打印输出的过程
8 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>// 获取java.lang.System类中名为"out"的静态字段(类型为java.io.PrintStream),并将其引用压入操作数栈
11 iload_3 把局部变量表索引为3的整数加载到操作数栈
12 invokevirtual #3 <java/io/PrintStream.println : (I)V>// 调用操作数栈顶的PrintStream对象(System.out)的println方法,传入操作数栈下一个整数(c的值3)作为参数,并消费该整数和PrintStream引用(方法返回类型为void,因此不产生返回值)
15 return
局部变量表一般是先加载的将变量进行初始化还没有被赋值,赋值还要从操作数栈拿
过程图:
iconst_2 把整数常量2压入操作数栈
istore_2 把操作数栈顶的整数(2)弹出,并加载到局部变量表的第2个槽位(索引为2)对应的变量就是b
这两步操作和上面两步一样的不重复画了
4.2、算术指令
所有的算术指令包括:
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、 fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem //remainder:余数
- 取反指令:ineg、lneg、fneg、dneg //negation:取反
- 自增指令:iinc
位运算指令,又可分为:
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
4.3、类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换。这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
4.3.1、宽化类型转换(小转大)
转换规则:Java虚拟机直接支持以下数值的宽化类型转换(小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:
- 从int类型到long、float或者double类型。对应的指令为:i2l、i2f、i2d
- 从long类型到float、double类型。对应的指令为:l2f、l2d
- 从float类型到double类型。对应的指令为:f2d
简化为:int --> long --> float --> double
精度损失问题
-
宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到 long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。
-
从int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式所得到的正确整数值。
尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常。
补充:
从byte、char和short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转为long时,使用的是i2l,可以看到在内部byte在这里已经等同于int类型处理,类似的还有short类型,这种处理方式有两个特点:
一方面可以减少实际的数据类型,如果为short和byte都准备一套指令,那么指令的数量就会大增,而虚拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将short和byte当做int处理也在情理之中。
另一方面,由于局部变量表中的槽位固定为32位,无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。
4.3.2、窄化类型转换(大转小)
Java虚拟机也直接支持以下窄化类型转换:
- 从int类型至byte、short或者char类型。对应的指令有:i2b、i2s、i2c
- 从long类型到int类型。对应的指令有:l2i
- 从float类型到int或者long类型。对应的指令有:f2i、f2l
- 从double类型到int、long或者float类型。对应的指令有:d2i、d2l、d2f
精度损失问题
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。
4.4、对象的创建与访问指令
Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令。
4.4.1、创建指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
-
创建类实例的指令:new
它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈。
-
**创建数组的指令:**newarray、anewarray、multianewarray。
newarray:创建基本类型数组
anewarray:创建引用类型数组
multianewarray:创建多维数组
4.4.2、字段访问指令
对象创建后,就可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素。
- 访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic
- 访问类实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield
4.4.3、数组操作指令
数组操作指令主要有:xastore和xaload指令。
具体为:
-
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
-
将一个操作数栈的值存储到数组元素中的指令:bastore、 castore、 sastore、iastore、 lastore、fastore、dastore、aastore
-
取数组长度的指令:arraylength
该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈。
数组操作指令对应表:
数组类型 | 加载指令 | 存储指令 |
---|---|---|
byte(boolean) | baload | bastore |
char | caload | castore |
short | saload | sastore |
int | iaload | iastore |
long | laload | lastore |
float | faload | fastore |
double | daload | dastore |
reference | aaload | aastore |
- 指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹岀栈顶这两个元素,并将a[i]重新压入栈。
- xastore则专门针对数组操作,以iastore为例,它用于给一个int数组的给定索引赋值。在iastore执行前,操作数栈顶需要以此准备3个元素:值、索引、数组引用,iastore会弹出这3个值,并将值赋给数组中指定索引的位置。
4.4.4、类型检查指令
检查类实例或数组类型的指令:instanceof、checkcast。
- 指令checkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常。
- 指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈。
4.5、方法调用与返回指令
方法调用指令:
invokevirtual、invokeinterface、invokespecial、invokestatic 、invokedynamic
- invokevirtual:指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。
- invokeinterface:指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
- invokespecial:指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发。
- invokestatic:指令用于调用命名类中的类方法(static方法)。这是静态绑定的。
- invokedynamic:调用动态绑定的方法,这个是JDK 1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在 java 虚拟机内部,而 invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法返回指令:
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。
- 包括ireturn(当返回值是 boolean、byte、char、short和int 类型时使用)、lreturn、freturn、dreturn和areturn
- 另外还有一条return 指令供声明为 void的方法、实例初始化方法以及类和接口的类初始化方法使用。
比如:通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。
4.6、操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。
包括:
- 将一个或两个元素从栈顶弹出,并且直接废弃: pop,pop2;
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶: dup, dup2, dup_x1, dup2_x1, dup_x2, dup2_x2;
- 将栈最顶端的两个Slot数值位置交换: swap。Java虚拟机没有提供交换两个64位数据类型(long、double)数值的指令。
- 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。
这些指令属于通用型,对栈的压入或者弹出无需指明数据类型。
4.7、控制转移指令
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令
大体上可以分为:
- 比较指令
- 条件跳转指令
- 比较条件跳转指令
- 多条件分支跳转指令
- 无条件跳转指令
4.7.1、条件跳转指令
条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。
条件跳转指令有: ifeq, iflt, ifle, ifne, ifgt, ifge, ifnull, ifnonnull。这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。
它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置。
注意:
- 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
- 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转
- 由于各类型的比较最终都会转为 int 类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。
4.7.2、比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
这类指令有:==if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。==其中指令助记符加上“if_”后,以字符“i”开头的指令针对int型整数操作(也包括short和byte类型),以字符“a”开头的指令表示对象引用的比较。
这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句。
4.7.3、多条件分支跳转指令
多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitch和lookupswitch。
从助记符上看,两者都是switch语句的实现,它们的区别:
- tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index, 可以立即定位到跳转偏移量位置,因此效率比较高。
- 指令lookupswitch内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低。
4.7.4、多条件分支跳转指令
目前主要的无条件跳转指令为goto。指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。
如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_w,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围。
4.8、异常处理指令
4.8.1、抛出异常指令
athrow指令
在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如当除数为零时,虚拟机会在 idiv或 ldiv指令中抛出 ArithmeticException异常。
正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上。
异常及异常的处理:
- 过程一:异常对象的生成过程 —> throw (手动 / 自动) —> 指令:athrow
- 过程二:异常的处理:抓抛模型。 try-catch-finally —> 使用异常表
4.8.2、异常处理与异常表
在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的。
异常表
如果一个方法定义了一个try-catch 或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息。异常表保存了每个异常处理信息。
比如:
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池中的索引
当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程。
不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标
4.9、同步控制指令
java虚拟机支持两种同步结构:方法级的同步 和 方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的。
4.9.1、方法级的同步
方法级别的同步是隐式的,就不需要通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机能从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法。
当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置。
- 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁。
- 在方法执行的期间,锁没被释放,其他的线程都无法再获得同一个锁。
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。
4.9.2、方法中指定指令序列的同步
同步一段指令集序列:通常是由java中的synchronized语句块来表示的。jvm的指令集有 monitorenter 和 monitorexit 两条指令来支持 synchronized关键字的语义。
monitorenter
- 作用:
monitorenter
指令用于获取对象的监视器锁(monitor lock)。当线程执行到这条指令时,它会尝试获取当前对象(this对象,对于实例方法,或类对象,对于静态方法)的监视器锁。 - 实现:如果监视器锁可用(即该对象未被其他线程锁定),则当前线程获得锁,并继续执行后续指令。如果监视器锁不可用,则当前线程被阻塞,直到锁被释放。
monitorexit
- 作用:
monitorexit
指令用于释放对象的监视器锁。当线程执行到这条指令时,它会释放当前对象的监视器锁,使得其他等待该锁的线程可以继续执行。 - 实现:这条指令通常位于同步代码块的末尾。如果在执行过程中抛出异常,Java编译器会在每个可能的异常抛出点插入
monitorexit
指令,以确保锁总是被正确释放。
当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入同时监视器计数器数量加1(可重入实现),否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。
当线程退岀同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。
指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。
只有线程1离开临界区之后其他线程才有机会进来
4.10、Java虚拟机中,数据类型可以分为哪几类?
Java虚拟机是通过某些数据类型来执行计算的,数据类型可以分为两种:基本类型和引用类型,基本类型的变量持有原始值,而引用类型的变量持有引用值。
Java语言中的所有基本类型同样也都是Java虚拟机中的基本类型。但是boolean有点特别,虽然Java虚拟机也把boolean看做基本类型,但是指令集对boolean只有很有限的支持,当编译器把Java源代码编译为字节码时,它会用int或者byte来表示boolean。在Java虚拟机中,false是由整数零来表示的,所有非零整数都表示true,涉及boolean值的操作则会使用int。另外,boolean数组是当做byte数组来访问的。
Java虚拟机还有一个只在内部使用的基本类型:returnAddress,Java程序员不能使用这个类型,**这个基本类型被用来实现Java程序中的finally子句。该类型是jsr, ret以及jsr_w指令需要使用到的,它的值是JVM指令的操作码的指针。**returnAddress类型不是简单意义上的数值,不属于任何一种基本类型,并且它的值是不能被运行中的程序所修改的。
Java虚拟机的引用类型被统称为“引用(reference)”,有三种引用类型:==类类型、接口类型、以及数组类型,==它们的值都是对动态创建对象的引用。类类型的值是对类实例的引用;数组类型的值是对数组对象的引用,在Java虚拟机中,数组是个真正的对象;而接口类型的值,则是对实现了该接口的某个类实例的引用。还有一种特殊的引用值是null,它表示该引用变量没有引用任何对象。
Java虚拟机中的数据类型:
原文地址:https://blog.csdn.net/qq_63754844/article/details/144296882
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!