qinghuan
← Java SE / 全部笔记

单元 16 / Java SE

P0 · 必会主线L1 · 基础

值、引用与对象:从一次方法调用说起

用两个变量指向同一个对象的例子,理解方法为什么能改对象内容,却不能直接替调用方重新赋值。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能预测方法修改对象和替换形参后的调用方结果。

学习理由、准备要求与知识点

为什么学它

理解按值传递,以及多个变量引用同一对象(别名)的情况,才能判断修改 DTO 或共享集合时会影响哪些调用者。

先读这些内容

知识点清单

  • 基本值与引用值
  • 对象与引用变量
  • null 引用
  • 引用别名
  • 方法按值传递
  • 修改对象与重绑形参
  • String 不可变性
  • 显式输入输出设计

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

想知道“改这里,那里会不会变”,先把变量和对象分开看。int n = 3 直接保存整数值;Box box = new Box(3) 中,box 保存的是指向 Box 对象的引用,对象里才有字段,也就是对象保存的数据。写 Box other = box 只复制这个引用,两个变量指向同一对象。所以通过任一个变量修改对象字段,另一个变量也可能看到变化。

方法接收的也是值的副本,这叫按值传递。把 user 传给方法后,方法里的 u 是另一个变量:u.name 改的是共享对象的内容;u = new User() 只是让 u 指向新对象,不会让外面的 user 跟着换。给 int 形参加一同样不会改外部整数变量。能通过引用改对象,并不表示 Java 按引用传递。

null 是引用类型可以保存的特殊值,表示这里没有指向对象,声明变量不等于创建对象。调用 null 的实例成员会失败;在接收输入或交接数据时尽早检查,比在多层调用后意外失败更容易定位。对象已无法再被程序访问,也就是不再可达时,才可能由垃圾回收机制清理。不能简单说某个局部变量离开大括号,对象就立刻销毁;其他引用仍可能让程序访问到它。

文中这几个词,先认一认

别名
多个引用指向同一个对象,通过其中一个修改对象时,其他引用也可能看到变化。
重新绑定
给引用变量重新赋值,让这个变量指向另一个对象或变成 null。
对象身份
是否为同一个具体对象,区别于两个不同对象的内容是否相同。
所有权
设计时约定谁能修改或保存数据、谁负责关闭资源,避免各方互相影响。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

观察一次方法调用可以分四步:先对实参表达式求值,再把这些值交给形参变量,随后执行方法体,最后把返回值交给调用者。返回一个新对象并由调用者显式赋回变量,才能清楚表达替换结果;依靠方法内部重新绑定形参无法实现普通的外部变量交换。

复制也分几种情况。只复制引用,两个变量仍指向同一个对象;复制容器,会得到新的存放位置,但里面的元素对象仍可能共用;再复制元素,才有机会把它们的修改分开。如果原来的两个位置指向同一个对象,而复制时每个位置都单独 new 一个对象,就改变了原有的共享关系。做深复制前,要先决定哪些对象仍应共用、相互引用成环时怎么办,以及不可变对象和外部资源如何处理,不能只说“复制所有字段”。

对象创建后不能再变,使用它的人就少了一件要担心的事:别人会不会把它改掉。但不可修改容器不等于里面的元素也不能改。final 引用只是不允许给这个变量重新赋值,对象本身仍可能变化;多个线程一起使用时,还要检查修改能否被看见,以及多步操作会不会互相干扰。图中的两条变量箭头表示它们共用一个对象;虚线指向新对象,表示只改变了方法里的形参,调用者的变量仍指向原对象。

正在加载流程图…

参数复制的是引用值;对象字段修改与引用变量重新赋值是两个动作。
查看流程图源码(Mermaid)
flowchart TD
 A["调用者变量 box"] --> O["原 Box 对象"]
 B["形参 local:引用值副本"] --> O
 B --> M["修改字段:两方观察同一对象"]
 B -. "local 重新赋值" .-> N["新 Box 对象"]
 A --> K["调用者引用仍指向原对象"]

按顺序看,每一步做了什么

  1. 列出所有变量及其保存的值。
  2. 为每个 new 画独立对象。
  3. 用箭头标出共享引用。
  4. 区分字段赋值与变量重新绑定。
  5. 为复制接口说明隔离深度与别名保留规则。

03对照实现代码

非空检查为何返回原引用而不是创建副本 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Objects.java · java.util.Objects#requireNonNull(T)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    public static <T> T checked(T reference) {
        // 验证只改变控制流,不创建或复制目标对象。
        if (reference == null) throw new NullPointerException("reference");
        return reference;
    }
}

真实 Objects.requireNonNull 的普通重载检查引用是否为 null,合法时直接返回传入引用。泛型返回类型让调用者可以把校验写在赋值或构造器初始化处,而不丢失静态类型。教学模型增加一条固定诊断信息,展示同一机制,不复制复杂错误消息重载的实现。

