一段代码两种表现,谁都遇过:方法里给参数对象 set 了字段,调用方看到了变化;改成给参数重新赋值,调用方什么都没变。于是有人总结"Java 是值传递,但对象传的是引用",这句话半对半错,代价是它让人以为"传引用"意味着共享。
准确的模型只有一句:Java 永远传值,对象的值是引用本身(一个地址)。所以你能通过这个地址改对象,但不能通过修改这个地址改变调用方的变量。
这个区别一旦模糊,就会产生两类真实事故:一类是方法意外改坏了调用方的对象(缺防御性拷贝),另一类是以为改好了其实没改(对参数重新赋值)。
1. 一个地址被复制了一份
把每次调用看成"复制一份值放进栈帧":
| 实参类型 | 复制的是什么 | 方法内能改到调用方吗 |
|---|---|---|
基本类型(int) |
数值本身 | 不能 |
| 对象引用 | 地址 | 能改对象内容,不能改调用方的变量 |
| 数组 | 地址 | 同上(改元素能,重新赋值不能) |
不可变对象(String) |
地址 | 不能(改不了内容) |
final 形参 |
地址,且不可改指向 | 不能重新赋值,能改内容 |
第二行与第三行的"能/不能"就是全部混乱的来源。list.add(x) 能生效,list = new ArrayList<>() 不生效——两者都是"对象操作",但一个动堆上的东西,一个动栈上的地址。
static void mutate(List<String> l) { l.add("b"); } // 生效
static void reassign(List<String> l) { l = new ArrayList<>(); l.add("b"); } // 不生效
// 调用方: List<String> a = new ArrayList<>(List.of("a"));
// mutate(a) → a = [a, b]
// reassign(a) → a = [a] ← 新列表在方法结束时就没人引用了
2. 为什么"防御性拷贝"是成本决策,不是习惯
知道"引用被共享"之后,下一个问题是:什么时候必须拷贝。这不是风格问题,是"谁能改到这个对象"的边界问题。
| 场景 | 不拷贝的后果 | 建议 |
|---|---|---|
| 构造器接收可变集合 | 调用方后续修改会"改到"你的对象 | 拷贝进(List.copyOf) |
| getter 返回内部集合 | 外部 clear() 直接清空内部状态 |
拷贝出或返回不可变视图 |
方法接收 Date |
调用方可改时间,你的对象跟着变 | 改 Instant/LocalDate |
| 缓存 key 用可变对象 | key 被改后哈希变,查不到了 | key 必须不可变 |
| 并发共享的配置对象 | 一方改、多方读到半更新状态 | 不可变 + 发布替换 |
第一、二行合起来是"进出都要拷贝"。听起来浪费,实际成本极低:小集合拷贝是 O(n) 且 n 通常 < 20;而它换来的是"这个对象的生命周期内,没人能在我不知道的时候改它"。
// 进出都拷贝:两行代码换掉一整类"谁改的"疑难
public final class Order {
private final List<Item> items;
public Order(List<Item> items) { this.items = List.copyOf(items); } // 进
public List<Item> items() { return items; } // 出(本身就是不可变的)
}
// 注意 List.copyOf 也拒绝 null 元素,这是顺带的收益
3. "改了却没生效"的四种形状
不是所有失效都来自"重新赋值"。四种形状要分开看,因为修法完全不同。
| 形状 | 代码特征 | 为什么没生效 |
|---|---|---|
| 形参重新赋值 | param = new X() |
动的是栈上的地址副本 |
| 传的是不可变对象 | s.trim()、d.plusDays(1) |
返回新对象,原对象没变 |
| 传的是基本类型 | void inc(int x) { x++; } |
复制的数值 |
| 改的是内部副本 | 方法内 new HashMap<>(map) 后改 |
改的是副本 |
第二行在 Java 时间 API 上最容易踩:LocalDate 的所有"修改"方法都返回新实例,date.plusDays(1) 单写一行等于没写。这类 bug 不会报错,只会让业务日期差一天。
// 四个"看起来改了"的写法,只有一个真的改了
date.plusDays(1); // 丢弃返回值 → 没改
date = date.plusDays(1); // 改了局部变量 → 生效
list.sort(cmp); // 原地排序 → 生效(List.sort 是原地)
str.replace("a","b"); // 返回新串 → 没改
4. 什么时候"共享引用"是对的
防御性拷贝不是无条件正确。有些场景共享是设计意图,拷贝反而破坏语义。
| 场景 | 该共享 | 理由 |
|---|---|---|
| 大对象只读传递 | 是 | 拷贝成本 >> 风险,且约定不改 |
| Builder 链式调用 | 是(返回 this) | 语义就是累积同一对象 |
| 流式处理管道 | 是 | 中间对象本就不打算复用 |
| 缓存/池化对象 | 是 | 拷贝会失去池的意义 |
| 跨线程共享 | 否,除非不可变 | 共享可变状态 = 竞态 |
第五行是硬线:跨线程共享的对象要么不可变,要么由锁保护,没有第三条路。所以"要不要拷贝"的真正判据不是"性能",而是"这个对象会不会同时被两方持有并至少一方会改"。
// 判据三问,任何一问为"是"就必须隔离
1 对象离开当前作用域后,还会被别处持有吗?
2 持有方里有任何一方会修改它吗?
3 修改与读取可能发生在不同线程吗?
// 1&2 是 = 拷贝或转不可变;再加 3 = 必须不可变或加锁
5. 值对象 vs 实体:拷贝策略的分水岭
领域建模里有个更省事的判据:值对象默认不可变且按值比较,实体按引用比较且可变。这个分法让"要不要拷贝"变成"它属于哪一类"。
| 类型 | 标识 | 可变性 | 拷贝策略 |
|---|---|---|---|
| 值对象(金额、区间、地址) | 无 id,按内容相等 | 不可变 | 无需拷贝,可自由共享 |
| 实体(订单、用户) | 有 id,按 id 相等 | 可变 | 传递时明确所有权 |
| DTO | 无 id | 可变但短命 | 边界处转换,不跨层共享 |
| 缓存条目 | 有 key | 必须不可变 | key 与值都不可变 |
第三行的"短命"是关键:DTO 只在一次请求内活,跨层传递不必拷贝;但一旦进缓存、进异步队列,它就不再短命,必须转成不可变结构。
// 同一份数据,两种生命周期,两种处理
record Money(BigDecimal amount, Currency cur) {} // 值对象:record 天生不可变
class Order { // 实体:有 id,可变
private final long id;
private List<Item> items; // 改这里要显式方法
public void replaceItems(List<Item> next) { this.items = List.copyOf(next); }
}
6. 排错:五种"改了没生效 / 改坏了别人"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 方法内赋值后外部没变 | 形参重新赋值 | 返回新值,或改对象内容 |
| 外部改集合导致内部状态乱 | getter 直接返回内部集合 | 返回不可变视图 |
| 缓存查不到刚存的 key | key 是可变对象且被改 | key 转不可变 |
| 时间算错一天 | 忽略了不可变对象的返回值 | 接住返回值 |
| 并发下读到半更新对象 | 共享可变对象无保护 | 不可变 + 整体替换 |
第五行的典型现场是"配置热更新":一个 Map 被多线程读写,更新时 clear() + putAll(),中间窗口里读线程看到空表。修法不是加锁,而是构造新 Map 后整体替换引用(volatile 或 AtomicReference)。
7. 边界:什么情况不必纠结传值语义
| 场景 | 为什么不必 | 说明 |
|---|---|---|
| 全是不可变对象 | 没有"改"这回事 | record / String / 时间类型 |
| 单线程短生命周期 | 无并发、无逃逸 | 拷贝是纯开销 |
| 大数组/大缓冲区 | 拷贝成本不可接受 | 明确约定所有权与只读 |
| 函数式数据流 | 每步返回新值 | 天然无共享 |
| 与外部库交互 | 语义由对方定义 | 按其契约拷贝或共享 |
判断口径一句话:当"这个引用会不会在我不控制的地方被改动"有明确答案时,拷贝策略就定了;答不出来,说明这个对象的所有权没定义清楚,那才是要修的东西。因为拷贝只是兜底——真正的设计动作是给每个可变对象指定唯一的所有者。
8. 自测
- "Java 传值,对象传引用"这句话错在哪?准确的说法是什么?
list.add(x)生效而list = new ArrayList<>()不生效,机制上的差别是什么?- 防御性拷贝为什么要求"进出都拷贝"?只做一半会怎样?
date.plusDays(1)单写一行为什么不生效?这类错误为什么不会报错?- "要不要共享引用"的三个判据问题是什么?哪一问是硬线?
- 值对象与实体在拷贝策略上的分水岭是什么?
9. 一句话总结
Java 永远传值,对象的值是地址:所以能改地址指向的东西,不能改地址本身。由此派生两条纪律——进出边界都拷贝(或转不可变),以及可变对象必须有唯一所有者。判据是三个问题:会不会被别处持有、会不会被改、改与读是否跨线程;前两问答"是"就要隔离,第三问也答"是"就只能不可变或加锁。