首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》5.4 字节码

关灯直达底部

到目前为止,在我们的讨论中,字节码一直有点幕后工作者的意思。我们先来回顾一下对它已经有了哪些了解,然后再对它进行详细介绍:

  • 字节码是程序的中间表示形式:介于人类可读的源码和机器码之间。

  • 字节码是通过javac处理源码文件产生的。

  • 某些高层语言特性在编译时已经从字节码中去掉了。比如说Java的循环结构(forwhile等)在字节码中就被转换成了分支指令。

  • 每个操作码都由一个字节表示(因此被叫做字节码)。

  • 字节码是一种抽象表示法,不是“某种虚拟CPU的机器码”。

  • 字节码可以进一步编译成机器码,通常是“即时编译”。

字节码解释起来有点像先有鸡还是先有蛋的问题。要彻底搞清楚状况,你既要懂字节码,又要明白执行它的运行时环境。

这是一个循环依赖,为了解决这个问题,我们先来探索一个相对简单的例子。即使这一次你不太明白,也可以在后续章节读到更多字节码相关内容时再回来看看。

在例子之后,我们会给出一些与运行时环境相关的上下文和JVM操作码的目录(其中包括用于数学计算、调用、快捷形式之类的字节码)。最后,我们会用另外一个基于字符串拼接的例子来结束。现在就先去看看如何检查.class文件的字节码吧。

5.4.1 示例:反编译类

用带有-c选项的javap可以对类进行反编译。我们会以代码清单5-5中的演算类为例,主要检查方法之内的字节码。我们还会加上-p选项,以便能见到私有方法内的字节码。

我们一节一节的来——javap输出的每一部分都有很多信息,很容易让人不堪重负。首先,让我们先看头部。这里没什么特别出人意料或让人喜出望外的:

