[分享] Java 重写方法与初始化有哪些隐患?

2015-10-29 16:44:16 +08:00
 maiziedu

在 Java 开发中,我们常常都需要用到重写方法和初始化方法,但在程序实现过程中,重写和初始化的隐患大家知道多少呢?今天小编就这隐患为大家分享一二。

虽然本文是针对 Java 语言的重写和初始化讲解,但是对于所有的面向对象程序设计语言的初始化都适用,废话不多说,直接进入正题。

问题

首先我们通过代码来看一个问题,有一个类 SuperClass

public class SuperClass {

private int mSuperX;

public SuperClass() {

    setX(99);

}

public void setX(int x) {

    mSuperX = x;

}

}

现在我们想随时知道 mSuperX 是什么值, 不用反射, 因为父类从不直接修改 mSuperX 的值, 总是通过 setX 来改, 那么最简单的方法就是继承 SuperClass, 重写 setX 方法, 监听它的改变就可以了。下面就是子类 SubClass:

public class SubClass extends SuperClass {

private int mSubX = 1;

public SubClass() {}

@Override

public void setX(int x) {

    super.setX(x);

    mSubX = x;

    System.out.println("SubX is assigned " + x);

}
public void printX() {

    System.out.println("SubX = " + mSubX);

}

}

使用 mSubX 来跟踪 mSuperX

因为在 ViewGroup 中, clipToPadding 默认值是 true(为了简化问题, 把它当成 boolean, 实际并不是), 而 ViewGroup 初始化有可能不调用 setClipToPadding, 此时是默认值, 为了模拟这种情况, 将 mSubX 初始化为 1.

最后在 main 里调用:

public class Main {

public static void main(String[] args) {

    SubClass sc = new SubClass();

    sc.printX();

}

}

那么问题来了,终端输出的结果是什么呢?相信很多人,都 认为终端输出的结果应该是:

SubX is assigned 99

SubX = 99

其实,真正运行后输出的是:

SubX is assigned 99

SubX = 1

实际分析

是不是很想知道,到底发生了什么?最简单的方法就是看程序到底是怎么执行的,比如单步调试, 或者直接一点,看看 Java 字节码。

下面是 Main 的字节码

Compiled from "Main.java"

public class bugme.Main {

......

public static void main(java.lang.String[]);

Code:

   0: new           #2                  // class bugme/SubClass

   3: dup           

   4: invokespecial #3                  // Method bugme/SubClass."<init>":()V

   ......

}

这是直接用 javap 反编译.class 文件得到的。虽说同样是 Java 语言写的, 用 apktool 反编译 APK 文件(其中的 dex 文件)得到的 smali 代码和 Java Bytecode 明显长得不一样(字节码,隐含了一个栈和局部变量表)。

这段代码首先 new 一个 SubClass 实例, 把引用入栈, dup 是把栈顶复制一份入栈, invokespecial #3 将栈顶元素出栈并调用它的某个方法, 这个方法具体是什么要看常量池里第 3 个条目是什么, 但是 javap 生成的字节码直接给我们写在旁边了, 即 SubClass.<init>.

接下来看 SubClass.<init>,

public class bugme.SubClass extends bugme.SuperClass {

public bugme.SubClass();

Code:

   0: aload_0       

   1: invokespecial #1                  // Method bugme/SuperClass."<init>":()V

   ......

这里面并没有方法叫<init>, 是因为 javap 为了方便我们阅读, 直接把它改成类名 bugme.SubClass, 顺便说明一下, bugme 是包名。 <init>方法并非通常意义上的构造方法, 这是 Java 帮我们合成的一个方法, 里面的指令会帮我们按顺序进行普通成员变量初始化, 也包括初始化块里的代码, 注意是按顺序执行, 这些都执行完了之后才轮到构造方法里代码生成的指令执行。 这里 aload_0 将局部变量表中下标为 0 的元素入栈, 其实就是 Java 中的 this, 结合 invokespecial #1, 是在调用父类的构造函数, 也就是我们常见的 super()。

所以我们再看 SuperClass.<init>

public class bugme.SuperClass {

public bugme.SuperClass();

Code:

   0: aload_0      

   1: invokespecial #1                  // Method java/lang/Object."<init>":()V

   4: aload_0   

   5: bipush        99

   7: invokevirtual #2                  // Method setX:(I)V

  10: return

......

}

同样是先调了父类 Object 的构造方法, 然后再将 this, 99 入栈, invokevirtual #2 旁边注释了是调用 setX, 参数分别是 this 和 99 也就是 this.setX(99), 然而这个方法被重写了, 调用的是子类的方法, 所以我们再看 SubClass.setX:

public class bugme.SubClass extends bugme.SuperClass {

......

public void setX(int);

Code:

   0: aload_0     

   1: iload_1      

   2: invokespecial #3                  // Method

   bugme/SuperClass.setX:(I)V

   ......

}

这里将局部变量表前两个元素都入栈, 第一个是 this, 第二个是括号里的参数, 也就是 99, invokespecial #3 调用的是父类的 setX, 也就是我们代码中写的 super.setX(int)

SuperClass.setX 就很简单了:

public class bugme.SuperClass {

......

public void setX(int);

Code:

