《Java 编程入门》6.2 值传递的真相

用 library 的借书、还书和替换对象三个实验,讲清 Java 只有值传递:基本类型复制数值,对象复制引用;方法内重新赋值不影响调用方,通过引用修改对象却会影响。最后说明为什么返回内部 List 会泄漏状态,以及借还逻辑通过引用改 Book 时风险在哪。

本节目标:能准确说出「Java 传的到底是什么」,能区分「重新赋值参数」和「通过引用修改对象」,并能判断一个方法会不会意外改到调用方的状态。
适用版本:JDK 25 LTS(Temurin 25.0.5);参数传递规则与 JDK 21 完全一致。

上一节留下的问题

6.1 把业务搬进了 LibraryService,LibraryApp 调用 service.addBook(...)、service.search(...)。方法一多,参数怎么传就成了必须说清的事:

  • addBook 把 Book 放进 List,调用方之后还能不能拿到同一个对象?
  • 如果 borrow 把 book.available 改成 false,调用方为什么能看到?
  • 如果 borrow 里写 book = new Book(...),调用方手里的书会变吗?
  • search 返回的 List 能不能直接改?改了会不会影响服务内部?

这四个问题的答案都来自同一条规则:Java 只有值传递(pass-by-value)。

传进去的是「值」,不是「变量」

「值传递」的意思是:调用方法时,Java 把实参的值复制一份给形参。方法里拿到的是副本,不是调用方那个变量本身。

基本类型和引用类型的区别,只在于「值」是什么:

类型变量里存的值传递时复制的东西
int、double、boolean具体的数值数值本身
对象引用(Book、String、数组、List)指向对象的引用引用(可以理解为地址)

所以:

  • 对基本类型,方法改形参,改的是副本里的数值,调用方看不到。
  • 对对象,方法改形参的指向(重新赋值),改的是副本里的引用,调用方也看不到。
  • 对对象,方法通过形参的引用修改对象内部状态,改的是两个引用共同指向的那个对象,调用方能看到。

最后一条经常被误称为「引用传递」。准确说法是:引用被按值传递。Java 从来没有把调用方的变量本身交给方法。

三个实验:改数值、换对象、改状态

下面这段程序把三种情况放在一起。Book 仍是第 4 章的公开字段版本。

package com.plume.library;

import java.util.ArrayList;
import java.util.List;

public final class PassByValueDemo {
    static void increment(int x) {
        x++;
    }

    static void replace(Book book) {
        book = new Book("000", "Replaced", "Nobody");
    }

    static void markBorrowed(Book book) {
        book.available = false;
    }

    static void swap(Book a, Book b) {
        Book tmp = a;
        a = b;
        b = tmp;
    }

    static void appendSuffix(String s) {
        s = s + "!";
    }

    static void clearList(List<Book> books) {
        books.clear();
    }

    public static void main(String[] args) {
        int n = 1;
        increment(n);
        System.out.println("n = " + n);

        Book book = new Book("978-7-111-1", "Effective Java", "Joshua Bloch");
        replace(book);
        System.out.println("title = " + book.title);

        markBorrowed(book);
        System.out.println("available = " + book.available);

        Book a = new Book("A", "Book A", "Author");
        Book b = new Book("B", "Book B", "Author");
        swap(a, b);
        System.out.println("a = " + a.title + ", b = " + b.title);

        String s = "library";
        appendSuffix(s);
        System.out.println("s = " + s);

        List<Book> list = new ArrayList<>();
        list.add(book);
        clearList(list);
        System.out.println("size = " + list.size());
    }
}

本机 JDK 25 输出:

n = 1
title = Effective Java
available = false
a = Book A, b = Book B
s = library
size = 0

六行输出,对应六条规则。

increment(n):改不到调用方

n 是 int,传参时把 1 复制给 x。x++ 改的是副本,方法返回后副本消失,n 仍是 1。这和大多数语言一致,没有争议。

replace(book):换对象改不到调用方

book 是一个引用,传参时把这个引用复制给形参。方法里 book = new Book(...) 让形参指向新对象,调用方的 book 仍指向原来的 Effective Java。图示:

调用方 book ──────> Book("978-7-111-1", "Effective Java", available=true)
形参 book   ──────> 同一个 Book 对象        (传参后的初始状态)

执行 book = new Book(...) 之后:

调用方 book ──────> Book("978-7-111-1", "Effective Java", available=true)
形参 book   ──────> Book("000", "Replaced", available=true)   (方法结束即丢弃)

