本节目标:能准确说出「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 作用域、生命周期与栈帧 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。