   0: aload_0       

   1: iload_1       

   2: putfield      #3                  // Field mSuperX:I

   5: return

}

这里先把 this 入栈, 再把参数入栈, putfield #3 使得前两个入栈的元素全部出栈, 而成员 mSuperX 被赋值, 这四条指令只对应代码里的一句 this.mSuperX = x;

接下来控制流回到子类的 setX:

public class bugme.SubClass extends bugme.SuperClass {

......

public void setX(int);

Code:

   0: aload_0    

   1: iload_1       

   2: invokespecial #3                  // Method bugme/SuperClass.setX:(I)V

 ->5: aload_0                           // 即将执行这句

   6: iload_1       

   7: putfield      #2                  // Field mSubX:I

  10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;

  13: new           #5                  // class java/lang/StringBuilder

  16: dup           

  17: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V

  20: ldc           #7                  // String SubX is assigned 

  22: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

 25: iload_1       

 26: invokevirtual #9                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;

 29: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

 32: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

 35: return

}

从 5 处开始继续分析, 5,6,7 将参数的值赋给 mSubX, 此时 mSubX 是 99 了, 下面那一堆则是在执行 System.out.println("SubX is assigned " + x);并返回, 还可以看到 Java 自动帮我们使用 StringBuilder 优化字符串拼接, 就不分析了.

说了这么多, 我们的代码才刚把下面箭头指着的这句执行完:

public class bugme.SubClass extends bugme.SuperClass {

public bugme.SubClass();

Code:

   0: aload_0       

 ->1: invokespecial #1                  // Method bugme/SuperClass."<init>":()V

   4: aload_0       

  5: iconst_1      

  6: putfield      #2                  // Field mSubX:I

  9: return

......

}

此时 mSubX 已经是 99 了, 再执行下面的 4,5,6, 这一部分是 SubClass 的初始化, 代码将把 1 赋给 mSubX, 99 被 1 覆盖了。

方法返回后, 相当于我们执行完了箭头指的这一句代码:

public class Main {

public static void main(String[] args) {

  ->SubClass sc = new SubClass();

    sc.printX();

}

}

接下来执行的代码将打印 mSubX 的值, 自然就是 1 了.

以前就听说过 JVM 是基于栈的, Dalvik 是基于寄存器的, 现在看了 Java 字节码, 回想一下 smali, 自然就能明白。 我在 Android 无需权限显示悬浮窗, 兼谈逆向分析 app 中有分析 smali 代码, smali 里面经常看到类似 v0, v1 这类东西, 是在操作寄存器, 而刚才分析的 bytecode, 指令常常伴随着入栈出栈.

理论解释

Java 是面向对象的语言, 面向对象三大特性之一多态性。假如父类构造方法中调用了某个方法, 这个方法恰好被子类重写了, 会发生什么?

根据多态性, 实际被调用的是子类的方法, 这个没错。 再考虑有继承时, 初始化的顺序, 如果是 new 一个子类, 那么初始化顺序是:

父类 static 成员 -> 子类 static 成员 -> 父类普通成员初始化和初始化块 -> 父类构造方法 -> 子类普通成员初始化和初始化块 -> 子类构造方法

父类构造方法中调用了一次 setX, 此时 mSubX 中已经是我们要跟踪的值, 但之后子类普通成员初始化将 mSubX 又初始化了一遍, 覆盖了前面我们跟踪的值, 自然得到的值就是错的。

Java 中, 在构造方法中唯一能安全调用的是基类中的 final 方法, 自己的 final 方法(自己的 private 方法自动 final), 如果类本身是 final 的, 自然就能安全调用自己所有的方法。

完全遵守这个准则, 可以保证不会出这个 bug. 实际上我们常常不能遵守, 所以要时刻小心这个问题.

题外话

关于默认初始化, 比如这样写:

public class SubClass extends SuperClass {

private int mSubX;

public SubClass() {}

......

}

如果父类保证一定会在初始化时调用 setX, 程序是不会出现上面说的 bug 的, 因为默认初始化并不是靠生成下面这样的代码默认初始化.

4: aload_0

5: iconst_1

6: putfield #2 // Field mSubX:I

所谓的默认初始化, 其实是我们要实例化一个对象之前, 需要一块内存放我们的数据, 这块内存被全部置为 0, 这就是默认初始化了.

下面这两句话, 虽然效果一样, 但实际是有区别的.

private int mSubX;

private int mSubX = 0;

一般情况下, 这两句代码对程序没有任何影响(除非你遇到这个 bug), 上面一句和下面一句的区别在于, 下面一句会导致<init>方法里面生成 3 条指令, 分别是 aload_0, iconst_0, putfield #**, 而上面一句则不会。所以如果你的成员变量使用默认值初始化, 就没必要自己赋那个默认值, 而且还能省 3 条指令。

以上就是 Java 这类面向对象语言在重写和初始化过程中,常常容易出现错误理解的地方,分享出来,希望对后续 Java 新人的学习理解有所帮助。

相关文章:《搜索量最大的 10 个 Java 问题》 http://www.maiziedu.com/group/article/6937/

1731 次点击
所在节点    Java
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/232053

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX