[译]Java字节码

原文:Java bytecode
基于Java 2 SDK标准版,v1.2.1 javac编译器。其他编译器生成的字节码可能略有不同。

为什么要理解字节码?

字节码是Java程序的中间层,就像汇编程序是C或C ++程序的中间层一样。年迈的C和C ++程序员知道他们面对的处理器的汇编指令集。在调试、优化性能和内存时,这些是重要的知识。了解编译后生成的汇编指令,有助于更好的编写代码。此外,在排查问题时,反汇编代码并逐步执行汇编程序通常很有用。

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表示char,d表示double,等等。
此前缀可让您立即了解正在操作的数据类型。

注意:单行代码通常称为操作码。多个操作码指令通常称为字节码。

细节

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

局部变量数组(也称为局部变量表)包含方法的参数,还保存局部变量的值。首先存储参数,从索引0开始。如果栈帧用于构造函数或实例方法,则this引用存储在位置0。然后,位置1包含第一个参数,位置2包含第二个,依此类推。对于静态方法,第一个形式方法参数存储在位置0中(没有this),第二个存储在位置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将this引用值从局部变量表的索引0压入到操作数栈。之前提到过,局部变量表用于将参数传递给方法。对于构造函数和实例方法,this引用始终存储在局部变量表的位置0处。必须压入this引用,因为该方法正在访问类的实例数据name。
下一个操作码指令getfield用于从对象中获取字段。执行此操作码时,会弹出堆栈中的最高值。然后#5对应构建类的运行时常量池的字段索引,其中存储了对name的引用。获取此引用时,它将被压入操作数栈。
最后一条指令areturn返回方法的引用。更具体地说,执行areturn会导致操作数栈的最顶部值(name的引用)被弹出并压入到调用方法的操作数栈上。
我们再看下每个操作码左侧的值。在employeeName方法的字节码中,这些值为0、1、4,每个方法都有一个对应的字节码数组。这些值对应于存储每个操作码及其参数的数组的索引。
你可能想知道为什么值不是连续的0、1、2。原因是操作码占用字节码不同。例如,aload_0指令没有参数,占据一个字节。因此,下一个操作码getfield位于位置1。但是,areturn位于位置4,这是因为getfield操作码及其参数占据位置1,2和3,位置1用于getfield操作码,位置2和3用来保存参数。
下图显示了employeeName方法的字节码数组:

实际上,如果使用十六进制编辑器查看.class文件,您将在字节码数组中看到以下值:

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引用推送到操作数堆栈。(请记住,实例方法和构造函数的局部变量表的第一位是this引用。)

位置1的下一个指令invokespecial调用该类的超类的构造函数。因为所有未显式继承的类都隐式继承自java.lang.Object,所以编译器提供必要的字节码来调用此基类的构造函数。在此操作码期间,弹出操作数堆栈的顶部值。

接下来的两个操作码,位置4和5,将前两个值从局部变量表压入到操作数栈。第一个值是this引用。第二个值是构造函数strName的第一个参数。推送这些值以准备位置6的putfield指令。

putfield操作码从操作数栈栈中弹出两个顶部值,并将对strName的引用存储到由this引用的对象的实例数据名中。

位置9,10和11的三个指令对num和idNumber执行相同的操作。

位置14,15和16的三个指令为storeData方法调用准备堆栈。这些指令分别压入this引用,strName和num。必须压入this引用,因为正在调用实例方法。如果方法声明为static,则不需要this引用。当storeData方法执行时,this引用、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使用synchronized方法修饰符,它不会生成额外的代码。相比之下,top2在方法体中使用synchronized语句。
(译者注:在JDK11下,这个结论依然生效)
在方法体中使用synchronized会生成monitorenter和monitorexit操作码的字节码,以及处理异常的其他代码。如果在同步块内部执行时生成异常,必须保证在退出同步块之前释放锁。top1的实现效率略高于top2,这导致小小的性能增益。

当synchronized修饰方法时,如在top1中,锁定的获取和后续释放不是使用monitorenter和monitorexit操作码完成的。相反,当JVM调用方法时,它会检查常量池方法表中ACC_SYNCHRONIZED标志。如果存在此标志,则正在执行的线程获取锁,然后在方法返回时释放锁。
如果从synchronized方法抛出异常,则在异常离开方法之前会自动释放锁。

注意:如果存在synchronized方法修饰符,则ACC_SYNCHRONIZED属性标志包含在方法的method_info结构中。

无论是使用synchronized作为方法修饰符还是使用synchronized块,都会产生大小影响。仅当代码需要同步并且了解使用成本时,才使用同步方法。如果整个方法需要同步,我更喜欢方法修饰符而不是同步块,以便生成更小,更快的代码。

这只是使用字节码知识使代码更小更快的一个例子; 更多信息可以在我的“ 实用Java”一书中找到。Practical 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编译器为特定源代码生成的字节码,对于编写尽可能最快和最小的代码至关重要。

Comments
Write a Comment