频道栏目
首页 > 资讯 > Java > 正文

Java字节码

17-08-22        来源:[db:作者]  
收藏   我要投稿

理解字节码可以使你变成一个更好的程序员

关于字节码的信息,以及这里提供的字节码,都是基于Java 2 SDK标准版v1.2.1 javac编译器的。其他编译器生成的字节码可能略有不同。

为什么要理解字节码?

字节码就是 Java 代码的中间表示,就像是汇编代码是 C/C++ 代码的中间表示一样。最熟悉 C/C++ 的程序员了解他们正在编译程序的处理器的汇编指令集。这些知识在调试程序,提高执行性能和内存调优时显得十分重要。如果你能理解你的代码在经过编译器编译后产生的汇编指令,你就会知道更多不同的方法来调优代码。另外,当你在 debug 时,使用 window 来反汇编源代码并且单步执行正在运行的汇编代码是非常有用的。

在学习 Java 的过程中,经常容易被忽视的一个知识点就是 javac 编译产生的字节码。
学习字节码,了解 Java 编译器会产生什么样的字节码对 Java 程序员是非常有帮助的,就像学习汇编对 C/C++ 程序员很有帮助一样。

字节码其实就是你写的程序。无论你的代码通过 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 
4 aload_0
5 aload_1
6 putfield #5 
9 aload_0
10 iload_2
11 putfield #4 
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 
20 return

Method java.lang.String employeeName()
0 aload_0
1 getfield #5 
4 areturn

Method int employeeNumber()
0 aload_0
1 getfield #4 
4 ireturn

Method void storeData(java.lang.String, int)
0 return

这个类非常简单。它包含了两个实例变量,一个构造器和三个方法。字节码文件的前5行列出了用于生成此代码的文件名,类定义,它的超类(默认情况下,所有类都继承自java.lang.Object),还有它的构造器和方法。接下来,列出了每个构造函数的字节码。然后,每个方法都按照字母顺序排列,并关联了相应的字节码。

你可能会注意到,在对字节码进行更仔细的检查时,某些操作码(opcode)的前缀是‘a’或‘i’。例如,在Employee类构造函数中,你可以看到 aload_0 和 iload_2 。前缀是操作码所使用的类型的代表。前缀‘a’表示操作码是在操作对象引用。前缀‘i’表示操作码是在操作一个整数。其他的操作码例如,‘b’表示byte,‘c’来表示char,‘d’表示double 等等。前缀可以让你立即知道被操纵的数据类型是什么。

注:单条代码统称为操作码(opcode),多条操作码指令统称为字节码(bytecode)

细节

为了了解字节码的细节,我们需要讨论 Java 虚拟机(JVM)是如何处理字节码的执行的。JVM是一个基于栈的模型,每个线程都有一个用于存储帧的JVM堆栈。每当调用一个方法时,就会创建一个栈帧,并由一个操作数堆栈、一个局部变量数组和当前方法类的运行时常量池的引用组成。从概念上讲,它可能是这样的:

图 1. 栈帧
这里写图片描述

局部变量数组,也称为局部变量表,包含方法的参数,也用于保存本地变量的值。并且参数从 0 索引开始存储。如果栈帧是用于构造函数或实例方法,那么该引用将存储在位置 0 中。然后从 1 位置开始存储形式参数,2 位置存储第二个参数,等等。对于静态方法,第一个形式参数存放在 0 位置,第二个在 1 位置,等等。

局部变量数组的大小在编译时确定,并且依赖于本地变量的数量和大小和形式参数。操作数堆栈是一个 LIFO(后入先出)栈,用来压入或抛出数值。它的大小也在编译时确定。某些操作码指令将值压入操作数堆栈。另一些则从堆栈中获取操作数,对其进行操作,并将结果压入栈。操作数堆栈也被用来从方法中接收返回值。

public String employeeName()
{
return name;
}

Method java.lang.String employeeName()
0 aload_0
1 getfield #5 
4 areturn

这个方法的字节码由三个操作码指令组成。第一个操作码是 aload_0,它将本地变量表的索引 0 压入操作数堆栈上。前面提到了局部变量表用于将参数传递给方法。这个引用总是在本地变量表的位置0中,用于存储构造函数和实例方法。这个引用必须被压入栈,因为这个方法要访问类的实例数据、名称。

下一个操作指令 getfield 用于从一个对象获取一个字段。当执行这个操作码时,堆栈的顶部值就会被抛出。然后,#5 被用来在类的运行时常量池中建立索引,在这个类中存储引用的名称。当获取这个引用时,它被压入操作数堆栈上。

最后一条指令 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方法的字节码数组

这里写图片描述

实际上,字节码数组包含表示指令的字节。看一个使用十六进制编辑器的 .class 文件,您将在字节码数组中看到以下值:

