004|Java 参数传递机制:值传递、对象引用与常见误区

Java语言基础与面向对象约 18 分钟阅读10,375 字符
知识库文档 5 / 251

阅读时间:约 42 分钟 | 难度:★★★☆☆ | 适用方向:所有 Java 开发者


目录

  1. 引子:一个让 90% Java 开发者答错的面试题
  2. 理论基础:值传递与引用传递的严格定义
  3. Java 中的参数传递:只有一个答案
  4. 基本类型参数传递:栈上的拷贝
  5. 对象类型参数传递:引用值的拷贝
  6. 核心误区拆解:你能区分"修改对象"和"修改引用"吗?
  7. 不可变对象参数的特殊性:String 与包装类
  8. JVM 底层:字节码和内存模型视角
  9. 数组参数传递:一种特殊的"看起来像引用传递"
  10. 工程实践:参数传递引发的 Bug 模式与防御
  11. 语言对比:Java vs C++ vs Python vs Go
  12. 总结:面试中如何一句话讲清楚 Java 的参数传递
  13. 面试官视角:8 道高频追问

1. 引子:一个让 90% Java 开发者答错的面试题

先看一段代码,判断输出结果:

java
public class PassByValueDemo {

    public static void main(String[ ] args) {

        // 场景一:基本类型
        int x = 10;
        modifyPrimitive(x);
        System.out.println("x = " + x);  // 输出什么?

        // 场景二:对象类型
        Person p = new Person("张三");
        modifyObject(p);
        System.out.println("p.name = " + p.name);  // 输出什么?

        // 场景三:重新赋值对象引用
        Person q = new Person("李四");
        reassignReference(q);
        System.out.println("q.name = " + q.name);  // 输出什么?

        // 场景四:String(不可变对象)
        String s = "hello";
        modifyString(s);
        System.out.println("s = " + s);  // 输出什么?

        // 场景五:swap 方法
        Integer a = 100, b = 200;
        swap(a, b);
        System.out.println("a = " + a + ", b = " + b);  // 输出什么?
    }

    static void modifyPrimitive(int n) { n = 20; }
    static void modifyObject(Person person) { person.name = "王五"; }
    static void reassignReference(Person person) { person = new Person("赵六"); }
    static void modifyString(String str) { str = str + " world"; }
    static void swap(Integer x, Integer y) {
        Integer temp = x;
        x = y;
        y = temp;
    }
}

class Person { String name; Person(String name) { this.name = name; } }

先不往下看,你自己在心里回答这五个问题。

实际输出是:

code
x = 10          // modifyPrimitive 改的是副本,不影响原值
p.name = 王五    // modifyObject 通过引用副本修改了对象内部状态
q.name = 李四    // reassignReference 改的是引用副本的指向,不影响原引用
s = hello       // String 不可变,str = str + " world" 创建了新对象
a = 100, b = 200 // swap 方法中交换的只是 Integer x 和 Integer y 两个引用副本,对原变量无影响

如果你全部答对,这篇文章仍然值得读——它从 JVM 字节码、内存模型、跨语言对比的深度帮你建立系统性的认知框架。

核心结论(一句话版):Java 永远是值传递。基本类型传的是值的拷贝,对象类型传的是引用值的拷贝(即"对象的地址值的拷贝"),不是对象本身,也不是引用本身。


2. 理论基础:值传递与引用传递的严格定义

要讲清楚 Java 的参数传递,必须先从计算机科学中的严格定义出发。很多争论的根源在于术语使用的模糊。

2.1 值传递(Pass by Value)

定义:调用方法时,将实参的值的副本传递给形参。形参是实参的一个独立拷贝,对形参的任何修改都不会影响实参。

code
实参值 → 拷贝 → 形参(独立副本)

2.2 引用传递(Pass by Reference)

定义:调用方法时,将实参的引用(地址)本身传递给形参。形参和实参指向同一个存储位置,对形参的修改会直接影响实参。

code
实参 ←→ 形参(同一存储位置,共享)

关键判定标准:如果在方法内部对形参进行赋值操作(不是修改其指向的对象,而是让形参指向其他值),实参是否也跟着改变?如果实参也跟着变,就是引用传递;如果不跟着变,就是值传递。

