前言
"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对象在堆上。
int a = 10; person p = new person();
内存示意:
栈帧(main 方法) 堆
┌──────────┐
│ a = 10 │
├──────────┤
│ p = ref ─┼──────────► ┌─────────────────┐
└──────────┘ │ person 对象 │
│ name = "tom" │
└─────────────────┘
关键认知:引用本身也是一个"值"。它和 int 一样,是可以被复制、被赋值的。理解了这一点,java 参数传递的所有疑惑都会迎刃而解。
2.2 方法调用时发生了什么
当你把一个变量作为实参传入方法时,jvm 做的事情非常朴素:把实参槽位里的内容,原样复制一份,放进形参的槽位里。
- 实参是
int,复制的就是那个数值; - 实参是引用,复制的就是那个引用。
注意,复制引用 ≠ 复制对象。堆上的对象自始至终只有一份,栈上多了两个指向它的引用:一个是实参的,一个是形参的。
三、代码验证:四个经典实验
空谈规范不如上代码。下面用四组实验,逐一验证。
实验一:基本类型 —— 毫无争议的值传递
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。形参只是实参数值的副本,修改副本不影响原件。这一点所有人都没有异议。
实验二:对象类型 —— 通过副本修改对象内容
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 看到。这就好比你把家里的钥匙复制了一把给朋友,朋友能用这把钥匙进你家搬东西,但他手里的始终是"钥匙的副本",不是你手里那把。
判断是不是引用传递,要看下面的实验三。
实验三:给形参赋新对象 —— 决定性的证据
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 的"不可修改"假象
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 中:
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++ 中真正的引用传递:
void swap(int& x, int& y) { // 注意 & 符号,声明引用参数
int t = x; x = y; y = t;
}
// 调用后,外部实参真的被交换了c++ 的 int& 是 x 的别名,对它的操作就是对原变量的操作。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# 的对比尤其能说明问题:
void change(ref person p) { // 显式 ref,这才是引用传递
p = new person(); // 外部变量会跟着改
}java 没有任何关键字能写出上面的效果。
七、几个高频延伸问题
7.1 既然引用被复制了,方法内修改对象会不会有线程安全 / 副作用问题?
会。值传递不能帮你隔离对象的修改。方法拿到引用副本后,对共享对象的任何写入,调用方都能看见。这正是 java 中防御性拷贝(defensive copy)和不可变对象(immutable object)设计模式存在的意义——比如 date、数组作为成员变量暴露时通常要 copy 一份,string 干脆设计成不可变。
7.2string为什么设计成不可变?
与参数传递无关,是出于字符串常量池复用、线程安全、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 等)间接实现。
到此这篇关于java只有值传递吗?一文讲透java中的参数传递机制的文章就介绍到这了,更多相关java参数传递内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论