前言
"Java 到底是值传递还是引用传递?"这个问题大概是 Java 面试中出现频率最高的八股题之一。网上的答案五花八门:有人说全是值传递,有人说基本类型是值传递、对象是引用传递,还有人振振有词地拿出代码"证明"Java 支持引用传递。
结论先行:Java 语言规范中,参数传递机制只有一种,就是值传递(Pass by Value)。 所谓"对象是引用传递"的说法,是对 Java 引用类型语义的误解。这篇文章尝试从语言规范、内存模型、代码验证、横向对比等多个角度,把这件事彻底讲清楚。
一、先把概念定义清楚
讨论问题之前,必须先统一术语,否则鸡同鸭讲。
1.1 值传递(Call by Value)
调用方法时,实参的值被复制一份,赋给方法的形参。形参和实参是两个独立的存储单元,方法内部对形参的任何重新赋值,都不会影响外部的实参。
1.2 引用传递(Call by Reference)
调用方法时,形参是实参变量的别名(alias),两者指向同一块存储。方法内部对形参的赋值,会直接反映到外部实参上------也就是说,你可以在方法里让外部的变量"换一个指向"。
1.3 传对象本身(Call by Object / 其它语言的做法)
有些语言(如早期的 Algol 类语言)还有传名调用、传结果调用等机制,这里不展开,只需要记住前两种定义即可。
区分值传递与引用传递的唯一试金石是:在方法内部对形参重新赋值,能否改变外部实参的指向。 能,就是引用传递;不能,就是值传递。至于"能否通过形参修改对象内部的数据",与传递机制无关,那是另一回事。
二、Java 的变量与引用到底是什么
要理解参数传递,得先理解 Java 中变量和对象的关系。
2.1 变量只是"栈上的一个槽位"
Java 中所有的局部变量都存放在栈帧的局部变量表里,它存储的内容分两种情况:
- 基本类型变量 :槽位中直接存储数值本身。比如
int a = 10;,a这个槽位里放的就是10这个值。 - 引用类型变量 :槽位中存储的不是对象,而是指向堆中对象的引用 (可以粗略理解为地址,虽然 JVM 规范并不保证引用就是一个裸地址)。比如
Person p = new Person();,p这个槽位里放的是引用,真正的Person对象在堆上。
java
int a = 10;
Person p = new Person();
内存示意:
栈帧(main 方法) 堆
┌──────────┐
│ a = 10 │
├──────────┤
│ p = ref ─┼──────────► ┌─────────────────┐
└──────────┘ │ Person 对象 │
│ name = "Tom" │
└─────────────────┘
关键认知:引用本身也是一个"值" 。它和 int 一样,是可以被复制、被赋值的。理解了这一点,Java 参数传递的所有疑惑都会迎刃而解。
2.2 方法调用时发生了什么
当你把一个变量作为实参传入方法时,JVM 做的事情非常朴素:把实参槽位里的内容,原样复制一份,放进形参的槽位里。
- 实参是
int,复制的就是那个数值; - 实参是引用,复制的就是那个引用。
注意,复制引用 ≠ 复制对象。堆上的对象自始至终只有一份,栈上多了两个指向它的引用:一个是实参的,一个是形参的。
三、代码验证:四个经典实验
空谈规范不如上代码。下面用四组实验,逐一验证。
实验一:基本类型 ------ 毫无争议的值传递
java
public class Test1 {
public static void main(String[] args) {
int x = 1;
change(x);
System.out.println(x); // 输出 1
}
static void change(int x) {
x = 100;
}
}
方法内把形参 x 改成 100,外部依然是 1。形参只是实参数值的副本,修改副本不影响原件。这一点所有人都没有异议。
实验二:对象类型 ------ 通过副本修改对象内容
java
public class Test2 {
static class Person {
String name;
}
public static void main(String[] args) {
Person p = new Person();
p.name = "Tom";
rename(p);
System.out.println(p.name); // 输出 Jerry
}
static void rename(Person person) {
person.name = "Jerry";
}
}
这里对象的内容确实被方法修改了,于是很多人得出结论:"看,这就是引用传递!"
但这是误判。 修改对象内容和传递机制无关。真相是:person 是 p 的副本,但两个副本指向堆上同一个对象 ,所以通过 person 修改的 name 字段,自然能通过 p 看到。这就好比你把家里的钥匙复制了一把给朋友,朋友能用这把钥匙进你家搬东西,但他手里的始终是"钥匙的副本",不是你手里那把。
判断是不是引用传递,要看下面的实验三。
实验三:给形参赋新对象 ------ 决定性的证据
java
public class Test3 {
static class Person {
String name;
}
public static void main(String[] args) {
Person p = new Person();
p.name = "Tom";
replace(p);
System.out.println(p.name); // 输出 Tom,不是 Jack!
}
static void replace(Person person) {
person = new Person(); // 让形参指向一个全新的对象
person.name = "Jack";
}
}
如果 Java 是引用传递,那么方法内执行 person = new Person() 之后,外部的 p 也应该指向这个新对象,最终打印 Jack。
但实际输出是 Tom。
原因很简单:replace 方法里的 person 只是一个引用副本 。对它重新赋值,只是让这个副本改指别处,main 方法栈帧里的 p 纹丝不动。方法结束后,那个新建的 Person 对象因为再没有任何引用指向它,会被 GC 回收。
内存变化过程:
调用前:
main 栈帧: p = ref1 ──► [Person: name="Tom"]
调用时(复制引用):
main 栈帧: p = ref1 ──┐
├──► [Person: name="Tom"]
replace 栈帧: person = ref1 ─┘
执行 person = new Person() 后:
main 栈帧: p = ref1 ──► [Person: name="Tom"]
replace 栈帧: person = ref2 ──► [Person: name="Jack"] ← 只有副本改了指向
这个实验就是值传递的铁证:真正的引用传递,形参是实参的别名,重新赋值形参必然影响实参。Java 做不到。
实验四:String 的"不可修改"假象
java
public class Test4 {
public static void main(String[] args) {
String s = "hello";
modify(s);
System.out.println(s); // 输出 hello
}
static void modify(String str) {
str = str + " world";
}
}
有人拿这个例子说"String 传进去连内容都改不了,说明连值传递都传了个寂寞"。其实这里的机制和实验三完全相同:
str是引用s的副本,两者指向字符串常量池中的"hello";str + " world"并不是修改原字符串------String是不可变类,任何"修改"操作实际上都是创建了一个新字符串对象"hello world";str = ...让这个引用副本指向了新对象,外部的s依然指向"hello"。
String 的表现特殊,不是因为传递机制特殊,而是因为对象本身不可变。
四、一个"标准答案"级别的验证:交换两个变量
判断一门语言是值传递还是引用传递,最经典的验证方法就是看能不能写出让 swap 生效的函数。
在 Java 中:
java
public class SwapTest {
public static void main(String[] args) {
int a = 1, b = 2;
swap(a, b);
System.out.println(a + ", " + b); // 输出 1, 2,交换失败
String s1 = "A", s2 = "B";
swap(s1, s2);
System.out.println(s1 + ", " + s2); // 输出 A, B,同样失败
}
static void swap(int x, int y) {
int t = x; x = y; y = t;
}
static void swap(String x, String y) {
String t = x; x = y; y = t;
}
}
无论基本类型还是引用类型,swap 一律无效------因为方法里摆弄的始终是副本。
对比 C++ 中真正的引用传递:
cpp
void swap(int& x, int& y) { // 注意 & 符号,声明引用参数
int t = x; x = y; y = t;
}
// 调用后,外部实参真的被交换了
C++ 的 int& 是 x 的别名,对它的操作就是对原变量的操作。Java 中没有语法能声明出这样的参数。
如果非要在 Java 中实现交换,只能借助"可变容器"------把要交换的值装进一个对象里,传这个对象的引用副本进去,然后修改对象内部的字段(而非重新赋值形参):
java
static void swap(int[] arr) {
int t = arr[0]; arr[0] = arr[1]; arr[1] = t;
}
// 或者用 AtomicInteger、自定义 Holder 类等
这个 workaround 恰恰反证了 Java 是值传递:你只有"修改共享对象"这一条路,而没有"重新绑定外部变量"这条路。
五、为什么这么多人误以为"对象是引用传递"
误解的产生有三个根源:
5.1 术语的坑:"引用类型" ≠ "引用传递"
Java 把数据类型分为基本类型和引用类型 (reference type)。"引用类型"说的是变量的类型 ------这个变量存的是一个引用;"引用传递"说的是参数传递的机制------形参是实参的别名。两个词只差一个字,含义完全不同。"引用类型的变量以值传递的方式被传入方法",这句话才是完整准确的表述。
5.2 现象的迷惑性:能改对象状态,就像能改一切
通过参数副本修改对象字段,效果上和 C++ 传指针是一样的。C++ 中传指针本身也是值传递(拷贝指针),但 C++ 社区也常常把它误称为引用传递。这个混淆在 Java 社区被进一步放大,因为 Java 的引用在语法上"看不见"------你写 person.name,感觉就是在直接操作对象。
5.3 教材和网络文章的以讹传讹
大量早期 Java 教材写着"基本类型传值,对象传引用",这句话被简化传播后变成了"Java 对象是引用传递"。而《Java 核心技术》(Core Java)中 Horstmann 早已明确纠正:"Java 程序设计语言总是采用值传递方式。"
六、横向对比:其他语言是怎么做的
把 Java 放到语言光谱里,能看得更清楚。
| 语言 | 机制 | 说明 |
|---|---|---|
| C | 值传递 | 想修改外部变量需显式传指针;传指针本身也是值传递 |
| C++ | 值传递 + 引用传递 | int&、T& 提供真正的别名语义 |
| Java | 只有值传递 | 引用类型传的是引用的副本 |
| C# | 值传递 + ref/out | 默认值传递;ref 关键字提供显式的引用传递 |
| Python | 传对象引用(call by object sharing) | 与 Java 行为几乎一致,形参是引用的绑定副本 |
| JavaScript | 值传递 | 对象参数传引用的副本,同 Java |
可以看到,Java、JavaScript、Python 这类"自动内存管理 + 引用语义"的语言,行为模式高度一致:都是把引用当值来传。真正提供引用传递语法的,是 C++(&)和 C#(ref/out)这类需要显式声明的语言。
C# 的对比尤其能说明问题:
csharp
void Change(ref Person p) { // 显式 ref,这才是引用传递
p = new Person(); // 外部变量会跟着改
}
Java 没有任何关键字能写出上面的效果。
七、几个高频延伸问题
7.1 既然引用被复制了,方法内修改对象会不会有线程安全 / 副作用问题?
会。值传递不能帮你隔离对象的修改。方法拿到引用副本后,对共享对象的任何写入,调用方都能看见。这正是 Java 中防御性拷贝(defensive copy)和不可变对象(immutable object)设计模式存在的意义------比如 Date、数组作为成员变量暴露时通常要 copy 一份,String 干脆设计成不可变。
7.2 String 为什么设计成不可变?
与参数传递无关,是出于字符串常量池复用、线程安全、hashCode 缓存、安全性(类加载路径、网络连接参数等)的综合考量。副作用之一就是让人误以为"String 传参后改不了"。
7.3 数组作为参数呢?
数组在 Java 中是对象,规则完全一致:传的是数组引用的副本。所以方法内可以修改数组元素(arr[0] = 99 外部可见),但 arr = new int[10] 不影响外部。
7.4 JVM 层面,参数具体是怎么传的?
以 HotSpot 为例,方法调用时参数按照调用约定被压入栈(或放入寄存器):基本类型直接压数值,引用类型压引用值(oop)。被调用方法创建新栈帧,参数落入新栈帧的局部变量表。整个过程就是"复制值",没有任何"传别名"的机制。字节码层面看 invokestatic/invokevirtual 前的参数装载指令(iload、aload 等),load 的都是值的副本。
7.5 面试标准回答模板
"Java 只有值传递。基本类型传的是数值的副本;引用类型传的是引用的副本,副本指向堆中同一个对象,所以方法内可以通过副本修改对象的内容,但无法改变外部引用本身的指向。判断值传递还是引用传递的标准是:形参重新赋值能否影响实参------Java 中不能,所以是值传递。"
如果面试官追问,再补上实验三(形参赋新对象)或 swap 失败的例子,基本无懈可击。
八、总结
- Java 的参数传递机制有且只有一种:值传递。
- 基本类型传递数值的副本;引用类型传递引用的副本。
- 引用副本与原始引用指向堆中同一个对象,因此可以通过副本修改对象内容------这是"像引用传递"的假象来源。
- 对形参重新赋值永远影响不到实参------这是值传递的铁证。
- "引用类型"是类型分类概念,"引用传递"是传参机制概念,两者不可混为一谈。
- Java 没有
&、ref、out之类的语法,无法声明真正的引用传递参数;需要"改外部变量"的效果时,只能用可变容器对象(数组、Holder、AtomicXxx 等)间接实现。