Java字节码里的invoke操作&&编译时的静态绑定与动态绑定
一个一直运行正常的应用突然无法运行了。在类库被更新之后,返回下面的错误。
- Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
- at com.nhn.service.UserService.add(UserService.java:14)
- at com.nhn.service.UserService.main(UserService.java:19)
应用的代码如下,而且它没有被改动过。
// UserService.java … public void add(String userName) { admin.addUser(userName); }
更新后的类库的源代码和原始的代码如下。
// UserAdmin.java - Updated library source code … public User addUser(String userName) { User user = new User(userName); User prevUser = userMap.put(userName, user); return prevUser; } // UserAdmin.java - Original library source code … public void addUser(String userName) { User user = new User(userName); userMap.put(userName, user); }
简而言之,之前没有返回值的addUser()被改修改成返回一个User类的实例的方法。不过,应用的代码没有做任何修改,因为它没有使用addUser()的返回值。
咋一看,om.nhn.user.UserAdmin.addUser()方法似乎仍然存在,如果存在的话,那么怎么还会出现NoSuchMethodError的错误呢?
原因
上面问题的原因是在于应用的代码没有用新的类库来进行编译。换句话来说,应用代码似乎是调了正确的方法,只是没有使用它的返回值而已。不管怎样,编译后的class文件表明了这个方法是有返回值的。你可以从下面的错误信息里看到答案。
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;) V
NoSuchMethodError出现的原因是“com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V”方法找不到。注意一下”Ljava/lang/String;”和最后面的“V”。在Java字节码的表达式里,”L<classname>;”表示的是类的实例。这里表示addUser()方法有一个java/lang/String的对象作为参数。在这个类库里,参数没有被改变,所以它是正常的。最后面的“V”表示这个方法的返回值。在Java字节码的表达式里,”V”表示没有返回子(Void)。综上所述,上面的错误信息是表示有一个java.lang.String类型的参数,并且没有返回值的com.nhn.user.UserAdmin.addUser方法没有找到。
因为应用是用之前的类库编译的,所以返回值为空的方法被调用了。但是在修改后的类库里,返回值为空的方法不存在,并且添加了一个返回值为“Lcom/nhn/user/User”的方法。因此,就出现了NoSuchMethodError。
再回到Java字节码上来。Java字节码是JVM很重要的部分。JVM是模拟执行Java字节码的一个模拟器。Java编译器不会直接把高级语言(例如C/C++)编写的代码直接转换成机器语言(CPU指令);它会把开发者可以理解的Java语言转换成JVM能够理解的Java字节码。因为Java字节码本身是平台无关的,所以它可以在任何安装了JVM(确切地说,是相匹配的JRE)的硬件上执行,即使是在CPU和OS都不相同的平台上(在Windows PC上开发和编译的字节码可以不做任何修改就直接运行在Linux机器上)。编译后的代码的大小和源代码大小基本一致,这样就可以很容易地通过网络来传输和执行编译后的代码。
Java class文件是一种人很难去理解的二进文件。为了便于理解它,JVM提供者提供了javap,反汇编器。使用javap产生的结果是Java汇编语言。在上面的例子中,下面的Java汇编代码是通过javap-c对UserServiceadd()方法进行反汇编得到的。
public void add(java.lang.String); Code: 0: aload_0 1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin; 4: aload_1 5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)V 8: return
ddUser()方法是在第四行的“5:invokevitual#23″进行调用的。这表示对应索引为23的方法会被调用。索引为23的方法的名称已经被javap给注解在旁边了。invokevirtual是Java字节码里调用方法的最基本的操作码。
在Java字节码里,有四种操作码可以用来调用一个方法,操作码的作用分别如下:
- invokespecial: 调用一个初始化(构造)方法,私有方法或者父类的方法
- invokestatic:调用静态方法
- invokevirtual:调用实例方法
- invokeinterface:调用接口方法
Java字节码的指令集由操作码和操作数组成。类似invokevirtual这样的操作数需要2个字节的操作数。
用更新的类库来编译上面的应用代码,然后反编译它,将会得到下面的结果。
public void add(java.lang.String); Code: 0: aload_0 1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin; 4: aload_1 5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User; 8: pop 9: return
你会发现,对应索引为23的方法被替换成了一个返回值为”Lcom/nhn/user/User”的方法。
它表示的是字节数。大概这就是为什么运行在JVM上面的代码成为Java“字节”码的原因。简而言之,Java字节码指令的操作码,例如aload_0,getfield和invokevirtual等,都是用一个字节的数字来表示的(aload_0=0x2a,getfield=0xb4,invokevirtual=0xb6)。由此可知Java字节码指令的操作码最多有256个。
aload_0和aload_1这样的指令不需要任何操作数。因此,aload_0指令的下一个字节是下一个指令的操作码。不过,getfield和invokevirtual指令需要2字节的操作数。因此,getfiled的下一条指令是跳过两个字节,写在第四个字节的位置上的。
---------------------------------------------------------invokespecial,invokevirtual/静态绑定与动态绑定-----------------------------
invokespecial指静态绑定后,由JVM产生调用的方法。如super(),以及super.someMethod(),都属于invokespecial。而invokevirtual指动态绑定后由JVM产生调用的方法,如obj.someMethod(),属于invokevirtual。
正是由于这两种绑定的不同,在子类覆盖超类的方法、并向上转型引用后,才产生了多态以及其他特殊的调用结果。运行时,invokespecial选择方法基于引用声明的类型,而不是对象实际的类型。但invokevirtual则选择当前引用的对象的类型。
以下情况使用invokespecial操作码:
1 init()函数,就是调用构造函数生产一个实例的时候。
2 私有方法
3 final方法
invokespecial和invokestatic是采用静态绑定,invokevirtual和invokeinterface是采用动态绑定。
程序绑定的概念:
绑定指的是一个方法的调用与方法所在的类(方法主体)关联起来。对java来说,绑定分为静态绑定和动态绑定;或者叫做前期绑定和后期绑定
静态绑定:
在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。例如:C。
针对java简单的可以理解为程序编译期的绑定;这里特别说明一点,java当中的方法只有final,static,private和构造方法是前期绑定
动态绑定:
后期绑定:在运行时根据具体对象的类型进行绑定。
若一种语言实现了后期绑定,同时必须提供一些机制,可在运行期间判断对象的类型,并分别调用适当的方法。也就是说,编译器此时依然不知道对象的类型,但方法调用机制能自己去调查,找到正确的方法主体。不同的语言对后期绑定的实现方法是有所区别的。但我们至少可以这样认为:它们都要在对象中安插某些特殊类型的信息。
动态绑定的过程:
虚拟机提取对象的实际类型的方法表;
虚拟机搜索方法签名;
调用方法。
关于绑定相关的总结:
在了解了三者的概念之后,很明显我们发现java属于后期绑定。在java中,几乎所有的方法都是后期绑定的,在运行时动态绑定方法属于子类还是基类。但是也有特殊,针对static方法和final方法由于不能被继承,因此在编译时就可以确定他们的值,他们是属于前期绑定的。特别说明的一点是,private声明的方法和成员变量不能被子类继承,所有的private方法都被隐式的指定为final的(由此我们也可以知道:将方法声明为final类型的一是为了防止方法被覆盖,二是为了有效的关闭java中的动态绑定)。java中的后期绑定是有JVM来实现的,我们不用去显式的声明它,而C++则不同,必须明确的声明某个方法具备后期绑定。
java当中的向上转型或者说多态是借助于动态绑定实现的,所以理解了动态绑定,也就搞定了向上转型和多态。
前面已经说了对于java当中的方法而言,除了final,static,private和构造方法是前期绑定外,其他的方法全部为动态绑定。而动态绑定的典型发生在父类和子类的转换声明之下:
其具体过程细节如下:
1:编译器检查对象的声明类型和方法名。假设我们调用x.f(args)方法,并且x已经被声明为C类的对象,那么编译器会列举出C类中所有的名称为f的方法和从C类的超类继承过来的f方法
2:接下来编译器检查方法调用中提供的参数类型。如果在所有名称为f 的方法中有一个参数类型和调用提供的参数类型最为匹配,那么就调用这个方法,这个过程叫做“重载解析”
3:当程序运行并且使用动态绑定调用方法时,虚拟机必须调用同x所指向的对象的实际类型相匹配的方法版本。
---------------------------------invokevirtual和invokeinterface的区别------------------------------
都说调用接口要比调用继承类要慢,但慢在何处?
先看byteCodeInterpreter.cpp里面对这invokevirtual和invokeInterface的区别。
CASE(_invokeinterface): { //调用接口 u2 index = Bytes::get_native_u2(pc+1); ConstantPoolCacheEntry* cache = cp->entry_at(index); methodOop callee; klassOop iclass = (klassOop)cache->f1(); int parms = cache->parameter_size(); oop rcvr = STACK_OBJECT(-parms); CHECK_NULL(rcvr); instanceKlass* int2 = (instanceKlass*) rcvr->klass()->klass_part(); itableOffsetEntry* ki = (itableOffsetEntry*) int2->start_of_itable(); int i; for ( i = 0 ; i < int2->itable_length() ; i++, ki++ ) {//搜索整个接口表,进行比较,直至找到 if (ki->interface_klass() == iclass) break; } ....... int mindex = cache->f2(); itableMethodEntry* im = ki->first_method_entry(rcvr->klass()); callee = im[mindex].method();//通过找到的接口,找到要调用的方法 //而invokevirtual(调用继承类) CASE(_invokevirtual): u2 index = Bytes::get_native_u2(pc+1); ConstantPoolCacheEntry* cache = cp->entry_at(index); methodOop callee; int parms = cache->parameter_size(); instanceKlass* rcvrKlass = (instanceKlass*) STACK_OBJECT(-parms)->klass()->klass_part(); callee = (methodOop) rcvrKlass->start_of_vtable()[ cache->f2()]; //直接调用方法
由上面可见,最大的区别就是接口调用每次都需要搜索接口表,而调用继承类可以直接找到。
再看看权威书籍《深入java虚拟机》P336页给出的答案,“java虚拟机使用不同于类引用的操作码来调用接口引用的方法,这是因为java不能象使用引用那样,使用许多与方法表偏移量相关的假设。对于类引用来说,无论对象实际的类是什么,方法在方法表始终占据相同的位置。但对于接口引用来说,情况就不是这样了,位于不同类的同一个方法所占据的位置是不同的,尽管这些类实现同一个接口。”
以下详细解释了上面这一段话,摘抄至http://stackoverflow.com/questions/1504633/what-is-the-point-of-invokeinterface
Each Java class is associated with a virtual method table that contains "links" to the bytecode of each method of a class. That table is inherited from the superclass of a particular class and extended with regard to the new methods of a subclass. E.g.,
class BaseClass {
public void method1() { }
public void method2() { }
public void method3() { }
}
class NextClass extends BaseClass {
public void method2() { } // overridden from BaseClass
public void method4() { }
}
results in the tables
BaseClass 1. BaseClass/method1() 2. BaseClass/method2() 3. BaseClass/method3() NextClass 1. BaseClass/method1() 2. NextClass/method2() 3. BaseClass/method3() 4. NextClass/method4()
Note, how the virtual method table of NextClass
retains the order of entries of the table ofBaseClass
and just overwrites the "link" of method2()
which it overrides.
An implementation of the JVM can thus optimize a call to invokevirtual
by remembering thatBaseClass/method3()
will always be the third entry in the virtual method table of any object this method will ever be invoked on.
With invokeinterface
this optimization is not possible. E.g.,
interface MyInterface {
void ifaceMethod();
}
class AnotherClass extends NextClass implements MyInterface {
public void method4() { } // overridden from NextClass
public void ifaceMethod() { }
}
class MyClass implements MyInterface {
public void method5() { }
public void ifaceMethod() { }
}
This class hierarchy results in the virtual method tables
AnotherClass 1. BaseClass/method1() 2. NextClass/method2() 3. BaseClass/method3() 4. AnotherClass/method4() 5. MyInterface/ifaceMethod() MyClass 1. MyClass/method5() 2. MyInterface/ifaceMethod()
As you can see, AnotherClass
contains the interface's method in its fifth entry and MyClass
contains it in its second entry. To actually find the correct entry in the virtual method table, a call to a method with invokeinterface
will always have to search the complete table without a chance for the style of optimization that invokevirtual
does.
There are additional differences like the fact, that invokeinterface
can be used together with object references that do not actually implement the interface. Therefore, invokeinterface
will have to check at runtime whether a method exists in the table and potentially throw an exception. If you want to dive deeper into the topic, I suggest, e.g., "Efficient Implementation of Java Interfaces: Invokeinterface Considered Harmless".
我的理解是,继承类的方法调用可以直接用序号就能找到想要的方法,因为继承类的方法在方法表里是有顺序的,而且是固定的,只会越来越多,但不会减少,所以用序号作为索引就能找到,但接口可以在不同的类里实现,导致上面的查找策略不可用了,只能全部遍历了。