【Java】主要思想与概念辨析

505 阅读13分钟

Java 所践行的设计思想非常非常多,非常值得学习的。很多 Java 的主要概念都是由实际思想所指导,加上设计的实现方式结合创造的。

这里面存在着思想的碰撞与抉择,存在着理念与现实的妥协,不可能一样东西是完美的,Java 也如此,但我们一定要知道完美是什么样子的,才能有所追求。

  • 本文所有信息基于作者实践验证,如有错误,恳请指正,不胜感谢。
  • 转载请于文首标明出处:【Java】主要思想与概念辨析 (juejin.cn)
  • 文章是记录型文章,内容会不断拓展与补充。
  • 没什么时间,所以还没作图,虽然一图胜千言,但请见谅。

先有 Class 还是先有 Object?

Ojbec 与 Class

在 Java 中,这是我们默认的常识:

  • Class 类是 Java 中一切类的抽象,所有类都是 Class 类型的实例。
  • Object 类是 Java 中一切对象的抽象,所有类都是 Object 类型的子类,因此实例都是 Object 类型的实例。 那 Object 类是 Class 的父类,Object 类又是 Class 的实例,那究竟是先有 Class 类还是先有 Object 类?

先不讨论是 Class 先有还是 Object 先有。而是先讨论这两个“常识"是否是对的。首先讨论 Object,Object 是所有类的父类,所有对象都是 Object 的实例,这是没错的,见 Object 的定义:

Class Object is the root of the class hierarchy. Every class has Object as a superclass.

接着讨论 Class,所有类都是 Class 类型的实例吗?并不是,所有的类并不是 Class 类型的实例。Class 类型的定义为:

Instances of the class Class represent classes and interfaces in a running Java application.

意思是,每个类都会有一个对应的 Class 对象,用于表示该类。Class 实例只是类的表示,提供了一系列获取类信息和操作类实例的方法,并不是类真正的实体。每一个类真正的实体是方法区中的数据结构,也就是类元数据,而类关联的 Class 实例只是类的 Java 对象表示而已,是程序访问方法区中类实体的一个入口。类似于 MySQL 客户端与 MySQL 服务器的关系,类的 Class 实例只是类的一个映像和操作接口,而非类本身。

因此,这个问题已经不攻自破了,肯定是先有 Object,因为 Class 并不是真正的类实体,这是一个假的“鸡蛋问题”,预设条件就是错的。

真正的问题

虽然 Class 实例并不能代表真正的类,但是依旧存在这种矛盾。JVM 规定:类元数据在方法区中存储之后,就应该在堆中实例化一个代表类的 Class 实例。那么假如现在是 JVM 启动最初,JVM 先构建 Object 类元数据,接着再实例化 Object 的 Class 实例,可是现在 Class 类型还没构建啊

所以题目的问题依旧存在,不过是,问题建立的条件发生了变化。但这一点很重要。

实际上,现实世界也会有很多相互依存的事物存在的,比如病毒与细胞、鸡与鸡蛋,难道现实世界也有这个 bug?实际上,事物之间相互依存关系的建立,并不是在其中一方已经发展到“完全”的状态下建立的。因为如果没有这个依存关系,某一方绝对无法达到“完全”的形态。就像如果真的凭空出现鸡这种生物,那从它出现这一刻,它就不是鸡了,因为它并不是从蛋中孵化出来的。“鸡”这个概念,其中的一个特点就是“从蛋中孵化而出”。

那这个关系是怎么建立的?

在双方进入一个“完全”形态之前,就是整段依存关系的建立过程。鸡最初可能是由一种不完全的生物形态发展而来,就像一切生物源于大海,源于单细胞一样。这种生物从非常简单的卵中孕育出来,后面逐步形成鸡这种形态,同时,它的卵慢慢地演变成了如今鸡蛋的形态。这段过程,就是鸡与蛋这两个概念形成的混沌期,在这期间,世上并没有鸡或者蛋的概念。在这段过程之后,鸡与鸡蛋就同时诞生了。

程序也如此,简单来讲,就是 Object 类型实体、Class 类型实体、Object 的 Class 实例、Class 的 Class 实例,这四者交叉初始化,一起创建,就 OK 了,例如:

  1. 方法区分配 Object 类型内存,构建 Object 类型元数据。此时 Object 没有 Class 实例。
  2. 方法区分配 Class 类型内存,Class 类型继承 Object 类型,构建 Class 类型元数据。此时 Class 也没有自己的 Class 实例。
  3. 构建 Object 的 Class 实例,此时的 Class<Object> objectClazz 是不完善的,无法使用 objectClazz.getClass() 等操作。
  4. 构建 Class 自己的 Class 实例,即 Class<Class> classClazz。此时双方一切功能完善。

