经典 Java 问题:方法参数是传递值还是传递引用?

Java 中的方法参数

在我们学习 Java 基础的时候,经常会看到类似:

  • Java 有基本类型和引用类型
  • Java 中不存在指针,只有引用
  • 方法参数传递分为引用传递和值传递
  • 值类型传递会产生副本对象,不会影响外部对象
  • 引用类型传递可以修改外部对象状态

等等基础言论。它们概念模糊,如此简洁的语言根本解释不到位,并且产生了不严格导致的自相矛盾。

  1. 假设我们在方法中对引用类型参数进行了重新赋值算不算修改对象?会不会影响外部对象状态?
  2. Java 既然不存在指针,那么引用类型参数传递究竟产生不产生副本?产生什么的副本?
  3. Java 参数传递为什么要区分引用类型传递和值类型传递?究竟是值传递还是引用传递或是两者都有?

关于 Java 究竟是不是 pass by reference?这是个非常经典的问题,在诸多地方被讨论了很多次,经久不衰。今天我也想谈谈这个问题。

代码验证

我们创建一个 Person 类:

class Person {
          
              private String name;
              private int age;
              
              // 省略 getter/setter ..
          
              public Person(String name, int age) {
                  this.name = name;
                  this.age = age;
              }
          
              /**
               * 过生日
               *
               * @param p 即将过生日的人
               */
              static void celebrateBirthday(Person p) {
                  p.setAge(p.getAge() + 1);
              }
          
              /**
               * 换人
               *
               * @param p 要被替换的人
               */
              static void replacePeron(Person p) {
                  p = new Person("路人", 18);
              }
          
              public static void main(String[] args) {
                  int oldAge = 12;
                  String name = "小明";
                  Person p = new Person(name, oldAge);    // (1)
                  celebrateBirthday(p);                   // (2)
                  if (p.getAge() == oldAge + 1) {
                      System.out.println("过生日成功,又长大了一岁。");
                  }
                  replacePeron(p);                        // (3)
                  if (!p.getName().equals(name)) {
                      System.out.println("换人成功!轮到" + p.getName() + "登场啦!");
                  } else {
                      System.out.println("换人失败,还是:" + p.getName() + "?");
                  }
              }
          }
          

输出:

过生日成功,又长大了一岁。
          换人失败,还是:小明?
          

我们来分析一下这个简单的程序:

(1)创建了一个 name 为 “小明”,age 为 12 的 Person 对象,我们将这个对象脑补为:小明这个人。
(2)调用了 celebrateBirthday 方法,内部调用了 p 对象的 setAge 方法给小明的年龄增加了一岁。结果是令人满意的,当前程序按照我们的意志执行。
(3)调用了 replacePeron 方法,内部将 p 对象重新赋值给一个新的 Person 对象企图换掉”小明“。但是结果却并非如此:小明还是小明,没有丝毫的改变,而”路人“却不知道去哪儿了。

如果 Java 在传递引用参数时不会创建副本,那么为什么方法外的 p 对象并没有被重新赋值呢?

我们写一段更好理解的,相同结果的程序(范例一):

Person p1 = new Person("小明", 13);
          Person p2 = p1;
          
          p1 = new Person("小红", 12);
          
          System.out.println(p2.getName()); // 小明
          

这段程序应该很好理解:p1 和 p2 起初指向同一个对象(原),后来 p1 指向了另一个的对象(新)。但是 p1 的重新赋值仅仅改变的是 p1 自身的指向, 跟 p2 不相关,p2 自然输出的是原对象小明。

如果我们将 p1 的赋值行为改为方法调用,修改对象数据(范例二):

Person p1 = new Person("小明", 13);
          Person p2 = p1;
          
          p1.setName("小红");
          
          System.out.println(p2.getName()); // 小红
          

会发现输出变成了“小红”。这个更好理解:p1 和 p2 始终指向同一个对象(共享同一个对象数据),无论发生了什么导致对象数据变化,p1 和 p2 都会被影响。此时的 p1 和 p2 可以理解为同一个对象的两个别名。

步骤解析

范例一程序:

首先创建 Person 类型的引用 p1 并分配相关对象:

接着,创建 p2 并将 p1 作为值(将 p2 指向 p1 的对象):

最后创建了一个新的对象,并赋值给 p1(将 p1 指向新对象):

可以看到,p2 的引用指向是没有变化的。p1 的重新赋值并不会影响到 p2 的指向。所以范例一中,p2 输出的是原对象的数据。

理解了范例一,那么范例二就更容易理解了:

p1 在 p2 同样指向一个对象的情况下,将 name 改为了 “小红”。所以 p2 的 name 会输出为修改后的数据。

而 Java 的方法在传递参数的时候,也会进行类似的赋值操作:首先创建了一个方法本地变量 p,注意这个 p 跟外面的 p 并不是同一个引用别名,但是具有相同的引用值。接着将传递进来的 p 赋值给方法本地变量 p,等同于范例一和范例二的操作。
如果理解到这里,就应该知道,第一段程序”路人”创建以后,实际上一直被本地变量 p 引用着,直到方法执行完成随着 p 的生命周期结束而失去引用,最后被回收。此过程完全无关外部 p 变量,它还好好的引用着”小明”呢。

引用值

如果你不理解,为什么 p2 = p1 的步骤就是直接让 p2 指向同一个对象了呢?
因为引用类型的赋值,其实就是修改引用别名(变量)的引用值。这个引用值可以当做逻辑上的地址,这个地址指向对象数据。

p2 = p1 其实可以理解为:p2 拷贝了 p1 的引用值(引用值副本)。此时 p1 和 p2 的引用值储存空间都是独立的,只是值相同而已。而这两个变量任意一个的引用值的变更都不会影响到另一个变量的值和对象。这点和指针是相同的。

所以其实一再的强调 Java 没有指针,没有指针的说法,其实害人不浅…

结论

  1. 传递引用对象不会产生参数副本?

    不,会的。会产生一个引用值的副本,它们指向同一个对象。产生的方法本地变量,它和外部对象的关系就是 p2 =p1 。只不过这个方法本地变量会让人误以为操作的是外部对象的引用,其实不是,它只是和外部对象引用的引用值相同的另一个引用而已。

  2. 究竟算不算传递引用?

    不算。能传递引用的语言,例如 C++ 的指针、C# ref 关键字,都不会存在值的副本产生。而 Java 实则是产生了引用值的副本,创建了新的本地变量来绑定。

  3. Java 能不能做到真正传递引用参数的效果?

    严格上来说,做到“传递引用”是不行的。但是做到“同样的效果”还是可以的。例如将引用包装在另一个引用对象内部,调用外部引用对象的方法来重新赋值内部引用对象,这种间接的方式。

附加:SO 上讨论非常热烈的相关问题

最后:请谨记 Java 中的参数传递全部是传递值,不要被刻意区分的引用类型、值类型参数差异而产生误解。