public class MyDemo {
private int a = 1;
private int b = 2;
public int sum() {
int c = a + b;
return c;
}
public static void main(String[] args) {
MyDemo myDemo = new MyDemo();
int sum = myDemo.sum();
System.out.println(sum);
}
}
上面的java文件编译得到的class文件,class文件是一个16进制字节码的二进制文件,我们通过winhex软件打开如下:
需要说明的是,class文件只有两种数据类型: 无符号数 和 表。
类型 | 名称 | 说明 | 长度 |
---|---|---|---|
u4 | magic | 魔数,识别Class文件格式 | 4个字节 |
u2 | minor_version | 副版本号 | 2个字节 |
u2 | major_version | 主版本号 | 2个字节 |
u2 | constant_pool_count | 常量池计算器 | 2个字节 |
cp_info | constant_pool | 常量池 | n个字节 |
u2 | access_flags | 访问标志 | 2个字节 |
u2 | this_class | 类索引 | 2个字节 |
u2 | super_class | 父类索引 | 2个字节 |
u2 | interfaces_count | 接口计数器 | 2个字节 |
u2 | interfaces | 接口索引集合 | 2个字节 |
u2 | fields_count | 字段个数 | 2个字节 |
field_info | fields | 字段集合 | n个字节 |
u2 | methods_count | 方法计数器 | 2个字节 |
method_info | methods | 方法集合 | n个字节 |
u2 | attributes_count | 附加属性计数器 | 2个字节 |
attribute_info | attributes | 附加属性集合 | n个字节 |
在class文件中,前4个字节就是魔数,如下:
魔数是用来区分文件类型的一种标识,0XCAFEBABE (咖啡豆/咖啡宝贝)表示就是class文件。
魔数后面的4位就是版本号了,同样也是4个字节,其中前2个字节表示副版本号,后2个字节表示主版本号。
前面两个字节是0x0000,也就是其值为0; 后面两个字节是0x0034,也就是其值为52; 所以上面的代码就是52.0版本
来编译的,也就是jdk1.8.0。
在版本号的后面就是常量池了。
由于常量池的数量不固定,所以需要通过2个字节来记录常量池的大小。
其值为0X0027,用十进制表示就是39,需要注意的是,常量池中只有38个常量。
常量池中的每一项都是一个表,其项目类型共有14种,如下表格所示:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_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_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MothodType_info | 16 | 标志方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
可以看到,第一个常量的值为10,对应到标志位,找到常量为 CONSTANT_Methodref_info (类中方法的符号引用),它的结构为:
后面的4个字节都是它的内容,记录着2个索引值:
第一个索引值为:0X0009,也就是9,指向常量池中第9项的索引。
第二个索引值为:0X0017,也就是23,指向常量池中第23项的索引。
可以看到,第二个常量的值为9,对应到表中的标志位,找到常量为 CONSTANT_Fieldref_info(字段的符号引用),它的结构为:
后面的4个字节都是它的内容,记录着2个索引值:
第一个索引值为:0X0004,也就是4,指向常量池中第4项的索引。
第二个索引值为:0X0018,也就是24,指向常量池中第24项的索引。
通过javap命令就可以将class文件转化为可读的字节码指令。
javap -v MyDemo.class > MyDemo.txt
生成的字节码指令如下:
Classfile /E:/xxx/jvm/MyDemo.class
Last modified 2020-7-20; size 564 bytes
MD5 checksum 0c32d2c6a7edb1bebe91f0a2f168d28d
Compiled from "MyDemo.java"
public class cn.learn.jvm.MyDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."":()V
#2 = Fieldref #4.#24 // cn/learn/jvm/MyDemo.a:I
#3 = Fieldref #4.#25 // cn/learn/jvm/MyDemo.b:I
#4 = Class #26 // cn/learn/jvm/MyDemo
#5 = Methodref #4.#23 // cn/learn/jvm/MyDemo."":()V
#6 = Methodref #4.#27 // cn/learn/jvm/MyDemo.sum:()I
#7 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#9 = Class #32 // java/lang/Object
#10 = Utf8 a
#11 = Utf8 I
#12 = Utf8 b
#13 = Utf8 <init>
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 sum
#18 = Utf8 ()I
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 SourceFile
#22 = Utf8 MyDemo.java
#23 = NameAndType #13:#14 // "":()V
#24 = NameAndType #10:#11 // a:I
#25 = NameAndType #12:#11 // b:I
#26 = Utf8 cn/itcast/jvm/MyDemo
#27 = NameAndType #17:#18 // sum:()I
#28 = Class #33 // java/lang/System
#29 = NameAndType #34:#35 // out:Ljava/io/PrintStream;
#30 = Class #36 // java/io/PrintStream
#31 = NameAndType #37:#38 // println:(I)V
#32 = Utf8 java/lang/Object
#33 = Utf8 java/lang/System
#34 = Utf8 out
#35 = Utf8 Ljava/io/PrintStream;
#36 = Utf8 java/io/PrintStream
#37 = Utf8 println
#38 = Utf8 (I)V
{
public cn.learn.jvm.MyDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: aload_0
5: iconst_1
6: putfield #2 // Field a:I
9: aload_0
10: iconst_2
11: putfield #3 // Field b:I
14: return
LineNumberTable:
line 3: 0
line 5: 4
line 6: 9
public int sum();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: aload_0
5: getfield #3 // Field b:I
8: iadd
9: istore_1
10: iload_1
11: ireturn
LineNumberTable:
line 9: 0
line 10: 10
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #4 // class cn/itcast/jvm/MyDemo
3: dup
4: invokespecial #5 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #6 // Method sum:()I
12: istore_2
13: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #8 // Method java/io/PrintStream.println:(I)V
20: return
LineNumberTable:
line 14: 0
line 15: 8
line 16: 13
line 17: 20
}
SourceFile: "MyDemo.java"
内容大致分为4个部分:
第一部分:显示了生成这个class的java源文件、版本信息、生成时间等。
第二部分:显示了该类中所涉及到常量池,共38个常量。
第三部分:显示该类的构造器,编译器自动插入的。
第四部分:显示了sum、main方的信息。(这个是需要我们重点关注的)
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.3
示例:
The method descriptor for the method:
Object m(int i, double d, Thread t) {…}
is:
(IDLjava/lang/Thread;)Ljava/lang/Object;
字符串的拼接在开发过程中使用是非常频繁的,常用的方式有三种:
StringBuffer是保证线程安全的,效率是比较低的,我们更多的是使用场景是不会涉及到线程安全的问题的,所以更多的时候会选择StringBuilder,效率会高一些。那么,问题来了,StringBuilder和“+”号拼接,哪个效率高呢?
通过探究字节码可知:使用+号拼接,但是在字节码中也被编译成了StringBuilder方式。
所以,可以得出结论,字符串拼接,+号和StringBuilder是相等的,效率一样。
Java源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
注意:是按部就班的开始,这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
比如:
在加载阶段,Java虚拟机需要完成以下三件事情:
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
实现"通过一个类的全限定名来获取描述该类的二进制字节流"这个动作的代码被称为“类加载器”(Class Loader)。
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader 。
从 Java 开发者的角度来看,类加载器可以划分为:
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
双亲委派模型的工作过程是:
1)如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类
2)而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此
3)因此所有的加载请求最终都应该传送到最顶层的启动类加载器中
4)只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此Object 类在程序的各种类加载器环境中都是同一个类。
双亲委派模型确保在程序的各种类加载器环境中没有重复的类,对于保证Java程序的稳定运作极为重要!
前端编译器就是将*.java文件编译成*.class文件的过程。
javac的编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
后端编译器是指把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程。
是指JVM在运行时将调用次数达到一定阈值的方法调用替换为方法体本身。
例如:
static class B {
int value;
final int get() {
return value;
}
}
//未内联的代码
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
//内联优化的代码
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
//可以继续优化
//冗余存储消除的代码
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
//复写传播的代码
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
//进行无用代码消除的代码
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
需要注意的是,以上优化是在编译器内部完成,而不是在代码中完成的,只是用代码来说明问题。
逃逸分析的基本原理是:
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低,则可能为这个对象实例采取不同程度的优化。
它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。
对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。
如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
例子:
//源码
int d = (c * b) * 12 + a + (a + b * c);
//编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变
int d = E * 12 + a + (a + E);
//还可以进一步优化
int d = E * 13 + a + a;
如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,即i必须满足“i>=0&&i
对于编译器而言,根据数据流分析来确定是否越界,如果没有越界,那么执行时就不需要再判断了。
如果在循环中,本身就是通过循环变量来控制对数组的访问,执行时也就不用再判断了,
这样就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。