「Java 到底是值传递还是引用传递」是一个被争论了二十多年的问题。结论可以一句话说完:Java 只有值传递(pass by value)——基本类型传的是值的拷贝,引用类型传的也是拷贝,只不过拷贝的内容是「引用的值」而非对象本体。这篇文章用三组实验把这个结论钉死。

它解决什么问题

很多 bug 的根源不是语法,而是对参数传递机制的错误心智模型。比如期望一个方法把传入的对象「换掉」、以为方法内对形参重新赋值能影响调用方、或者看到方法改了对象字段就断言「Java 是引用传递」。这些困惑都指向同一个问题:方法调用那一刻,实参到形参之间到底发生了什么?

先把两个术语固定下来。**实参(argument)**是调用处实际传入的值,**形参(parameter)**是方法声明中用来接收的那个变量。传参的本质操作只有一步:把实参的值绑定到形参变量上。分歧不在这步操作,而在「绑定的是什么」。

核心原理:三种求值策略

编程语言理论中,参数传递的求值策略(evaluation strategy)主要有三种:

  • 传值调用(call by value):形参得到实参值的副本。方法内对形参的任何修改,都不会影响调用方的变量。
  • 传引用调用(call by reference):形参是实参变量的别名,两者指向同一块存储。方法内给形参重新赋值,调用方的变量跟着变。C++ 的 int& 参数是典型代表。
  • 传共享调用(call by sharing):形参得到实参引用的副本。副本与原件指向同一个对象,因此通过形参可以修改对象内容;但给形参重新赋值只改了副本,不影响原件。

Java 的行为与第三种完全吻合。需要说明的是,「传共享」这个概念由 Barbara Liskov 提出,多用于描述 Python、Ruby、JavaScript 这类语言;而在 Java 社区的官方语境里,它被直接归入值传递——JLS 明确规定参数传递是值传递,引用类型的「值」就是引用本身。两种说法描述的是同一行为:拷贝的是引用的值,不是对象,更不是变量的别名。

深入机制:三组实验

实验一:基本类型的交换

public static void main(String[] args) {
int num1 = 10;
int num2 = 20;
swap(num1, num2);
System.out.println("num1 = " + num1); // 10
System.out.println("num2 = " + num2); // 20
}

static void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
System.out.println("a = " + a); // 20
System.out.println("b = " + b); // 10
}

方法内确实交换成功了,方法外纹丝不动。因为 a、b 是 num1、num2 的副本,基本类型的值就是数据本身,改副本不会影响原件。这一点几乎没有争议。

实验二:通过形参修改对象内容

public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
change(arr);
System.out.println(arr[0]); // 0
}

static void change(int[] array) {
array[0] = 0;
}

数组首元素被改了。很多人据此断言「引用类型是引用传递」。正确的解释是:arr 变量里存的不是数组本体,而是指向堆上数组对象的引用;调用时这份引用被拷贝给形参 array,两个变量此刻指向同一个数组。通过 array 修改数组内容,等价于通过 arr 修改——但这是「两个引用操作同一对象」,不是「形参是实参的别名」。要区分这两点,需要下一个实验。

实验三:交换两个引用类型形参

这是判决性实验(crucial experiment):

public class Person {
private String name;
public Person(String name) { this.name = name; }
public String getName() { return name; }
}

public static void main(String[] args) {
Person xiaoZhang = new Person("小张");
Person xiaoLi = new Person("小李");
swap(xiaoZhang, xiaoLi);
System.out.println(xiaoZhang.getName()); // 小张
System.out.println(xiaoLi.getName()); // 小李
}

static void swap(Person p1, Person p2) {
Person temp = p1;
p1 = p2;
p2 = temp;
System.out.println(p1.getName()); // 小李
System.out.println(p2.getName()); // 小张
}

如果 Java 是引用传递,交换形参就等于交换实参,方法外的 xiaoZhang、xiaoLi 应该互换指向。实际输出说明外部变量毫无变化——p1、p2 拿到的只是引用的副本,交换副本的指向对原件没有影响。这就证伪了「引用传递」,也完整刻画了 Java 的行为:方法可以改你传来的对象,但换不掉你手里的引用。

同理,方法内 param = new Person("新人") 也只是让形参改指新对象;String 不可变,方法内 s = "别的" 同样只是改局部引用的指向。这些现象全部可以用「拷贝引用」一个模型解释。

反面对照:C++ 的引用传递

void incr(int& num) {
num++;
}

int main() {
int age = 10;
incr(age);
std::cout << age; // 11,实参被直接修改
}

int& 让形参成为实参的别名,函数内对 num 的赋值直接写进 age 的存储。Java 中不存在这种语法,也没有任何手段让一个变量成为另一个变量的别名。

工程实践

  • 防御性拷贝的依据:既然方法能改传入对象的内容,对外暴露内部集合时应返回副本(new ArrayList<>(items))或不可变视图,否则调用方可以绕过封装直接改你的内部状态。
  • 不可变对象天然免疫:String、包装类型、记录类(record)这类不可变对象,无论引用怎么拷贝,对象内容都不会被方法篡改,心智负担最小,这也是多线程环境推荐不可变对象的原因之一。
  • 「交换两个对象」的正确写法:想交换调用方手里的两个引用,只能把它们包进数组或容器返回,或让方法返回新值由调用方赋值(a = transform(a)),指望 swap 方法直接生效是徒劳的。
  • 返回值是唯一的输出通道(对引用本身而言):方法无法改写调用方的引用变量,想「替换」调用方持有的对象,唯一途径是返回新引用并让调用方接住。

对比与权衡

维度 传值(Java 基本类型) 传共享(Java 引用类型) 传引用(C++ T&)
拷贝的内容 数据本身 引用的值 不拷贝,形参是实参别名
方法内改对象内容,外部可见 不适用 可见 可见
方法内让形参指向新对象,外部可见 不适用 不可见 可见
交换两个实参是否可行 不可行 不可行 可行

面试视角

Java 是值传递还是引用传递? 只有值传递。基本类型传递的是数据副本;引用类型传递的是引用值的副本,形参与实参指向同一对象,所以方法能改对象内容,但无法让实参指向另一个对象。用交换两个引用的实验可以证伪引用传递。

为什么方法能修改传入对象的字段,却说不是引用传递? 修改对象内容靠的是「两份引用指向同一对象」,与传递机制无关。引用传递的判定标准是「形参重新赋值是否影响实参变量」,实验三证明不影响,故不是引用传递。

String 作为参数有什么特殊性? 机制上毫无特殊,同样拷贝引用;但 String 不可变,方法内对形参的任何「修改」实际都是创建新对象并让形参改指,外部原引用指向的字符串不受影响,效果上等价于传值。

方法内给形参 new 了一个对象,为什么外部拿到的还是旧的? 形参只是实参引用的副本,new 只改变副本的指向。想让外部拿到新对象,只能通过返回值返回,由调用方显式赋值。

小结

  • Java 参数传递只有值传递:基本类型拷贝数据,引用类型拷贝引用的值,对象本体从不被拷贝。
  • 「方法能改对象内容」与「方法改不掉调用方的引用」同时成立,两者都由「拷贝引用」解释,互换实验是判决性证据。
  • 学术上的传共享调用(call by sharing)描述了同一行为,与「Java 只有值传递」并不矛盾。
  • 工程推论:防御性拷贝保护内部状态,不可变对象降低共享风险,替换引用只能靠返回值。