面试知识

Object(对象基类)、String(字符串)与不可变设计

09-Java核心基础与集合 面试知识整理。

Object(对象基类)、String(字符串)与不可变设计

你完成本章后,能够把“对象到底何时算相等”“为什么键会失联”“为什么 String(字符串)能安全复用”“如何设计真正不可变的订单或配置快照”串成一条可用于面试、排障和项目设计的主线。

1. 简历关联与面试主线

这不是孤立的语言题。在支付资金一致性中,幂等键必须稳定;在 WMS(仓储管理系统)库存聚合中,缓存键必须准确且可复现;在 Runner(执行器)调度中,运行中的任务不能被配置热更新“改到一半”;在异步导出中,整份文件拼在内存里会放大堆压力。它们共同依赖两件事:业务身份如何定义,以及共享数据如何保持稳定

建议按下面顺序回答相关面试题:

  1. 先区分地址、对象身份和业务身份;
  2. 再说明 HashMap(哈希映射)用“先定位、再比较”的两阶段算法;
  3. 由可变键失联,引出不可变键和稳定幂等键;
  4. 最后用 String(字符串)与订单/配置快照说明不可变设计的收益、代价和分布式边界。

2. Object(对象基类):默认身份语义与业务相等

Object(对象基类)是所有普通 Java(编程语言)对象的根类。它的默认 equals(相等判断方法)实现等价于 this == obj:比较两个引用是否指向同一个对象。这是 identity semantics(身份语义),而不是“字段看起来一样就相等”。

2.1 五个容易混淆的概念

概念判断问题例子不能替代什么
引用相等两个变量是否指向同一对象?a == b不能判断订单号是否相同
默认身份语义未重写时,对象是否是同一实例?Object(对象基类)的 equals(相等判断方法)不能表达业务去重
business equality(业务相等)两个对象是否代表同一业务事实?同一渠道、同一支付事件标识不能省略哈希契约
equivalence relation(等价关系)该“相等”规则是否稳定、可推理?自反、对称、传递、一致、非空不能用随时变化的状态字段定义
哈希相同两个对象是否先进入同一个候选范围?两个键落入同一 bucket(桶)不代表对象一定业务相等

业务相等不是“所有字段都相同”。支付事件可按“渠道 + 渠道事件标识”定义;订单实体可按不可变订单号定义;尚未持久化、没有稳定业务标识的临时命令对象,通常保留默认身份语义更安全。把状态、更新时间、重试次数、可变明细塞进相等逻辑,会让“同一个对象”的定义随着业务流程漂移。

2.2 equals(相等判断方法)的五项契约与继承陷阱

契约要求违反后的现象
自反性x.equals(x) 必须为 true(真值)同一对象在集合中都可能判断失败
对称性x.equals(y)y.equals(x) 结果一致父类/子类混用时出现单向相等
传递性x=yy=z 时,x=z去重、排序、测试结论互相矛盾
一致性参与比较的状态未变时,多次结果相同缓存和集合行为随机化
非空性x.equals(null) 必须为 false(假值)空值路径出现错误或歧义

最危险的陷阱是给可继承的值对象追加字段。假设父类金额值对象按金额和币种比较,子类代金券金额对象又希望比较时额外考虑券批次:父类若接受子类,子类再比较批次,就可能出现父类认为相等、子类认为不相等,破坏对称性;若忽略批次,又丢掉子类身份。解决方式不是补更多条件分支,而是优先让值对象不可继承,或使用组合;确有继承层次时,明确只和同一运行时类型比较,并评估子类是否仍适合作为同一等价关系的一员。

/** 支付幂等身份,只包含创建后不变化的业务字段。 */
public final class PaymentEventKey {
    private final String provider;
    private final String eventId;

    /**
     * 创建稳定的支付事件身份。
     * @param provider 渠道标识,不能为空
     * @param eventId 渠道事件标识,不能为空
     */
    public PaymentEventKey(String provider, String eventId) {
        this.provider = java.util.Objects.requireNonNull(provider);
        this.eventId = java.util.Objects.requireNonNull(eventId);
    }

    /**
     * 按渠道与事件标识判断是否代表同一支付事件。
     * @param other 待比较对象
     * @return 两个稳定业务字段均相同时返回 true
     */
    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof PaymentEventKey)) return false;
        PaymentEventKey that = (PaymentEventKey) other;
        return provider.equals(that.provider) && eventId.equals(that.eventId);
    }

    /**
     * 返回与相等字段一致的哈希值。
     * @return 渠道与事件标识的组合哈希值
     */
    @Override
    public int hashCode() {
        return java.util.Objects.hash(provider, eventId);
    }
}

代码中没有把“支付状态”放进 equals(相等判断方法)或 hashCode(哈希码方法)。状态是流程属性,会迁移;幂等身份是识别属性,应保持稳定。对象的内存规则只负责进程内语义,跨进程仍必须由数据库唯一约束、事务和状态机兜底,后文会展开。

2.3 章节题:对象相等的边界

