从Java的隐藏开销思考关于Rust零开销抽象的部分

今天在翻某群历史消息的时候,看到有一篇关于Android的性能优化文章归档的博客,其中有一篇,叫探索Java隐藏的开销。就从字节码和本地代码层面,以及各大App中的情况来探究了语言开销的问题。虽然文章更多的是以安卓的习惯和角度去探寻问题,但很多语言相关及分析思路上,是相通的。写到这里时,我突然又想到了kotlin,kotlin也是java字节码平台上的一种语言,那么它的实现也依赖于并受限于字节码和jvm提供的功能,是不是也会有相同的问题(见第二小点内容,嵌套类的实现)。

第一个小点,讲的是一个Java类中,隐藏(自动创建)的方法。如下代码所示,下面这段代码有多少个方法?

1
2
class Example{
}

脑子里回想起学习Java基础的“每个类在没有显示声明构造方法时,都会隐式的有一个空参构造方法”。再想想其他的,好像没有了,遂回答:“一个”。

继续看文章,文章中编译到了dex文件,使用dexdump查看信息,打印出函数列表(method_ids_size )为2,有点出乎意料,猜想是否与继承有关(java的类都继承自Object)。使用javap查看字节码信息,如下所示:

1
2
3
4
5
6
7
8
$ javap -c Example.class
class Example {
    Example();
        Code:
            0: aload_0
            1: invokespecial #1 //java/lang/Object."<init>":()V
            4: return
}

在索引 1 处,是我们的对象构造函数,它被父类的构造函数调用。这是因为,即使我们不声明它,Example 也是继承于 Object 的。每一个构造函数都会调用它的父类的构造函数。它是自动插入的。这意味着我们的 class 流中有两个方法。 所有这些关于我的初始问题的答案都是对的。区别就是术语不同。这是真实的情况。我们没有定义任何方法。但是只有人类关心它。作为人类,我们读写这些源文件。我们是唯一关心它们内部构造的人。另外两个方法更重要,方法的个数实际上是编译进 class 文件里面了。无论是否声明,这些方法都在 class 的内部。

上面这段是原文章的回答,目前我还不是很能理解第二个函数关于父类构造函数。我对题目的理解是该Example类包含定义的方法,而父类构造方法是调用的部分,不知道为啥这里也被包含了进去。和同事讨论后,他的理解是,父类的方法是放在了子类的里面。所以回到了Java这里,继承到底是怎么实现的问题。之前也和他有凭感觉的猜想过,得出的结论是类似于大圆包含小圆,子类包含了父类。今天回想起来,感觉好像不是很对,于是手动尝试上面的实验。测试如下:

1
2
3
4
5
6
7
8
public class Example extends B {
    public static void main(String[] args) {
    }
}
class B extends C {
}
class C {
}

这里编写了3个类,Example、B和C,其中Example继承了B,B继承了C,除了因为需要打jar包的缘故,给Example类写了一个main方法外,都是没有写其他方法的。使用文章中提到的工具dex-method-list,打印出详细信息,如下:

1
2
3
4
5
6
$ java -jar diffuse.jar members --jar test.jar
B <init>()
C <init>()
Example <init>()
Example main(String[])
java.lang.Object <init>()

可以看到,总共有5个方法,分别是Example类、B类和C类的构造方法,main 方法和一个Object的构造方法。到这里,可以得出,之前和同事猜想的结论是错误的,属于瞎猜。然后这里主要的疑问点,其实是dexdump工具这里的method_ids_size计数,到底是指哪一部分的问题。所以又写了如下来进行测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Example {
    public static void main(String[] args) {
        String _s = String.valueOf(42);
    }
}
$ javac Example.java
$ dx --dex --output=example.dex Example.class
$ dexdump -f example.dex
...
method_ids_size : 4
...
$ java -jar diffuse.jar members --jar test.jar
Example <init>()
Example main(String[])
java.lang.Object <init>()
java.lang.String valueOf(int)  String

所以dexdump方法统计的应该是所有包含的方法的数量,类似于方法表一样。另一方面,子类初始化时,不会实例化父类。说到初始化,这里简单回顾一下:

首先Java的类通过new关键字和构造方法来实例化,这里构造方法其实应该翻译为构造器(constructor )比较好,因为它实际上和Java里的方法并不一样。

1、在概念层面,方法属于Java的类成员,而构造器不属于。构造器和方法是平等的概念,而不是包含(Filed:成员变量、Method:普通方法、Type:内部定义的其他类型,如内部接口、内部类等),

