Java字节码

这篇文章能让你对Java字节码有个了解,这可以帮你成为一个更好的程序员。就像C或C++编译器将源码编译为汇编码,Java编译器会将Java源码编译成字节码。Java程序员应该花费时间去理解什么是字节码,它是怎样工作的,更重要地是,Java编译器产生了什么样的字节码。在某些情况下,产生的字节码并非是你能预料的。

此处关于字节码的信息和提供的字节码都是基于Java 2 SDK标准版v1.2.1 javac编译器。通过其他编译器产生的字节码可能和这稍微有些不同。

一、为什么要了解字节码?

字节码是Java程序的中间表示,就好比汇编是C或C++程序的中间表示。C和C++程序员最了解他们编译的处理器汇编指令集。在调试,优化性能和调节内存分配时,这项知识是至关重要的。了解编译器为你写的代码生成的汇编指令,有助于帮你认识到如何以不同的编码实现内存或性能目标。此外,当跟踪一个问题的时候,使用调试器(debugger)对源码反汇编,然后对正在执行的汇编代码进行单步调试是有益的。

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 <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
这个类很简单。它包含两个实例变量,一个构造器和三个方法。字节码文件的前5行列出了用于产生该代码的文件名,类定义,它的继承层次(默认,所有类都继承自java.lang.Object),构造器和方法。接下来,每个构造器的字节码被列出。然后,每个方法和它们的字节码被以字母顺序列出。
通过检查字节码,你可能注意到一些以‘a‘或‘i‘作为前缀的操作码。比如,在Employee类的构造器中,你可以看到aload_0和iload_2。这些前缀代表操作码的类型。前缀‘a’表示操作码正在操纵一个对象引用。前缀‘i’意味着操作码正在操纵一个整数。其他的操作码使用前缀‘b‘来代表byte,‘c’代表char,‘d’代表double等。这些前缀能帮你了解正在操纵的数据类型。

注意:单独的代码常称为操作码。复杂的操作码常称为字节码。

三、字节码详情

为了理解字节码的详细信息,我们需要讨论Java虚拟机(JVM)是如何处理执行过程中的字节码的。JVM是基于栈的机器。每一个线程都有一个用来存储帧集(frames)的JVM栈。每次方法调用都会创建一个帧,这个帧包括一个操作栈,一个本地变量的数组和一个运行时常量池的引用。

从概念上,帧如下图所示:

Java字节码

图1、一个帧

本地变量的数组也称为本地变量表,包括方法的参数,它也被用来存储本地变量的值。首先存放的是参数,从0开始编码。如果帧是一个构造器或实例方法的,this引用将会存储在地址0处。地址1存放第一个参数,地址2存储第二个参数,依次类推。对于静态方法,第一个方法参数被存放在地址0,第二个存放在地址1,依次类推。
本地变量数组的大小是在编译期间决定的,它取决于本地变量和正常方法参数的数量和大小。操作栈是一个用于push和pop值的后进先出的栈。它的大小也是在编译期决定。一些操作码指令将值push到操作栈;其他的操作码指令从栈上获取操作数,操作它们,将结果push回去。操作栈常用来接收方法的返回值。

public String employeeName()
{
return name;
}

Method java.lang.String employeeName()
0 aload_0
1 getfield #5 <Field java.lang.String name>
4 areturn
这个方法的字节码由3个操作码指令组成。第一个操作码,aload_0,用于将本地变量表中索引为0的变量的值推送(push)到操作栈上。前面提到过,本地变量表是用来为方法传递参数的。构造器和实例方法的this引用总是存放在本地变量表的地址0处。this引用必须入栈,因为方法需要访问实例的数据,名称和类。
下一个操作码指令,getfield,用于从对象中提取字段。当该操作码执行的时候,操作栈顶部的值就会弹出(pop).然后#5被用来在类的运行时常量池中构建一个用于存放字段name引用的地址的索引。当这个引用被提取的时候,它将会推送到操作栈上。
最后一个指令,areturn,返回一个来自方法的引用。比较特殊的是,areturn的执行会导致操作栈顶部的值,name字段的引用都会被弹出,然后推送到调用方法的操作栈。
employeeName方法相当简单。在考虑一个更复杂的例子之前,我们需要检查每个操作码左边的值。在employeeName方法的字节码中,这些值是0,1,和4。每一个方法都有一个对应的字节码数组。这些值对应每个操作码和它们的参数数组中的索引。你可能好奇为什么这些值不是顺序的。正如字节码这个名字所显示的那样,每个指令占据1个字节,那为何索引不是0,1,2?原因是,一些操作码含有参数,这些参数会占据字节数组的空间。比如,aload_0指令没有参数,自然地在字节数组中就占据一个字节。因此,下一个操作码,getfield就在位置1上。然而,areturn在位置4上。因为getfield操作码和它的参数占据了位置1,2,和3。位置1被getfield操作码使用,位置2和位置3被用于存放参数。这些参数用于构成在类的运行时常量池中存放值的地方的一个索引。下面的图展示了employeeName方法的字节码数组看起来是什么样子的:
Java字节码

图2、employeeName方法的字节码数组

实际上,字节码数组包含代表指令的字节。使用一个16进制的编辑器查看class文件,可能看到字节码数组中有下面的值:

Java字节码

图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 <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,将引用推送到操作栈上。(记住,本地变量表用于实例方法和构造器的第一个入口就是该引用)。
下一个操作码指令在位置1,invokespecial,调用父类的构造器。因为,所有没有明确从任何其他类继承的类都隐式继承了java.lang.Object。编译器提供必需的字节码用于调用基类的构造器。在这些操作码中,操作栈的顶部值将会弹出。
下两个操作码,位于位置4和5,将本地变量表中的前两个实体推送到操作栈。第一个值是一个引用。第二个值是构造器的第一个正式的参数,strName。这些推送的值是为位于位置6的putfield操作码准备的。
putfield操作码弹出位于操作栈顶部的两个值,存储strName的一个引用到通过this引用的对象的实例属性name中。
下3个操作码指令位于9,10和11,使用第二个正常的构造器参数num,和实例变量idNumber,执行相同的操作。
接着的3个操作码指令,位于14,15和16,为storeData的方法调用准备栈数据。这些指令分别将this引用,strName和num入栈。这个引用必须入栈,因为一个实例方法被调用。如果该方法被声明为静态的,这个this引用就不需要入栈。由于strName和num是storeData方法的参数,所以它们的值需要入栈。当storeData方法执行时,this引用,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           //将本地变量表中索引为0的对象引用this入栈。
                      
   1 getfield #6 <Field int intArr[]>
                       //弹出对象引用this,将访问常量池的intArr对象引用入栈。
                      
   4 iconst_0          //将0入栈。
   5 iaload            //弹出栈顶的两个值,将intArr中索引为0的值入栈。
                       
   6 ireturn           //弹出栈顶的值,将其压入调用方法的操作栈,并退出。
                    

Method int top2()
   0 aload_0           //将本地变量表中索引为0的对象引用this入栈。
   1 astore_2          //弹出this引用,存放到本地变量表中索引为2的地方。
   2 aload_2           //将this引用入栈。
   3 monitorenter      //弹出this引用,获取对象的监视器。
                      
   4 aload_0           //开始进入同步块。将this引用压入本地变量表索引为0的地方。
                       
   5 getfield #6 <Field int intArr[]>
                       //弹出this引用,压入访问常量池的intArr引用。
                     
   8 iconst_0          //压入0。
   9 iaload            //弹出顶部的两个值,压入intArr索引为0的值。
             
  10 istore_1          //弹出值,将它存放到本地变量表索引为1的地方。
                       
  11 jsr 19            //压入下一个操作码(14)的地址,并跳转到位置19。
  14 iload_1           //压入本地变量表中索引为1的值。
                      
  15 ireturn           //弹出顶部的值,并将其压入到调用方法的操作栈中,退出。
                      
  16 aload_2           //同步块结束。将this引用压入到本地变量表索引为2的地方。 
                      
  17 monitorexit       //弹出this引用,退出监视器。
                     
  18 athrow            //弹出this引用,抛出异常。
                       
  19 astore_3          //弹出返回地址(14),并将其存放到本地变量表索引为3的地方。
                      
  20 aload_2           //将this引用压入到本地变量索引为2的地方。
                       
  21 monitorexit       //弹出this引用,并退出监视器。
                       
  22 ret 3             //从本地变量表索引为3的值(14)指示的地方返回。