热门面试题

  1. 问题(基础题)== 和 equals(相等判断方法)有什么区别?

    • 考点:引用相等、默认身份语义、业务相等。
    • 回答思路:先按基本类型与引用类型区分 ==,再说明 Object(对象基类)默认实现,最后落到业务对象的字段语义。
    • 详细答案:对基本类型,== 比较数值;对引用类型,== 比较是否指向同一个对象。Object(对象基类)默认的 equals(相等判断方法)也是引用比较,所以没有重写时两者在效果上相同。业务对象常要回答“是否代表同一业务事实”,例如从消息反序列化出的支付事件与从数据库查询出的支付事件虽然是不同实例,只要渠道和渠道事件标识相同,就应当业务相等。此时重写 equals(相等判断方法)不是为了让代码“好看”,而是把业务身份写成可执行规则。不能把地址相等、内容相等和数据库同一行混成一个概念:实体更新前后可以是同一行,但若把可变状态参与比较,它在集合中的相等结果会变化。
    • 进阶追问:为什么继承会让 equals(相等判断方法)变得危险?
    • 进阶回答:父类若允许与子类比较,子类又增加了影响业务身份的字段,就很难同时保住对称性和传递性。父类按公共字段判断相等,子类按公共字段加新增字段判断,可能得到 父类对象.equals(子类对象) 为 true(真值)、反向为 false(假值)。优先使用不可继承的值对象或组合;若必须比较运行时类型不同的对象,就应明确它们不是同一等价关系,不能为了复用而强行相等。
  2. 问题(原理题):equals(相等判断方法)五项契约为什么是集合正确性的前提?

    • 考点:等价关系、一致性、集合去重。
    • 回答思路:逐项说明契约,再用集合的查找和去重依赖这些稳定判断来解释后果。
    • 详细答案:集合需要把“是否同一个元素”的判断当作稳定事实。自反性保证元素能匹配自己;对称性保证调用方向不会改变结果;传递性保证三方比较能形成一致分组;一致性保证未改变身份字段时,重复查找不会时灵时不灵;非空性让空值处理有确定边界。违反其中任一项,HashSet(哈希集合)可能保留逻辑重复项,HashMap(哈希映射)的覆盖、删除或断言会在不同调用路径得到不同答案。尤其是把状态、时间戳或可变列表纳入比较,会直接破坏一致性;这不是集合的缺陷,而是键的业务身份被设计成了移动目标。
    • 进阶追问:订单实体应该按全部字段实现相等吗?
    • 进阶回答:通常不应。订单号一旦生成就是识别该订单的稳定字段,金额、地址、状态和更新时间会合法变化。若订单对象确实要作为内存集合键,应只使用稳定且已分配的业务标识;如果对象尚未有订单号,不应贸然把多个“待创建订单”按可变内容等同。持久化层的同一行、领域对象的业务等价和一次请求的命令参数,也可以有不同边界,必须在模型中说清楚。
  3. 问题(项目题):支付回调对象能直接作为幂等集合的键吗?

    • 考点:稳定身份、可变键、多实例边界。
    • 回答思路:先说明回调对象为什么可变,再给出稳定键、唯一约束、状态机和失败恢复方案。
    • 详细答案:不建议直接使用完整回调对象。它通常会在验签、解析、补充订单号和处理状态后发生变化;只要这些字段参与 equals(相等判断方法)或 hashCode(哈希码方法),键就会失联。正确做法是把渠道标识与渠道事件标识规范化为独立、不可变的幂等键;事务内先写入带唯一约束的事件记录,冲突就读取既有结果并返回成功;随后由支付状态机限制“已成功不能再次成功扣款”等非法迁移。进程内 HashSet(哈希集合)或 Redis(远程字典服务)只适合作为削峰和快速拦截:服务重启、多实例并发、锁过期或缓存丢失时,它们都不能替代数据库唯一约束和状态机。
    • 进阶追问:两个重复回调同时到达时,如何避免两个线程都继续扣款?
    • 进阶回答:让两条请求竞争同一个数据库唯一键,并把“写事件记录、推进支付单状态、写账务或待发送事件”放入同一事务。先插入成功的事务才拥有继续处理资格;后来的事务因唯一键冲突而停止副作用,并查询既有处理状态。若首个事务回滚,唯一键记录也回滚,后续请求可安全重试;若消息发送失败,应由事务内记录的待发送事件异步重发,而不是再次执行业务扣款。

3. HashMap(哈希映射):两阶段查找与可变键失联

3.1 为什么 equals(相等判断方法)与 hashCode(哈希码方法)必须一起重写

HashMap(哈希映射)不能为了找一个键而扫描全部 bucket(桶)。它先调用键的 hashCode(哈希码方法),再进行扰动并按数组容量计算下标;只在命中的 bucket(桶)内,才比较哈希值和 equals(相等判断方法)。因此:**业务相等的两个对象必须有相同哈希值;哈希值相同的两个对象不必业务相等。**后者就是 hash collision(哈希冲突),由第二阶段消解。

flowchart TD
    A[查询 key(键)] --> B[调用 hashCode(哈希码方法)]
    B --> C[扰动高位并计算 index(下标)]
    C --> D{目标 bucket(桶)为空?}
    D -- 是 --> E[返回未命中]
    D -- 否 --> F[逐个比较节点哈希]
    F --> G{哈希相同且 equals(相等判断方法)为 true(真值)?}
    G -- 是 --> H[命中节点并返回 value(值)]
    G -- 否 --> I{还有链表或树节点?}
    I -- 是 --> F
    I -- 否 --> E

图的逐步解读:

  1. 节点 A 到 C:hashCode(哈希码方法)只负责快速缩小候选范围。以 JDK(Java 开发工具包)8 为例,HashMap(哈希映射)会把原始哈希与其高位混合,再用 (容量 - 1) & 混合哈希 定位;这不是业务相等判断。
  2. 节点 D 到 E:桶为空即结束,所以若相等对象的哈希不同,它们可能从一开始就走进不同桶,根本不会调用 equals(相等判断方法)。
  3. 节点 F 到 H:同桶中的节点还要比较哈希和 equals(相等判断方法)。这一步允许 hash collision(哈希冲突)存在,却不会把不同键误判为同一个键。
  4. 节点 I:桶内结构可能是链表或树;不论结构如何,第二阶段的业务相等判断都不能省。
  5. 适用范围与误区:这是进程内 HashMap(哈希映射)查找的语义,不是数据库查重协议。即使内存映射查到了“已处理”,也不能证明其他实例、重启后的进程或数据库没有并发写入。

3.2 bucket(桶)定位、冲突与可变键数据演绎

下表用容量为 16 的 HashMap(哈希映射)演示。数值用于说明定位路径;真实对象的哈希值由其实现决定。这里的“混合哈希”表示参与下标计算后的结果。

