1. Java 中方法重写与方法重载的区别?
1.1 方法重写
Java 中方法重写是针对具有父子关系类而言的,如果子类定义的方法的名称、参数个数和类型以及返回值(或者返回值的子类)都相同,这种情况下就发生了重写(或覆盖)了
// 父类
public class Father {
public int test(int a, int b) {
return a + b;
}
}
// 子类
class Child extends Father {
@Override
public int test(int a, int b) {
return a + b;
}
}
张雨迪老师<<深入拆解Java虚拟机>> 04 | JVM是如何执行方法调用的?(上)
但是Java语义上的重写跟JVM中的重写不一定相同。
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。至于方法描述符,它是由方法的参数类型以及返回类型所构成。
JVM中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。
可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。
对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。
我们可以通过反编译class文件来验证,下面我使用javap
工具来进行验证
// 将上面代码进行修改
public class Father {
public Number test(int a, int b) {
return a + b;
}
}
// 子类
class Child extends Father {
// 返回值类型为Number的子类
@Override
public Integer test(int a, int b) {
return a + b;
}
}
// 执行javap -V 之后的输出
public java.lang.Integer test(int, int);
descriptor: (II)Ljava/lang/Integer;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
// local variable ['this',int,int]
// op stack [int,int]
// | |
// add
// op stack [int]
0: iload_1
1: iload_2
2: iadd
3: invokestatic #7 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: areturn
LineNumberTable:
line 12: 0
// 这段字节码就是编译器通过桥接方法生成的
public java.lang.Number test(int, int);
descriptor: (II)Ljava/lang/Number;
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
// local variable ['this',int,int]
// op stack [this,int,int]
// #13 对应常量表 第13个
stack=3, locals=3, args_size=3
0: aload_0 // this 对应本地变量表第一个 也就是下标 0
1: iload_1 // 第一个 int 下标 1
2: iload_2 // 第二个 int 下标 2
3: invokevirtual #13 // Method test:(II)Ljava/lang/Integer;
6: areturn
LineNumberTable:
line 9: 0
}
除此之外,泛型参数类型也会造成方法参数不一致
interface Customer {
boolean isVIP();
}
class Merchant {
public double a(double price, T customer) {
return 0;
}
}
interface VIP extends Customer {
}
class VIPOnlyMerchat extends Merchant {
@Override
public double a(double price, VIP c) {
return 0;
}
}
// javap 之后的
// VIPOnlyMerchat的字节码
public double a(double, VIP);
descriptor: (DLVIP;)D
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
// local variable [this,[double,''],VIP]
// op stack [double,'']
// 对于 float和double 占两个slot
0: dconst_0
1: dreturn
LineNumberTable:
line 33: 0
public double a(double, Customer);
descriptor: (DLCustomer;)D
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=4, locals=4, args_size=3
// local variable [this,[double,''],VIP]
// op stack [this,[double,''],Customer]
// 对于 float和double 占两个slot
0: aload_0
1: dload_1
2: aload_3
3: checkcast #7 // class VIP
6: invokevirtual #9 // Method a:(DLVIP;)D
9: dreturn
LineNumberTable:
line 30: 0
}
Signature: #19 // LMerchant;
下面我们来看看接口会不会也出现上述情况?
public interface Passenger {
default Number test(int a, int b) {
return a + b;
}
}
interface B extends Passenger {
@Override
default Integer test(int a, int b) {
return a + b;
}
}
// 接口B的字节码
// javap 查看
public default java.lang.Integer test(int, int);
descriptor: (II)Ljava/lang/Integer;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
// local variable [this,int,int]
// op stack [int,int]
// -----------| |------
// iadd
// [int]
0: iload_1
1: iload_2
2: iadd
3: invokestatic #1 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: areturn
LineNumberTable:
line 12: 0
public default java.lang.Number test(int, int);
descriptor: (II)Ljava/lang/Number;
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=3, locals=3, args_size=3
// local variable [this,int,int]
// op stack [this,int,int]
0: aload_0
1: iload_1
2: iload_2
3: invokeinterface #7, 3 // InterfaceMethod test:(II)Ljava/lang/Integer;
8: areturn
LineNumberTable:
line 9: 0
}
由此可以看出,在 class 文件中是允许存在方法名与参数个数和类型都一致的情况。
那么我们应该如何确定呢, 我使用 IDEA 打开 class 文件是看不见编译器生成的这个桥接方法(图一)
那么我们该如何在 IDEA 中能够看见呢? 我们可以使用 asmtools工具对 class 文件进行修改,可以参考这篇文章下载asmtools.jar。
linux 使用java -cp [asmtools.jar所在目录] org.openjdk.asmtools.jdis.Main B.class > B.jasm
然后使用记事本打开(图二)
修改后(图三)
执行命令java -cp [asmtools.jar所在目录] org.openjdk.asmtools.jasm.Main B.jasm
生成修改后的 class 文件,在 IDEA 中打开(图四)。
使用如下代码进行验证
public class Passenger {
public static void main(String[] args) {
System.out.println(new s().test(1,2).getClass());
}
}
public interface Test {
default Number test(int a, int b) {
System.out.println(1);
int c = a + b;
return a + b;
}
}
interface B extends Test {
@Override
default Integer test(int a, int b) {
System.out.println("testi");
return a + b;
}
}
class s implements B{
}
output: class java.lang.Integer
那么我们该如何调用返回值为 Number 方法呢? 也是一样通过 asmtools 工具修改 Passenger 文件。
命令同上,这里不再作演示。
修改后
执行结果
output:
testb
testi
8
class java.lang.Integer
1.2 方法重载
Java 中方法重载指的是在同一个类中存在(或者从父类继承(可见)的方法)方法名相同但是参数个数、类型以及顺序不同,但是返回值不作为判断依据。
那么在 Java 虚拟机中又是如何的呢?
我们可以使用 amstools 工具来验证我们的猜想。
public class Demo1 {
public void test() {
}
public void test(int a) {
}
public int test(float a) {
return 0;
}
public int test(int...a){
return a[0];
}
public int test(float...a){
return (int)a[0];
}
}
public class DemoTest {
public static void main(String[] args) {
Demo1 demo1 = new Demo1();
System.out.println(demo1.test( 1,1,1));
}
}
下面修改对应的 class 文件
结果返回的是output: 1.0
,下面来解释一下为什么一开始在 DemoTest 中给 test 方法传入的是 int 数组而不是 float 数组?因为我是先修改了 Demo1 的 class 文件,所以要是传入的是 float 数组,那么编译就会报错。
DemoTest.java:4: 错误: 对test的引用不明确
System.out.println(demo1.test( 1.0f,1.0f,1.0f));
^
Demo1 中的方法 test(float...) 和 Demo1 中的方法 test(float...) 都匹配
1 个错误
下面我先把参数类型改成 int 数组之后进行编译,我们看看javap
之后的信息
#16 = Methodref #7.#17 // Demo1.test:([I)I
#17 = NameAndType #18:#19 // test:([I)I
#18 = Utf8 test
#19 = Utf8 ([I)I
#20 = Methodref #21.#22 // java/io/PrintStream.println:(I)V
27: invokevirtual #16 // Method Demo1.test:([I)I
30: invokevirtual #20 // Method java/io/PrintStream.println:(I)V
我们可以看到27这一行的注释要执行的方法是 int test(int...a)
,而想要其执行我们修改之后方法float test(float...a)
我们要对这部分进行修改,因此在对应的 jasm 文件中改为invokevirtual Method Demo1.test:"([F)F";
,可以看见这是在编译的时候就已经完成重载了。
张雨迪老师<<深入拆解Java虚拟机>> 04 | JVM是如何执行方法调用的?(上)
在 Java 虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
(1) 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
(2) 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
(3) 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
关于最后一段,我们可以通过这个例子来进行验证。
public class Demo1 {
public void test(String...s) {
System.out.println("String");
}
public void test(Object...s) {
System.out.println("Object");
}
}
public class Test {
public static void main(String[] args) {
Demo1 d = new Demo1();
d.test(null);
d.test(null,"");
d.test(null,"",111);
}
}
请大家将这段代码尝试一下,看看跟自己猜想是否一致。
public class DemoTest {
public static void main(String[] args) {
Collection>[] collections = {new HashSet(),new ArrayList(),new HashMap().values()};
Super s = new Sub();
for (Collection c: collections) {
System.out.println(s.getType(c));
}
System.out.println("-----------------------------------------");
System.out.println(Sub.getType(null));
System.out.println("-----------------------------------------");
System.out.println(Sub.getType(collections[0]));
System.out.println("-----------------------------------------");
System.out.println(s.getType(new HashSet<>()));
}
abstract static class Super {
public static String getType(List> list) {
return "Super: list";
}
public static String getType(Collection> collection) {
return "Super: Conllection";
}
public static String getType(Set> set) {
return "Super: set";
}
public static String getType(HashSet> set) {
return "Super: hset";
}
public static String getType(ArrayList> arrayList) {
return "Super: arraylist";
}
}
static class Sub extends Super {
public static String getType(Collection> collection) {
return "Sub";
}
}
}