详细的图又需要的,评论区留言,后面发给你
在之前的JVM开篇里面说过
- 任何一个Class文件都对应着
唯一一个类或者接口的定义信息
- 类或者接口并不一定都得定义在文件中或者叫以磁盘文件的形式存在(类或者接口是可以通过类加载器直接生成的)
从侧面来讲,这也是Java虚拟机的平台无关性与语言无关性的体现。今天来瞅瞅具体的存储这字节码的Class是怎样的。
首先咱们写的一个.java文件是这样:
public class DemoObject{
private String name;
private int age;
public String getName(){
return name;
}
public int add(int a, int b){
return a + b;
}
}
JVM虚拟机过来说:DemoObject.java,你先瘦一下身,我准备加载运行你了
- 要瘦身是因为Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在Class文件之中,中间没有添加任何分隔符,没有空隙存在
- 当需要占用8位字节以上的空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储
- Class文件的结构中的数据项的顺序以及数量都是被严格规定的,哪个字节代表什么含义、长度是多少、先后顺序如何都不能随意改变
publicclassFlashObject{privateStringname;privateintage;publicintadd(inta,intb){returna+b;}
JVM虚拟机说:DemoObject.java,你是不是有病,这叫瘦身?
那你虚拟机规定的Class文件格式到底是啥样的呢,知道了这我才知道咋瘦身嘛:
- Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:
- 无符号数
- 表
- 无符号数
- 整个Class文件本质上就是一张由下面的数据项构成的一张表【根据 Java 虚拟机规范,
Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体
。】
ClassFile {
u4 magic;//Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//Class文件的字段属性
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//Class文件的方法数量
method_info methods[methods_count];//一个类可以有多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
- 通过分析 ClassFile 的内容,我们便可以知道 class 文件的组成。此外可以通过 IDEA 插件 jclasslib 查看能够更直观看到 Class 文件结构。【使用 jclasslib 不光可以直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息】
不管数据项有几个,按照咱们Java的习惯,咱肯定是从类、接口开始看起。自己本身看完了,看看自己的父类、父接口、子类、子接口,然后看看自己类、父子类父子接口里面的成员变量呀、成员方法呀等等,其实也不就是这些东西嘛
-
PART1:类信息
但是,咱们已经知道自己类本身看完了,还得看看自己的父类、父接口、子类、子接口。java每个类都默认继承Object,所以为了规范咱们以全类名的方式记录在后面
- 为了使连续的多个全限定名之间不混淆,把全类名中的.换成了/,并在使用时最后一般加一个;表示全限定名结束
-
这样放问题又来了,你这样紧挨着放,谁知道类之间的分界点在哪里?所以在每个类的前面加个长度,就两个字节表示吧
- 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时经常会使 **
用一个前置的容量计数器加若干个连续的数据项
**的形式(这一系列连续的某一类型的数据也称为某一类型的集合)常量池结束之后紧接着的两个字节代表访问标志
(access_flags),这个标志用于识别一些类或者接口层次的访问信息(这个Class是类还是接口呀、是否权限修饰符定义为public类型呀、是否为abstract呀、如果是类的话是不是final)
然后后面一个接口一个接口紧挨着放,如下:
- 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时经常会使 **
-
PART2:常量池(紧接着主版本号和次版本号之后的是常量池入口):上面类呀、父类呀、接口呀,虽然不是很细致但总算找了个地方安置下来,那成员变量成员方法呢?属性名、方法名、属性的类名、方法的入参名、返回值类型名…,打眼一看这也不少呀,所以得找
一个新的结构来统一存储这些字符串,这个结构就为常量池
- 要按照上面那种方格一直排下去那得多长呀,而且很乱
- 另一方面,很多字符串都是重复的,比如属性 name 的类名 String,与方法 getName 的返回值类名 String,重复写两遍,就浪费了空间。
刚刚的类、父类、接口,就都可以指向这个索引了,也因此可以将长度固定下来
,但是还可能有整型、浮点型的值作为常量,甚至还有可能是个引用类型,然后这个引用类型再次指向常量池中的一个索引,有点像指针的指针。- 类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引是一组u2类型的数据的集合。Class文件由这三项数据来确定这个类的继承关系
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名
,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。- 接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中
- 字段表集合(Fields):
- 字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
- field info(字段表)的结构:
- 方法表集合(Methods)
- method_info(方法表的) 结构:Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
- method_info(方法表的) 结构:Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
- 属性表集合(Attributes)
那这么多类型,必然就还需要一个记录类型信息的地方
- 类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引是一组u2类型的数据的集合。Class文件由这三项数据来确定这个类的继承关系
- 所以总的来说,咱们是,
开头存常量池,之后需要的常量就全放到常量池中,用一个索引或者叫指针指向常量池中的东西,紧接着存放类本身的相关信息(比如当前类、父类以及接口的信息)
- 由于常量池中的常量的数量是不固定的,所以在
常量池的入口需要放置一项u2类型的数据来代表常量池容量计数值(constant_pool_count)
紧接着主次版本号之后的是常量池
,常量池的数量是 constant_pool_count-1(常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。【.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-> temp.txt :将结果输出到 temp.txt 文件)。】
- 常量池中主要放两大类常量:方法区中有个运行时常量池,还有其余三种常量池,还有这个常量池,区别见这篇
- 字面量:(相当于常量,比如文本字符串、声明为final的常量值等)
- 常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型。
- 常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型。
- 符号引用:包括了下面三种常量:虚拟机运行时需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址中
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- 字面量:(相当于常量,比如文本字符串、声明为final的常量值等)
- 由于常量池中的常量的数量是不固定的,所以在
-
PART3:变量:常量池解决了大部分类本身的信息,变量还没解决呢,所以此时咱们还是**
开头存数量,后面紧跟着各个存放变量的数据结构:两字节的标记、两字节的类型描述符、两字节的变量名称(就是咱们一个变量的数据结构)
**。
变量咱们知道,比较长的可能就是:权限修饰符、静态非静态、可变final、并发可见性volatile、可否被序列化、8种基本数据类型—只能引用常量池中的常量来描述
public static final String name;
所以,咱们借助一下OS中文件系统的思路,用位图的方式,每一个标记(权限修饰符、static、final、等)用一个位来表示
- PART4:成员方法:变量和类安排的差不多了,该说方法了。可以借鉴一下之前的经验,那不就是,数量后面跟上存储不同方法的数据结构,然后每个方法有自己的标记、方法名、方法描述符(也就是方法的入参与返回值)等
- 标记部分:
- 方法描述符(也就是方法的入参与返回值)
- int add(int a, int b)就表示为(II)I,不要看不起人家几个杠(这也是个字符串,也可以存储在常量池里)
- (至于参数 a 和 b 这个名字,不需要保存起来,实际上在转换的字节码以及实际虚拟机中运行时,
只需要知道局部变量表中的位置
即可,叫什么名字都无所谓)
- (至于参数 a 和 b 这个名字,不需要保存起来,实际上在转换的字节码以及实际虚拟机中运行时,
- int add(int a, int b)就表示为(II)I,不要看不起人家几个杠(这也是个字符串,也可以存储在常量池里)
- 方法名称:放常量池
- 代码部分、异常、注解等:我们效仿常量池的做法,把这些部分都叫“方法的属性”,一个方法可能有多个属性
再一次,咱们的常量池就究极进化成了这样:
肯定不止上面PART1+PART2+PART3+PART4就完了呀,开篇那个数据项表中还有些东西呢呀,咱们再看一看这个表
- 标记部分:
- PART5:魔数(Magic Number)与Class文件的版本
- 每个存储字节码的Class文件的
开头四个字节
成为魔数,魔数存在的唯一作用就是确定这个文件是否为一个能被虚拟机接受的Class文件
。识别身份嘛,这种是不是见多了嘛,谁谁谁的id,咱们的身份证ID Card…- 其实很多文件存储标准中都是用魔数而不是后缀名来对文件们进行身份识别,原因是后缀名容易被改动
u4 magic
; //Class 文件的标志
- 紧接着魔数的4个字节存储的是Class文件的版本号
- 第五个和第六个字节是次版本号:u2 minor_version;//Class 的小版本号
- 第七个和第八个字节是主版本号:u2 major_version;//Class 的大版本号
- 每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致
。
- 每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。
- 每个存储字节码的Class文件的
上面Class前世今生瞅完之后该看看:许多 Java虚拟机的执行引擎在执行Java代码
时都有 两种选择:
- 解释执行(通过解释器执行)
- 编译执行(通过即时编译器产生本地代码执行)
大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前都需要经过几个步骤:(中间那条分支就是解释执行的过程)
- 词法分析、语法分析、优化器以及目标代码生成器都可以独立于执行引擎,形成一个完整意义的编译器去实现,这类代表就是C/C++
- 也可以把其中一部分步骤(比如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言
- Java语言中Java编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再到遍历语法树生成线性的字节码指令流的过程(这一部分动作是在Java虚拟机之外进行的,而解释器又在虚拟机的内部),所以Java程序的编译就是半独立的实现。
- 也可以把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子中,如大多数的JavaScript执行器
上面说到Java编译器,可以顺便回忆一下指令集架构:
基于栈的指令集架构(Instruction Set Architecture, ISA)
:Java编译器输出的指令流基本上是一种基于栈的指令集架构(Instruction Set Architecture, ISA):- 指令流中的指令大部分都是零地址指令(也就是**
这些指令依赖操作数栈进行工作
**,你不依赖人家操作数栈你地址哪里来呀~操作数栈看这里) - 特点:
- 优点:
- 可移植(寄存器由硬件直接提供,可以由虚拟机实现来自行决定把一些访问最频繁的数据(PC程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能或者怎样怎样)。如果是基于寄存器的指令集架构,程序直接依赖这些硬件寄存器,这样一来肯定会受到硬件的约束
- 代码相对紧凑,每个字节码中每个字节就对应一条指令
- 编译器实现不需要考虑空间分配的问题,所需的空间都在栈上操作,更加简单
- 缺点:执行速度慢,因为完成相同功能所需的指令数量一般会比寄存器架构多。另外栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,你访问内存怎么比得上访问寄存器呢
- 优点:
- 举个例子,计算一下1+1
Slot见这篇,点一点别怕
- 指令流中的指令大部分都是零地址指令(也就是**
基于寄存器的指令集架构
:x86的二地址指令集,也就是咱们现在主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作
。- 举个例子,计算一下1+1
- 举个例子,计算一下1+1
那上面也说了了咱们Java是个半自动,那到底基于栈的解释器执行过程是怎样的呢?,咱们用一段代码看一下和、实际是如何执行的:其实前面那个1+1的也算一个例子,么事,咱多看几个没坏处:
/**
* Copyright (c) 2013-Now http://AIminminAI.com All rights reserved.
*/
/**
*
* @author HHB
* @version 2022年4月15日
*/
public class TestLanguageType {
public static void main(String[] args) {
int res = new TestLanguageType().calc();
System.out.println(res);
}
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
}
咱们用javap命令看看字节码指令
字节码指令:
Classfile /D:/kaohuixianqumin/Eclipse exercise/AdjustJVMDemo/bin/TestLanguageType.class
Last modified 2022-4-16; size 706 bytes
MD5 checksum 86faa4aaf1c3a0dd48a80f059221b3fc
Compiled from "TestLanguageType.java"
public class TestLanguageType
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Class #2 // TestLanguageType
#2 = Utf8 TestLanguageType
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Methodref #3.#9 // java/lang/Object."<init>":()V
#9 = NameAndType #5:#6 // "<init>":()V
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LTestLanguageType;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Methodref #1.#9 // TestLanguageType."<init>":()V
#17 = Methodref #1.#18 // TestLanguageType.calc:()I
#18 = NameAndType #19:#20 // calc:()I
#19 = Utf8 calc
#20 = Utf8 ()I
#21 = Fieldref #22.#24 // java/lang/System.out:Ljava/io/PrintStream;
#22 = Class #23 // java/lang/System
#23 = Utf8 java/lang/System
#24 = NameAndType #25:#26 // out:Ljava/io/PrintStream;
#25 = Utf8 out
#26 = Utf8 Ljava/io/PrintStream;
#27 = Methodref #28.#30 // java/io/PrintStream.println:(I)V
#28 = Class #29 // java/io/PrintStream
#29 = Utf8 java/io/PrintStream
#30 = NameAndType #31:#32 // println:(I)V
#31 = Utf8 println
#32 = Utf8 (I)V
#33 = Utf8 args
#34 = Utf8 [Ljava/lang/String;
#35 = Utf8 res
#36 = Utf8 I
#37 = Utf8 a
#38 = Utf8 b
#39 = Utf8 c
#40 = Utf8 SourceFile
#41 = Utf8 TestLanguageType.java
{
public TestLanguageType();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 10: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTestLanguageType;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #1 // class TestLanguageType
3: dup
4: invokespecial #16 // Method "<init>":()V
7: invokevirtual #17 // Method calc:()I
10: istore_1
11: getstatic #21 // Field java/lang/System.out:Ljava/io/PrintStream;
14: iload_1
15: invokevirtual #27 // Method java/io/PrintStream.println:(I)V
18: return
LineNumberTable:
line 12: 0
line 13: 11
line 14: 18
LocalVariableTable:
Start Length Slot Name Signature
0 19 0 args [Ljava/lang/String;
11 8 1 res I
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
LineNumberTable:
line 17: 0
line 18: 3
line 19: 7
line 20: 11
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 this LTestLanguageType;
3 14 1 a I
7 10 2 b I
11 6 3 c I
}
SourceFile: "TestLanguageType.java"
可以具体看看代码执行过程中的代码、操作数栈和局部变量表的变化情况:
- 下面的执行过程可能虚拟机最终会对执行过程做一些优化来提高性能,也就是说实际的运作过程不一定是这样(因为虚拟机中解析器和即时编译器都会对输入的字节码进行优化)
- 记住,咱们例子中的几个单字节的整型常量值(-128~127)100、200、300,基本上都是分两步:
- 先推入操作数栈顶,跟随有一个参数来指明推送的常量值(也就是100、200、300)
- 然后将操作数栈顶的整型值弹出栈并存放到局部变量Slot中~Slot见这篇,点一点,别怕
下图中的程序计数器里面是要不断变化的,我没改,但是实际是要改的
巨人的肩膀:
低并发编程
深入理解Java虚拟机