时刻键的稳定字段混合哈希混合哈希 & 15实际节点位置/结果
T1 写入订单号 A100、仓库 80x000100044节点写入 bucket(桶)4
T2 正常查询订单号 A100、仓库 80x000100044进入 bucket(桶)4,equals(相等判断方法)命中
T3 键被修改订单号 A100、仓库改为 90x0002000E14旧节点仍物理留在 bucket(桶)4
T4 再次查询订单号 A100、仓库 90x0002000E14查 bucket(桶)14,返回未命中
T5 删除订单号 A100、仓库 90x0002000E14删除失败,bucket(桶)4 的旧节点难以正常访问

这叫“可变键失联”:对象没有消失,引用还在映射内部,但新的定位路径无法到达旧位置。若新旧哈希巧合落在同一桶,equals(相等判断方法)的结果也可能变化,问题只会更隐蔽。

场景错误设计直接后果修复方案
库存缓存键用含“可售数量”的对象作键数量变化后读取旧库存失败或产生重复项用租户、仓库、SKU(库存单位)等不可变维度组成规范字符串键
支付幂等用完整回调对象作键解析后补字段导致集合找不到已处理事件用渠道 + 渠道事件标识;数据库唯一约束最终裁决
订单快照用可变订单实体作映射键地址/状态更新影响相等规则使用订单号键,快照对象与实体分离
Runner(执行器)配置用热更新中的配置对象作键版本切换后任务查不到原配置用不可变配置快照和版本号;原子替换当前引用

修复原则很简单但不能简化为“所有对象都重写”:只有业务确实需要值语义时,才让稳定字段同时参与 equals(相等判断方法)和 hashCode(哈希码方法);一旦作为键写入,禁止改变这些字段。若身份自然可变,则不要把对象本身作为键,改用稳定标识或在变化后删除旧键、创建新键。后者只适用于受控的单进程重建流程,不能作为支付、库存等分布式正确性保障。

3.3 章节题:哈希键设计与故障定位

热门面试题

  1. 问题(原理题):为什么重写 equals(相等判断方法)必须同时重写 hashCode(哈希码方法)?

    • 考点:两阶段查找、哈希冲突、相等契约。
    • 回答思路:先讲哈希定位,再讲桶内业务比较,最后说明“相等对象同哈希”的必要性与反向不成立。
    • 详细答案:HashMap(哈希映射)先以 hashCode(哈希码方法)将搜索范围缩小到一个 bucket(桶),然后才调用 equals(相等判断方法)确认业务是否相等。若两个对象业务相等却返回不同哈希值,它们会进入不同桶,查询不会遍历到对方,导致 put(写入)后 get(读取)不到、HashSet(哈希集合)保留逻辑重复元素、remove(删除)失败。反过来,哈希相同只表示候选范围相同,仍要依靠 equals(相等判断方法)处理 hash collision(哈希冲突)。只重写 hashCode(哈希码方法)而保留地址比较,也不能完成业务去重;两个方法描述的是同一份身份字段的“快速索引”和“精确确认”。
    • 进阶追问:哈希冲突是不是说明 hashCode(哈希码方法)实现错了?
    • 进阶回答:不是。哈希值空间有限,而对象状态组合远多于整数取值,冲突是正常现象;好的哈希实现目标是让常见输入尽量分散,并始终满足“相等对象同哈希”。不能通过要求绝对不冲突来替代 equals(相等判断方法)。如果线上出现某一桶极端拥挤,应检查键是否低基数、哈希实现是否只用了固定字段,或是否遭遇恶意构造输入;这影响性能,但不应改变正确性。
  2. 问题(排障题):HashMap(哈希映射)里明明有一个订单键,为什么 get(读取)返回空?

    • 考点:可变键失联、字段审计、复现路径。
    • 回答思路:先确认是否修改了相等/哈希字段,再对比写入和读取时的哈希、下标与对象状态,最后给出止血和长期修复。
    • 详细答案:先不要只打印对象地址或 toString(字符串展示方法)。应记录写入和读取时的键字段、hashCode(哈希码方法)、映射容量,以及计算出的 bucket(桶)下标;如果同一对象写入时下标为 4、读取时变为 14,就能证明参与哈希的字段被改动。常见根因是把仓库、状态、时间或可变集合写入了相等逻辑。临时止血是停止对已入表键的原地修改,并用稳定订单号重新建立映射;长期修复是把键设计为不可变值对象,限制字段可见性,在测试中加入“写入后修改字段仍应被禁止”的用例。不要通过遍历整张映射寻找节点来掩盖问题,那会把 O(1)(常数复杂度)附近的查找退化成偶发全表扫描,且仍无法修正重复键。
    • 进阶追问:为什么只在某些订单上偶发?
    • 进阶回答:若修改后的混合哈希刚好仍落在原 bucket(桶),表面上可能继续命中;若字段修改发生在读取前、对象只用于一个请求或映射很小,问题也会被掩盖。扩容后重新分布还会改变症状。这种偶发性正是可变键最危险之处:不是“没有问题”,而是定位路径偶然重合。必须按契约消除根因,而不是依赖压测未复现。
  3. 问题(项目题):如何规范库存缓存键,避免把进程内集合当成分布式正确性?

    • 考点:键规范、缓存边界、库存最终保障。
    • 回答思路:先定义库存唯一维度,再定义可读、稳定、版本化的键格式,最后说明扣减的数据库条件更新或状态约束。
    • 详细答案:库存缓存键应只含定义库存范围的不变维度,例如租户、货主、仓库、SKU(库存单位)和库存类型,并统一大小写、空白、分隔符与编码规则;例如逻辑格式可为“库存:租户:仓库:库存单位:类型”。可售数量、最后刷新时间和请求追踪标识属于值或观察信息,不能混入键。缓存用于加速读、削峰或短时预扣提示,不能证明分布式扣减只执行一次:多实例并发、缓存失效和消息重复都可能绕过它。最终库存防超卖应由数据库条件更新(如可售量大于等于扣减量才更新)、库存流水唯一业务号、订单状态机和必要的补偿/对账共同保障;缓存命中只是性能路径,不是正确性判决。
    • 进阶追问:用 String(字符串)组合键会不会发生碰撞?
    • 进阶回答:不同字符序列的 String(字符串)在业务上不相等,equals(相等判断方法)会继续确认;即使 hashCode(哈希码方法)碰撞,也只增加桶内比较,不会把两条业务库存合并。真正风险是格式歧义,例如未经转义地拼接使“仓库 1、库存单位 23”和“仓库 12、库存单位 3”产生相同文本。应固定分隔符并对分段做校验或长度编码;更关键的是,缓存键的唯一性仍不等于数据库库存约束的原子性。