返回原引用意味着身份保持:checked(box) == box 对非空输入成立。该方法没有承诺对象内容有效,也不会使可变对象成为不可变对象。把名字中的 require 理解为全部业务校验会产生误判;例如用户对象非空,邮箱仍可能缺失,金额字段仍可能越界。

非空检查能把错误尽早定位,但不保证所有后续读取都永远非空。局部变量检查后没有重新赋值,较容易维持这个前提;共享字段却可能被其他线程改动,再从字段读一次是另一回事。可以先取到局部变量,再围绕这同一个引用检查和操作。

这个很短的方法适合作为源码阅读的起点:看清输入、分支、返回与对象分配即可解释它的完整核心语义。不要因为返回值类型与输入相同,就猜测它执行了转换或克隆;反过来,真正的复制方法必须能够指出新对象在哪里创建,以及哪些字段仍沿用旧引用。

代码先调用谁,再调用谁

  1. 调用者计算引用值
  2. requireNonNull 判断 null
  3. 合法分支原样返回引用值
  4. 调用者选择保存或继续使用该引用

04动手写一个例子

复制对象列表,同时保留列表内部的别名关系

输入 List<Box>,Box 只有一个可变 int 字段。返回新列表和新 Box;输入中同一 Box 的重复位置在输出中仍共享同一个新 Box,不同 Box 即使值相等也不能合并。允许 null 元素,拒绝 null 列表。

输入 1
[A(3),A(3)]
输出 1
[A'(3),A'(3)],A' 与 A 不同实例
输入 2
[A(3),B(3)]
输出 2
两个不同的新实例
输入 3
[]
输出 3
[]

普通逐位置复制会把一个共享对象复制成多个不同对象,改变别名结构。需要建立旧对象到新对象的映射:首次遇见旧对象时创建副本并记录,之后遇见同一旧对象就复用已创建副本。映射必须按身份判断键,不能依赖 Box 将来可能重写的值相等逻辑。

IdentityHashMap 采用引用身份作为键比较语义,正好表达这里的旧实例映射。null 元素直接保留 null,因为没有对象需要复制;输入列表本身 null 则属于参数错误。输出列表独立分配,副本字段读取自原对象,但本题只有 int 字段,所以没有更深一层可变引用需要处理。

测试要同时验证值、隔离和关系。只比较所有字段值不足以证明复制正确:输出两个位置可能意外拆散共享,也可能错误合并值相同的不同对象。修改输出副本后原对象应保持不变,而输出中的别名位置应共同看到变化,这两个观察一起验证目标契约。

为什么这样写是对的每处理一个位置,映射为已见每个旧 Box 身份保存且只保存一个新 Box,输出前缀保持相同的别名关系。

时间 期望 O(n)。额外空间 O(n),包含输出列表和至多 n 项身份映射。