文章配图
文章配图

2.3 严格区分"传什么"和"改什么"

操作类型 描述 能否影响调用方?
修改形参本身(赋值) param = newValue 值传递 ❌ / 引用传递 ✅
修改形参指向的对象状态 param.field = newValue 值传递 ✅(如果 param 是指向对象的引用值)
修改形参指向的对象引用 param = new Object() 值传递 ❌ / 引用传递 ✅

这是整个话题中最核心的区分。Java 属于第一列的情况:修改形参本身(赋值)不影响调用方,但可以通过形参修改所指向对象的状态。


3. Java 中的参数传递:只有一个答案

3.1 官方权威来源

Java 语言规范(JLS §8.4.1)明确指出:

"When the method or constructor is invoked (§15.12), the values of the actual argument expressions initialize newly created parameter variables, each of the declared type, before execution of the body of the method or constructor."

翻译:方法调用时,实参表达式的值初始化新创建的形参变量。这是典型的值传递语义。

James Gosling(Java 之父)在多次采访中明确表示 Java 是 pass-by-value。Oracle 官方 Java Tutorial 中写道:

"Primitive arguments, such as an int or a double, are passed into methods by value. ... Reference data type parameters, such as objects, are also passed into methods by value. This means that when the method returns, the passed-in reference still references the same object as before."

3.2 为什么有人坚称 Java "传引用"?

这是一个历史遗留的术语混淆问题,主要来源于三个层面:

  1. 用词不精确:很多人把"传递对象的引用"简称为"传引用",但计算机科学的"传引用"(pass-by-reference)有特定含义,是指形参成为实参的别名(alias)。
  2. 行为混淆:因为通过形参可以修改对象内部状态,看起来"像是"引用传递。但这是两种不同的操作——修改对象状态 ≠ 修改引用本身。
  3. Java 教材的历史错误:早期一些中文 Java 教材使用了"对象是引用传递"的表述,导致了一代程序员的集体误解。Scott Meyers 在《Effective C++》中专门用一句话澄清:"Java is strictly pass-by-value. The fact that you can modify the state of an object through a parameter doesn't make it pass-by-reference."

3.3 一个终极判定实验