4. String(字符串):不可变不是只有 final(最终关键字)

String(字符串)的不可变性是一套封装边界,而不是单个修饰符的魔法。它让字符内容可以安全共享,让哈希结果能够缓存,也让字符串池有复用前提。

4.1 不可变设计的完整条件

设计层String(字符串)的做法若缺失会怎样
类封闭类为 final(最终关键字)子类可改变比较、哈希或访问行为,破坏已建立语义
状态封装内部存储是 private(私有访问控制)字段外部可直接替换或读取内部可变数组
一次赋值内容引用为 final(最终关键字)构造后可换成另一块存储
构造防御公共 char[](字符数组)构造会复制输入内容调用方改数组即可篡改字符串
输出防御toCharArray(转为字符数组)返回副本调用方拿到内部数组可原地修改
无原地修改接口变换不修改自身共享引用会被另一个调用方污染
返回逻辑结果concat(连接)、replace(替换)、substring(子串)保证原实例内容不变;逻辑内容变化时通常得到不同结果对象不能把“不变”误解成“每次调用都新分配”
哈希缓存内容固定后可缓存 hashCode(哈希码方法)每次作为键都要重复扫描字符

final(最终关键字)引用不等于对象不可变:final byte[] bytes 不能重新指向另一个数组,但 bytes[0] 仍可修改。真正的不可变对象还必须隔离所有可变对象的进入和流出路径,包含数组、集合以及集合内部的嵌套对象。

flowchart LR
    A["原 String(字符串)A: PAY"] --> B["调用 concat(连接)"]
    B --> C["逻辑结果 B<br/>内容变化时为新对象: PAY-1001"]
    A --> D["原对象内容仍为 PAY"]
    C --> E["可安全共享或作为 HashMap(哈希映射)key(键)"]
    F["外部 char[](字符数组)"] --> G["构造时复制"]
    G --> A
    A --> H["toCharArray(转为字符数组)复制"]
    H --> I["调用者修改副本不影响 A"]

图的逐步解读:

  1. A 到 B 到 C:变换请求不会改变 A。示例中内容从 PAY 变为 PAY-1001,逻辑结果 B 通常是不同对象;若逻辑内容未变,例如空连接、未命中替换或全范围子串,实现可以返回 this(当前对象引用)。代码若忽略返回值,原引用仍表示旧文本。
  2. A 到 D:多个线程、缓存或映射可以共享 A,因为没有公开路径会改它的字符内容。
  3. F 到 G 到 A:构造阶段复制外部数组,调用方之后修改 F 不会回写 A。这是防御性拷贝,不是简单地把数组引用赋给字段。
  4. A 到 H 到 I:输出数组同样要隔离;否则“没有 setter(设值方法)”仍不足以阻止外部修改内部状态。
  5. 适用范围与误区:这解释正常 Java(编程语言)封装语义,不把反射或非安全内存接口绕过封装的行为当作普通可变能力;不可变契约保证原实例的可观察内容不变,而不承诺每次变换都分配新对象。业务文本比较仍只能使用 equals(相等判断方法),不能依赖是否新分配,更不能用 == 代替内容比较。

4.2 版本事实:从 char[](字符数组)到 byte[](字节数组)

版本/阶段主要存储与行为对开发者的影响核对来源
JDK(Java 开发工具包)7u6 前char[](字符数组)外加 offset(偏移量)与 count(长度);substring(子串)可创建共享原数组的视图从超大文本截取很短片段后,短片段可能继续持有整块大数组,造成意外内存保留OpenJDK(开放 Java 开发工具包)jdk7u 历史提交 bbf16c...jdk/src/share/classes/java/lang/String.java:114-126,1950-1962
JDK(Java 开发工具包)7u6 起、JDK(Java 开发工具包)8仅以 char[](字符数组)保存内容;公共数组构造复制输入;内容变化的 substring(子串)通常构造独立字符存储,全范围子串可返回 this(当前对象引用)短子串不再因共享父串数组而长期保留整块字符数组,但切片有复制成本;调用方不能依赖是否分配新对象本机 corretto-1.8.0_402/.../src.zipjava/lang/String.java:111-117,165-167,1958-1976
JDK(Java 开发工具包)9+,以本机 JDK(Java 开发工具包)21 为证byte[](字节数组)+ coder(编码标记);Latin-1(拉丁字符集)内容可紧凑存放,不能表示时使用 UTF-16(16 位统一字符编码)主要是内部空间表示优化,不改变 String(字符串)的字符语义或不可变契约本机 openjdk-21.0.2/.../lib/src.zipjava.base/java/lang/String.java:142-180,277-304,2832-2843
JDK(Java 开发工具包)8hash(哈希)字段缓存非零 hashCode(哈希码方法)结果同一字符串重复作为哈希键时可避免重复扫描;空串或哈希为零仍会再次计算本机 JDK(Java 开发工具包)8 java/lang/String.java:114-117,1465-1475
JDK(Java 开发工具包)21hash(哈希)外增加 hashIsZero(哈希为零标记)能区分“还未算”和“算过且结果恰为零”;源码明确该良性数据竞争依赖不可变状态与幂等计算本机 JDK(Java 开发工具包)21 java.base/java/lang/String.java:173-180,2358-2375

这里的历史结论只描述实现演进,不要把它误读为“所有 String(字符串)操作都复制”或“JDK(Java 开发工具包)9+ 一定省一半内存”。不可变契约只要求原实例内容不变;内容未变化时,concat(连接)、replace(替换)或全范围 substring(子串)可以复用原实例。是否采用紧凑表示、是否分配结果对象以及具体对象数量,都要以目标运行环境测量;业务判断始终使用 equals(相等判断方法)。