2、在虚拟机层面构造器使用invokespecial指令执行,而方法使用invokevirtual指令执行。

Constructors, static initializers, and instance initializers are not members and therefore are not inherited. ---- 8.2 Class Memebers章节 构造方法,静态初始化器,对象初始化器,都不是类的成员,因此它们也不可以被子类继承。

a constructor declaration looks just like a method declaration that has no result... ---- 8.8 Constructor Declarations 章节 一个 constructor 的定义就像是一个没有返回值的 method 的定义。

说到这里,有一些相关的题目:“Java的构造方法到底有还是没有返回值?”、“Java中的构造方法是方法吗?”。现在这些问题大家心里就有自己的见解了吧。

然后

上面说的Java关于new一个对象的侧重点是由这篇文章衍生的部分,是围绕文章相关来的,所以其中还有更多的细节,例如对象的访问定位问题、类加载过程等,大家可以自己去更深入的了解学习。相信下面这类题目,已经完全没有难度了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Father{     
    private int i = 5;     
    public Father() {     
        System.out.println("Father's i is " + this.i);     
        test();     
    }     
    public void test(){     
        System.out.println(this.i - 1);     
    }     
}     

class Son extends Father{     
    private int i = 55;     

    public Son() {     
        System.out.println("Son's i is " + this.i);     
    }     

    @Override    
    public void test() {     
        System.out.println(this.i + 1);     
    }     
    public static void main(String[] args) {     
        new Son();     
    }     
}

在上面查找资料的过程中,还有另一个有意思的问题。在查看字节码时,发现在构造方法中有一个字节码指令aload_0,不知道是做什么的,于是就查了一下百度,看到这样一条信息:

总结起来:在非静态方法中, aload_0 表示对this的操作,在static 方法中,aload_0表示对方法的第一参数的操作。

尝试编写如下代码,并编译到字节码查看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Example {
    public static void f1(Integer a, Integer b) {
        Integer _c = a;
        Integer _d = b;
    }

    public static void f2(int a, Integer b) {
        int _c = a;
        Integer _d = b;
    }

    public void f3(int a, Integer b) {
        Example _e = this;

        int _c = a;
        Integer _d = b;
    }
}
$ javap -c Example.java
Compiled from "Example.java"
class Example {
  Example();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void f1(java.lang.Integer, java.lang.Integer);
    Code:
       0: aload_0
       1: astore_2
       2: aload_1
       3: astore_3
       4: return

  public static void f2(int, java.lang.Integer);
    Code:
       0: iload_0
       1: istore_2
       2: aload_1
       3: astore_3
       4: return

  public void f3(int, java.lang.Integer);
    Code:
       0: aload_0
       1: astore_3
       2: iload_1
       3: istore        4
       5: aload_2
       6: astore        5
       8: return
}

这里总共写了3个方法,前两个是静态方法,第三个是非静态方法。参数上面,分别使用了基本类型和引用类型进行对照。

现在来看字节码的情况,首先方法f1中,果然出现了aload_0代表第一个参数,但是在方法f2中,就不是aload_0了,而是iload_0,所以aload_0应该是加载第一个引用类型的变量。然后继续看非静态方法f3,可以看到,虽然在Java代码中它的第一个参数是基本类型,但是第一行指令任然是aload_0,而第一个参数int类型使用的指令却是iload_1,不是从0开始,而是从1开始了。所以结论就很明显了,*load_*是加载指令,其中load前面的代表变量的类型,而load后面的代表参数位置,但是非静态方法比较特殊。这个时候,要是以前的我呀,多半会去死记硬背了:”非静态方法的第一个参数是从1开始“。

但是,恰好最近学了Rust,又恰好,其中有一部分概念比较相似,所以我们可以用更加理解的方式,去学习。看如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct A();

impl A {
    fn f1() {}
    fn f2(&self) {}
}

fn main() {
    let a = A();
    // a.f1();   //error: this is an associated function, not a method
    a.f2();
    A::f1();
    A::f2(&a);
}

struct A当作Java中的类,main方法中,let a = A();就相当于Java中new一个实例对象出来。

回到文章,第二部分讲的是关于java嵌套类的隐藏开销,如下代码:

1
2
3
4
5
// Outer.java
public class Outer {
    private class Example {
    }
}
updatedupdated2022-09-082022-09-08
加载评论