replace 里创建的新对象没有被任何人引用,方法结束后就可被回收。调用方看不到任何变化,title 仍是 Effective Java。

markBorrowed(book):改状态调用方看得到

markBorrowed 没有重新赋值形参,而是通过引用访问对象字段:book.available = false。调用方和形参指向同一个对象,所以调用方打印 available 得到 false。

这不是「引用传递」的证据,恰恰是「引用按值复制」的结果:两个引用指向同一对象,通过任意一个改对象,另一个都能看到。

swap(a, b):交换不了

static void swap(Book a, Book b) {
    Book tmp = a;
    a = b;
    b = tmp;
}

方法内确实交换了两个形参的指向,但形参是副本。方法返回后,调用方的 a 仍指向 Book A,b 仍指向 Book B。输出 a = Book A, b = Book B。

如果 Java 是引用传递,swap 应该能交换调用方的变量;它不能,这就是最直接的反证。想在调用方层面交换两个引用,只能把交换结果作为返回值,或者把两个引用放进一个容器,交换容器里的元素。

appendSuffix(s):String 不可变

s = s + "!" 先创建一个新字符串,再让形参指向它。调用方的 s 仍指向 "library"。即使 String 可变,重新赋值也只改形参;这里还多了一层:String 本身不可变,任何「修改」都是创建新对象。第 15 章会展开字符串池和拼接的代价。

clearList(list):改集合内容看得到

books.clear() 没有重新赋值形参,而是调用对象方法清空内部数组。调用方和形参指向同一个 ArrayList,所以 size() 变成 0。这和 markBorrowed 是同一类:通过引用修改对象状态。

为什么这件事在 library 里重要

LibraryService.borrow 正是「通过引用修改对象状态」的例子:

public boolean borrow(String isbn, String memberId) {
    Book book = findByIsbn(isbn);
    if (book == null || !book.available) {
        return false;
    }
    book.available = false;
    loans.put(isbn, memberId);
    return true;
}

findByIsbn 返回的是列表里那个 Book 对象的引用,不是副本。borrow 把 available 改成 false,服务内部的书单立刻反映这个变化。之后 search、list 看到的都是「已借出」。

这段代码能工作,但它把「借出状态」放在了 Book 里,任何拿到 Book 引用的代码都能改。6.1 的 addBook 返回 Book,调用方可以写:

Book book = service.addBook("978-7-111-1", "Effective Java", "Joshua Bloch", 2008);
book.available = false;      // 绕过 borrow,直接把书标成借出

服务完全不知道这件事,loans 里也没有记录。第 7 章会把 available 从 Book 里拿掉,把「借出」变成服务内部的状态,正是为了堵住这个口子。

返回内部集合:另一种泄漏

search 每次新建 hits 列表并返回,调用方改它不影响服务。但如果为了省事直接返回内部列表:

static final class Shelf {
    private final List<Book> books = new ArrayList<>();

    void add(Book book) {
        books.add(book);
    }

    List<Book> books() {
        return books;
    }

    int size() {
        return books.size();
    }
}

调用方拿到引用后可以清空它:

Shelf shelf = new Shelf();
shelf.add(new Book("978-7-111-1", "Effective Java", "Joshua Bloch"));
shelf.books().clear();
System.out.println("size = " + shelf.size());

本机 JDK 25 输出:

size = 0

private 只约束字段名的直接访问,不约束通过 getter 拿到的引用。返回可变内部集合,等于把 private 的保护范围缩小到「不能重新赋值字段」,但内容仍然任人修改。 正确做法是返回副本(new ArrayList<>(books))、返回不可修改视图(Collections.unmodifiableList),或者在设计上只暴露查询方法。第 7 章会统一用不可变对象和防御性复制处理。

在 library 里落地

把借还命令接进 CLI,观察「改状态」和「换对象」在真实调用链里的差别。LibraryService 增加一个 Map<String, String> loans 字段记录 ISBN 到会员 ID 的映射,再增加三个方法:

public boolean borrow(String isbn, String memberId) {
    Book book = findByIsbn(isbn);
    if (book == null || !book.available) {
        return false;
    }
    book.available = false;
    loans.put(isbn, memberId);
    return true;
}

public boolean giveBack(String isbn) {
    Book book = findByIsbn(isbn);
    if (book == null || book.available) {
        return false;
    }
    book.available = true;
    loans.remove(isbn);
    return true;
}