4.3 字符串池、intern(手动入池方法)与引用关系

Java(编程语言)语言/API(应用程序接口)层:字面量和字符串值的 compile-time constant(编译期常量)表达式具有入池语义;String(字符串)公开文档还规定,String(字符串)的 intern(手动入池方法)会返回池中与当前内容相等的规范引用,池中不存在时将当前对象作为该规范引用。这里的关键是“按内容规范化”和公开的返回关系,而不是某个 JVM(Java 虚拟机)内部类名。无论引用是否恰好相同,业务文本比较都使用 equals(相等判断方法)。

HotSpot(热点虚拟机)层:StringTable(字符串常量池表)是 HotSpot(热点虚拟机)对上述字符串规范化机制的具体实现示例。固定到 JDK(Java 开发工具包)21 GA(正式发布)标签 jdk-21+35 的 OpenJDK(开放 Java 开发工具包)源码中,StringTable 类声明了 intern(手动入池方法)和查找入口,stringTable.cpp 以并发哈希表保存本地表。它说明 HotSpot(热点虚拟机)如何实现,不扩大 Java(编程语言)语言/API(应用程序接口)层保证;其他 JVM(Java 虚拟机)实现无需使用同名结构。本机 JDK(Java 开发工具包)21 的 lib/src.zip 只用于核对 Java(编程语言)库源码,不包含这两份 HotSpot(热点虚拟机)源码。

表达式与字面量 "pay-001"==equals(相等判断方法)结果原因
"pay-001"true(真值)true(真值)同一字面量引用同一池中对象
"pay-" + "001"true(真值)true(真值)两侧都是编译期常量,可折叠为同一字面量
prefix + "001",其中 prefix 是变量不保证内容相同则 true(真值)运行期拼接结果不应以引用相等作业务判断
new String("pay-001")false(假值)true(真值)显式创建了新对象,内容仍相同
new String("pay-001").intern()true(真值)true(真值)返回池中规范引用

因此,业务代码比较文本一律使用 equals(相等判断方法);== 只能用于确认是否同一对象,或在你明确控制并理解入池前提的低层诊断中使用。不要把“某次变换是否新分配”与“是否入池”当作业务判断依据。对高基数、一次性或外部输入的大量文本盲目调用 intern(手动入池方法)可能制造额外池压力,不能把它当作通用性能开关。

4.4 字符串拼接:编译器策略不等于循环成本消失

场景JDK(Java 开发工具包)8 常见编译策略JDK(Java 开发工具包)9+ 默认策略推荐写法
全为编译期常量的 +编译期折叠编译期折叠直接使用字面量即可
单个表达式的少量变量 +javac(Java 编译器)通常生成 StringBuilder(可变字符串构建器)式 append(追加)再 toString(转字符串)通常以 invokedynamic(动态调用指令)链接到 StringConcatFactory(字符串拼接工厂)策略以可读性为先,不要为了少量字段手写复杂拼接
循环中 result = result + part每轮都需基于旧结果构造新文本,累计复制量可能呈平方增长编译策略改变不了跨迭代依赖,仍有重复复制与分配循环外创建 StringBuilder(可变字符串构建器)
多线程共享同一可变缓冲区StringBuilder(可变字符串构建器)不提供同步同左尽量改为每线程私有构建;确需共享再评估 StringBuffer(线程安全字符串构建器)或更高层协调
大文件/异步导出堆中累积整份文本会放大内存与 GC(垃圾回收)压力同左分页读取、分批构建、流式写出

JDK(Java 开发工具包)9+ 的源码中,StringConcatFactory(字符串拼接工厂)明确服务于字符串连接的 invokedynamic(动态调用指令)调用点;这是本机 JDK(Java 开发工具包)21 java.base/java/lang/invoke/StringConcatFactory.java:35-80 可核对的事实。它不意味着每个 + 都“没有对象”,更不意味着循环拼接自动变为线性成本:循环的上一轮结果是下一轮输入,累积内容仍需要反复复制。

4.5 章节题:不可变性、字符串池与拼接

