Java参数传递:值传递还是引用传递?

这个问题我在面试中问过无数遍,答案五花八门。有人斩钉截铁地说"Java是引用传递",有人犹犹豫豫地说"应该是值传递吧",还有人干脆放弃抵抗:"我不太确定..."

实际上,Java永远是值传递,没有例外。问题在于这个"值"到底传递的是什么。对于基本类型,传递的是值的副本;对于引用类型,传递的是引用的副本(也就是地址值)。这个区别让很多人混淆。

今天我们把这个知识点彻底讲透。

一、面试场景还原

候选人小李坐在面试间,面试官问他:

"Java是值传递还是引用传递?"

小李回答:"Java是引用传递,因为对象是通过引用来操作的。"

面试官点点头,继续追问:"那你写个代码看看。"

public class Person {
    String name;

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

public class Test {
    public static void changeName(Person p) {
        p.name = "张三";
    }

    public static void main(String[] args) {
        Person person = new Person("李四");
        System.out.println("调用前:" + person.name);
        changeName(person);
        System.out.println("调用后:" + person.name);
    }
}

小李说:"输出应该是张三,因为传递的是引用。"

面试官又问:"那这个呢?"

public class Test {
    public static void changePerson(Person p) {
        p = new Person("王五");
    }

    public static void main(String[] args) {
        Person person = new Person("李四");
        System.out.println("调用前:" + person.name);
        changePerson(person);
        System.out.println("调用后:" + person.name);
    }
}

小李说:"输出应该是王五,因为引用被重新赋值了。"

面试官说:"你跑一下看看。"

小李跑完发现,实际输出是"李四"。

小李愣住了。

【面试官心理】 这道题我用来测试候选人是否真正理解Java的参数传递机制。第一段代码确实会改变name,但原因是修改了对象的属性,而不是修改了引用。第二段代码完全没有改变name,因为内部对形参的重新赋值不影响实参。知道"为什么"的候选人,才能真正讲清楚Java是值传递而非引用传递。

二、核心概念:值传递 vs 引用传递

2.1 什么是值传递?

值传递(Pass by Value):把变量的值复制一份传递给方法,方法内部对参数的修改不影响原变量。

public class Test {
    public static void changeValue(int x) {
        x = 100;  // 修改的是局部变量x,不是main中的a
    }

    public static void main(String[] args) {
        int a = 10;
        changeValue(a);
        System.out.println(a);  // 输出:10,而不是100
    }
}

2.2 什么是引用传递?

引用传递(Pass by Reference):把变量的引用(内存地址)传递给方法,方法内部对参数的修改直接影响原变量。

如果Java是引用传递,上面第二段代码的输出应该是"王五",但实际输出是"李四",说明Java不是引用传递。

2.3 Java的参数传递机制

Java只有值传递,没有引用传递。 但这个"值"比较特殊:

  1. 基本类型:传递的是值的副本
  2. 引用类型:传递的是引用(地址)的副本
int a = 10;  // 基本类型,a保存的是值10
Person p = new Person("张三");  // 引用类型,p保存的是对象的内存地址

调用方法时:

changeValue(a);  // 把a的值(10)复制一份传给方法
changePerson(p);  // 把p的值(地址)复制一份传给方法

两种情况的共同点是:传递的都是"值",只是这个值的类型不同。一个是整数本身,一个是指针(内存地址)。

💡

"引用类型传递的是引用"这句话没错,但这个"引用"是被复制了一份传递的(值传递),不是原封不动地传递。所以Java的官方表述是"Java is always pass by value",而不是"pass by reference"。

三、内存模型图解

理解了值传递,我们用内存图来深入理解。

3.1 基本类型的值传递

public class Test {
    public static void changeValue(int num) {
        num = 100;
    }

