Java 中只有值传递吗?

前言

"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";
    }
}

这里对象的内容确实被方法修改了,于是很多人得出结论:"看,这就是引用传递!"

但这是误判。 修改对象内容和传递机制无关。真相是:personp 的副本,但两个副本指向堆上同一个对象 ,所以通过 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 传进去连内容都改不了,说明连值传递都传了个寂寞"。其实这里的机制和实验三完全相同:

  1. str 是引用 s 的副本,两者指向字符串常量池中的 "hello"
  2. str + " world" 并不是修改原字符串------String 是不可变类,任何"修改"操作实际上都是创建了一个新字符串对象 "hello world"
  3. 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 前的参数装载指令(iloadaload 等),load 的都是值的副本。

7.5 面试标准回答模板

"Java 只有值传递。基本类型传的是数值的副本;引用类型传的是引用的副本,副本指向堆中同一个对象,所以方法内可以通过副本修改对象的内容,但无法改变外部引用本身的指向。判断值传递还是引用传递的标准是:形参重新赋值能否影响实参------Java 中不能,所以是值传递。"

如果面试官追问,再补上实验三(形参赋新对象)或 swap 失败的例子,基本无懈可击。


八、总结

  1. Java 的参数传递机制有且只有一种:值传递。
  2. 基本类型传递数值的副本;引用类型传递引用的副本
  3. 引用副本与原始引用指向堆中同一个对象,因此可以通过副本修改对象内容------这是"像引用传递"的假象来源。
  4. 对形参重新赋值永远影响不到实参------这是值传递的铁证。
  5. "引用类型"是类型分类概念,"引用传递"是传参机制概念,两者不可混为一谈。
  6. Java 没有 &refout 之类的语法,无法声明真正的引用传递参数;需要"改外部变量"的效果时,只能用可变容器对象(数组、Holder、AtomicXxx 等)间接实现。
相关推荐
名字还没想好☜1 小时前
Spring @EventListener 事件驱动解耦实战:同步转异步、事务绑定与顺序控制
java·数据库·后端·python·spring
一木 之林1 小时前
插件、MCP、Skill 的区别?
java·c++·人工智能
可乐鸡翅yeah_1 小时前
hls.js 播放质量埋点实战,采集卡顿、起播、错误指标定位线上用户问题
开发语言·前端·javascript·ecmascript·音视频·m3u8·音视频在线播放
小刘在重生~1 小时前
面试重点:final、finalize、finally 三者区别
java
szarron1 小时前
RF Demo Kit|NanoVNA 射频演示测试板完整上手教程,滤波器、衰减器、SOLT 校准学习板
开发语言·人工智能·学习·php·射频工程·频谱仪
边境悍匪2 小时前
蜗牛学苑 Java 智能体学习 Day39|SpringAI 会话存储、Function‑Call 工具调用、MCP 思维导图复盘
java·开发语言·spring boot·学习·阿里云
ellenwan20262 小时前
看到“最新 AI 量化学习”时,先让表达变清楚
人工智能·python
万物智能信息科技2 小时前
RK3568 的多路显示移植—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
linux·开发语言·华为·开源·harmonyos
蓝悦无人机2 小时前
Qt Design Studio(QDS)4.8.2项目文件结构详解
开发语言·qt·qt creator·qds·项目文件结构·qtc