热门面试题

  1. 问题(原理题):String(字符串)为什么不可变?

    • 考点:类封闭、私有最终字段、防御性处理、共享价值。
    • 回答思路:从结构保证讲起,再连接线程共享、字符串池、哈希缓存和安全边界,最后说明代价。
    • 详细答案:String(字符串)不可变由多层设计共同实现:类被 final(最终关键字)封闭,子类不能改写核心语义;内部内容字段是 private(私有访问控制)且在构造时确定;公共数组输入会复制,输出数组也返回副本;没有公开的原地修改接口。concat(连接)、replace(替换)和 substring(子串)保证原实例内容不变:当逻辑内容变化时通常返回不同结果对象,内容未变化时实现可返回 this(当前对象引用),因此不能用 == 判断文本业务相等。内容稳定后,同一实例可被多个线程安全共享,Java(编程语言)字符串规范化机制才能复用等值文本,hashCode(哈希码方法)也能缓存并安全地作为 HashMap(哈希映射)键。安全价值体现在路径、权限标识、类名和业务编号传递给其他组件后不容易被中途篡改。代价是每次真正变化可能创建新对象并复制内容,因此高频构建要控制分配,而不能把“不可变”误说成“免费”。
    • 进阶追问:final(最终关键字)字段是否足以让类不可变?
    • 进阶回答:不够。final(最终关键字)只限制引用不能改指向,不限制其所指数组、集合或嵌套对象的内部状态。一个类即使所有字段都是 final(最终关键字),只要构造器保存了调用方传入的数组、访问器直接返回内部列表,或者列表元素本身可变,外部仍能改写它。不可变类必须同时处理输入、内部层次和输出三个方向,并避免构造期间把 this(当前对象引用)泄漏给其他线程。
  2. 问题(版本题):JDK(Java 开发工具包)8 与 JDK(Java 开发工具包)9+ 的 String(字符串)实现有哪些关键差异?

    • 考点:char[](字符数组)、byte[](字节数组)、coder(编码标记)、哈希缓存、substring(子串)历史。
    • 回答思路:先对比存储,再讲哈希缓存,最后把 JDK(Java 开发工具包)7u6 前后的子串内存保留问题作为历史边界。
    • 详细答案:本机 JDK(Java 开发工具包)8 源码显示 String(字符串)以私有、最终的 char[](字符数组)保存内容并有 hash(哈希)缓存字段;本机 JDK(Java 开发工具包)21 则以私有、最终的 byte[](字节数组)和 coder(编码标记)表示内容,Latin-1(拉丁字符集)可使用更紧凑存储,必要时使用 UTF-16(16 位统一字符编码)。JDK(Java 开发工具包)21 还用 hashIsZero(哈希为零标记)避免哈希结果恰为零时重复计算。历史上,JDK(Java 开发工具包)7u6 前的 substring(子串)对象带 offset(偏移量)与 count(长度),可共享父串数组;JDK(Java 开发工具包)7u6 起的内容变化子串通常保存独立字符内容,全范围子串可返回 this(当前对象引用),从而避免短子串长期保留大父串。存储表示和缓存细节会变,不可变语义、内容相等规则和公开接口契约保持一致,调用方不能用对象身份推断内容相等。
    • 进阶追问:为什么 JDK(Java 开发工具包)21 的 hashCode(哈希码方法)缓存允许良性数据竞争?
    • 进阶回答:源码注释说明,多个线程可能同时计算同一个字符串的哈希,但计算只依赖永不改变的字符内容,且同一输入得到同一结果;一个线程看到旧缓存值时最多重复计算,看到写入后的值也仍正确。这个结论依赖对象状态不可变和计算幂等,不能照搬到会修改字段、会产生不同结果或需要原子复合更新的普通类上。
  3. 问题(项目题):异步导出为什么不能不停用 String(字符串)+ 拼接?

    • 考点:循环复制、对象分配、内存压力、流式输出。
    • 回答思路:说明单表达式优化的边界,再用循环累计成本解释堆压力,最后给出分页与流式写出方案。
    • 详细答案:单条日志或少量字段的 + 可能被编译器优化,但异步导出通常在循环中累计数万到数百万行,每轮都把旧结果和新片段组合成一个新 String(字符串)。旧结果越长,每轮复制越多,临时对象和最终大对象会共同占用堆,GC(垃圾回收)频率上升,严重时任务队列积压与内存溢出相互放大。单线程的一个小批次可以使用 StringBuilder(可变字符串构建器),预估容量以减少扩容;更稳妥的方案是分页拉取数据、逐行或分批写入 BufferedWriter(缓冲字符输出流)等输出流,并设置单任务大小、并发数、背压和失败清理策略。导出任务的状态与文件落点要可恢复,不能因为一次内存构建失败就重放全部业务副作用。
    • 进阶追问:StringBuffer(线程安全字符串构建器)能解决并发导出内存问题吗?
    • 进阶回答:不能。StringBuffer(线程安全字符串构建器)只是在单个缓冲区的操作上提供同步,无法降低把全量文件保留在一个缓冲区中的空间复杂度;共享一个缓冲区还会增加锁竞争并破坏输出顺序。更好的设计是每个导出任务拥有私有、受限大小的批次缓冲,统一由输出端顺序写入,或让每个分片写独立临时文件后受控合并。内存预算、并发阈值和流式写出才是根治点。

5. 真正不可变对象:从浅拷贝到安全发布

5.1 浅拷贝、深拷贝与不可修改视图

做法容器结构是否隔离嵌套可变对象是否隔离典型误区
直接赋值两个引用指向同一对象
浅拷贝复制列表后,内部明细仍可被改动
deep copy(深拷贝)必须定义复制到第几层、成本和对象图边界
unmodifiable view(不可修改视图)不一定禁止通过视图修改,不等于原列表不会被别处修改
snapshot(快照)取决于元素是否不可变或已深拷贝只复制外层就把可变元素误当成快照

Collections.unmodifiableList(不可修改列表)是一层访问限制:持有原列表的代码仍可以增删,视图会随之变化。List.copyOf(列表复制方法)会复制容器结构并返回不可修改结果,但其元素仍是同一个引用;若元素是可变订单明细,依然需要逐项复制或把明细本身设计成不可变。所谓 deep copy(深拷贝)不是固定招式,而是业务边界决策:订单快照通常要复制会影响履约、金额和审计的明细;大体积只读共享字典可以使用明确的只读值对象,而不是盲目复制整个对象图。

5.2 一个可审计的配置快照设计

/** Runner(执行器)任务在启动时绑定的不可变配置快照。 */
public final class RunnerConfigSnapshot {
    private final long version;
    private final int timeoutSeconds;
    private final java.util.List<RouteRule> rules;

    /** 单条路由规则;示例中只保留不可变的路由标识。 */
    public static final class RouteRule {
        private final String route;

        /**
         * 创建一条不可变路由规则。
         * @param route 路由标识,不能为空
         */
        public RouteRule(String route) {
            this.route = java.util.Objects.requireNonNull(route);
        }

        /**
         * 返回路由标识。
         * @return 不可变字符串路由标识
         */
        public String route() {
            return route;
        }
    }

    /**
     * 创建配置快照并隔离调用方的规则列表。
     * @param version 已校验的配置版本
     * @param timeoutSeconds 超时秒数,必须大于零
     * @param rules 路由规则;每个规则必须不可变或已复制
     * @throws IllegalArgumentException 超时秒数非法时抛出
     */
    public RunnerConfigSnapshot(long version, int timeoutSeconds,
            java.util.List<RouteRule> rules) {
        if (timeoutSeconds <= 0) {
            throw new IllegalArgumentException("超时秒数必须大于零");
        }
        this.version = version;
        this.timeoutSeconds = timeoutSeconds;
        // 复制容器,防止调用方后续增删规则影响已启动任务。
        this.rules = java.util.Collections.unmodifiableList(
                new java.util.ArrayList<>(rules));
    }