$ javap -c -p wgjd/ch04/ScratchImpl.classCompiled from "ScratchImpl.java"public class wgjd.ch04.ScratchImpl extends java.lang.Object {  private static wgjd.ch04.ScratchImpl inst;  

接下来是静态块。变量的初始化就放在这里,所以这表示inst被初始化为null了。看起来putstatic可能是一个把值放到静态域中的字节码。

static {};  Code:     0: aconst_null     1: putstatic #10 // Field inst:Lwgjd/ch04/ScratchImpl;     4: return  

代码前面的数字表示从方法开始算起的字节码偏移量。所以字节1是putstatic操作码,字节2和3表示一个16位的常量池索引,这个16位索引在这里的值是10,表示该值(此处为null)会存在常量池的条目#10所指明的域中。从字节码流开始的第4个字节是return操作符,表明这个代码块结束了。

接下来是构造方法。

private wgjd.ch04.ScratchImpl;  Code:     0: aload_0     1: invokespecial #15 // Method java/lang/Object."<init>":V     4: return  

在Java中,void构造方法总会隐式调用超类中的构造方法。这从上面的字节码里就能看出来invokespecial指令。一般来说,任何方法调用都会转换成VM的某一调用指令。

run方法中没有代码,因为这只是一个空白的演算类。

private void run;  Code:     0: return  

main方法中,你初始化了inst,还做了点对象创建。这说明了辨识通用字节码的基本模式:

public static void main(java.lang.String);  Code:     0: new #1                // class wgjd/ch04/ScratchImpl     3: dup     4: invokespecial #21    // Method "<init>":V  

这种3个字节码指令的模式——newdup和一个<init>invokespecial——都表示创建新实例。

操作码new只为新实例分配内存。dup复制栈顶上的元素。要完整创建该对象,你需要调用构造方法的代码块。<init>方法中包含构造方法的代码,所以可以用invokespecial调用那段代码。我们继续看main方法中其余的字节码:

  7: putstatic      #10         // Field inst:Lwgjd/ch04/ScratchImpl;  10: getstatic     #10         // Field inst:Lwgjd/ch04/ScratchImpl;  13: invokespecial #22         // Method run:V  16: return}  

指令7保存刚刚创建的单例实例。指令10把它放回到栈顶上,这样指令13就可以调用它上面的方法了。注意,因为调用的run是私有方法,所以13是invokespecial。私有方法不能重写,所以不能用Java的标准虚拟查询。大多数方法调用都会转换成invokevirtual指令。

注意 通常来说,javac产生的字节码没有经过特别优化,是非常简单的表示形式。基本策略是由JIT编译器来完成大部分的优化工作,所以简单直白的起点对它们是很有帮助的。VM实现者表示,“字节码就应该傻傻的”,这是他们对从源语言产生的字节码的总体感觉。

接下来我们要讨论字节码所需的运行时环境,之后会介绍用来描述字节码指令主要“家庭成员”的表格,其中包括加载/存储,数学计算,执行控制,方法调用和平台操作。然后我们会讨论操作码可能的快捷形式,最后会再给出一个例子。

5.4.2 运行时环境

因为JVM使用堆栈机,所以理解堆栈机的操作对理解字节码至关重要。

图5-4 将栈用于数学运算

JVM与硬件CPU(比如x64或ARM芯片)最显著的差别在于它没有处理器寄存器,而是用栈完成所有的计算和操作。有时候也这也被称为操作数栈(或计算堆栈)。图5-4展示了如何用操作数栈完成两个int数值的相加运算。

正如我们前面讨论过的,当一个类被链接进运行时环境时,它的字节码会受到检查,并且其中很多验证都可以归结为对栈中类型模式的分析。

注意 栈中的值只有类型正确时对它的处理才能生效。比如,如果我们把对一个对象的引用压入栈,然后试图将其作为int型进行数学计算,就可能会发生未定义或糟糕的事情。类加载过程中的验证阶段会进行广泛的检查,以确保新加载的类中不会有滥用栈的方法。这样做能够防止系统接受了损坏(或恶意)的类并引发问题。

方法在运行时需要一块内存区域作为计算堆栈来计算新值。另外,每个运行的线程都需要一个调用堆栈(栈跟踪中会报告的那个栈)来记录当前正在执行的方法。在某些情况下,这两个栈会有交互。看下面这行代码:

return 3 + petRecords.getNumberOfPets("Ben");  

要计算出这行代码的结果,需要把3压入操作数栈。然后调用方法计算Ben有多少只宠物。为此,你需要把接收对象(方法属主,即petRecords)压入计算堆栈,要传入的所有参数尾随其后。

然后invoke操作符会调用方法getNumberOfPets,把控制权移交给被调用的方法,刚刚进入的方法会出现在调用堆栈中。但进入新方法后,需要启用不同的操作数栈,所以已经在调用者的操作数栈中的值不可能影响被调用方法的计算结果。

getNumberOfPets完成时,返回结果会被放到调用者的操作数栈中,进程中与getNumberOfPets相关的部分也会从调用堆栈中移走。然后相加运算可以得到两个值并把它们加在一起。

现在我们开始审视字节码。这是个大课题,而且有很多特殊情况,所以我们即将呈现的只是主要特性的概览,而不是完整的介绍。

5.4.3 操作码介绍

JVM字节码由操作码(opcode)序列构成,每个指令后面可能会跟着一些参数。操作码希望看到栈处于指定状态中,然后它对栈进行转换,把参数移走,放入结果。

每个操作码都由一个单字节值表示,所有最多只能有255个操作码。当前仅用了200个左右。对我们来说,把它们全列出来有点儿太多了,好在大多数操作码都可以归为几大族系。我们会逐一对这些族系进行讨论,帮助你理解它们。还有一些操作码不好界定应该归为哪一族系,但好在你不会经常遇见它们。

注意 JVM不是纯粹的面向对象运行时环境——它支持原始类型。这在某些操作码族系中有所体现——其中一些基本操作码类型(比如存储和相加)要有一些变体,在处理原始类型时会有所不同。

操作码表有四列:

  • 名称:这是操作码类型的通用名称。大多数情况下,都会有几个相关的操作码在做类似的事情。

  • 参数:操作码的参数。以i打头的参数是用来作为常量池或局部变量中的查询索引的几个字节。如果有更多的此类参数,它们会合并在一起,所以i1i2表示“从这两个字节中生成一个16位的索引”。如果参数出现在括号里,就表明不是所有形式的操作码都会使用它。

  • 堆栈布局:它展示了栈在操作码执行前后的状态。括号中的元素表明不是所有形式的操作码都使用它们,或者这些元素是可选的(比如调用操作码)。

  • 描述:操作码的用处。

我们从表5-4中拿过来一行代码做例子,检查一下操作码getfield的条目。这个操作码用于从对象的域中读出一个值。

getfield i1, i2 [obj] → [val]        从栈顶端对象的常量池中取出指定位置的域。  

第一列给出了操作码的名字:getfield。后面一列说明在字节码流中有两个参数跟在操作码后面。这些参数合在一起构成一个16位的值,可以用来从常量池里找到想要的域(记住常量池的索引总是16位的)。

堆栈布局那一列表明在找到栈顶端对象的类的常量池中的索引位置之后,该对象被移除,它的位置被那个域的值所替代。

这种把移走对象作为操作一部分的模式是一种让字节码变得紧凑的办法,没有繁琐的清理工作,也不用记着要挪走处理完的对象实例。

5.4.4 加载和储存操作码

加载和储存操作码这个族系负责将值加载到栈或检索值。表5-4给出了加载/储存族系的主要操作。

表5-4 加载和储存操作码

名称 参数 堆栈布局 描述load (i1) → [val]从局部变量加载值(原始型或引用型)到栈上。有快捷形式,并且有针对不同类型的变体ldc i1 → [val] 从池中加载常量到栈上,针对不同类型有不同的变体,并且范围广泛store (i1) [val] → 把值(原始型或引用型)从进程的栈中移走,存到局部变量中。有快捷形式,有针对不同类型的变体dup [val] → [val, val] 复制栈顶部的值,有不同形式的变体getfield i1, i2 [obj] → [val] 从栈顶部对象的常量池中得到指定位置的域putfieldi1, i2[obj,val] → 把值放入对象在常量池中指定位置的域上

前面提过,加载和储存指令有很多不同形式的变体。比如用来把双精度数从局部变量加载到栈上的dload操作码,以及用来把对象引用从栈弹出到局部变量中的astore操作码。

5.4.5 数学运算操作码

这些操作符在栈上执行数学运算。它们从栈顶端取出参数并进行计算。这些参数(总是原始型)必须完全匹配,但平台提供了很多对原始型进行类型转换的操作码。表5-5给出了基本的数学运算操作码。

类型转换(cast)操作码的名称非常短,比如i2d是把int转为double的操作码。需要特别说明的是,类型转换操作码中并没有cast,所以在表5-5中用括号把它括了起来。

表5-5 数学运算操作码

名称参数 堆栈布局 描述add [val1, val2] → [res] 把栈顶端的两个值相加(必须是相同的原始类型),并把结果存在栈中。有快捷形式,有针对不同类型的变体sub [val1, val2] → [res] 把栈顶端的两个值相减(必须是相同的原始类型),并把结果存在栈中。有快捷形式,有针对不同类型的变体p [val1, val2] → [res] 把栈顶端的两个值相除(必须是相同的原始类型),并把结果存在栈中。有快捷形式,有针对不同类型的变体mul [val1, val2] → [res] 把栈顶端的两个值相乘(必须是相同的原始类型),并把结果存在栈中。有快捷形式,有针对不同类型的变体(cast) [value] → [res] 把值从一种原始类型转换为另外一种。每一种可能的类型转换都有对应的形式

5.4.6 执行控制操作码

如前所述,高级语言的控制结构在JVM字节码中没有出现。相反,流程控制是由很少的几个原始指令完成的,如表5-6所示。

表5-6 流程控制操作码

名称 参数 堆栈布局 描述if b1, b2 [val1, val2] → [val1] → 如果符合特定条件,则跳转到特定分支的偏移处goto b1, b2无条件地跳转到分支偏移处。有宽大形式jsr b1, b2 → [ret] 跳到本地子流程中,并把返回地址(下一个操作码的偏移地址)放到栈中。有宽大形式ret 索引 返回到索引的局部变量所指向的偏移地址tableswitch {依情况而定} [index] → 用于实现switchlookupswitch {依情况而定} [key] → 用于实现switch

就像用于查找常量的索引字节,参数b1b2用于构造方法内部的字节码跳转地址。jsr指令用于访问主流程之外一个自成体系的字节码区域(偏移地址可能在方法的主字节码之外)。在某些情况下,比如在异常处理块中,可能会用到它。

gotojsr指令的宽大形式要用4个字节的参数,并且所构造的偏移量大于64 KB。但这并不常用。

5.4.7 调用操作码

调用操作码中有四个操作码可以处理普通的方法调用,还有一个Java 7中新出的特别操作码invokedynamic(5.5节有更多细节)。这五个方法调用操作码如表5-7所示。

表5-7 调用操作码

名称参数 堆栈布局 描述invokestatici1, i2[(val1, ...)] → 调用一个静态方法invokevirtuali1, i2[obj, (val1, ...)] → 调用一个“常规”的实例方法invokeinterface i1, i2,count, 0 [obj, (val1, ...)] → 调用一个接口方法invokespecial i1, i2 [obj, (val1, ...)] → 调用一个“特殊”的实例方法invokedynamic i1, i2, 0,0 [val1, ...] → 动态调用,见5.5节

在调用操作码中,有两个地方需要注意。第一个是invokeinterface中多出来的参数。这些参数基于历史原因和向后兼容而产生,但现在已经用不到了。在invokedynamic的参数中多出来的两个0是基于前向兼容而产生的。

另外一个是常规和特别实例方法调用之间的差别。常规调用是虚拟的。这就是说被调用的方法是在运行时按照标准的Java方法重写规则查找的。特殊调用不考虑重写。在两种情况下这很重要,即私有方法和超类方法的调用。在这两种情况下,你不想触发重写规则,所以需要不同的调用操作码处理这种情况。

5.4.8 平台操作操作码

平台操作族系的操作码包括new,用于分配新的对象实例,还有与线程相关的操作码,比如monitorentermonitorexit。详细内容请参见表5-8。

平台操作码用来控制对象生命周期,比如创建新对象并锁住它们。一定要注意,new操作码只分配存储空间。对象构建的高层概念还包括运行构造方法内的代码。

表5-8 平台操作码

名称 参数 堆栈布局 描述new i1, i2 → [obj] 为新对象分配内存,类型由指定位置的常量确定monitorenter [obj] → 锁住对象monitorexit [obj] → 解锁对象

在字节码这一级,构造方法被转换成带有特殊名称<init>的方法。这不能由用户代码调用,但可以由字节码调用。这便形成了一个与对象创建直接相关的不同字节码模式:new之后跟着一个dup,然后是一个调用<init>方法的invokespecial

5.4.9 操作码的快捷形式

为了节省字节,很多字节码都有快捷形式。通常对某些局部变量的访问要比其他的访问更加频繁,所以用特殊的操作码来表示“在局部变量上直接执行常见操作”便很有价值。因此加载/存储族系中出现了aload_0dstore_2这种操作码。

我们来检查一下其中的理论,再来看一个例子。

5.4.10 示例:字符串拼接

我们给演算类中加点料,来阐明几个稍微高级点的字节码,下面的例子会涉及字节码主要族系中的大多数。

别忘了,Java中的字符串是不可变的。那在用+运算符把两个字符串拼在一起时发生了什么?你必须创建一个新字符串,但实际上可能不止这么简单。

看一下修改了run方法之后的演算类:

private void run(String args) {  String str = "foo";  if (args.length > 0) str = args[0];    System.out.println("this is my string: " + str);}  

这个简单方法对应的字节码为:

$ javap -c -p wgjd/ch04/ScratchImpl.class  Compiled from "ScratchImpl.java"private void run(java.lang.String);  Code:     0: ldc                 #17                  // String foo     2: astore_2     3: aload_1     4: arraylength     5: ifle                 12 #A  

如果传入数组尺寸小于等于0,跳到指令12。

8: aload_19: iconst_010: aaload11: astore_212: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream;  

上面这行是访问System.out的字节码。

15: new           #25    // class java/lang/StringBuilder18: dup19: ldc           #27    // String this is my string:21: invokespecial #29    // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V24: aload_225: invokevirtual #32    // Method java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder;28: invokevirtual #36    // Method java/lang/StringBuilder.toString:Ljava/lang/String;  

这些指令展示了拼接字符串的创建过程。特别是15~23表示对象创建(newdupinvokespecial)的指令,但在这个例子中dup之后还有一个ldc(加载常量)。这种模式表明字节码调用的是一个非空构造方法,在此是StringBuilder(String)

这个结果一开始可能有些出乎你的意料。你只是想把一些字符串拼在一起,但到了底层突然变成了创建额外的StringBuilder对象,并调用append,然后又调用toString。这是因为java中的字符串是不可变的。你不能通过拼接修改字符串对象,所以必须创建新的对象。StringBuilder是完成这个任务的便捷方法。

最后是调用相应的方法输出结果:

31: invokevirtual #40     // Method java/io/PrintStream.println:(Ljava/lang/String;)V34: return  

最终,输出字符串拼好了,你可以调用println方法。因为此时栈顶部的两个元素是[System.out,<output string>],所以这是在System.out之上调用的。就跟你在看表5-7(定义了有效的invokevirtual的堆栈布局)时所预期的一样。

要成为一名真正优秀的Java开发人员,你应该找几个自己写的类用javap运行一下,并学会识别通用的字节码模式。现在,让我们带着对字节码的简单了解,进入下一主题——Java 7中重要的新特性invokedynamic