2607 字
约 8 分钟
12
S04-A27 · 传值还是传引用:为什么"方法里改了对象"却没生效

一段代码两种表现,谁都遇过:方法里给参数对象 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. 自测

  1. "Java 传值,对象传引用"这句话错在哪?准确的说法是什么?
  2. list.add(x) 生效而 list = new ArrayList<>() 不生效,机制上的差别是什么?
  3. 防御性拷贝为什么要求"进出都拷贝"?只做一半会怎样?
  4. date.plusDays(1) 单写一行为什么不生效?这类错误为什么不会报错?
  5. "要不要共享引用"的三个判据问题是什么?哪一问是硬线?
  6. 值对象与实体在拷贝策略上的分水岭是什么?

9. 一句话总结

Java 永远传值,对象的值是地址:所以能改地址指向的东西,不能改地址本身。由此派生两条纪律——进出边界都拷贝(或转不可变),以及可变对象必须有唯一所有者。判据是三个问题:会不会被别处持有、会不会被改、改与读是否跨线程;前两问答"是"就要隔离,第三问也答"是"就只能不可变或加锁。

S04-A27 · 传值还是传引用:为什么"方法里改了对象"却没生效
https://www.clxhxhhr.top/posts/6728/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。