真正的引用传递语言(如 C++ 的 &引用参数或 C# 的 ref 参数)能做到的事,Java 做不到:

cpp
// C++: 真正的引用传递
void swap(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}
int x = 1, y = 2;
swap(x, y);
// x = 2, y = 1  ← 交换成功!
java
// Java: 无法实现同样的效果
void swap(Integer a, Integer b) {  // 这是值传递!
    Integer temp = a;
    a = b;
    b = temp;
}
Integer x = 1, y = 2;
swap(x, y);
// x = 1, y = 2  ← 交换失败!

这个实验是区分值传递和引用传递的"试金石"——如果 swap 不生效,就是值传递。


4. 基本类型参数传递:栈上的拷贝

4.1 内存模型

文章配图
文章配图

当调用 modifyPrimitive(x) 时,JVM 在栈上发生了什么:

code
调用前:                    进入方法后:
┌─────────────┐           ┌─────────────┐
│  main 栈帧   │           │ modifyPri   │
│  x = 10     │   调用    │  n = 10     │  ← 新创建的副本
│             │ ──────→  │             │
│             │           ├─────────────┤
│             │           │  main 栈帧   │
│             │           │  x = 10     │  ← 不受影响
└─────────────┘           └─────────────┘

基本类型的参数传递是最简单的模型:从实参到形参是一个纯粹的位拷贝(bitwise copy)。

4.2 字节码层面

java

public static void main(String[ ] args) {

    int x = 10;
    modifyPrimitive(x);  // ← 关键调用
}

static void modifyPrimitive(int n) {
    n = 20;
}

编译后 main 方法的关键字节码:

code
 0: bipush 10        // 将常量 10 压入操作数栈
 2: istore_1         // 弹出栈顶,存入局部变量表 slot 1(即 x)
 3: iload_1          // 将 x 的值(10)压入操作数栈
 4: invokestatic modifyPrimitive  // 调用方法:栈顶的 10 被复制为形参 n 的初始值

关键点:iload_1 将 x 的值(10)压入栈,然后 invokestatic 指令将这个值作为实参传递给 modifyPrimitive。形参 n 从栈上获取的只是 10 这个值,不包含 x 的任何引用信息。方法内 n = 20 改变的只是 modifyPrimitive 栈帧中 slot 0 的值。

4.3 每种基本类型的传递细节

类型 大小 传递方式 注
byte/short 1/2 字节 符号扩展为 int 后复制 JVM 指令集以 int 为基础
char 2 字节 零扩展为 int 后复制 无符号
int 4 字节 直接复制 4 字节值 最常用
long 8 字节 直接复制 8 字节值 占两个 slot
float 4 字节 IEEE 754 位模式复制
double 8 字节 IEEE 754 位模式复制 占两个 slot
boolean 1 字节 映射为 int 0/1 复制 JVM 层面没有 boolean 类型

5. 对象类型参数传递:引用值的拷贝

这是整个话题中最容易被误解的部分。我们来逐层拆解。

5.1 "引用"到底是什么?

在 Java 中,一个对象类型的变量存储的不是对象本身,而是引用(reference)。引用本质上是一个指向堆中对象的"指针",由 JVM 管理,开发者无法直接操作其值。

code
Person p = new Person("张三");

栈                         堆
┌──────────┐            ┌──────────────┐
│ p = 0xA0 │ ───────→  │ Person 对象  │
│  (引用)   │  指向     │ name = "张三" │
└──────────┘            └──────────────┘

变量 p 存在栈上,它的值是一个引用(可以理解为对象在堆中的地址 0xA0)。对象本体存在堆上。

5.2 传递对象参数时发生了什么

文章配图
文章配图

java
Person p = new Person("张三");  // p 的值 = 引用 0xA0
modifyObject(p);

执行流程:

code
步骤1: main 栈帧                    步骤2: modifyObject 栈帧
┌──────────────────┐               ┌──────────────────┐
│ p = 0xA0         │    拷贝      │ person = 0xA0     │  ← 引用值的拷贝!
│ (指向 Person@A0) │ ─────────→  │ (也指向 Person@A0) │
└──────────────────┘               └──────────────────┘
                                           │
                                   两者都指向同一个对象 ↓
                                   ┌──────────────────┐
                                   │ Person@A0        │
                                   │ name = "张三"    │
                                   └──────────────────┘

关键理解:形参 person 得到了实参 p 的引用值的拷贝。这导致:

  • person 和 p 是两个独立的引用变量,位于两个不同的栈帧中
  • 但它们当前指向同一个堆上的 Person 对象
  • 如果通过 person.name = "王五" 修改对象状态,p 也能看到变化(因为指向同一对象)
  • 但如果 person = new Person("赵六") 让形参指向新对象,p 不受影响(因为改变的是形参这个副本的值)

5.3 图解:修改对象状态 vs 修改引用本身

文章配图
文章配图

这是整个 Java 参数传递话题中最核心的一张图,请仔细对比两种情况的差异,这决定了你面试和工作排查 Bug 的能力。

情况 A:修改对象状态(**person.name = "王五"**)

code
方法调用前:                      方法执行 person.name = "王五" 后:
main: p = 0xA0 ──────→ ①       main: p = 0xA0 ──────→ ①
modify: person = 0xA0 ─→ ①      modify: person = 0xA0 ─→ ①
                              ┌─────────────┐
                    ┌──────→ │ Person@0xA0 │
                    │        │ name="王五"  │  ← 两个引用看到同样的变化
                    └──────→ └─────────────┘
    结论: ✅ 调用方能看到变化

情况 B:修改引用本身(**person = new Person("赵六")**)

code
方法调用前:                      方法执行 person = new Person("赵六") 后:
main: p = 0xA0 ──────→ ①       main: p = 0xA0 ──────→ ① Person@0xA0
modify: person = 0xA0 ─→ ①      modify: person = 0xB0 ─→ ② Person@0xB0
                                                          name="赵六"

    结论: ❌ main 中的 p 仍然指向 0xA0,完全不受影响
          因为 person 只是 p 的引用值副本,改变副本的指向不影响原变量

这就是一切误解的根源:当有人喊"Java 对象是传引用的!因为我能改对象的内容!",他们混淆了"通过引用副本修改对象状态"和"修改引用本身"。前者可行是因为引用副本和目标引用指向同一对象;后者不可行恰恰证明了这是值传递。


6. 核心误区拆解:你能区分"修改对象"和"修改引用"吗?

6.1 误区一:"Java 基本类型传值,对象传引用"

这是流传最广的错误认知。真相:Java 基本类型传值,对象类型也传值(传的是引用值)。

这段话本身是自相矛盾的——如果对象"传引用",那 swap 两个对象引用就应该生效。但事实上:

java
public static void swap(Person a, Person b) {
    Person temp = a;
    a = b;
    b = temp;
}
// 调用方:p1 和 p2 不会交换

6.2 误区二:"List.add 能生效,所以是引用传递"

java
void addItem(List<String> list) {
    list.add("新元素");  // ✅ 调用方能看到
}

这不能证明是引用传递。因为 list.add() 修改的是 list 指向的 ArrayList 对象的内部状态,而形参 list 和实参 myList 都指向同一个 ArrayList。这完全符合值传递的行为模式。真正的引用传递是 list = new ArrayList<>() 也能影响调用方。

[ ] 来说明"传引用"

一些人用反射修改 String 内部 value 数组来"证明" Java 传引用。这是混淆概念——反射修改的是 String 对象的内部状态(虽然是极端 hack),不是修改了引用本身。而且 JDK 12+ 中 String 改用 byte[ ] 编码(compact strings),这个 hack 的行为更不可预测。

6.4 用"修改引用"测试来证明值传递

给一个面试中百试百灵的判断方法:

如果方法内部**param = new XXX()**之后,调用方的原变量也指向新对象,就是引用传递;否则就是值传递。

Java 中这永远不成立,因为 Java 是值传递。


7. 不可变对象参数的特殊性:String 与包装类

7.1 String 的"陷阱"

文章配图
文章配图

java
String s = "hello";
modifyString(s);
// s 仍然是 "hello"

很多初学者困惑:"不是说对象传递的是引用吗?那通过引用修改 String,为什么原 String 没变?"

答案分两层:

第一层:String 是不可变对象(immutable)。str = str + " world" 这行代码实际上做了三件事:

  1. 创建一个新的 StringBuilder
  2. 将 "hello" 和 " world" 拼接
  3. 返回一个新的 String 对象("hello world")
  4. 将局部变量 str 指向这个新对象

这属于"修改引用本身"的场景,自然不影响调用方的 s。

第二层(更深):String 的不可变性决定了你不可能通过任何公开方法修改 String 对象的状态。即使你想"修改对象状态",也没有合法的 API。

7.2 包装类(Integer、Long 等)的类似行为

java
void increment(Integer n) {
    n = n + 1;  // 等价于 n = Integer.valueOf(n.intValue() + 1)
}

n = n + 1 涉及自动拆箱、加法运算、自动装箱,最终创建了一个新的 Integer 对象赋给形参 n。这是修改引用本身,不影响调用方。这和 int 的"值拷贝"行为看起来一样,但底层机制不同——一个是基本类型的值拷贝,一个是引用值的拷贝后指向新对象。

7.3 一张表说清楚不同对象类型的行为

参数类型 修改对象状态(.field = / .set()) 修改引用本身(= new)
普通可变对象(Person、ArrayList) ✅ 影响调用方 ❌ 不影响调用方
不可变对象(String、Integer) ❌ 无公开 API,不可修改 ❌ 不影响调用方

| 数组 | ✅ 影响调用方(arr[0] = x) | ❌ 不影响调用方(arr = new int[ ]) |


8. JVM 底层:字节码和内存模型视角

8.1 对象参数传递的完整字节码

java
Person p = new Person("张三");
modifyObject(p);

编译后的字节码:

code
 0: new #2               // 在堆上分配 Person 对象内存
 3: dup                  // 复制栈顶引用(一个给初始化、一个给赋值)
 4: ldc #3               // 加载常量 "张三"
 6: invokespecial #4     // 调用 Person.<init>(String) 构造方法
 9: astore_1            // 将对象引用存入局部变量表 slot 1(即 p)
10: aload_1             // 将 p 的引用值压入操作数栈
11: invokestatic #5     // 调用 modifyObject:栈顶引用值被复制为形参的初始值

关键:aload_1 指令将 p 变量中存储的引用值(例如 0xA0)压入栈顶,然后 invokestatic 指令将这个引用值复制给 modifyObject 方法的形参 person。形参 person 得到的是引用值 0xA0 的拷贝,而不是 p 变量本身的地址。

8.2 栈帧结构详解

文章配图
文章配图

当执行 modifyObject(p) 时,JVM 栈的完整结构:

code
 ┌─────────────────────────────────┐
 │  modifyObject 栈帧              │ ← 栈顶
 │  ┌───────────────────────────┐  │
 │  │ 局部变量表:               │  │
 │  │   slot 0: person = 0xA0  │  │ ← 引用值的拷贝
 │  │ 操作数栈: (empty)         │  │
 │  │ PC: 正在执行              │  │
 │  └───────────────────────────┘  │
 ├─────────────────────────────────┤
 │  main 栈帧                      │
 │  ┌───────────────────────────┐  │
 │  │ 局部变量表:               │  │
 │  │   slot 0: args            │  │
 │  │   slot 1: p = 0xA0        │  │ ← 原始引用
 │  │ 操作数栈: (empty)         │  │
 │  │ PC: 等待 modifyObject 返回│  │
 │  └───────────────────────────┘  │
 └─────────────────────────────────┘

8.3 HotSpot JVM 中的引用实现

在 HotSpot JVM 中,引用有两种实现方式:

  • 直接指针(默认,-XX:+UseCompressedOops 开启压缩时占用 4 字节):引用直接存对象的堆地址。
  • 句柄(通过 -XX:+UseCompressedClassPointers 间接影响):引用指向句柄池中的句柄,句柄再指向对象实例。这种方式在 GC 时移动对象只需更新句柄,但多一次间接访问。

无论哪种实现,传递的都是引用值的拷贝这一本质不变。

8.4 Java 为什么选择纯值传递?

这是一个设计哲学问题,答案来自 James Gosling 的设计理念:

  1. 简单性:值传递的语义单一,不需要区分 int 和 int&,降低学习和使用成本。
  2. 安全性:方法无法意外修改调用方的变量绑定,只能通过返回值通信,单向数据流更易理解。
  3. GC 友好:值传递避免了变量的别名问题,使逃逸分析和栈上分配更准确。
  4. 与 C++ 的分野:Java 在设计之初就刻意避免了 C++ 的引用、指针等复杂概念,选择了更安全简洁的路线。

9. 数组参数传递:一种特殊的"看起来像引用传递"

9.1 数组在 Java 中的本质

Java 中数组是对象。

  • [ ] arr = new int[3] 在堆上分配一个数组对象,

arr 是栈上的引用变量。

java

void modifyArray(int[ ] arr) {

    arr[0] = 999;  // ✅ 调用方能看到变化

    arr = new int[ ]{1, 2, 3};  // ❌ 调用方看不到新数组

}

数组的行为和其他对象类型完全一致——传递引用值的拷贝。

9.2 多维数组的传递

java

void modify2D(int[ ][ ] matrix) {

    matrix[0][0] = 999;      // ✅ 影响调用方
    matrix[0] = new int[5];  // ✅ 影响调用方!(matrix[0] 是对象状态的一部分)
    matrix = new int[2][2];  // ❌ 不影响调用方
}

注意:matrix[0] = new int[5] 修改的是 matrix 指向的数组对象的元素(元素本身也是引用),属于修改对象状态,所以会影响调用方。而 matrix = new int[2][2] 修改的是形参本身,不影响调用方。


10. 工程实践:参数传递引发的 Bug 模式与防御

10.1 Bug 模式一:方法意外修改了传入对象

java
public class OrderService {
    public void calculateDiscount(Order order) {
        // 业务逻辑...
        order.setTotalAmount(order.getTotalAmount() * 0.8);  // ❌ 意外修改原对象!
        // 调用方的 Order 对象被污染
    }
}

防御策略:参数防御性拷贝(Defensive Copy)

java
public void calculateDiscount(Order original) {
    Order order = original.clone();  // 深拷贝
    order.setTotalAmount(order.getTotalAmount() * 0.8);
    // 原对象不变
}

或者使用不可变对象设计(JDK 16+ record):

java
public record Order(BigDecimal totalAmount, List<OrderItem> items) {
    public Order withDiscount(double rate) {
        return new Order(totalAmount.multiply(BigDecimal.valueOf(rate)), items);
    }
}

10.2 Bug 模式二:以为修改了对象但实际没有

java
public void initConfig(Properties props) {
    props = new Properties();  // ❌ 认为在初始化,实际创建了局部对象
    props.setProperty("key", "value");  // 设置到了新对象上
    // 调用方拿到的是 null!
}

防御策略:不要修改传入的引用参数,通过返回值传递:

java
public Properties createConfig() {
    Properties props = new Properties();
    props.setProperty("key", "value");
    return props;  // 明确返回新对象
}

10.3 Bug 模式三:集合参数被意外清空或修改

java
public void processBatch(List<String> items) {
    items.clear();  // ❌ 调用方的列表被清空!
    items.addAll(fetchFromDB());
}

防御策略:

java
public List<String> processBatch(List<String> items) {
    List<String> result = new ArrayList<>(items);  // 防御性拷贝
    result.addAll(fetchFromDB());
    return result;
}

或者在方法签名上明确约定(Javadoc 注明是否会修改参数):

java
/**
 * 处理批次数据。
 * @param items 输入数据(不会被修改)
 * @return 处理后的新数据
 */

10.4 多线程环境中的参数传递陷阱

java
public void submitTask(Order order) {
    executor.submit(() -> {
        process(order);  // order 的引用值被捕获到闭包中
    });
    // 方法返回后,order 引用不再指向任何对象
    // 但闭包持有了 order 的引用值副本,堆上的 Order 对象不会被 GC
    // 如果此时另一个任务也在修改这个 Order...
}

这里的值传递行为和多线程叠加后产生了更复杂的问题——对象状态共享导致竞态条件。解决方案是传递不可变对象或深拷贝。


11. 语言对比:Java vs C++ vs Python vs Go

理解 Java 的独特性,最好的方式是和兄弟语言做严格对比。

11.1 C++:三种传递方式共存

cpp
void byValue(int x) { x = 10; }           // 值传递,不影响实参
void byPointer(int* x) { *x = 10; }       // 指针传递(本质也是值传递,传了指针值的拷贝)
void byReference(int& x) { x = 10; }      // 引用传递,影响实参

C++ 是少数支持真正引用传递的语言。int& 让形参成为实参的别名——修改形参就是修改实参。

Java 的"引用"更接近 C++ 的"指针"(受限版),而非 C++ 的"引用"。

11.2 Python:Everything is Object,但也是值传递?

Python 的情况和 Java 几乎完全一样——"call by sharing"(共享传递),但 Python 社区倾向于称之为"pass by object reference"。

python
def modify(lst):
    lst.append(4)      # ✅ 影响调用方
    lst = [1, 2, 3]    # ❌ 不影响调用方

和 Java 一致:传递引用值的拷贝。Python 没有基本类型,所有变量都是引用,但这不改变传递语义。

11.3 Go:值传递 + 显式指针

go
func byValue(x int) { x = 10 }            // 值传递
func byPointer(x *int) { *x = 10 }        // 指针值传递
// Go 没有引用传递,和 Java 一样!

Go 的语言规范和 Java 在这一点上一致——都是纯值传递。Go 的指针让值传递更显式,而 Java 隐藏了指针概念。

11.4 C#:有真正的引用传递

csharp
void byValue(int x) { x = 10; }           // 值传递
void byRef(ref int x) { x = 10; }         // 引用传递!swap 能生效
void byOut(out int x) { x = 10; }         // 输出参数

C# 的 ref 关键字提供了真正的引用传递。Java 没有等价机制。这是 Java 和 C# 在参数传递上最重要的区别。

11.5 对比总结

文章配图
文章配图

语言 基本类型 对象/引用类型 是否有真正引用传递
Java 值传递 值传递(引用值的拷贝) ❌
C++ 值传递,可选 &amp;amp; 引用传递 同左 ✅(&amp;amp;)
Python 无基本类型 共享传递(和 Java 等效) ❌
Go 值传递 值传递(指针值的拷贝) ❌
C# 值传递,可选 ref 同左 ✅(ref)
JavaScript 值传递 共享传递(和 Java 等效) ❌

Java 和 C++/C# 的关键差距:Java 缺少修改调用方变量绑定的能力。这不是缺陷,是有意为之的设计选择。


12. 总结:面试中如何一句话讲清楚 Java 的参数传递

标准回答模板

"Java 的参数传递永远是值传递。对于基本类型,传递的是值的拷贝;对于对象类型,传递的是引用值的拷贝——即对象在堆中地址的拷贝。这意味着方法内的形参和调用方的实参是两个独立的引用变量,但初始时指向同一个堆对象。因此,通过形参修改对象状态会影响调用方看到的对象,但让形参指向新对象不会影响调用方。Java 不支持真正的引用传递,所以无法实现像 C++ 的 swap(int &a, int &b) 那样在方法内部交换两个变量。"

核心模型速记

文章配图
文章配图

code
传递的是 → 值的拷贝
基本类型 → 42 这个值的拷贝
对象类型 → 0xA0 这个引用值的拷贝(不是引用本身的拷贝!)

修改对象状态 → ✅ 影响调用方(两个引用指向同一对象)
修改引用本身 → ❌ 不影响调用方(改的是副本)

一行法则

引用变量存储的是对象的"门牌号",参数传递复制的是"门牌号",而不是钥匙或房子本身。


13. 面试官视角:8 道高频追问

以下问题从面试官视角设计,考察对 Java 参数传递的深度理解,而非仅复述结论。

Q1:Java 的参数传递是值传递还是引用传递?请用代码证明你的结论。

答案:值传递。证明代码:

java

public static void main(String[ ] args) {

    Point p = new Point(1, 2);
    reassign(p);
    System.out.println(p.x + "," + p.y);  // 输出 1,2 而非 3,4
    
    Integer a = 10, b = 20;
    swap(a, b);
    System.out.println(a + "," + b);  // 输出 10,20 而非 20,10
}
static void reassign(Point pt) { pt = new Point(3, 4); }
static void swap(Integer x, Integer y) { Integer t = x; x = y; y = t; }

如果是引用传递,reassign 会使 p 指向 (3,4),swap 会交换 a 和 b。但两件事都没发生,证明是值传递。

Q2:既然 Java 是值传递,为什么**list.add(x)**能在方法外看到效果?这不矛盾吗?

不矛盾。list.add(x) 修改的是 list 所指向的 ArrayList 对象的内部状态(元素数组),而形参 list 和实参 myList 持有相同的引用值,都指向堆上同一个 ArrayList。这就像两个人拿着同一张房卡(引用值的拷贝),都能进同一个房间(对象)改变房间里的东西。但如果一个人换了一张房卡(list = new ArrayList<>()),另一个人手里的房卡不受影响——这恰恰是值传递的特征。判断标准不是"能不能通过形参影响对象",而是"修改形参本身(赋值)是否影响实参"。

Q3:String 作为参数传入方法,方法内拼接字符串后,为什么外面的 String 没变?

两层原因。第一层:String 是不可变对象,str += " suffix" 在底层等价于 str = new StringBuilder(str).append(" suffix").toString(),创建了一个全新的 String 对象并赋给局部变量 str——这是修改引用本身,不影响调用方。第二层:即使 String 不是不可变的(假设有 setter),str.setValue(...) 修改对象状态会影响调用方,但 str = new String(...) 修改引用本身仍然不会影响调用方。不可变性只是让"修改对象状态"这条路走不通,不改变参数传递的值传递本质。

Q4:Java 中参数传递在 JVM 层面是怎么实现的?你能结合字节码说明吗?

在 JVM 层面,参数传递通过栈帧的局部变量表实现:

  1. 调用方执行类型对应的 load 指令(iload/aload/fload 等)将值压入操作数栈
  2. invokevirtual/invokestatic 等调用指令从操作数栈取值为被调用方法创建栈帧
  3. 被调用方法的局部变量表从 slot 0 开始接收参数值——这是值的拷贝

对于对象参数,aload 指令将引用值(直接指针或压缩指针)压入栈,被调用方的 slot 中得到的是这个引用值的拷贝。astore 修改被调用方 slot 中的引用值不会影响调用方的局部变量表。这就是值传递在 JVM 层面的实现。

Q5:如果你想在 Java 中实现类似 C++ 引用传递的效果(方法内修改外部变量),有哪些替代方案?

没有直接等价方案,但可以通过以下方式实现类似效果:

  1. 包装对象:AtomicReference<T>、MutableObject<T>(Apache Commons)、Holder<T>
  2. 单元素数组:
  • [ ] result = new int[1],方法内

result[0] = newValue 3. 返回值:最推荐的方式,返回新值而非修改参数 4. 回调/Consumer:Consumer<T> callback,方法内 callback.accept(newValue) 5. 成员变量:如果是实例方法,修改成员变量

但这些都是 workaround,都增加了复杂度。Java 的设计哲学认为"通过返回值通信"比"通过修改参数通信"更清晰。

Q6:**Arrays.sort(arr)**能对原数组排序,**Collections.sort(list)**能对原集合排序,这是引用传递吗?为什么 JDK 这样设计?

不是引用传递。sort 方法修改的是传入对象(数组或集合)的内部状态(元素排列顺序),通过引用值的拷贝访问到同一个堆对象进行修改。这和 list.add() 的原理一致。

JDK 这样设计的原因是:1)排序操作的本质是重排元素,而不是创建新容器;2)原地排序比创建新容器更高效(避免额外内存分配);3)开发者通常期望容器本身被排序,而非返回新容器。如果不想修改原容器,可以用 Stream API:list.stream().sorted().collect(toList())。