public String borrowerOf(String isbn) {
    return loans.get(isbn);
}

borrow 返回 boolean 而不是抛异常,是本章的临时选择:调用方只需要知道「成没成」,失败原因(书不存在、已借出)暂时合并成 false。第 13 章会用异常区分这两种情况。

LibraryApp 增加 borrow 和 return 两条命令:

private void handleBorrow(String[] parts) {
    if (parts.length < 2) {
        System.out.println("usage: borrow <isbn> <memberId>");
        return;
    }
    String[] args = parts[1].split("\\s+");
    if (args.length != 2) {
        System.out.println("usage: borrow <isbn> <memberId>");
        return;
    }
    if (service.borrow(args[0], args[1])) {
        System.out.println("borrowed: " + args[0] + " -> " + args[1]);
    } else {
        System.out.println("cannot borrow: " + args[0]);
    }
}

handleReturn 结构相同,只接收一个 ISBN。编译后回放:

javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp

输入与输出(本机 JDK 25):

add 978-7-111-1|Effective Java|Joshua Bloch|2008
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz|2006
added: Java Concurrency in Practice
borrow 978-7-111-1 m-001
borrowed: 978-7-111-1 -> m-001
borrow 978-7-111-1 m-002
cannot borrow: 978-7-111-1
return 978-7-111-1
returned: 978-7-111-1
borrow 978-7-111-1 m-002
borrowed: 978-7-111-1 -> m-002
borrow 978-9-999-9 m-003
cannot borrow: 978-9-999-9
return 978-7-111-1
returned: 978-7-111-1
quit
bye

第二次 borrow 失败,是因为第一次已经把同一个对象的 available 改成了 false;return 之后又改回 true,所以第三次能借。整条链路没有复制 Book,所有方法操作的都是列表里那一个对象。

如果想验证「换对象不影响调用方」,可以在 borrow 里临时加一行 book = new Book(...) 再改 available,然后观察 search 里的书状态没有变化。这个实验不需要改接口,改完记得删掉——它只用于确认你对引用的理解。

常见错误与边界

以为「对象传引用」等于「引用传递」。 Java 复制的是引用值,不是调用方变量。方法内 book = new Book(...) 不会改变调用方变量的指向;只有 book.field = ... 才会改变共享对象的状态。判断时分两问:有没有对形参重新赋值?有没有通过引用写字段或调用可变方法?

写一个交换方法。 swap(a, b) 交换不了调用方的两个变量,原因同上。需要交换时返回一个包含两个元素的对象或数组,或者让调用方自己交换。

以为 String 拼接会改原字符串。 s += "!"、s.concat("!") 都产生新对象;方法内拼接不影响调用方。StringBuilder 可变,但把它传进方法后追加内容,调用方能看到——因为它改的是对象状态。

返回内部可变集合。 List<Book> books() { return books; } 让调用方可以 clear()、add()、remove(),绕过所有业务校验。要么返回副本,要么返回不可修改视图,要么不暴露集合本身。

用 == 判断两个 Book 是否「同一本书」。 默认的 == 和 Object.equals 都比较引用。两个内容相同的 Book 对象用 == 是 false。第 7 章会讲 equals 的写法,第 9 章换成 record 后会自动生成基于字段的 equals。

把 null 传进方法后直接解引用。 值传递不改变 null:形参拿到 null,book.available 立刻 NPE。方法入口用 Objects.requireNonNull(book, "book") 可以把错误提前到第一行,栈追踪更短。

小结

  • Java 只有值传递:基本类型复制数值,对象复制引用;调用方的变量本身从不交给方法。
  • 方法内重新赋值形参,只改副本,调用方看不到;通过引用修改对象字段或集合内容,调用方看得到。
  • swap 交换不了调用方变量,这是「不是引用传递」的最直接证据。
  • String 不可变,拼接产生新对象;StringBuilder、List、数组是可变的,通过引用修改会影响所有持有者。
  • 返回内部可变集合会泄漏状态,private 字段挡不住 getter 返回的引用。
  • library 的借还逻辑通过引用改 Book.available,调用链上共享同一个对象;这也暴露了「状态放在可变对象里」的风险,第 7 章会收口。

知道了参数怎么传,还有一个问题没回答:方法里的局部变量到底活多久?为什么递归太深会 StackOverflowError?下一节从栈帧的角度看作用域和生命周期。

阅读导航:上一节:6.1 方法签名、重载与可变参数 · 下一节:6.3 作用域、生命周期与栈帧 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发