图 3. 字节码数组中的值

这里写图片描述

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 
4 aload_0
5 aload_1
6 putfield #5 
9 aload_0
10 iload_2
11 putfield #4 
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 
20 return

在位置0处的第一个操作指令,aload_0,将这个引用压入到操作数堆栈上。(请记住,实例方法和构造函数的本地变量表的第一个条就是这个引用。)

下一条操作指令在位置 1, invokespecial,调用该类的父类的构造函数。因为不显式地扩展其他类的所有类都隐式地继承了java.lang.Object,编译器提供必要的字节码来调用这个基类构造函数。在这个操作码中,操作数堆栈的顶部值被抛出。

接下来的两个操作码,在位置 4 和 5 中,将前两个条目从本地变量表压入操作数堆栈。第一个被压入的值是这个引用。第二个值是构造函数的第一个形式参数,strName 。这些值是为在位置 6 处的 putfield 操作指令而准备的。

putfield操作码通过从栈顶中抛出两个值将一个 strName 的引用存储到该对象的实例名所指的引用中。

在位置9、10 和 11 的下三个操作指令执行相同的操作,使用第二个形式参数对构造函数、num 和实例变量 idNumber 进行相同的操作。

在14、15 和 16 的位置上,接下来的三个操作指令将为 storeData 方法调用准备堆栈。这些指令分别压入了些引用,strName 和 num。这些引用必须被压入,因为一个实例方法正在被调用。如果方法被声明为静态,那么这个引用就不需要被压入了。由于它们是 storeData 方法的参数,所以将 strName 和 num 的值压入。当 storeData 方法执行时,这个引用、strName 和 num 将分别占据该方法栈帧中包含的局部变量表的索引 0、1 和 2。

代码大小和执行效率的问题

对于许多使用 Java 的桌面和服务器系统来说,性能是一个非常重要的问题。随着Java从这些系统转移到更小的嵌入式设备,大小问题也变得很重要。了解为一系列Java指令生成的字节码可以帮助你编写更小、更有效的代码。例如,Java中的同步。以下两种方法从作为数组实现的一组整数中返回顶部元素。这两种方法都使用同步功能,在功能上是等价的:

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 
                       //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 
                       //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 更慢。注意,top1使用了 synchronized 修饰符,它不会生成额外的代码。相比之下,top2在方法的主体中使用了一个同步语句。

在方法的主体中使用 synchronized 生成的字节码包含 monitorenter(监视器进入) 和 monitorexit(监视器退出) 操作码,并且为异常处理提供额外的代码。如果在同步块(监视器)中出现一个异常,那么在退出同步块之前,同步锁就会被释放。top1 的实现比 top2 的效率稍高一些; 这导致了很小的性能提升。
当同步方法修饰符出现时,就像在top1中一样,锁的获取和随后的释放并不是通过monitorenter(监视器进入)和monitorexit(监视器退出)操作码来完成的。相反,当JVM调用一个方法时,它会检查 ACC_SYNCHRONIZED(同步属性)标志。如果该标志出现,那么执行的线程将获得一个锁,调用该方法,然后在方法返回时释放锁。如果从同步方法抛出一个异常,那么在异常离开该方法之前,该锁会自动释放。

注:如果 ACC_SYNCHRONIZED(同步属性)标志存在,则包含在方法的方法信息结构中。

无论您使用同步作为方法修饰符还是同步块,都对代码大小的影响。只有当你的代码需要同步时才使用同步方法,并且你需要知道使用它们所带来的成本。如果整个方法需要同步,那么我更喜欢在同步块上使用方法修饰符,以生成更小、更快的代码。

这只是使用字节码知识的一个例子,使您的代码更小、更快; 如果你想了解更多,可以看我的书,Practical Java

编译器选项

javac编译器提供了一些您需要知道的选项。第一个是 -O 选项。JDK文档声称 -O 将优化您的代码以执行速度。使用带有Sun Java 2 SDK的javac编译器对生成的代码没有任何影响。以前版本的Sun javac编译器执行了一些基本的字节码优化,但是这些都被删除了。然而,SDK文档还没有更新。 -O 仍然是一个选项的唯一的原因,是为了与旧的文件兼容。因此,目前没有理由使用它。
这也意味着由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编译器对特定源代码生成的字节码,对于编写尽可能快的、最小的代码是至关重要的。

 

相关TAG标签
上一篇:Java并发编程:同步容器
下一篇:SSM整合的第一个登录案例(详细)
相关文章
图文推荐

关于我们 | 联系我们 | 广告服务 | 投资合作 | 版权申明 | 在线帮助 | 网站地图 | 作品发布 | Vip技术培训 | 举报中心

版权所有: 红黑联盟--致力于做实用的IT技术学习网站