    /**
     * 返回任务绑定的路由规则。
     * @return 不可修改的规则集合;规则元素本身也必须不可变
     */
    public java.util.List<RouteRule> rules() {
        return rules;
    }
}

上例成立还有一个前提:RouteRule(路由规则)必须是不可变对象;若它包含数组、可变标签或可修改映射,仅仅 List.copyOf(列表复制方法)仍是浅拷贝。对字节数组等必须在构造时 clone(克隆)或复制,在访问器中再次返回副本。不要为了“不可变”把每次读取都深拷贝巨大对象图;先划清任务启动时必须固定的边界,再用版本号和有限大小的快照控制成本。

record(记录类)能减少样板代码:其组件字段是 final(最终关键字),记录类也不能被继承,但它提供的是浅层不可变结构,不是深层不可变保证。record Delivery(byte[] signature)(记录类)若不在紧凑构造器中复制数组、也不重写访问器返回副本,调用方仍可改动 signature(签名数组)内容;含可变集合或可变明细时同样如此。

5.3 安全发布、共享与项目边界

不可变对象适合跨线程共享,因为构造完成后状态不会变;final(最终关键字)字段还有专门的内存模型可见性保障。但前提是构造期间不能让 this(当前对象引用)逃逸,所有可达状态也必须已正确防御。配置热更新应完整校验新快照后,以单次原子引用替换当前版本;已经开始的 Runner(执行器)任务继续持有旧快照,新任务取得新快照,从而避免同一任务在重试过程中混用两个配置版本。

订单快照和配置快照解决的是“一个进程内、一个执行时点”的稳定视图,不自动解决分布式一致性。支付与库存仍要使用数据库唯一约束、条件更新、事务、状态机、消息去重和对账;不可变对象减少了错误输入与共享状态,却不能替代跨节点的并发控制和持久化事实。

5.4 不可变设计检查表

检查项通过标准常见反例
身份字段已定义且创建后不变用状态、更新时间作哈希键
类扩展边界final(最终关键字)或受控构造/组合子类改写相等语义
字段private(私有访问控制)且 final(最终关键字)final List(最终列表)却可增删
构造输入数组/集合/可变元素已复制保存调用方传入的列表引用
访问输出不暴露内部数组和可变集合直接返回 byte[](字节数组)
嵌套对象不可变或按业务边界深拷贝复制外层列表但共享明细
变换方法返回逻辑结果;内容有变化时创建新版本setStatus(设置状态)修改快照
并发发布构造完成后一次发布,不泄漏 this(当前对象引用)构造器中注册监听器
成本控制对大对象分页、分片、版本化每次读取都全量深拷贝
分布式保障数据库约束、状态机、事务仍在用本地集合声称“全局幂等”

5.5 章节题:快照、深拷贝与系统正确性

热门面试题

  1. 问题(基础题):final(最终关键字)引用为什么不等于对象不可变?

    • 考点:引用重绑定、对象内部状态、数组与集合泄漏。
    • 回答思路:先说明 final(最终关键字)限制的是引用,再给出数组/集合反例,最后列出真正不可变的补充条件。
    • 详细答案:final(最终关键字)引用只能保证变量不能改指向另一个对象,例如不能把 final List(最终列表)重新赋为新列表;它不会阻止对原列表执行添加、删除或修改元素,也不会阻止 final byte[](最终字节数组)的元素赋值。因此它最多是不可变设计的一个必要部件。真正的不可变对象还要封闭或控制继承、将字段设为 private(私有访问控制)、构造时验证并复制可变输入、访问时不泄漏可变内部状态、没有改变自身状态的方法,并保证嵌套对象也不可变或做深拷贝。只要任一输入或输出引用绕过这些边界,外部就能改变对象可观察状态。
    • 进阶追问:返回 unmodifiable view(不可修改视图)能代替快照吗?
    • 进阶回答:不能完全代替。unmodifiable view(不可修改视图)通常只禁止通过该视图调用修改方法,底层原集合仍被其他持有者修改时,读取结果会变化;集合元素本身也可能可变。快照至少要复制容器结构,并要求元素不可变或按需要深拷贝。对 Runner(执行器)运行参数,任务必须拿到固定版本快照,不能只拿一个会随配置中心刷新而变化的视图。
  2. 问题(设计题):如何设计订单快照和 Runner(执行器)配置快照?

    • 考点:浅拷贝/深拷贝、版本化、安全发布、成本权衡。
    • 回答思路:先定义快照边界,再处理可变输入与嵌套对象,最后说明版本发布和大对象成本控制。
    • 详细答案:先明确哪些字段必须冻结:订单快照至少包括订单号、收货信息、金额币种、履约明细和规则版本;Runner(执行器)快照至少包括配置版本、超时、重试、并发限制和路由规则。构造时对列表、映射、数组复制;对会影响业务结果的可变明细逐项深拷贝或改造成不可变值对象;访问器返回不可修改集合或新数组副本。动态配置更新时,先完整校验新对象,再原子替换“当前快照”引用;新任务读取新版本,已运行任务继续持有原版本。对于大型订单或导出规则,不应每次请求全图复制,而应按任务实际需要截取字段、使用版本号和分页/分片数据,既保证一致视图也控制内存。
    • 进阶追问:record(记录类)是否可以直接作为订单快照?
    • 进阶回答:可以作为外层数据载体,但不能默认认为深层不可变。record(记录类)的组件引用不可重新赋值,然而数组、可变列表和可变明细仍可从外部修改。若组件是 byte[](字节数组),紧凑构造器必须复制输入,访问器也应返回副本;若组件是列表,需要复制容器,并确保元素不可变或逐项复制。记录类减少了样板代码,不替代边界分析。
  3. 问题(系统题):不可变幂等键和配置快照能保证支付、库存的分布式正确性吗?

    • 考点:进程内语义、跨节点竞争、最终保障。
    • 回答思路:肯定它们对稳定输入和减少共享错误的价值,再清晰划出数据库与状态机必须承担的责任。
    • 详细答案:不能单独保证。不可变幂等键确保同一进程内的 HashMap(哈希映射)、缓存或任务上下文不会因字段改变而失联;配置快照保证一个执行中的 Runner(执行器)任务不会混用新旧参数。这些都是正确系统的重要基础,但多实例并发、服务重启、网络超时、消息重复和缓存失效仍会让不同节点各自看到不同内存状态。支付最终要靠数据库唯一约束、事务边界、支付状态机和对账补偿;库存最终要靠条件扣减、库存流水唯一号、订单状态迁移和补偿。Redis(远程字典服务)锁或本地集合可以降低冲突概率和压力,却不能在没有持久化约束时证明“绝不会重复”。
    • 进阶追问:线上如何判断导出内存问题来自深拷贝还是字符串累计?
    • 进阶回答:先按任务维度关联堆使用、任务并发、单批记录数、导出文件大小和 GC(垃圾回收)暂停;再获取堆转储,检查大对象是 char[](字符数组)/byte[](字节数组)、String(字符串)、StringBuilder(可变字符串构建器)还是订单明细对象。若单个长文本和构建器占主导,优先改为流式写出并限制批次;若大量相似嵌套明细对象占主导,检查快照是否对每次重试重复深拷贝,改为一次构建、版本复用或只复制必要字段。止血可以降低并发和批次大小,但长期修复必须让对象生命周期与任务边界一致。

