1.1 走进 JVM
我们在刚刚学习 Java 的时候,就明确的知道,因为 JVM 的存在,才让 Java 可以一次编译到处运行。我们只需要把我们编写的字节码交给 JVM 去处理执行即可。完全不需要考虑不同平台的问题。那么 JVM 到底是撒呢?难道就只有这一个浅层的方面嘛。
VM:虚拟的跑操作系统
JVM:虚拟的跑程序
也正是得益于这种统一规范,除了 Java 之外,还有其他的 JVM 语言,比如 Kotlin、Groovy 等,它们的语法虽然和 Java 不一样,但是最终编译得到的字节码文件,和 Java 是同样的规范,同样可以交给 JVM 处理。(IDEA 反编译的时候,你会发现 跟 反编译 Java 的代码 一毛一样)
1.2 技术概述
Java 虚拟机是 基于 栈的指令集架构
传统程序设计 一般都是 基于 寄存器的指令集架构
int main() { //实现一个最简的a+b功能,并存入变量c
int a = 10;
int b = 20;
int c = a + b;
return c;
}
gcc -S main.c
- x86 架构下的 C 语言汇编指令
.file "main.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc ;rbp寄存器是64位CPU下的基址寄存器,和8086CPU的16位bp一样
pushq %rbp ;该函数中需要用到rbp寄存器,所以需要先把他原来的值压栈保护起来
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp ;rsp是64位下的栈指针寄存器,这里是将rsp的值丢给rbp,因为局部变量是存放在栈中的,之后会使用rbp来访问局部变量
.cfi_def_cfa_register 6
movl $10, -12(%rbp) ;将10存入rbp所指向位置-12的位置 -> int a = 10;
movl $20, -8(%rbp) ;将20存入rbp所指向位置-8的位置 -> int b = 20;
movl -12(%rbp), %edx ;将变量a的值交给DX寄存器(32位下叫edx,因为是int,这里只使用了32位)
movl -8(%rbp), %eax ;同上,变量b的值丢给AX寄存器
addl %edx, %eax ;将DX和AX寄存器中的值相加,并将结果存在AX中 -> tmp = a + b
movl %eax, -4(%rbp) ;将20存入rbp所指向位置-4的位置 -> int c = tmp;与上面合在一起就是int c = a + b;
movl -4(%rbp), %eax ;根据约定,将函数返回值放在AX -> return c;
popq %rbp ;函数执行完毕,出栈
.cfi_def_cfa 7, 8
ret ;函数返回
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-6ubuntu2) 7.5.0"
.section .note.GNU-stack,"",@progbits
- arm 架构下
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 12, 0 sdk_version 12, 1
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #16 ; =16
.cfi_def_cfa_offset 16
str wzr, [sp, #12]
mov w8, #10
str w8, [sp, #8]
mov w8, #20
str w8, [sp, #4]
ldr w8, [sp, #8]
ldr w9, [sp, #4]
add w8, w8, w9
str w8, [sp]
ldr w0, [sp]
add sp, sp, #16 ; =16
ret
.cfi_endproc
; -- End function
.subsections_via_symbols
我们发现 上述传统的都要依赖于 寄存器,而我们接下来的 Java 是没有依赖于寄存器的,而是更多的利用操作栈来完成一些功能。这样不仅设计和实现起来更简单,并且也能够更加方便地实现跨平台,不太依赖于硬件的支持。
public class Main {
public int test(){ //和上面的例子一样
int a = 10;
int b = 20;
int c = a + b;
return c;
}
}
javap -v target/classes/com/test/Main.class #使用javap命令对class文件进行反编译
- Java 反汇编
...
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: iload_3
11: ireturn
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lcom/test/Main;
3 9 1 a I
6 6 2 b I
10 2 3 c I
bipush
:将 单个字节(1个字节)的常量值 推到栈顶。
istore_1
:将栈顶的int类型数值 存入到第二个本地变量。
istore_n
:将栈顶的int类型数值 存入到第n个本地变量。
iload_1
:将第二个本地变量存储的数值 推到栈顶。
iload_n
:将第n个本地变量存储的数值 推到栈顶。
iadd
:将栈顶的两个 int 类型变量相加,并将结果重新压入栈顶。
ireturn
:将栈顶的数值返回给方法。
JVM运行字节码时,所有的操作基本都是围绕两种数据结构,一种是堆栈(本质是栈结构),还有一种是队列,如果JVM执行某条指令时,该指令需要对数据进行操作,那么被操作的数据在指令执行前,必须要压到堆栈上,JVM会自动将栈顶数据作为操作数。如果堆栈上的数据需要暂时保存起来时,那么它就会被存储到局部变量队列上。
descriptor: ()I //参数以及返回值类型,()I就表示没有形式参数,返回值为基本类型int
flags: ACC_PUBLIC //public访问权限
Code:
stack=2, locals=4, args_size=1 //stack表示要用到的最大栈深度,本地变量数,堆栈上最大对象数量(这里指的是this)
0: bipush 10 //0是程序偏移地址,然后是指令,最后是操作数
2: istore_1
指令前面的 数字是 程序的偏移地址,就是执行的这条指令,是哪个地方的。
实际上我们发现,JVM 执行命令的时候,基本都是 入栈出栈。而且大部分的指令还都没有 操作数,所以相比较来说,它要比 C语言编译出来的汇编指令,执行起来要更加的复杂,指令条数也会更多。所以 Java 的执行效率实际上是不如 C/C++ 的。
总结:虽然能实现跨平台,但是性能上却大打折扣!这也是为什么在 Android 上采用的是 定制版的 JVM,并且采用的还是 基于寄存器的指令集架构。此外,我们还知道 native 关键字 可以调用 C/C++ 编写的程序(通过JNI机制 来实现!),所以整体来看还是可以的!