[HotSpot VM] 关于包可访问的虚方法被覆写的一个例子

大家新年快乐!

之前在私信收到一个问题,是关于Java里包可访问(package access或者叫package-private)的虚方法被同包内的派生类覆写(override),以及被不同包内的派生类无法覆写,在JVM里是如何实现的。这个问题应该还挺多同学会感兴趣的,所以这里我把我在私信里的回答整理一下发出来。

本文涉及的代码例子和实验日志我发在GitHub Gist上了,心急的同学可以直接跳过去看,看不懂再回来看本文后面的内容。(发的时候忘记看看自己有没有登录GitHub了,结果一开始发成了anonymous的…orz)

=============================================

Java语言层面的规定

先放几个传送门来让大家温习一下Java的package-private方法相关的规定:

官方教程中的一个表格总结得很清楚:

The following table shows the access to members permitted by each modifier.

                   Access Levels
-------------+--------+---------+----------+------
Modifier     | Class  | Package | Subclass | World
-------------+--------+---------+----------+------
public       | Y      | Y       | Y        | Y
protected    | Y      | Y       | Y        | N
no modifier  | Y      | Y       | N        | N
private      | Y      | N       | N        | N

The first data column indicates whether the class itself has access to the member defined by the access level. As you can see, a class always has access to its own members. The second column indicates whether classes in the same package as the class (regardless of their parentage) have access to the member. The third column indicates whether subclasses of the class declared outside this package have access to the member. The fourth column indicates whether all classes have access to the member.

其中"no modifier"就是package-private的情况。其中第三列“Subclass”指的是当派生类与基类不在同一个包里的时候的可访问性。可以看到,基类中的package-private方法,在不同包的派生类中是不可访问——“看不到”的。

因而就有Java语言规范的Example 6.6-4给的例子的情况:

If none of the access modifiers public, protected, or private are specified, a class member or constructor has package access: it is accessible throughout the package that contains the declaration of the class in which the class member is declared, but the class member or constructor is not accessible in any other package.

If a public class has a method or constructor with package access, then this method or constructor is not accessible to or inherited by a subclass declared outside this package.

...

a subclass in another package may declare an unrelated move method, with the same signature (§8.4.2) and return type.

=============================================

例子

pkga/A.java

package pkga;

public class A {
  /* packcage access */ void fun() {
    System.out.println("A::fun()");
  }
}

pkga/B.java

package pkga;

public class B extends A {
  @Override
  void fun() {
    System.out.println("B::fun()");
  }
}

pkgb/C.java

package pkgb;

import pkga.A;

public class C extends A {
  // @Override // error: method does not override or implement a method from a supertype
  void fun() {
    System.out.println("C::fun()");
  }
}

pkga/Main.java

package pkga;

import pkgb.C;

public class Main {
  public static void main(String[] args) {
                       // bytecodes
    A ref1 = new B();  // new pkga/B
                       // dup
                       // invokespecial pkga/B.<init>:()V
                       // astore_1
    A ref2 = new C();  // new pkgb/C
                       // dup
                       // invokespecial pkgb/C.<init>:()V
                       // astore_2
    ref1.fun();        // aload_1
                       // invokevirtual pkga/A.fun:()V
    ref2.fun();        // aload_2
                       // invokevirtual pkga/A.fun:()V
                       // return
  }
}

根据前面所说的语言规范的规定,可以知道pkgb.C类虽然是pkga.A类的派生类,但由于不在同一个包里所以无法访问到A.fun(),所以C.fun()并不覆写A.fun(),而是新开了一个同名、同signature的虚方法。

所以,在pkga.Main.main(String[])里,通过pkga.A类型的引用去指向一个pkgb.C类实例,然后通过这个引用去调用A.fun()所指定的虚方法时,会调用到A.fun()而不是C.fun()。我在这里把main()方法的字节码列在源码旁边是为了帮助大家看到ref1.fun()与ref2.fun()都是“调用pkga.A.fun()这个符号所指定的虚方法”——它只关心A.fun()如果有被覆写的话要调用最具体的覆写版本,而不关心其它同名同signature但没有覆写的情况。

于是许多同学感到困惑的就是:A.fun()是一个虚方法,那么JVM是如何实现的,能够让A类型的引用指向C类型的实例时,调用A.fun()不经过虚方法分派去到C.fun()而还是调用到A.fun()呢?

简单回答就是:这里仍然体现了虚方法的虚分派(virtual dispatch),而且恰好体现了C.fun()没有覆写A.fun()的语义。

=============================================

HotSpot VM中的实现

Java语言是一门单根单继承、基于类的面向对象语言。在HotSpot VM中,每个Java类都在C++层面由一个InstanceKlass对象表示,每个Java对象实例的对象头都有一个C++层面的隐藏字段可以访问到其对应的类的InstanceKlass对象。

HotSpot VM中,虚方法分派的信息通过虚方法表(virtual method table,简称vtable)来存储。每个Java类有一个自己的vtable,内嵌在InstanceKlass对象的可变长尾部之中。HotSpot VM里虚方法分派最基础(同时也是最慢)的方式就是通过vtable查找来分派。请参考下面几个传送门来了解关于HotSpot VM中的对象模型、对象布局、虚方法分派相关的背景知识:

要留意一点:vtable的本质是一个缓存,内容为顺着继承链递归查找某个名字+signature的虚方法在某个具体类上应该使用哪个版本的信息。这样只要最初构建好vtable后,后续查找就不必再顺着继承链做递归查找了。这种缓存有限深度的递归查找结果的思路叫做“display”,裘宗燕老师在《程序设计语言——实践之路》第二版一书中将其翻译为“区头向量”。写这个只是为了提醒一下同学们,vtable不是虚方法分派的本质,只是一种非常常见的优化方式,常见到大家对它习以为常,觉得这就是默认的做法了。