6. 线上排查与项目话术

6.1 排查路径

现象首要证据高概率根因处理方式
内存映射偶发查不到刚写入的键写入/读取时键字段、hashCode(哈希码方法)、桶下标可变键或相等/哈希字段不一致立即停止修改键,改稳定值对象并补回归测试
HashSet(哈希集合)出现逻辑重复支付事件两对象的业务字段、equals(相等判断方法)和 hashCode(哈希码方法)仅重写一者或键格式未规范化同步重写;统一渠道标识、大小写和分隔规则
导出任务 GC(垃圾回收)频繁、堆持续上升堆转储中的大字符串/构建器,任务批次和队列长度循环拼接、全量缓存、重复深拷贝流式写出、分页、限并发、限制任务大小
Runner(执行器)同一任务前后行为不一致任务记录的配置版本、重试日志运行中读取了可变全局配置启动时绑定不可变快照,原子替换新版本

6.2 可直接复述的项目话术

“在支付回调和库存聚合里,我先把业务身份与业务状态分开。渠道事件标识、订单号、仓库和 SKU(库存单位)这类创建后不变的字段用于构造不可变键;状态、数量和刷新时间只作为值,不参与键的相等和哈希计算。这样进程内的 HashMap(哈希映射)不会因状态变化而失联。对于跨实例重复请求,我不把本地集合或 Redis(远程字典服务)当最终方案,而是在数据库用唯一约束竞争首次处理权,再由状态机和事务保证合法迁移。Runner(执行器)动态配置采用版本化不可变快照,任务启动后固定使用一个版本;异步导出按页流式写出,避免循环字符串拼接把整份文件留在堆里。”

7. 复习清单

  • 能区分引用相等、默认身份语义、业务相等、等价关系和哈希相同。
  • 能逐条说明 equals(相等判断方法)的五项契约,并解释继承下的对称性/传递性陷阱。
  • 能画出 HashMap(哈希映射)“哈希定位 -> 桶内 equals(相等判断方法)确认”的两阶段流程。
  • 能用具体下标说明可变键为什么失联,以及为什么不能遍历映射来掩盖问题。
  • 能说清 JDK(Java 开发工具包)8 的 char[](字符数组)与 JDK(Java 开发工具包)9+ 的 byte[](字节数组)+ coder(编码标记)差异。
  • 能解释 JDK(Java 开发工具包)7u6 前后 substring(子串)的内存保留差异、字符串池与 intern(手动入池方法)的边界。
  • 能区分单个 + 表达式优化与循环拼接成本,并给出流式导出方案。
  • 能审计数组、集合、嵌套对象、unmodifiable view(不可修改视图)与 record(记录类)的浅层不可变陷阱。
  • 能把不可变键、数据库唯一约束、条件更新和状态机分别放到正确的责任边界中。

8. 版本与参考来源

  • 本机 JDK(Java 开发工具包)8:/Users/Lever/Library/Java/JavaVirtualMachines/corretto-1.8.0_402/Contents/Home/src.zip,条目 java/lang/String.java;核对类声明、char[](字符数组)、hash(哈希)、数组构造、substring(子串)、toCharArray(转为字符数组)与 intern(手动入池方法)。
  • 本机 JDK(Java 开发工具包)21:/Users/Lever/Library/Java/JavaVirtualMachines/openjdk-21.0.2/Contents/Home/lib/src.zip,条目 java.base/java/lang/String.javajava.base/java/lang/invoke/StringConcatFactory.java;核对 byte[](字节数组)+ coder(编码标记)、hashIsZero(哈希为零标记)、substring(子串)和 invokedynamic(动态调用指令)拼接支撑。该 lib/src.zip 不包含 HotSpot(热点虚拟机)源码。
  • JDK(Java 开发工具包)7u6 前的历史对照:OpenJDK(开放 Java 开发工具包)jdk7u 历史提交 bbf16c0b3a9f164e3bfb0bb90f5f0d5a2d92746cjdk/src/share/classes/java/lang/String.java;核对 offset(偏移量)、count(长度)字段及 substring(子串)共享存储构造路径。
  • HotSpot(热点虚拟机)StringTable(字符串常量池表)示例:OpenJDK(开放 Java 开发工具包)JDK(Java 开发工具包)21 GA(正式发布)标签 jdk-21+35,已实际验证可访问:stringTable.hppstringTable.cpp。核对 StringTable 类、intern(手动入池方法)入口与并发哈希表实现;它是 HotSpot(热点虚拟机)实现证据,不是 Java(编程语言)语言/API(应用程序接口)层的专有保证。