    public static void main(String[] args) {
        int a = 10;
        changeValue(a);
        System.out.println(a);  // 输出:10
    }
}

内存变化:

main方法栈帧:
┌─────────────────┐
│ a: 10           │
└─────────────────┘
        ↓ 复制a的值
changeValue栈帧:
┌─────────────────┐
│ num: 10 → 100   │  // num是a的副本,修改num不影响a
└─────────────────┘

3.2 引用类型的值传递(最关键的混淆点)

public class Person {
    String name;
}

public class Test {
    public static void changeName(Person p) {
        p.name = "张三";
    }

    public static void main(String[] args) {
        Person person = new Person("李四");
        changeName(person);
        System.out.println(person.name);  // 输出:张三
    }
}

内存变化:

main方法栈帧:
┌─────────────────────────┐
│ person: 0x1234 (地址)    │──→ 堆: Person对象{name="李四"}
└─────────────────────────┘
        ↓ 复制person的值(地址)
changeName栈帧:
┌─────────────────────────┐
│ p: 0x1234 (地址副本)     │──→ 同一个堆对象
│ p.name = "张三"          │    对象内容被修改
└─────────────────────────┘

看到了吗?personp 指向同一个对象,所以修改对象的属性会影响到外部的 person。但这不是引用传递,而是值传递(传递的是地址值)

3.3 【直观类比】地址副本

想象你给邻居发快递:

  • 值传递(基本类型):你复印了一份文件寄过去,邻居涂改那份复印件,你的原件不受影响。

  • 值传递(引用类型,修改属性):你把文件的存放地址寄过去,邻居根据地址找到文件,在上面做了修改,原文件被改了。

  • 值传递(引用类型,重新赋值):你还是把地址寄过去,但邻居把地址纸条换成了一张新纸条,他自己找别的文件去了,你的原件不受影响。

第三种情况就是前面的第二段代码:

public static void changePerson(Person p) {
    p = new Person("王五");  // p指向了新对象,原来的person不受影响
}
⚠️

这里容易混淆:p.name = "张三" 改了外部的对象,但 p = new Person("王五") 没有改。这是因为前者修改的是堆中的对象,后者修改的是栈中的局部变量(形参)本身。不要把"修改对象属性"和"修改引用"混为一谈。

四、常见面试题解析

4.1 经典题目:交换两个数

public class Test {
    public static void swap(int a, int b) {
        int temp = a;
        a = b;
        b = temp;
    }

    public static void main(String[] args) {
        int x = 1, y = 2;
        swap(x, y);
        System.out.println("x=" + x + ", y=" + y);  // 输出:x=1, y=2
    }
}

很多人觉得交换应该成功,但实际上 xy 完全没有变化。为什么?因为 swap 方法内部只是在交换形参 ab 的值,和实参 xy 没有任何关系。这是值传递的铁证。

4.2 面试官追问:为什么不能直接交换?

因为 Java 没有指针,不能直接操作变量的地址。所有赋值操作都是"值赋值",要么复制基本类型的值,要么复制引用的地址。要在方法内部交换两个变量,必须用其他方式:

// 用数组包装
public static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

// 或者用AtomicInteger

4.3 String的特殊性

String 是引用类型,但有些行为看起来像是值传递:

public class Test {
    public static void changeString(String s) {
        s = "World";  // 重新赋值
    }

    public static void main(String[] args) {
        String str = "Hello";
        changeString(str);
        System.out.println(str);  // 输出:Hello
    }
}

这和前面 changePerson 的例子一样,s 被重新赋值为新的 String 对象,原来的 str 不受影响。

但是如果这样:

public static void changeString(String s) {
    s = s + " World";  // 创建了新字符串
}

public static void main(String[] args) {
    String str = "Hello";
    changeString(str);
    System.out.println(str);  // 输出:Hello(没变)
}

String 的不可变性导致它看起来"像值传递"。但本质上还是值传递(传递的是地址副本)。

:::tip 💡 String 为什么设计成不可变的?主要原因是:

