JVM-字节码篇
虚拟机体系结构
线程共享:堆、方法区
线程私有:虚拟机栈,本地方法栈,程序计数器。其中虚拟机栈中包括局部变量表,和操作数栈。
字节码文件概述
字节码文件是跨平台的吗? 是的
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。
- 无论使用何种语言进行软件开发,只要能将源文件编译为正确的Class文件,那么这种语言就可以在Java虚拟机上执行。可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁。
字节码文件里面是什么?
- 源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM的指令, 而不像C、C++经由编译器直接生成机器码。
- 随着Java平台的不断发展,在将来,Class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整。
能介绍一下生成class文件的编译器吗?
位置上
前端编译器 还是 后端编译器 ? 前端编译器,不是JIT
Java两条腿走路:JIT编译器和解释器
Java是半编译半解释性的语言!
这一点并不是指先用javac进行编译然后用java运行,而是指编译成字节码文件之后,解释器和JIT及时编译器都会参与。
- JIT及时编译器会对热点代码进行及时编译之后,并对编译之后对指令进行缓存,然后直接翻译成机器指令进行执行,效率高很多;
- 但是JIT会有个预热的过程,就是寻找热点代码的过程
- 为了解决预热问题,引入AOT,提前进行编译的这样一个行为
- 前端编译器:
- 最常见的:javac(全量编译器)
- Java源代码的编译结果是字节码,那么肯定需要有种编译器能够将Java源码编译为字节码,承担这个重要责任的就是配置在path环境变量中的javac编译器。
- javac是一种能够将Java源码编译为字节码的前端编译器HotSpotVM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别即可。
- 在Java的前端编译器领域,除了javac之外,还有一种被大家经常用到的前端编译器,那就是内置在Eclipse中的ECJ编译器,和Javac的全量编译不同,Eclipse是增量编译。
- 在Eclipse中,当开发人员编写完代码后,使用“Ctrl+S" 快捷键时,ECJ编译 器所采取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此ECJ的编译效率会比javac更加迅速和高效,当然编译质量和javac相比大致还是-样的。
- ECJ不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是采用GPLv2的开源协议进行源代码公开,所以,大家可以登录eclipse官网下载ECJ编译器的源码进行二次开发。
- 默认情况下,Inte11iJ IDEA使用javac 编译器。(还可以自己设置为AspectJ编译器ajc)
- 前端编译器的任务
- 前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件。
- javac编译步骤:
- 目前前端编译器局限性javac:
- 前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给HotSpot的了IT编译器负
责。
- 前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给HotSpot的了IT编译器负
- 复习:AOT(静态提前编译器,Ahead Of Time Compiler)
- jdk9引入了AOT编译器(静态提前编译器,Ahead of Time Compiler)
- Java 9 引入了实验性 AOT 编译工具jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
- 所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直按运行的机器码,并部署至托管环境中的过程。而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程
- java -> .class -> .sol
- 最大好处:Java虚拟机加载己经预编译成二进制库,可以直按执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验。
- 缺点:
- 破坏了java“一次编译,到处运行”,必须为每个不同硬件、os编译对应的发行包。
- 降低了Java链接过程的动态性,加载的代码在编译期就必须全部己知。
哪些类型对应有Class对象?
- class: 外部类,成员(成员内部类,静态内部类),局部内部类,匿名内部类
- interface:接口
- []:数组
- enum:枚举
- annotation: 注解@interface
- primitive type:基本数据类型
- void
字节码指令
- 什么是字节码指令(byte code)?
- Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码 (opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand) 所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
- Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码 (opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand) 所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
- i++与++i的问题
- i++:取值、++、赋值
- ++i:++,赋值,取值
- IntegerCache:
- 当int类型处于-128~127之间,会存储在IntegerCache中,[数值+128] 就是下标(因为要存储负数所以要加128),里面的值是在静态代码块里面初始化的。因为地址不等于指所以还需要进行初始化。
-
public static Integer valueof(int i){ if(i >= IntegerCache. Low && i<= IntegerCache.high) return IntegerCache.cache[i +(-IntegerCache.low)]; return new Integer(i); }
- 扩展:Byte、Short、Integer、Long都存在缓存对象,并且都是-128~127
- Character缓存对象:0~127 ; Boolean缓存对象:true和false
- String:
-
String str = new String("hello") + new String("world"); // str.intern(); // 在此处加这句话会输出true String str1 = “helloworld"; // str.intern(); // 在此处加这句话会出false System.out.printin(str==str1); // false
- 分析:String声明的字面量数据都存放在字符串常量池中
- jdk 6中字符串常量池存放在方法区(即永久代中)
- jdk7 及以后字符串常量池存放在堆空间
- intern( )方法:
intern()
方法的作用是将str
对象的字符串值添加到字符串常量池(如果字符串常量池中没有相同内容的字符串),或者返回字符串常量池中已经存在的相同内容的字符串。给常量池赋值是引用复制,直接指向刚才创建的变量。- 如果在str1创建之前去intern,由于常量池放在堆里面,常量池会保存str的地址,str1再次赋值的时候,会查常量池然后返回str的地址。
- 先intern细致分析:
- 在这行代码中:
通过String str = new String("hello") + new String("world");
new String("hello")
和new String("world")
创建了两个新的字符串对象,然后使用+
操作符将它们连接起来,创建了一个新的字符串 “helloworld”。这个新字符串被赋值给了变量str
。这些字符串对象是在堆内存中创建的。 - 在这行代码中:
调用了str.intern();
str
对象的intern()
方法。这会将str
的字符串值 “helloworld” 添加到字符串常量池(如果字符串常量池中没有相同内容的字符串),或者返回字符串常量池中已经存在的相同内容的字符串。在这个例子中,字符串常量池中没有 “helloworld” 字符串,因此将str
的值 “helloworld” 添加到字符串常量池。 - 在这行代码中:
由于 “helloworld” 是一个字符串字面量,所以它会被存储在字符串常量池中。此时,由于之前调用了String str1 = "helloworld";
str.intern()
,字符串常量池中已经存在了 “helloworld” 字符串,因此str1
引用的是字符串常量池中的 “helloworld” 字符串。 - 在这行代码中:
这里使用System.out.println(str == str1);
==
操作符比较str
和str1
变量引用的对象。虽然str
引用的是堆内存中的一个字符串对象,但由于之前调用了intern()
方法,str
的值 “helloworld” 已经被添加到了字符串常量池。而str1
引用的也是字符串常量池中的 “helloworld” 字符串。因此,它们的引用是相等的,输出结果为true
。
- 在这行代码中:
- 如果在str1创建之后去intern,常量池里面已经有了helloworld单词,所以常量池不会保存str的地址,str和str1地址指向各自的地址。
- 后intern细致分析:
- 在这行代码中:
String str = new String("hello") + new String("world");
new String("hello")
和new String("world")
创建了两个新的字符串对象,然后使用+
操作符将它们连接起来,创建了一个新的字符串 “helloworld”。这个新字符串被赋值给了变量str
。这些字符串对象是在堆内存中创建的。 - 在这行代码中:
- 在这行代码中:
由于 “helloworld” 是一个字符串字面量,所以它会被存储在字符串常量池中。变量String str1 = "helloworld";
str1
引用的是字符串常量池中的 “helloworld” 字符串。 - 在这行代码中:
调用了str.intern();
str
对象的intern()
方法。这会将str
的字符串值 “helloworld” 添加到字符串常量池(如果字符串常量池中没有相同内容的字符串),或者返回字符串常量池中已经存在的相同内容的字符串。在这个例子中,字符串常量池中已经存在了 “helloworld”(由于之前创建的str1
变量),因此这行代码实际上没有改变字符串常量池的状态。 - 在这行代码中:
这里使用System.out.println(str == str1);
==
操作符比较str
和str1
变量引用的对象。由于str
引用的是堆内存中的一个字符串对象,而str1
引用的是字符串常量池中的一个字符串对象,它们的引用是不相等的。因此,输出结果为false
。 - 如果想要让输出结果为
true
,可以修改代码如下:
在这个修改后的代码中,将String str = new String("hello") + new String("world"); String str1 = "helloworld"; str = str.intern(); System.out.println(str == str1);
str.intern()
的返回值赋值给了str
变量。这使得str
和str1
都引用了字符串常量池中的 “helloworld” 字符串,因此比较结果为true
。 - 扩展一下,当str和str1指向同一个地址时,此时对str进行substring,再判断str和str1相等情况
-
String str = new String("hello") + new String("world"); str.intern(); String str1 = "helloworld"; str = str.substring(0,str.length()-1); System.out.println(str == str1);//false --> true (加上intern() 在str声明之前) System.out.println(str1); // 结果 // false // helloworld
- 这里substring剪完之后会产生一个新对象,地址不再一样。
-
- 再扩展一下:如果str和str1都不再是 "helloworld"这个值的时候,那么常量池里面的这个值会怎么样?
- 会被java垃圾回收器回收,回收的时间取决于回收算法和内存压力,可能会回收也可能一直在内存。
-
类初始化案例
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 ByteCodeInterview1 {
public static void main(String[] args) {
Father f = new Son();
System.out.println(f.x);
}
}
// 输出结果:
// Son.x = 0
// Son.x = 30
// 20
解释如下:
Father f = new Son();
创建一个Son
对象。由于Son
继承自Father
,所以首先会调用Father
类的构造方法。- 在
Father
类的构造方法中,首先调用this.print();
。由于这里的this
引用的是一个Son
对象,因此调用的是Son
类的print()
方法。在这个时候,Son
类的变量x
还未被初始化,所以其值为默认值0
。因此,输出结果为Son.x = 0
。 - 然后,
Father
类的构造方法将x
的值设置为20
。 - 接下来,调用
Son
类的构造方法。同样,首先调用this.print();
,此时Son
类的变量x
已经被初始化为30
,因此输出结果为Son.x = 30
。 - 然后,
Son
类的构造方法将x
的值设置为40
。但是请注意,这不会影响到Father
类的变量x
,因为Father
和Son
类中的x
变量是属于不同的作用域。 - 最后,执行
System.out.println(f.x);
。由于f
是一个Father
类型的引用,因此访问的是Father
类中的变量x
,其值为20
。所以,输出结果为20
。
class文件结构分析
- class文件结构概述
- Class文件的结构并不是一成不变的,随着Java虚拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。
- Class文件的总体结构如下:
- 魔数
- Class文件版本
- 常量池
- 访问标识(或标志)
- 类索引,父类索引,接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
- 魔数:class文件的标志
- 每个 Class 文件开头的4个字节的无符号整数称为魔数(Magic Number)
- 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。 即:魔数是Class文件的标识符。
- 魔数值固定为OXCAFEBABE。不会改变。
- 如果一个Class文件不以OXCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出错误:
- 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。
- 如何保证高版本的JVM可执行低版本的class文件:
- 存一个最低版本和一个最高版本
- 高版本的JVM可以兼容低版本的class文件,但是低版本的JVM无法执行高版本的class文件
- 常量池计数器
- 由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。
- 常量池容量计数值(u2类型):从1开始,表示常量池中有多少项常量。即constant_pool_count=1表示常量池中有0个常量项。
- 字面量和符号引用
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只
要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目
标并不一定已经加载到了内存中。 - 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标
的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例
上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存
在于内存之中了。
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只
字节码指令集
- 参考链接:https://blog.csdn.net/qq_42200163/article/details/121898700,未整理完的部分,直接参考博客。
- 执行模型
// 字节码执行方式 while(字节码流长度>0){ 自动计算Pc寄存器的值加1; 根据Pc寄存器指示的位置,从字节码流中取出操作码; if(字节码存在操作数)从字节码流中取出操作数; 执行操作码所定义的操作; }
- 字节码与数据类型
-
i
代表对int
类型的数据操作。 -
l
代表long
类型。 -
s
代表short
类型。 -
b
代表byte
类型。 -
c
代表char
类型。 -
f
代表float
类型。 -
d
代表double
类型。 -
a
代表reference
类型。 -
也有一些指令的助记符中没有明确指明操作类型的字母,例如
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类型作为运算类型。
-
- 字节码指令分类:为了能够更快地熟悉和了解这些基本指令,这里将JVM中的字节码指令集按用途大致分成9类:
-
加载与存储指令 load & store
- 加载和存储指令用于将数据从栈帧中的局部变量表和操作数栈之间来回传输。对应两个操作:
- 加载:压栈的操作;
- 存储:存进局部变量表的操作
- 补充: 对于iload_<n> 和 iload <n>的区别
- 前者n的范围是0-3,将操作数集成进指令,主要就是节省空间,意思就是将局部变量表索引为0的位置上的数压入操作数栈。
- 后者n的范围不限,占用空间多一点,显示完成上面一样的任务。
- 补充:常量入栈指令有所不同:const指令、push指令、ldc指令,先不做展开了
-
算术指令
-
类型转换指令
-
对象的创建与访问指令
-
方法调用与返回指令
-
操作数栈管理指令
-
比较控制指令
-
异常处理指令
-
同步控制指令
-
方法调用指令
- 方法调用指令:invokevirtual、 invokeinterface、 invokespecial、invokestatic、invokedynamic
- 以下5条指令用于方法调用:
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。常见于多态中
- invokeinterface指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法, 并找出适合的方法进行调用。
- 接口的匿名内部类实现也会调用这个指令
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发。
- invokestatic指令用于调用命名类中的类方法(static方法)。这是静态绑定的。
- 补充:静态方法不能被重写,因为重写是多态的特性,多态是指父类引用指向子类对象调用子类方法,需要进行实例化。而静态方法与类关联,不与实例关联,静态方法不具备多态性
- invokedynamic:调用动态绑定的方法,这个是JDK 1.7后新加入的指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在 java 虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。lambda表达式使用这个指令。
- 补充:字节码层面比较lambda和匿名内部类:
匿名内部类: List<Integer> list = new ArrayList<>(); list.sort(new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o1-o2; } }); 对于这样一个匿名内部类,执行的关键两行字节码: 13 invokespecial #5 <com/atguigu/java1/MethodInvokeTest$1.<init> : ()V> 16 invokeinterface #6 <java/util/List.sort : (Ljava/util/Comparator;)V> count 2 匿名内部类,系统是会创建一个类的,因为没有名字,就会用$加数字的形式创建出来,例如MethodInvokeTest$1 匿名内部类使用的是special和interface两个指令,首先实例化接口匿名内部类,之后调用接口方法 labmda List<Integer> list = new ArrayList<>(); list.sort(Integer::compareTo); 9 invokedynamic #4 <compare, BootstrapMethods #0> 14 invokeinterface #5 <java/util/List.sort : (Ljava/util/Comparator;)V> count 2 并没有去创建一个匿名内部类,而是使用了bootstraps和MethodHandle
- 相关面试题:
- int a =1 a从哪里去? 答:局部变量表,
-
public void test1(){ Integer X = 5; int y = 5; System. out.printin(x == y);//true 自动拆箱
- 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,et以及jsru指令需要使用到的,它的值是JW指令的操作码的指针。returnAddress类型不是简单意义上的数值,不属于任何一种基本类型,并且它的值是不能被运行中的程序所修改的。
- Java虚拟机的引用类型被统称为“引用(reference)”,有三种引用类型。类类型、接口类型、以及数组类型,它们的值都是对动态创建对象的引用。类类型的值是对类实例的引用:数组类型的值是对数组对象的引I用,在]ava虚拟机中,数组是个真正的对象:而接口类型的值,则是对实现了该接口的某个类实例的引用。还有一种特殊的引用值是nul1,它表示该引用变量没有引用任何对象。
- 为什么不把基本类型放到堆中?
- 堆空间要大,但是栈比堆运算速度快。栈负责运行,堆负责存储
- 将复杂数据类型放在堆中的目的是为了不影响栈的效率,而是通过引用的方式去堆中查找。(八大基本类型的大小创建时候已经确立大小。三大引用类型创建时候无法确定大小)
- 简单数据类型比较稳定,并且它只占据很小的内存,将它放在空间小、运算速度快的栈中,能够提高效率。
- Java中的参数传递是传值呢?还是传引用? 答:值传递
- 解释一下:基本类型懂,对象类型中,传递的是引用值,可以将其看作是指向对象内存地址的指针,改引用的值也会改变原本的值。
- Java中有没有指针?没有。c++中的指针可以直接做指针运算,例如一个数组指针变量可以进行++,直接指向下一个数组地址,而java数组引用底层不是这样实现的,他是基于索引实现。