有关字节码以及此处显示的字节码的信息基于Java 2 SDK Standard Edition v1.2.1 javac编译器。 其他编译器生成的字节码可能略有不同。
为什么要理解字节码?
字节码是Java程序的中间表示,就像汇编程序是C或C ++程序的中间表示一样。 最有名的C和C ++程序员知道他们要为其编译的处理器的汇编程序指令集。 在调试以及进行性能和内存使用调整时,此知识至关重要。 了解编译器为您编写的源代码生成的汇编程序指令,可以帮助您了解如何不同地编码以实现内存或性能目标。 此外,在跟踪问题时,使用调试器反汇编源代码并逐步执行正在执行的汇编器代码通常很有用。
Java经常被忽略的一个方面是javac编译器生成的字节码。 理解字节码以及Java编译器可能生成的字节码可以像汇编程序知识可以帮助C或C ++程序员一样帮助Java程序员。
字节码是您的程序。 无论JIT或Hotspot运行时如何,字节码都是代码大小和执行速度的重要组成部分。 考虑到字节码越多,.class文件越大,JIT或Hotspot运行时必须编译的代码也越多。 本文的其余部分将使您深入了解Java字节码。
产生字节码
javac Employee.java
javap -c Employee > Employee.bc
Compiled from Employee.java
class Employee extends java.lang.Object {
public Employee(java.lang.String,int);
public java.lang.String employeeName();
public int employeeNumber();
}
Method Employee(java.lang.String,int)
0 aload_0
1 invokespecial #3 <Method java.lang.Object()>
4 aload_0
5 aload_1
6 putfield #5 <Field java.lang.String name>
9 aload_0
10 iload_2
11 putfield #4 <Field int idNumber>
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 <Method void storeData(java.lang.String, int)>
20 return
Method java.lang.String employeeName()
0 aload_0
1 getfield #5 <Field java.lang.String name>
4 areturn
Method int employeeNumber()
0 aload_0
1 getfield #4 <Field int idNumber>
4 ireturn
Method void storeData(java.lang.String, int)
0 return
这个课很简单。 它包含两个实例变量,一个构造函数和三个方法。 字节码文件的前五行列出了用于生成此代码的文件名,类定义,其继承(默认情况下,所有类均继承自java.lang.Object)及其构造函数和方法。 接下来,列出每个构造函数的字节码。 然后,将按字母顺序列出每种方法及其相关的字节码。
在仔细检查字节码时,您可能会注意到某些操作码以'a'或'i'为前缀。 例如,在Employee类的构造函数中,您看到aload_0和iload_2。 前缀代表操作码所使用的类型。 前缀“ a”表示操作码正在操纵对象引用。 前缀“ i”表示操作码正在操作整数。 其他操作码使用“ b”表示字节,“ c”表示字符,“ d”表示双精度,等等。该前缀使您立即了解要处理的数据类型。
注意 :个别代码通常称为操作码。 多个操作码指令通常称为字节码。
细节
要了解字节码的详细信息,我们需要讨论Java虚拟机(JVM)如何执行字节码。 JVM是基于堆栈的计算机。 每个线程都有一个存储框架的JVM堆栈。 每次调用方法时都会创建一个框架,该框架包括操作数堆栈,局部变量数组和对当前方法类的运行时常量池的引用。 从概念上讲,它可能看起来像这样:
图1.一个框架
![帧](https://i-blog.csdnimg.cn/blog_migrate/b0be4aa08388fe6dc4ad028f50e75999.png)
局部变量数组(也称为局部变量表)包含方法的参数,还用于保存局部变量的值。 首先从索引0开始存储参数。如果框架用于构造函数或实例方法,则将引用存储在位置0。然后位置1包含第一个形式参数,位置2包含第二个形式参数,依此类推。 对于静态方法,第一个形式化方法参数存储在位置0中,第二个存储在位置1中,依此类推。
局部变量数组的大小在编译时确定,并取决于局部变量的数量和大小以及形式化方法参数。 操作数堆栈是用于推送和弹出值的LIFO堆栈。 它的大小也在编译时确定。 某些操作码指令将值压入操作数堆栈。 其他的则从堆栈中获取操作数,对其进行操作并推入结果。 操作数堆栈还用于接收方法的返回值。
public String employeeName()
{
return name;
}
Method java.lang.String employeeName()
0 aload_0
1 getfield #5 <Field java.lang.String name>
4 areturn
此方法的字节码由三个操作码指令组成。 第一个操作码aload_0将值从局部变量表的索引0推入操作数堆栈。 前面提到过,局部变量表用于将参数传递给方法。 对于构造函数和实例方法,此引用始终存储在局部变量表的位置0。 必须推送此引用,因为该方法正在访问类的实例数据,名称。
下一条操作码指令getfield用于从对象中获取字段。 执行此操作码时,将弹出堆栈中的最高值this。 然后,使用#5将索引建立到存储名称参考的类的运行时常量池中。 提取此引用后,会将其压入操作数堆栈。
最后一条指令areturn从方法返回引用。 更具体地说,areturn的执行会导致操作数堆栈上的最高值(即对名称的引用)弹出并推入调用方法的操作数堆栈上。
employeeName方法非常简单。 在看一个更复杂的例子之前,我们需要检查每个操作码左侧的值。 在employeeName方法的字节码中,这些值为0、1和4。每个方法都有一个对应的字节码数组。 这些值对应于存储每个操作码及其参数的数组索引。 您可能想知道为什么这些值不是连续的。 由于字节码的名称是因为每条指令占用一个字节,所以索引为什么不为0、1和2? 原因是某些操作码的参数占用字节码数组中的空间。 例如,aload_0指令没有参数,自然在字节码数组中占据一个字节。 因此,下一个操作码getfield位于位置1。但是,areturn位于位置4。这是因为getfield操作码及其参数占据位置1、2和3。位置1用于getfield操作码,位置2。和3用于保留其参数。 这些参数用于为存储值的类的运行时常量池构造索引。 下图显示了employeeName方法的字节码数组的外观:
图2. employeeName方法的字节码数组
![字节码数组](https://i-blog.csdnimg.cn/blog_migrate/6d585efc0f74a1fcfdeb9d2d261a1ad9.png)
实际上,字节码数组包含代表指令的字节。 使用十六进制编辑器查看.class文件,您会在字节码数组中看到以下值:
图3.字节码数组中的值
![字节码值](https://i-blog.csdnimg.cn/blog_migrate/dd6af31c4ca99a1668a6f9afb942cf5b.png)
2A,B4和B0分别对应于aload_0,getfield和areturn。
public Employee(String strName, int num)
{
name = strName;
idNumber = num;
storeData(strName, num);
}
Method Employee(java.lang.String,int)
0 aload_0
1 invokespecial #3 <Method java.lang.Object()>
4 aload_0
5 aload_1
6 putfield #5 <Field java.lang.String name>
9 aload_0
10 iload_2
11 putfield #4 <Field int idNumber>
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 <Method void storeData(java.lang.String, int)>
20 return
位于位置0的第一条操作码指令aload_0将此引用压入操作数堆栈。 (请记住,实例方法和构造函数的局部变量表的第一个条目是this引用。)
位于位置1的下一条操作码指令invokespecial调用此类的超类的构造函数。 因为所有未显式扩展任何其他类的类都隐式继承自java.lang.Object,所以编译器提供了必要的字节码来调用此基类构造函数。 在此操作码期间,将弹出操作数堆栈的最高值this。
接下来的两个操作码位于位置4和5,将来自本地变量表的前两个条目推入操作数堆栈。 要推送的第一个值是此参考。 第二个值是构造函数strName的第一个形式参数。 推送这些值以准备在位置6处的putfield操作码指令。
putfield操作码从堆栈中弹出两个顶部值,并将对strName的引用存储到this引用的对象的实例数据名称中。
位置9、10和11处的下三个操作码指令使用构造函数的第二个形式参数num和实例变量idNumber执行相同的操作。
位置14、15和16处的下三个操作码指令为storeData方法调用准备了堆栈。 这些指令分别推送此引用strName和num。 必须调用此引用,因为正在调用实例方法。 如果该方法被声明为静态方法,则无需推送此引用。 由于strName和num值是storeData方法的参数,因此将其推送。 当执行storeData方法时,此引用strName和num将分别占据该方法框架中包含的局部变量表的索引0、1和2。
大小和速度问题
对于许多使用Java的台式机和服务器系统来说,性能是一个关键问题。 随着Java从这些系统转移到较小的嵌入式设备,尺寸问题也变得越来越重要。 知道为Java指令序列生成的字节码可以帮助您编写更小,更高效的代码。 例如,考虑Java中的同步。 以下两个方法从以数组形式实现的整数堆栈中返回top元素。 两种方法都使用同步,并且在功能上等效:
public synchronized int top1()
{
return intArr[0];
}
public int top2()
{
synchronized (this) {
return intArr[0];
}
}
这些方法尽管以不同的方式使用同步,但是在功能上是相同的。 然而,不明显的是它们具有不同的性能和尺寸特征。 在这种情况下,top1比top2快约13%,并且要小得多。 检查生成的字节码,以查看这些方法有何不同。 注释会添加到字节码中,以帮助理解每个操作码的作用。
Method int top1()
0 aload_0 //Push the object reference(this) at index
//0 of the local variable table.
1 getfield #6 <Field int intArr[]>
//Pop the object reference(this) and push
//the object reference for intArr accessed
//from the constant pool.
4 iconst_0 //Push 0.
5 iaload //Pop the top two values and push the
//value at index 0 of intArr.
6 ireturn //Pop top value and push it on the operand
//stack of the invoking method. Exit.
Method int top2()
0 aload_0 //Push the object reference(this) at index
//0 of the local variable table.
1 astore_2 //Pop the object reference(this) and store
//at index 2 of the local variable table.
2 aload_2 //Push the object reference(this).
3 monitorenter //Pop the object reference(this) and
//acquire the object's monitor.
4 aload_0 //Beginning of the synchronized block.
//Push the object reference(this) at index
//0 of the local variable table.
5 getfield #6 <Field int intArr[]>
//Pop the object reference(this) and push
//the object reference for intArr accessed
//from the constant pool.
8 iconst_0 //Push 0.
9 iaload //Pop the top two values and push the
//value at index 0 of intArr.
10 istore_1 //Pop the value and store it at index 1 of
//the local variable table.
11 jsr 19 //Push the address of the next opcode(14)
//and jump to location 19.
14 iload_1 //Push the value at index 1 of the local
//variable table.
15 ireturn //Pop top value and push it on the operand
//stack of the invoking method. Exit.
16 aload_2 //End of the synchronized block. Push the
//object reference(this) at index 2 of the
//local variable table.
17 monitorexit //Pop the object reference(this) and exit
//the monitor.
18 athrow //Pop the object reference(this) and throw
//an exception.
19 astore_3 //Pop the return address(14) and store it
//at index 3 of the local variable table.
20 aload_2 //Push the object reference(this) at
//index 2 of the local variable table.
21 monitorexit //Pop the object reference(this) and exit
//the monitor.
22 ret 3 //Return to the location indicated by
//index 3 of the local variable table(14).
Exception table: //If any exception occurs between
from to target type //location 4 (inclusive) and location
4 16 16 any //16 (exclusive) jump to location 16.
由于同步和异常处理是如何完成的,因此top2比top1更大且更慢。 请注意,top1使用同步方法修饰符,该修饰符不会生成额外的代码。 相比之下,top2在方法主体中使用同步语句。
在方法的主体中使用synced会生成monitorenter和monitorexit操作码的字节码,以及用于处理异常的其他代码。 如果在同步块(监视器)内部执行时生成了异常,则可以保证在退出同步块之前先释放锁定。 top1的实现比top2的实现效率更高; 这会导致非常小的性能提升。
当存在同步方法修饰符时,如top1中所示,不使用monitorenter和monitorexit操作码来完成锁定的获取和释放。 相反,当JVM调用方法时,它会检查ACC_SYNCHRONIZED属性标志。 如果存在此标志,则正在执行的线程获取一个锁,调用该方法,然后在该方法返回时释放该锁。 如果从同步方法引发异常,则在异常离开该方法之前会自动释放锁。
注意 :如果存在同步方法修饰符,则ACC_SYNCHRONIZED属性标志包含在方法的method_info结构中。
无论您将synced用作方法修饰符还是与synchronized块一起使用,都涉及大小。 仅当您的代码需要同步并且您了解使用它们带来的成本时,才使用同步方法。 如果需要同步整个方法,则我希望使用方法修饰符而不是同步块,以便产生更小且速度更快的代码。
这只是利用字节码知识使代码更小,更快的一个例子。 有关更多信息,请参见我的书《 实用Java》 。
编译器选项
javac编译器提供了一些您需要知道的选项。 第一个是-O选项。 JDK文档声称-O将优化您的代码以提高执行速度。 将-O与Sun Java 2 SDK的javac编译器一起使用不会对生成的代码产生影响。 Sun Javac编译器的早期版本执行了一些基本的字节码优化,但此后已删除。 但是,SDK文档尚未更新。 保留-O的唯一原因是为了与较早的make文件兼容。 因此,当前没有理由使用它。
这也意味着javac编译器生成的字节码并不比您编写的代码更好。 例如,如果编写包含不变式的循环,则javac编译器不会从循环中删除该不变式。 程序员习惯于使用其他语言的编译器来清理编写错误的代码。 不幸的是,javac不会这样做。 更重要的是,javac编译器不会执行简单的优化,例如循环展开,代数简化,强度降低等。 为了获得这些好处和其他简单的优化,程序员必须在Java源代码上执行它们,而不要依靠javac编译器来执行它们。 您可以使用多种技术来使Java编译器生成更快,更小的字节码。 不幸的是,在Java编译器执行它们之前,您必须自己实现它们以实现其好处。
javac编译器还提供-g和-g:none选项。 -g选项告诉编译器生成所有调试信息。 -g:none选项告诉编译器不生成任何调试信息。 用-g:none编译会生成最小的.class文件。 因此,在尝试在部署之前生成最小的.class文件时,应使用此选项。
Java调试器
我尚未在Java调试器中看到的一个非常有用的功能是类似于C或C ++调试器的反汇编视图。 反汇编Java代码会显示字节码,就像反汇编C或C ++代码会显示汇编代码一样。 除此功能外,另一个有用的功能可能是单步执行字节码的功能,一次执行一个操作码。
这种功能水平将使程序员可以直接查看Java编译器生成的字节码,以及在调试过程中逐步查看字节码。 程序员拥有有关生成和执行的代码的信息越多,避免问题的机会就越大。 这种类型的调试器功能还可以鼓励程序员查看并了解为其源代码执行的字节码。
摘要
本文为您提供了Java字节码的概述和一般理解。 任何语言的最佳程序员都可以在执行之前理解高级语言被翻译成的中间形式。 对于Java,此中间表示是字节码。 了解它,了解它的工作原理,更重要的是,了解Java编译器为特定源代码生成的字节码,对于编写最快和最小的代码至关重要。
翻译自: https://www.ibm.com/developerworks/java/library/it-haggar_bytecode/index.html