Exception table:       //如果在位置4(包括4)和位置16(排除16)中出现异常,则跳转到位置16.
from to target type    
 4   16   16   any     
top2比top1大,还慢,是因为采取的同步和异常处理方式。注意到top1使用synchronized方法修饰符,这不会产生额外的代码。相反,top2在方法体中使用synchronized语句。
在方法体中使用synchronized会产生monitorenter和monitorexit操作码的字节码,还有额外的用于处理异常的代码。如果在执行到同步锁的块(一个监视器)内部时,出现一个异常,这个锁要保证在退出同步块前被释放。top1的实现比top2略微高效些。这能获取到一小点的性能提升。
当synchronized方法修饰符出现时,就像top1中的那样,锁的获取和随后的释放不是通过monitorenter和monitorexit操作码实现的。而是在JVM调用一个方法时,它检查ACC_SYNCHRONIZED属性标识。如果有这个属性标识,正在执行的线程获取一个锁,调用方法,然后在方法返回时释放锁。如果同步方法抛出异常,在异常离开方法前锁会自动释放。
注意:如果出现synchronized方法修饰符,在方法的method_info结构中将会包含ACC_SYNCHRONIZED属性标识。
不管你使用synchronized作为方法修饰符还是作为同步块,这都有相同的含义。仅在你的代码需要同步,并且你明白它的代价时才使用同步方法。如果整个方法需要同步,我喜欢使用方法修饰符而不是同步代码块,以此来获取更小,更快的代码。
这只是使用字节码的知识来让你的代码更小,更快的一个示例。更多的信息可以参考我的书,Java实践。
五、编译选项

javac编译器提供了一些你有必要了解的可选项。第一个是-O。JDK文档中声明-O能够优化你的代码以提高运行速度。在Sun Java 2 SDK的javac编译器中使用-O对产生的字节码没有影响。Sun javac编译器的老版本提供了一些基本的字节码优化,但那些都被删除了。然而SDK文档一直没有更新。保留-O作为一个可选项的原因是因为它兼容老版本。因此,当前没有必要使用它。
这意味着通过javac编译器产生的字节码并没有比你写的代码好很多。例如,如果你写一个包含一个不变量的循环,javac编译器并不会移除这个不变量。程序员常常使用其他语言的编译器来清除那些代码异味。不幸的是,javac不能做这些。更重要的是,javac编译器不会执行简单的优化,比如循环展开,代数化简,复杂运算简化等。为了获取这些好处和其他简单的优化,程序员需要在Java源码级别来做这些,而不是依赖于javac编译器。这里有很多技术你可以用来让javac编译器产生更快,更小的字节码。不幸的是,为了获取这些好处,在Java编译器执行它们之前,你必须实现它们。
javac编译器也支持-g和-g:none的可选项。-g选项告诉编译器产生所有的调试信息。g:none选项告诉编译器不产生调试信息。使用-g:none选项编译可能产生最小的类文件。
因此,当试着产生最小的类文件时可以使用该选项。

六、Java调试器

我见过的一个非常有用的Java编辑器的特性就是和C或C++调试器类似的反编译视图。反编译Java代码可能会暴露字节码,就像反编译C或C++代码会暴露汇编代码。除了这个特性外,另一个比较有用的特性可能是通过字节码进行单步调试的能力,一次执行一个操作码。
这种级别的功能允许程序员看到一手的,通过Java编译器产生的字节码,同时也能在调试期间通过它进行单步执行。程序员获取产生和执行的代码信息越多,就越有机会避免出错。调试器的类型特性鼓励程序员去查看,理解生成的字节码。
七、总结
这篇文章为你展示一个Java字节码到的综述和理解。任何语言的最好的程序员理解高级语言转换的在执行前的中间形式。对于Java,中间形式就是字节码。理解它,知道它如何工作,更重要的是,针对特别的源码,Java编译器会产生什么样的字节码,这可能会写出最快,最小的代码。


注:本篇文章翻译自Java bytecode: Understanding bytecode makes you a better programmer

翻译的惨不忍睹,各位见谅,如有其他翻译本文的,还请提供下链接,参考下。

Java字节码,布布扣,bubuko.com

Java字节码

上一篇:python学习笔记(一)


下一篇:C++ Primer 3rd 读书笔记1/2