  1. 安全性:字符串经常用于存储密码、URL、文件路径等,不可变可以防止被意外或恶意修改
  2. 字符串常量池:不可变才能实现字符串池化,节省内存
  3. 线程安全:不可变的对象天然线程安全
  4. HashCode缓存:String 的 hashCode 可以被缓存,提高哈希表性能 :::

五、生产场景与避坑

5.1 ❌ 错误示范:在方法内重新实例化对象试图修改外部引用

public class UserService {
    private User user;

    public void initUser() {
        user = new User();  // 初始化
    }

    public void resetUser() {
        user = new User();  // 错误!这是给局部变量重新赋值,不是修改引用
    }

    public User getUser() {
        return user;
    }
}

等等,这个例子其实能工作,因为 user 是成员变量。真正有问题的场景是:

public void process(List<String> list) {
    list = new ArrayList<>();  // 错误!这行代码毫无意义
    list.add("item");          // 添加到这个新list,而不是原来的list
}

5.2 ✅ 正确做法:直接修改对象属性,不要重新赋值

public void process(List<String> list) {
    list.clear();              // ✅ 正确:清空原列表
    list.add("item");          // ✅ 添加到原列表
}

5.3 实际应用:Builder模式

Builder模式经常需要返回一个修改后的引用:

public class UserBuilder {
    private String name;
    private int age;

    public UserBuilder name(String name) {
        this.name = name;
        return this;  // 返回this,调用者可以继续链式调用
    }

    public UserBuilder age(int age) {
        this.age = age;
        return this;
    }

    public User build() {
        return new User(name, age);
    }
}

使用:

User user = new UserBuilder()
    .name("张三")
    .age(25)
    .build();

六、面试追问链

第一层:基础概念

面试官问:"Java是值传递还是引用传递?"

标准回答:Java永远是值传递。对于基本类型,传递的是值的副本;对于引用类型,传递的是引用(地址)的副本。虽然传递引用类型时看起来能修改对象,但这不是引用传递,而是值传递(传递的是地址值)。

第二层:手写代码验证

面试官追问:"那你证明一下Java是值传递。"

可以写交换两个数的例子,说明形参的改变不影响实参。或者写引用类型的例子,说明重新赋值形参不影响实参。

第三层:原理深入

面试官追问:"为什么设计成值传递?有什么好处?"

值传递的好处是简单、可预测。调用方可以确信:传递基本类型不会被修改,传递引用类型也只可能通过对象属性被间接修改(而不是引用本身被替换)。这降低了副作用,让代码更容易理解和调试。

第四层:对比其他语言

面试官追问:"那C++的引用传递是什么意思?和Java有什么区别?"

C++的引用传递(用&声明引用参数)是真正的引用传递,形参和实参是同一个东西。Java没有这个机制,Java的引用类型参数传递的是引用的副本。

【学习小结】 Java的参数传递机制只有一种:值传递。基本类型传递值的副本,引用类型传递地址的副本。理解这一点,关键是分清"修改对象属性"(通过引用访问堆)和"修改变量本身"(重新赋值变量)的区别。

七、工程建议

  1. 不要在方法内部重新实例化参数:如果你想修改一个引用类型参数指向的对象,直接修改对象的属性,而不是重新给它赋值。

  2. 使用返回值而不是依赖副作用:想让方法返回修改后的对象时,使用返回值而不是依赖引用参数被修改。

  3. 理解不可变对象的好处:String、LocalDate等不可变对象在并发场景下有巨大优势,因为不需要担心被意外修改。

  4. 注意方法参数的副作用:当方法参数是引用类型时,方法内部可能会修改对象的内容(虽然不能修改引用本身)。如果是共享对象,要注意线程安全问题。

【面试官心理】 问这个问题,我最想听到的是候选人能讲清楚"地址副本"这个概念。很多候选人说"Java是值传递"但说不出为什么,那只能说明他背过这个结论。真正理解的人会画内存图、会举反例、会说清楚"修改属性"和"重新赋值"的区别。这才是P6+应该有的水平。