Java 值传递
Java 方法调用只有值传递,也就是把实参当前保存的值复制给形参。本文先区分基本值、引用值和对象,再通过赋值与传参示例解释共享修改,最后说明 final 引用与浅拷贝。
1. 面试核心
先记住这几条,再读正文- 变量保存的值。 基本类型变量保存基本值,引用变量保存引用值或
null。 - 值复制。 Java 的赋值和传参都复制当前保存的值,引用类型复制的是引用值。卡 · 变量保存的值
- 对象修改与重新赋值。 形参与实参指向同一对象时,修改对象对调用方可见,而重新给形参赋值只改变形参变量。卡 · 赋值与传参
- final 引用。
final引用初始化后保持指向不变,它指向的可变对象仍允许修改内容。卡 · final 引用与浅拷贝
2. 变量保存的值
理解赋值前,要先区分基本值与引用值。基本类型变量保存具体值,比如 int 变量保存整数。下面把整数复制给另一个变量,再修改副本:
int original = 101;
int copied = original;
copied = 202;
System.out.println(original); // 101
copied 得到的是 101 这个值,之后给它赋值为 202,只会改变 copied。因此,original 仍然是 101。
引用类型变量保存的是用于访问对象的引用值,也可以保存 null。复制引用后,两个变量就能访问同一个对象:
User first = new User("Lin");
User second = first;
second.setName("Asuka");
System.out.println(first.getName()); // Asuka
first 和 second 是两个变量,但保存着指向同一个 User 的引用。因此,通过 second 修改对象姓名后,通过 first 也会读到 Asuka。这次操作改动的是共享对象的字段。
不要用“基本类型一定在栈、对象一定在堆”解释语言语义。JVM 可以优化实际存储位置,赋值和传参规则不会因此改变。
3. 赋值与传参
修改对象字段与重新给引用变量赋值,会产生不同结果。先看重新赋值:它只改变当前变量保存的引用。
User first = new User("Lin");
User second = first;
second = new User("Asuka");
System.out.println(first.getName()); // Lin
System.out.println(second.getName()); // Asuka
second 重新指向新对象后,first 仍指向原来的 User,所以两次输出分别是 Lin 和 Asuka。
方法调用也遵循复制规则。调用处传入的值称为实参,方法声明中接收值的变量称为形参。每次调用都会创建新的形参变量,并用实参当前保存的值初始化:
static void rename(User user) {
user.setName("Asuka");
}
static void replace(User user) {
user = new User("Asuka");
}
假设调用方传入姓名为 Lin 的用户,两种操作的区别如下:
| 操作 | 修改的内容 | 调用方是否可见 |
|---|---|---|
user.setName(...) | 双方共同指向的对象 | 可见 |
user = new User(...) | 形参变量保存的引用 | 不可见 |
调用 rename 后,共享对象的姓名变为 Asuka,调用方能读到这个变化。单独调用 replace 则只改变局部形参 user,调用方仍指向姓名为 Lin 的原对象。
两种结果都来自引用值的复制。Java 没有让方法通过形参直接替换调用方变量的引用传递机制。
4. 数组与 String
数组和 String 都是引用类型,传参时同样复制引用值。它们的差别在于内容是否允许修改,先看可以修改元素的数组:
static void update(int[] values) {
values[0] = 101;
values = new int[] {202};
}
int[] numbers = {1};
update(numbers);
System.out.println(numbers[0]); // 101
values[0] = 101 修改的是共享数组,因此调用方读到 101。随后给 values 赋一个新数组,只改变局部形参,numbers 仍指向原来的数组。
String 的内容不可变,所以示例中的拼接会得到另一个字符串,再把新引用赋给局部形参:
static void append(String value) {
value = value + " 101";
}
String title = "Coding";
append(title);
System.out.println(title); // Coding
value 得到了拼接后的新引用,调用方的 title 则保持原引用。因此,方法返回后仍输出 Coding,这与其他引用类型遵循相同的传参规则。
5. final 引用与浅拷贝
限制变量重新赋值,与复制对象,是两种不同的操作。final 引用初始化后保持指向不变,但对象是否可修改仍由对象自身决定。数组的 clone() 则会创建新数组,下面一起观察它们对共享修改的影响:
final List<String> names = new ArrayList<>();
names.add("Lin"); // 合法:修改列表
// names = new ArrayList<>(); // 编译错误:重新赋值
StringBuilder[] first = {new StringBuilder("A")};
StringBuilder[] second = first.clone();
second[0].append("B");
System.out.println(first[0]); // AB
names.add("Lin") 修改列表内容,引用仍指向原列表,所以合法。注释中的赋值会改变 names 的指向,因此是编译错误。
数组克隆创建了新数组,但数组中的元素引用只是被复制。这称为浅拷贝:外层容器不同,内部对象仍然共享。所以,second[0].append("B") 修改了共享的 StringBuilder,通过 first[0] 也会读到 AB。
深拷贝还要复制可变子对象,才能隔离这些修改。存在多个引用共享同一个子对象时,还需定义复制后是否保留这种共享关系。
6. 常见问题
6.1 为什么交换两个对象参数无法交换调用方变量?
因为方法内交换的是两个形参保存的引用副本,改动只作用于局部变量。调用方的两个变量保持原来的引用,所以方法返回后仍访问原来的对象。
6.2 方法怎样返回多个结果?
可以把多个结果放进记录类型、结果对象或集合,再作为一个返回值交给调用方。比如,下面的记录类型一起保存最小值和最大值:
record Range(int min, int max) {}
方法可以返回 new Range(min, max),调用方再通过 min() 和 max() 读取结果。这样就无需通过重新给参数赋值来传出多个值。
6.3 引用值就是内存地址吗?
引用是访问对象的值,不保证等于应用能直接操作的内存地址。具体表示由 JVM 决定,垃圾收集器(GC)还可以移动对象。应用通过引用访问对象即可,赋值与传参的规则保持不变。
7 · 面试题卡片
本页题目与参考回答
7.1 Java 是值传递还是引用传递
出现公司:腾讯、网易、美团
考察重点
- 区分基本类型保存的具体值与引用类型保存的引用值。
- 根据复制后的引用关系,解释修改对象与重新赋值形参的不同结果。
参考回答
Java 只有值传递。
调用方法时,形参是新变量,由实参当前保存的值初始化。基本类型复制具体值,引用类型复制引用值。
复制引用后,形参和实参指向同一对象,所以通过形参修改字段会被调用方看到。重新给形参赋值则只改变局部变量,调用方保存的引用保持不变。
7.2 rename 与 replace 为什么产生不同结果
出现公司:哈啰数科
考察重点
- 说明修改对象状态与重新给引用变量赋值分别改变了什么。
- 画出形参、实参与对象之间的关系,并据此推导调用后的结果。
相关内容:赋值与传参。
参考回答
rename 修改共享对象,replace 只改变形参的引用,所以调用方看到的结果不同。
两个方法收到的都是引用值副本,最初都与调用方指向同一个对象。
rename 通过这个副本修改姓名,因此调用方也能读到新姓名。replace 让局部形参指向另一个对象,而调用方变量仍保存原引用,所以原对象保持不变。
7.3 final 引用和数组 clone 能否隔离对象修改?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释 final 约束的是哪个变量,以及浅拷贝后哪些对象仍被共享。
相关内容:final 引用与浅拷贝。
参考回答
两者都无法隔离对共享可变对象的修改。
final 只约束变量重新赋值,所以 final 列表仍可以添加元素。
引用数组的 clone() 创建新数组,但复制的是元素引用,因此两个数组仍共享可变元素。例如,first[0] 指向内容为 A 的 StringBuilder,执行 second = first.clone() 后,再调用 second[0].append("B"),通过 first[0] 也会读到 AB。要隔离这类修改,还需复制内部的可变对象。