这一设计也提醒我们:调用框架或第三方库方法前,要阅读文档确认是否会修改传入参数——这是契约的一部分。

Q7:在多线程环境中,把对象作为方法参数传递到另一个线程执行,会有什么问题?和参数传递机制有什么关系?

问题的根源不是参数传递机制本身,但值传递的"引用值拷贝"特性放大了问题:

  1. 对象共享:传递给线程池任务的引用值拷贝仍然指向同一个堆对象。如果多个线程持有同一个对象的引用值拷贝,就形成了隐式的对象共享。
  2. 可见性问题:线程 A 通过引用值拷贝修改对象的字段,线程 B 不一定能立即看到——JMM 的可见性规则介入。
  3. 竞态条件:两个线程同时通过各自的引用值拷贝修改同一对象。

解决方案:1)传递不可变对象或深拷贝;2)使用并发安全的数据结构;3)在方法签名上明确声明参数的所有权转移("调用后调用方不应再修改此对象")。

这和参数传递的关系在于:正是因为值传递只复制引用值而不复制对象本身,才产生了对象共享的可能性。如果 Java 是"传递对象的深拷贝",这个问题就不存在(但会有巨大的性能开销)。

Q8:JDK 16 引入的**record**类型作为方法参数传递时,行为有什么特点?和普通类有什么不同?

record 作为参数传递时,传递的仍然是引用值的拷贝(值传递语义不变),但因为 record 的特性,行为上有以下不同:

  1. 自动不可变:record 的所有字段都是 final,没有 setter。这意味着无法通过引用值副本修改 record 的状态——自然消除了"意外修改参数对象"的 Bug 类别。
  2. 防御性拷贝更简单:record 的规范构造器可以轻松实现集合字段的防御性拷贝:
  3. 线程安全:不可变 record 实例天然线程安全,传递给多线程执行不需要额外的同步。

本质上 record 没有改变参数传递的语义,但通过限制对象可变性,让值传递的行为变得更可预测和安全。这也是 Java 语言演进的一个方向——通过类型系统减少出错可能,而非改变底层传递机制。


本文是 "Java 技术专家与架构师面试:250 个高频考点总览" 系列的第 004 篇。下一篇:005|equals 与 hashCode 契约。