在HotSpot VM中,一个Java类的vtable初始化可以看做分3步走:

  1. 在加载类的时候,计算该类的vtable所需要的大小:在基类的vtable大小的基础上,加上自己新增加的虚方法的所需要的空间。在JDK8的HotSpot VM中,实现代码在ClassFileParser::parseClassFile()调用klassVtable::compute_vtable_size_and_num_mirandas()
  2. 把基类的vtable拷贝到自己的vtable的最开头(比基类多出来的部分暂时留空)
  3. 遍历自己的方法数组(_methods),并与自己的vtable中当前已分配的项去匹配;如果找到可以override的项(例如说B类的fun()与A类的fun())则覆盖这个项中的内容,如果找不到可override的项则新分配一个vtable slot。注意这里所谓“新分配”的空间都是在(1)里面已经计算好的空间之内的。当然其实也可以省略(1),直接在这里同时进行vtable内容的计算和空间扩张,但是像HotSpot VM的话因为vtable内嵌于InstanceKlass对象之中,它必须避免扩容,所以用分别的两步来做这个计算。

具体到上面给的例子的代码,用一个debug/fastdebug版的JDK8 HotSpot VM来运行例子,指定 -XX:+PrintVtables -XX:+Verbose -XX:+TraceClassLoading 参数,可以看到这样的日志(完整日志请参考本文开头的Gist链接;这里只截取其中一些相关的信息):

[Loaded java.lang.Object from $JAVA_HOME/jre/lib/rt.jar]
Initializing: java/lang/Object
adding java.lang.Object.finalize()V at index 0, flags: protected 
adding java.lang.Object.equals(Ljava/lang/Object;)Z at index 1, flags: public 
adding java.lang.Object.toString()Ljava/lang/String; at index 2, flags: public 
adding java.lang.Object.hashCode()I at index 3, flags: public native 
adding java.lang.Object.clone()Ljava/lang/Object; at index 4, flags: protected native 
...
[Loaded pkga.A from file:/private/tmp/test_pkg/]
[Loaded pkga.B from file:/private/tmp/test_pkg/]
[Loaded pkgb.C from file:/private/tmp/test_pkg/]
Initializing: pkga/A
copy vtable from java.lang.Object to pkga.A size 6
adding pkga.A.fun()V at index 5, flags: 
Initializing: pkga/B
copy vtable from pkga.A to pkga.B size 6
adding pkga.B.fun()V at index 5, flags: 
overriding with pkga.B::pkga.B.fun()V index 5, original flags: overriders flags: 
Initializing: pkgb/C
copy vtable from pkga.A to pkgb.C size 7
NOT overriding with pkgb.C::pkgb.C.fun()V index 5, original flags: overriders flags: 
adding pkgb.C.fun()V at index 6, flags: 
invokevirtual resolved method: caller-class:pkga.Main, compile-time-class:pkga.A, method:pkga.A.fun()V, method_holder:pkga.A, access_flags: 
invokevirtual selected method: receiver-class:pkga.B, resolved-class:pkga.A, method:pkga.A.fun()V, method_holder:pkga.B, vtable_index:5, access_flags:

这是debug/fastdebug版HotSpot VM可以针对类加载和vtable初始化所输出的日志。


可以看到,在JDK8的HotSpot VM上,基类java.lang.Object的vtable布局是这样的:

[0]: java.lang.Object.finalize()V
[1]: java.lang.Object.equals(Ljava/lang/Object;)Z
[2]: java.lang.Object.toString()Ljava/lang/String;
[3]: java.lang.Object.hashCode()I
[4]: java.lang.Object.clone()Ljava/lang/Object;

然后类pkga.A的vtable布局比Object多了fun的项:

[0]: java.lang.Object.finalize()V
[1]: java.lang.Object.equals(Ljava/lang/Object;)Z
[2]: java.lang.Object.toString()Ljava/lang/String;
[3]: java.lang.Object.hashCode()I
[4]: java.lang.Object.clone()Ljava/lang/Object
[5]: pkga.A.fun()V

类pkga.B继承了A并且覆写了fun(),其vtable布局是:

[0]: java.lang.Object.finalize()V
[1]: java.lang.Object.equals(Ljava/lang/Object;)Z
[2]: java.lang.Object.toString()Ljava/lang/String;
[3]: java.lang.Object.hashCode()I
[4]: java.lang.Object.clone()Ljava/lang/Object;
[5]: pkga.B.fun()V

可以看到B的vtable布局跟A是一样的,只不过fun()的那项用B.fun()来覆盖了。

然而pkgb.C同样继承A,其vtable布局却是这样的:

[0]: java.lang.Object.finalize()V
[1]: java.lang.Object.equals(Ljava/lang/Object;)Z
[2]: java.lang.Object.toString()Ljava/lang/String;
[3]: java.lang.Object.hashCode()I
[4]: java.lang.Object.clone()Ljava/lang/Object;
[5]: pkga.A.fun()V
[6]: pkgb.C.fun()V

于是,从Main里对A.fun()做决议(resolution)的时候,会决定这个方法就应该从vtable[5]去查找,这样的话从B的实例就会查找到B.fun(),而从C的实例则会查找到A.fun()。

就这样,很简单对不对?^_^

文章被以下专栏收录
13 条评论
评论已关闭
推荐阅读
track image