具体的建立过程请见 R 大的回答:先有Class还是先有Object? - 知乎 (zhihu.com)

至此 Object 与 Class 的依存关系构建完成,双方相互成就进入彼此的“完全”形态。此后 JVM 所有类的加载,都必须严格遵守 Object 与 Class 的关系构建。

整个过程就像 A 与 B 之间相互引用的建立过程,相互引用就是这种相互依存的关系,你必须先实例化 A,再实例化 B,再将 A 的指针域指向 B,再将 B 的指针域指向 A。如果你只实例化了 A,那么是不存在“A 与 B 相互引用”这个关系的。

Java 并非引用传递

程序在获取函数或方法的参数这个过程中采取的规则我们称为求值策略(Evaluation strategies),分为三种。首先我们先明确程序中使用变量的方式,再来讨论这三种求值策略。

变量与引用

在 Java 中,基本数据类型的使用不需要引用,对象类型的使用就采用引用的方式,例如 a = 8,a 这个变量槽直接存储的是 8 的值,而非 8 这个值的地址;而对于 a = "Java",a 这个变量槽存储的是 "Java" 的地址,程序取值需要根据地址去取值。

但是在其他高级语言中,有的会选择一切变量都是“引用 + 变量”的方式使用,不存在某些类型是值存储的方式。如 a = 8 中,变量槽存储的是 8 的地址,要用到 8 这个值需要根据地址去内存取值。

求值策略

根据调用方将变量传入被调方的方式的不同,分为三种求值策略,在这里我们使用一切变量都是 “引用 + 变量” 的方式来解释这三者,这样会更加清晰。

  1. 值传递:调用方将变量复制一份副本,将副本变量的地址传递给被调方。此时被调方无论对变量或者地址做出什么样的修改,都不会影响到调用方。

    func foo() {
        Object o = new Object(12);
        // o.hashCode = 001; o.a = 12;
        bar(o);
        // o.hashCode = 001; o.a = 12; 无影响
    }
    func bar(Object obj) {
        obj.a = 10;
        obj = new Object(13);
    }
    
  2. 引用传递:调用方将变量的地址传递给被调方,此时被调方对变量的修改肯定会影响到调用方。同时被调方对地址做出的修改,也会影响到调用方。

    func foo() {
        Object o = new Object(12);
        // o.hashCode = 001; o.a = 12;
        bar(o);
        // o.hashCode = 002; o.a = 13; 整个对象都换了,因为变量 o 的地址被修改成新对象的地址  
    }
    func bar(Object obj) {
        obj.a = 10;
        obj = new Object(13);
    }
    
  3. 共享对象传递:调用方将变量的地址复制一份,将地址的副本传递给被调方,此时被调方对变量的修改肯定会影响到调用方。但是被调方对地址的修改并不会影响到调用方。

    func foo() {
        Object o = new Object(12);
        // o.hashCode = 001; o.a = 12;
        bar(o);
        // o.hashCode = 001; o.a = 12; 对象没换,但是 a 数据域被修改了。  
    }
    func bar(Object obj) {
        obj.a = 10;
        obj = new Object(13);
    }
    

    TODO:补示意图。

类比解释的话,你有一个箱子和箱钥匙,你(调用方)要将箱子给你朋友(被调方)。

值传递相当于你搞了个新的一模一样的箱子和打了把新的一模一样的钥匙给你朋友,此时你朋友对钥匙或者箱子做的任何事,都不会影响到你。

引用传递相当于你直接把你的钥匙和箱子给你朋友,你朋友对收到的钥匙刻字,或者钥匙换成另一个箱子的钥匙,或者是把箱子里的东西换了,都会影响到你。

共享对象传递相当于你打了把一模一样的钥匙和原来的箱子给你朋友,你朋友对箱子的任何操作都会影响到你,但是对钥匙的任何操作都不关你的事。

但对于 Java 而言,值传递的定义不同,在 Java 中,值传递是指直接将变量的内容复制一遍传递到被调方法中,因为基本数据类型不存在引用地址这个概念。同时对于对象,Java 实际上所有对象变量存的都是对象的引用地址,例如 String str = "JAVA"str 这个变量存的就是 JAVA 的引用地址。在使用到 JAVA 这个对象时,JVM 需要先获取 str 的值,再根据这个值去找 JAVA。不像 C++ 或是其他语言,str 这个对象直接就指向 JAVA 这个对象。

Java 采用值传递的方式

对于基本数据类型,Java 直接复制值进行传参这一点毋庸置疑。

对于对象,其实 Java 在运行过程中,变量存储的永远是对象的引用,也就是对象的地址。传递参数时不可能直接传递整个对象的,永远都是传递地址。问题是,这个地址是怎么传递的?验证一下便知:

public class Test {

    private static class MyObj {
        int a;
        public MyObj(int a) {this.a = a;}
    }

    public static void main(String[] args) {
        MyObj o = new MyObj(10);
        System.out.println(o.hashCode() + ", " + o.a); // 325040804, 10
        testParameterTransfer(o);
        System.out.println(o.hashCode() + ", " + o.a); // 325040804, 11
    }

    private static void testParameterTransfer(MyObj obj) {
        obj.a = 11;
        obj = new MyObj(12);
    }
}

乍一看你会觉得是共享对象传递的方式进行的,但实际上是值传递的方式,因为传的是这个 o 变量,这个变量的内容实际上是对象的地址。因此呢,传递的方式实际上是值传递的。理不清楚没关系,细细品味,反正你已经能够判断出 Java 并非引用传递了。

《The Java™ Tutorials》中也介绍了 Java 的求值策略,也点明了 Java 只存在值传递这一特性:

Primitive arguments, such as an int or a double, are passed into methods by value. This means that any changes to the values of the parameters exist only within the scope of the method. When the method returns, the parameters are gone and any changes to them are lost.

Reference data type parameters, such as objects, are also passed into methods by value. This means that when the method returns, the passed-in reference still references the same object as before. However, the values of the object’s fields can be changed in the method, if they have the proper access level.

关于此问题的讨论参见:methods - Is Java "pass-by-reference" or "pass-by-value"? - Stack Overflow

返回值不同,其实是可以重载的?

在 JVM 中,方法的唯一性由方法名称 + 方法描述符确定,方法描述符的描述方式是先参数列表,后返回值的方式。如:

public void foo(String param1, int param2, int[] param3) {}

它的描述符见 descriptor 字段:

public void foo(java.lang.String, int, int[]);
  descriptor: (Ljava/lang/String;I[I)V
  flags: ACC_PUBLIC

Iint 的描述符,[ 是数组的描述符,因此 [I 描述的是整型数组。
L 是对象类型的描述符,Ljava/lang/String 描述的是字符串对象。

重载的规则

在 Java 语言中,重载允许方法名称相同,只要参数列表不同就行。参数列表的不同包括参数顺序和参数类型(参数数量的不同实际上包含在参数类型的不同中了)。

所以在 Java 中如果两个方法的名称和参数列表都相同,是无法进行重载的,也就不能通过编译:

public static void foo(int num) {}
public static char foo(int num) {}

编译结果:

.../Main.java:14:24
java: .../Main.java:14: 已在 Main 中定义 foo(int)

方法特征签名

实际上,重载规则的本质是要遵守方法的唯一性,也就是一个类中不能出现两个相同的方法。在 Java 中,方法的唯一性由方法特征签名确定

《Java 语言规范》指出,方法只要特征签名不同就可以共存,特征签名是指方法的名称、参数顺序与参数类型。也就是说,在同一个类中,如果两个方法名称、参数列表相同就不能共存。

但这其实不一定,实际上《JVM 规范》与《Java 语言规范》定义的方法特征签名的概念是不一致的,《JVM 规范》中定义了方法的特征签名包括:方法名称、参数顺序、参数类型、方法返回值与受查异常表。也就是说,在 JVM 看来,只要方法的返回值不同,两个方法就算名称、参数列表相同也可以共存。

那又怎么验证?除了直接写字节码和用其他 JVM 语言之外,想通过 Java 测试我们可以欺骗编译器,有一些编译器没有太严格的检查机制就可以让方法名和参数相同的代码通过编译。1

我们知道泛型其实会在编译过程中擦除为裸类型,因此 List<String>List<Integer 在编译之后其实都是 List,那么我们可以用泛型来骗过某些没有检查泛型参数的编译器,如 JDK6 的 javac 编译器:

public static char foo(List<String> list) {
    System.out.println("foo(List<String> list)");
    return ' ';
}

public static long foo(List<Integer> list) {
    System.out.println("foo(List<Integer> list)");
    return 0L;
}

' 编译过后的运行结果:

foo(List<String> list)
foo(List<Integer> list)

二者的描述符分别为:(Ljava/util/List;)C(Ljava/util/List;)J,返回值为 CJ 并不相同,因此可以在任意 JVM 中执行。

所以,实际上在 JVM 中,仅返回值不同的方法是可以重载的。但由于《Java 语言规范》的定义,现在已经没有编译器会让仅仅返回值不同的方法成功通过编译,但 JVM 可不仅仅只是给 Java 用。

因此,谈论“返回值不同究竟能不能重载”这个问题,必须先确定好前提,是对于 Java 语言,还是对于 JVM。

Footnotes

  1. 周志明.深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)[M].北京:机械工业出版社,2019.373