每个输入位置至多执行一次身份表查询、一次创建与一次追加;每个不同旧对象只复制一次,散列表常见期望成本作为时间前提。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 47 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
public class Main {
    static final class Box {
        int value;
        Box(int value) { this.value = value; }
    }
    static List<Box> copy(List<Box> input) {
        if (input == null) throw new IllegalArgumentException("input");
        IdentityHashMap<Box,Box> mapping = new IdentityHashMap<>();
        List<Box> output = new ArrayList<>(input.size());
        for (Box old : input) {
            // 空槽位没有目标对象需要复制,保持它的位置。
            if (old == null) { output.add(null); continue; }
            // 按旧对象身份复用副本,保留重复引用关系。
            Box fresh = mapping.get(old);
            if (fresh == null) {
                fresh = new Box(old.value);
                mapping.put(old, fresh);
            }
            output.add(fresh);
        }
        return output;
    }
    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
    public static void main(String[] args) {
        require(copy(List.of()).isEmpty(), "empty");
        Box a = new Box(3), b = new Box(3);
        List<Box> repeated = copy(List.of(a,a));
        require(repeated.get(0) == repeated.get(1), "preserve alias");
        require(repeated.get(0) != a, "new identity");
        List<Box> distinct = copy(List.of(a,b));
        require(distinct.get(0) != distinct.get(1), "do not merge equal values");
        // 修改副本同时检验隔离与副本内部的共享。
        repeated.get(0).value = 9;
        require(a.value == 3 && repeated.get(1).value == 9, "isolation");
        List<Box> withNull = new ArrayList<>(); withNull.add(null);
        require(copy(withNull).get(0) == null, "null element");
        boolean rejected = false;
        try { copy(null); } catch (IllegalArgumentException e) { rejected = true; }
        require(rejected, "null list");
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空列表返回独立空列表。
  • null 元素保留,null 列表拒绝。
  • 值相等的不同对象不合并。
  • 重复引用只创建一份副本。
进阶选读 · 可选深入:含环对象图复制

场景对象字段继续引用其它对象,可能形成环或共享子图。

做法先创建并登记空壳副本,再遍历引用边填充字段;用工作队列避免无界递归,并为不可复制资源制定策略。

复杂度对 V 个节点 E 条引用边,期望 O(V+E) 时间与空间。

好处与代价复制不自动获得并发一致快照,文件句柄与连接等资源也不能按普通字段复制。

05项目里怎么用

后端方法收到 DTO 后直接改字段,会让调用者观察到副作用;这可能是约定的更新,也可能是意外污染。接口应通过名字、返回值和文档说明是否修改输入。需要转换时返回新的结果对象,让调用者决定如何替换,更容易测试和复用,但要评估额外分配。

缓存中的可变对象尤其需要明确所有权。若返回内部对象引用,某个请求的修改可能传播到其它请求;只包装不可修改列表不能防止元素字段变化。可以存储不可变值、在边界复制,或把修改操作集中到拥有同步策略的组件,具体选择由访问频率和一致性需求决定。

深复制不自动等于一致快照。复制过程中别人若正在修改,可能把多个不同时刻的字段拼在一起,而这样的整体状态从未真实存在。需要一致结果时,要采用不可变整体替换、合适的锁或版本检查。复制还要考虑资源、引用环和共用对象,不能把序列化再读回来当成万能办法。

容易踩的坑

  • 把 Java 称为按引用传递。
  • 认为 final 或非空检查会冻结对象内容。
  • 逐位置深复制意外破坏共享关系。
  • 把没有同步的复制结果当成一致快照。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
共享可变引用少分配且可表达共同状态副作用传播与并发管理复杂明确所有权的内部状态
不可变值替换状态变化易追踪且便于共享更新时需创建新值配置、标识与读多数据
边界复制隔离调用者修改复制深度与快照一致性需要设计公开接口与缓存边界

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01把形参设为 null 会改变外部变量吗?

展开参考答案与推导

不会。形参与调用者变量分别保存引用值,赋 null 只改变形参。通过原引用修改对象字段则作用于共享对象,所以应按赋值目标区分这两类行为。

02非空检查为什么不等于对象有效?

展开参考答案与推导

它只证明这一次引用值不是 null,并未检查内部字段、业务范围或并发状态。对象可能存在但处于不合法业务状态,应按对象职责执行进一步验证。

03值相等与身份相同有什么区别?

展开参考答案与推导

身份回答是不是同一个实例,值相等由类型定义内容规则。本题复制拓扑需要保留实例关系,所以使用身份映射,不能因为两个 Box 的数值相同就合并。

换个情况,还会处理吗

01Service 改 DTO 后控制器也看见变化,原因是什么?

展开参考答案与推导

两层通过不同引用变量访问同一个 DTO 对象,字段修改作用于共享实例。若接口不应修改输入,返回新结果并在边界明确转换,而不是把方法参数改成 final 期待隔离。

02复制缓存列表后元素仍被串改,哪里没有复制?

展开参考答案与推导

很可能只复制了列表容器,元素引用仍指向缓存中的可变对象。检查每一层新对象的创建位置,决定采用不可变元素还是逐元素复制,并明确并发快照要求。

03想在方法里交换调用者两个引用,普通参数够吗?

展开参考答案与推导

普通参数重新赋值只交换局部副本。可以返回包含新顺序的结果,由调用者显式赋回,或修改调用者传入的容器槽位;后一种方式必须明确副作用。

写代码试一试

01复制后验证输入列表也未被修改

输入 / 条件
[A]
输出 / 预期
输入长度仍为 1

提示 向输出列表追加元素。

展开参考答案与推导

调用 copy 后向返回列表追加一个新 Box,再断言输入大小仍为一。它验证外层容器隔离,与修改 Box 字段验证元素隔离是两个独立维度。

02让 Box 增加可变 List 字段

输入 / 条件
Box.items=[1,2]
输出 / 预期
副本列表独立

提示 先规定元素是否可变。

展开参考答案与推导

在复制新 Box 时额外创建新的列表容器。如果列表元素也是可变对象,还需决定共享或递归复制;不能仅新增字段赋值就继续声称整对象已经完全隔离。

03验证别名位置并非必须相邻

输入 / 条件
[A,B,A]
输出 / 预期
输出第 0 与第 2 项身份相同

提示 身份映射持续到整个调用结束。

展开参考答案与推导

加入三项输入并比较输出第零与第二项的 ==,同时确认第一项不同。映射若只记上一对象会在不相邻重复时失败,因此该用例检查映射的作用范围。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能预测方法修改对象和替换形参后的调用方结果。

本篇重点加练

重点检查方法调用中的值传递与输入输出合同,避免调用者误判副作用和返回值。

  • 方法按值传递
  • 显式输入输出设计

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    设 Box 只有 int value 字段:a=new Box(7),b=a,c=new Box(7),随后 b.value=9、a=null。逐行画出变量槽位和对象关系,说明每个可执行字段读取的值以及哪些读取需要拒绝;先推演再运行。

    提交内容
    各步骤对象图、预测表和实际运行记录。
    怎样算完成
    • 区分变量、引用值与对象身份
    • 逐步覆盖 null 和别名变化

    练到的知识点基本值与引用值 · 对象与引用变量 · null 引用 · 引用别名

  2. 作业 02原理问答

    设计两个同名但职责不同的方法草案:一个修改传入 Box 的字段,一个试图替换调用方 Box。说明各自输入输出合同,并用调用前后变量图审查草案是否兑现合同。

    提交内容
    两份方法合同与参数传递图。
    怎样算完成
    • 区分形参与调用方变量
    • 合同能从调用结果验证

    练到的知识点方法按值传递 · 修改对象与重绑形参

  3. 作业 03代码推演

    方法 receive(int n, Box b) 先执行 n+=2,再修改 b.value,最后返回 n;调用表达式又把返回值赋给另一个变量。自定具体初值,逐个列出调用前后所有变量的来源,禁止只展示最终打印值。

    提交内容
    带实参求值、形参建立及返回赋值的时间线。
    怎样算完成
    • 每个变量均对应声明位置
    • 输入样本能分辨形参和返回值

    练到的知识点方法按值传递

综合应用

  1. 作业 04设计评审

    昵称规范化函数接收 String,产品要求保留原始昵称供审计、返回规范化昵称供展示。评审“方法返回 void 并在内部反复给形参赋值”的提案,重新定义接口与调用方式。

    提交内容
    接口签名、调用示例及原始值保留说明。
    怎样算完成
    • 解释 String 操作结果的去向
    • 输入与输出职责明确

    练到的知识点String 不可变性 · 显式输入输出设计

  2. 作业 05编程实践

    编写仅使用基础语法的 swapFields(Box left, Box right),交换两个对象的 value;自行定义 null、同一对象和相同数值的处理合同。不要把交换对象字段与交换调用方变量混为一项需求。

    提交内容
    完整 Java 文件、自写正常及边界测试、编译运行证据。
    怎样算完成
    • 测试验证合同而非只打印
    • 覆盖两个参数指向同一实例
    • 能说明函数的副作用范围

    练到的知识点基本值与引用值 · 对象与引用变量 · 引用别名 · 方法按值传递 · 修改对象与重绑形参

  3. 作业 06面试场景

    一个个人记账程序把同一笔 Record 同时放入“本月记录”和“待审核记录”,编辑前者后审核页面随之改变。判断这是否违反需求需要询问哪些事实,并给出两个不同业务合同下的接口设计。

    提交内容
    事实清单、关系图和两份接口提案。
    怎样算完成
    • 先确定共享是否为业务要求
    • 说明每种设计的修改传播范围

    练到的知识点引用别名 · 显式输入输出设计

  4. 作业 07编程实践

    实现两种扣减积分接口:一种返回新的 Balance,另一种按合同修改传入 Balance;用同一组输入比较调用方必须执行的步骤,并明确余额不足时的处理。

    提交内容
    Java 代码、自写正常/不足/零扣减测试与运行证据。
    怎样算完成
    • 调用方式符合各自合同
    • 失败时原对象状态有检查

    练到的知识点方法按值传递 · 显式输入输出设计

  5. 作业 08设计评审

    转换方法同时修改输入对象并返回另一个对象,接口文档只写“更新完成”。为调用方、日志记录者和后续处理者分别列出歧义,再制定能独立验收的合同。

    提交内容
    三个使用场景和重写后的方法合同。
    怎样算完成
    • 副作用与返回值身份均明确
    • 每项合同有可观察判据

    练到的知识点显式输入输出设计

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成集合基础后:评审一个含父子关系与共享标签的对象图导出需求,规定哪些身份关系必须保留、哪些对象不得复制;提出能够检测实现是否满足这些约束的验收案例。

    提交内容
    复制边界文档与至少五个关系验收案例。
    怎样算完成
    • 明确共享与环的合同
    • 不把字段值相同等同于身份相同

    练到的知识点引用别名 · 显式输入输出设计

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    把积分接口的新需求改成“允许调用者忽略返回值,但任何已保存的旧对象都必须保持原样”。分析这些约束能否与原调用方式同时满足;可修改接口,但要说明调用方兼容性。

    提交内容
    约束表、可行性论证与调用示例。
    怎样算完成
    • 论证涉及对象身份和数据变化
    • 可行方案或冲突均有具体证据

    练到的知识点方法按值传递 · 显式输入输出设计

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