Java(编程语言)核心基础与集合
正式学习入口
本文件保留早期单篇教材,便于兼容既有链接;精通级复习、详细答案、图解和综合题请以拆分后的正式子文档为准。
| 顺序 | 正式子文档 | 核心目标 |
|---|---|---|
| 0 | 知识图谱与复习路线 | 学习顺序、版本边界和迁移说明 |
| 1 | Object(对象基类)、String(字符串)与不可变设计 | 对象契约、字符串底层和稳定业务键 |
| 2 | 泛型、反射、注解与异常 | 类型系统、元数据和事务异常边界 |
| 3 | List(列表接口)、Set(集合接口)与 Queue(队列接口)集合体系 | 集合选型、扩容、顺序与背压 |
| 4 | HashMap(哈希映射)与哈希集合 | 桶定位、扩容、树化和可变键故障 |
| 5 | ConcurrentHashMap(并发哈希映射)与集合并发 | 桶级协调、协助扩容和单机并发边界 |
| 6 | 项目案例、排障与综合题库 | WMS(仓储管理系统)、支付、IoT(物联网)与线上排障 |
兼容保留的旧版正文
1. 简历关联点
简历里写到 Java(编程语言)全栈高级开发、WMS(仓储管理系统)、跨境物流、库存防超卖、支付资金一致性、异步任务、Runner(执行器)调度和 IoT(物联网)报警风暴治理。这些项目虽然看起来更偏架构和业务,但面试官经常会从 Java(编程语言)基础和集合体系切入,判断候选人是不是只会使用框架。
本模块要支撑下面几类追问:
- Object(对象基类)的 equals(相等判断方法)和 hashCode(哈希码方法)为什么必须一起重写。
- String(字符串)为什么不可变,StringBuilder(可变字符串构建器)和 StringBuffer(线程安全字符串构建器)怎么选。
- final(最终关键字)、static(静态关键字)、泛型、反射、注解、异常体系在工程里怎么用。
- ArrayList(数组列表)、LinkedList(链表列表)、HashMap(哈希映射)、LinkedHashMap(链式哈希映射)、TreeMap(树映射)、HashSet(哈希集合)分别适合什么场景。
- HashMap(哈希映射)底层数组、链表、红黑树、扩容、扰动函数、线程不安全怎么讲。
- ConcurrentHashMap(并发哈希映射)为什么比 Hashtable(哈希表)更适合高并发场景。
2. 面试主线
回答 Java(编程语言)核心基础问题时,不要只背 API(应用程序接口),要按“对象语义 -> 内存和不可变性 -> 集合数据结构 -> 并发安全 -> 项目落地”展开。
- Object(对象基类)定义了 Java(编程语言)对象的基础行为,equals(相等判断方法)和 hashCode(哈希码方法)决定对象在 HashMap(哈希映射)和 HashSet(哈希集合)里的身份语义。
- String(字符串)不可变性带来线程安全、缓存复用和哈希稳定,但大量拼接要用 StringBuilder(可变字符串构建器)。
- 集合不是只看“能不能存数据”,要看查询、插入、删除、排序、去重、并发和内存成本。
- HashMap(哈希映射)是高频重点,必须能讲清 hash(哈希)定位、冲突处理、树化、扩容和线程不安全。
- 项目落地时,要把集合选择和订单去重、库存聚合、支付流水幂等、报警窗口聚合这些场景绑定起来。
3. 基础知识
3.1 Object(对象基类)核心方法
Object(对象基类)是所有 Java(编程语言)类的根类。面试重点不是背有哪些方法,而是讲清对象身份、对象相等、对象哈希、对象克隆和对象生命周期相关方法的边界。
| 方法 | 作用 | 面试重点 |
|---|---|---|
| equals(相等判断方法) | 判断两个对象业务语义是否相等 | 默认比较引用地址,业务对象通常要重写 |
| hashCode(哈希码方法) | 返回对象哈希值 | 与 equals(相等判断方法)保持一致,否则 HashMap(哈希映射)查找会异常 |
| toString(字符串展示方法) | 输出对象可读信息 | 日志排查时很重要,避免输出敏感字段 |
| clone(克隆) | 创建对象副本 | 浅拷贝和深拷贝要分清 |
| wait(等待方法)/notify(通知方法) | 对象监视器协作 | 必须持有 synchronized(同步锁)监视器 |
| finalize(终结方法) | 对象回收前回调 | 不推荐依赖,可能导致 GC(垃圾回收)不可控 |
equals(相等判断方法)和 hashCode(哈希码方法)的核心约束:
- 如果两个对象 equals(相等判断方法)返回 true(真值),hashCode(哈希码方法)必须相同。
- 如果两个对象 hashCode(哈希码方法)相同,equals(相等判断方法)不一定返回 true(真值),因为可能发生 hash collision(哈希冲突)。
- 作为 HashMap(哈希映射)键的对象,参与 equals(相等判断方法)和 hashCode(哈希码方法)计算的字段最好保持不可变。
HashMap(哈希映射)判断“键是否已经存在”不是只调用 equals(相等判断方法),而是分两级完成:
flowchart LR
A["传入 key(键)"] --> B["调用 hashCode(哈希码方法)"]
B --> C["扰动并计算 bucket(桶)下标"]
C --> D{"bucket(桶)是否为空?"}
D -->|"是"| E["确定不存在"]
D -->|"否"| F["比较 hash(哈希值)"]
F --> G{"hash(哈希值)相同?"}
G -->|"否"| H["继续检查下一个节点"]
G -->|"是"| I["调用 equals(相等判断方法)"]
I --> J{"业务语义相等?"}
J -->|"是"| K["命中同一个键"]
J -->|"否"| H具体数据演绎:有两个支付对象 A 和 B,它们的支付流水号都是 PAY-1001。业务重写 equals(相等判断方法)后,A.equals(B) 返回 true(真值),但如果没有同步重写 hashCode(哈希码方法),二者仍可能使用对象身份生成不同哈希值。假设容量为 16,A 最终进入 3 号 bucket(桶),B 查询时却先定位到 11 号 bucket(桶)。HashMap(哈希映射)只在 11 号 bucket(桶)中继续调用 equals(相等判断方法),根本不会遍历 3 号 bucket(桶),所以会错误地认为 A 不存在。
| 操作 | 对象 | equals(相等判断方法)业务字段 | hashCode(哈希码方法) | bucket(桶) | 结果 |
|---|---|---|---|---|---|
| 写入 | A | PAY-1001 | 35 | 3 | 写入成功 |
| 查询 | B | PAY-1001 | 75 | 11 | 查找失败 |
正确实现应让相同业务字段参与 equals(相等判断方法)和 hashCode(哈希码方法)计算,使 A 与 B 得到一致的哈希结果。反方向则不要求成立:不同对象可以有相同 hashCode(哈希码方法),HashMap(哈希映射)会继续用 equals(相等判断方法)解决 hash collision(哈希冲突)。
热门面试题
问题(基础题):equals(相等判断方法)和
==有什么区别?- 考点:引用相等、业务相等、对象语义。
- 回答思路:
==对引用类型比较对象地址,equals(相等判断方法)默认也是地址比较,但业务类可以重写成按字段比较;如果对象要放入 HashMap(哈希映射)或 HashSet(哈希集合),必须同步考虑 hashCode(哈希码方法)。 - 详细答案:对基本数据类型,
==比较数值是否相同;对引用类型,==比较两个引用是否指向同一个对象。Object(对象基类)的 equals(相等判断方法)默认实现本质上也是引用比较,因此没有重写时与==效果一致。业务对象通常需要表达“字段相同就视为同一业务实体”,例如两个不同实例只要支付流水号都为PAY-1001,就应该在业务上相等,此时必须重写 equals(相等判断方法)。重写时应满足自反性、对称性、传递性、一致性和非空性,否则集合去重、缓存命中和测试断言都会出现不稳定结果。 - 进阶追问:为什么 String(字符串)的 equals(相等判断方法)比较内容,而不是比较地址?
- 进阶回答:String(字符串)的业务语义就是字符序列。两个来源不同的 String(字符串)只要字符内容相同,就应代表同一文本;若按地址比较,从数据库读取的
PAY-1001与代码中构造的PAY-1001会被判断为不同文本。String(字符串)因此重写 equals(相等判断方法)逐个比较内容,并重写 hashCode(哈希码方法)让相同内容得到相同哈希值。常量池只是一种复用优化,不能代替 equals(相等判断方法)进行内容判断。
问题(原理题):为什么重写 equals(相等判断方法)必须重写 hashCode(哈希码方法)?
- 考点:哈希集合定位过程、桶定位、冲突比较。
- 回答思路:HashMap(哈希映射)先用 hashCode(哈希码方法)定位 bucket(桶),再用 equals(相等判断方法)比较链表或红黑树节点;如果两个业务相等对象 hashCode(哈希码方法)不同,就会落到不同 bucket(桶),导致查不到。
- 详细答案:这是 Object(对象基类)方法契约和哈希容器算法共同决定的。equals(相等判断方法)负责定义业务相等,hashCode(哈希码方法)负责把候选对象快速缩小到某个 bucket(桶)。哈希容器不会为了寻找一个键而扫描全部 bucket(桶),所以业务相等的两个对象必须先落入同一个 bucket(桶),之后才有机会执行 equals(相等判断方法)。只重写 equals(相等判断方法)会破坏“相等对象必须具有相同哈希值”的契约,直接表现为
put(写入)后get(读取)不到、HashSet(哈希集合)出现重复元素、remove(删除)失败。只重写 hashCode(哈希码方法)而不重写 equals(相等判断方法)通常不会破坏定位,但不同实例仍按地址判断不相等,业务去重依然失败。 - 进阶追问:如果支付流水对象的流水号被修改,会对幂等去重造成什么影响?
- 进阶回答:如果对象已经作为 HashMap(哈希映射)或 HashSet(哈希集合)的键写入,之后又修改了参与 hashCode(哈希码方法)的支付流水号,重新查询时会按新哈希值进入另一个 bucket(桶),旧节点却仍留在原 bucket(桶),导致查找和删除失败。这叫可变键问题。支付幂等不应依赖可变回调对象,而应使用创建后不变的 providerEventId(支付通道事件标识)或 paymentNo(支付流水号),并在数据库增加唯一约束作为最终防线。
问题(项目追问题):在支付回调幂等里,能不能直接用一个回调对象作为 HashSet(哈希集合)的 key(键)?
- 考点:可变对象、幂等键、业务唯一性。
- 回答思路:不建议直接用可变回调对象,应抽取 paymentNo(支付流水号)或 providerEventId(支付通道事件标识)作为稳定幂等键;否则字段变化后 hashCode(哈希码方法)变化,集合无法正确定位。
- 详细答案:不能把进程内 HashSet(哈希集合)当成支付回调的最终幂等设施。第一,回调对象可能包含状态、时间等可变字段,字段变化会改变相等和哈希结果;第二,多实例部署时每个进程有自己的集合,实例甲处理过不代表实例乙知道;第三,服务重启后内存记录会丢失。可靠设计是先规范化渠道标识和 providerEventId(支付通道事件标识),以它们组成稳定 Idempotency Key(幂等键),在数据库建立唯一索引;事务内先尝试插入事件记录,唯一键冲突即表示已处理,再通过支付状态机只允许合法状态迁移。Redis(远程字典服务)可以承担快速拦截,但数据库唯一约束和状态机才是持久化兜底。
- 进阶追问:如果同一个 Webhook(回调通知)重复到达两次,如何保证只处理一次?
- 进阶回答:两次请求即使同时到达,也让它们竞争同一个数据库唯一键。只有一个事务能够首次插入成功并继续更新支付单、写账务分录和 Outbox Event(发件箱事件);另一个事务收到唯一键冲突后读取已有处理结果并返回成功,避免渠道继续重试。若第一次事务回滚,唯一键记录也应随事务回滚,后续请求仍可重试;若下游消息发送失败,则由 Outbox(发件箱)任务重发,而不是重新执行扣款逻辑。
3.2 String(字符串)与不可变对象
String(字符串)不可变,意味着对象创建后内容不能被修改。看似简单,但它支撑了字符串常量池、线程安全、缓存复用和 HashMap(哈希映射)键稳定性。
| 类型 | 是否可变 | 是否线程安全 | 适用场景 |
|---|---|---|---|
| String(字符串) | 否 | 是 | 常量、配置、缓存 key(键)、业务编号 |
| StringBuilder(可变字符串构建器) | 是 | 否 | 单线程大量字符串拼接 |
| StringBuffer(线程安全字符串构建器) | 是 | 是 | 多线程共享拼接,实际项目较少使用 |
String(字符串)不可变的价值:
- 线程安全:多个线程共享同一个 String(字符串)对象不会改变内容。
- 常量池复用:相同字面量可以复用,减少内存浪费。
- 哈希稳定:作为 HashMap(哈希映射)键时,hashCode(哈希码方法)不会因为内容变化而失效。
- 安全边界:URL(统一资源定位符)、文件路径、类名、权限标识不容易被中途篡改。
String(字符串)的不可变性不是只靠 final(最终关键字),而是由一组设计共同保证:
- String(字符串)类本身使用 final(最终关键字)修饰,外部不能通过继承覆盖关键方法来伪造“内容不变”的语义。
- 保存字符内容的字段对外不可见。JDK(Java 开发工具包)8 主要使用
private final char[];JDK(Java 开发工具包)9 以后主要使用private final byte[]和 coder(编码标记),这项 Compact Strings(紧凑字符串)设计可让拉丁字符以单字节保存,降低内存占用。 - String(字符串)没有公开修改内部存储的方法。concat(连接)、replace(替换)、substring(截取)等操作需要变化时返回新的 String(字符串),原对象仍保持不变;若结果没有变化,某些方法也可以直接返回原对象。
- 构造和转换过程不应把可变数组直接暴露给调用者,而是通过复制或受控创建避免外部持有内部存储引用;toCharArray(转为字符数组)返回的是副本,修改副本不会改变原 String(字符串)。
- hashCode(哈希码方法)可以缓存计算结果,因为内容永远不变;同一个 String(字符串)多次作为 HashMap(哈希映射)的键时无需担心哈希值改变。
flowchart LR
A["String(字符串)对象 A<br/>内容: PAY"] --> B["concat(连接)追加 -1001"]
B --> C["创建新 String(字符串)对象 B<br/>内容: PAY-1001"]
A --> D["原对象仍为 PAY"]
C --> E["B 可进入 StringTable(字符串常量池表)或作为稳定 key(键)"]要区分“引用不变”和“对象不变”:final(最终关键字)引用只是不允许重新指向,若它指向普通数组,数组元素依然能被修改。String(字符串)之所以不可变,是类封闭、字段封装、没有修改入口、防御性处理和所有变换返回新对象共同形成的结果。使用反射或底层不安全接口强行改内存属于突破封装,不是正常 Java(编程语言)语义下的可变能力。
热门面试题
问题(基础题):String(字符串)为什么设计成不可变?
- 考点:线程安全、常量池、哈希稳定、安全性。
- 回答思路:不可变可以让 String(字符串)安全共享,支持常量池复用,作为 HashMap(哈希映射)键时哈希稳定,也能避免路径、类名、权限标识被修改。
- 详细答案:底层原因不是一句“被 final(最终关键字)修饰”就结束。类不能被继承破坏语义,内容存储字段私有且只在构造阶段确定,公开接口不提供原地修改,所有产生不同内容的操作返回新对象,向外提供数组时避免泄漏内部引用。这使同一 String(字符串)能在多个线程间安全共享,也让 StringTable(字符串常量池表)可以复用相同字面量;作为 HashMap(哈希映射)的键时,内容和 hashCode(哈希码方法)不会中途变化;作为类名、文件路径、网络地址和权限标识传递时,也不会被被调用方悄悄修改。JDK(Java 开发工具包)9 把主要存储从 char[](字符数组)优化为 byte[](字节数组)加 coder(编码标记),改变的是空间表示,不改变不可变语义。
- 进阶追问:不可变对象是不是一定没有性能问题?
- 进阶回答:不是。不可变降低了共享和并发复杂度,但每次内容变化可能创建新对象并复制数据。少量拼接可由编译器优化,大量循环拼接会产生较多临时对象、增加内存分配和 GC(垃圾回收)压力。单线程批量构建文本应使用 StringBuilder(可变字符串构建器);超大导出更应采用分页读取、流式写出和固定缓冲区,而不是把全部结果拼成一个巨大的 String(字符串)。
问题(原理题):
String s = "a" + "b"和循环里不断s += item有什么区别?- 考点:编译期常量折叠、运行期对象创建、字符串拼接成本。
- 回答思路:常量拼接可能在编译期折叠成一个 String(字符串);循环里
+=会不断创建新对象或临时 StringBuilder(可变字符串构建器),大量拼接建议显式使用 StringBuilder(可变字符串构建器)。 - 详细答案:两个编译期常量的拼接通常会在编译阶段直接折叠为字面量
ab,运行时不需要逐步创建a、b的拼接结果。变量参与的单次+通常会被编译器转换成构建器或更现代的动态拼接策略,但循环中的s += item语义要求每次都基于旧 String(字符串)生成新 String(字符串);随着内容变长,重复复制会让总成本接近平方级增长,并制造大量短命对象。把一个 StringBuilder(可变字符串构建器)放在循环外,只扩容内部缓冲区并在最后生成一次 String(字符串),可以显著减少复制和对象分配。 - 进阶追问:导出 10 万条订单拼接 CSV(逗号分隔值)时怎么避免内存抖动?
- 进阶回答:不能只把 String(字符串)换成一个超大的 StringBuilder(可变字符串构建器),因为最终内容仍全部驻留内存。应按主键或游标分页查询订单,每读一批就通过 BufferedWriter(缓冲字符输出流)写入文件或对象存储上传流,写完立即释放批次对象;设置合理缓冲区和批量大小,限制并发导出数,并记录任务进度。这样内存复杂度从与总订单量线性增长,变成主要与单批大小相关。
问题(项目追问题):库存缓存 key(键)为什么通常用 String(字符串)而不是可变对象?
- 考点:缓存 key(键)稳定性、可观测性、跨系统一致性。
- 回答思路:库存缓存 key(键)需要跨线程、跨进程、跨 Redis(远程字典服务)实例稳定识别,String(字符串)天然不可变且易序列化,例如
stock:sku:1001:warehouse:8。 - 详细答案:缓存键是跨语言和跨进程的数据协议,而不是单个 JVM(Java 虚拟机)里的对象身份。String(字符串)具有确定的字节表示、可直接打印排查、内容不可变和哈希稳定等特点,适合表达命名空间、业务主键和维度。推荐固定格式为“环境:业务:实体:主键:维度”,并集中封装生成逻辑,例如库存键同时包含商品和仓库,避免不同仓库互相覆盖。可变对象不仅序列化结果可能随字段变化,还容易受字段顺序、空值和版本升级影响,造成旧键无法命中。
- 进阶追问:如果 key(键)拼接规则不统一,会导致哪些线上问题?
- 进阶回答:同一库存可能产生多份缓存,形成数据分叉;删除或更新时只处理其中一份,产生长期脏数据;不同业务也可能发生键冲突并互相覆盖;监控无法按统一前缀统计,big key(大键)和 hot key(热键)排查困难。工程上应提供统一 KeyBuilder(键构建器),明确分隔符、空值、版本号和租户维度,变更规则时采用双读迁移并设置旧键过期时间。
3.3 final(最终关键字)、static(静态关键字)和不可变设计
final(最终关键字)用于限制变化,static(静态关键字)用于表达类级别共享。面试时要能讲清“限制变化”和“共享状态”的边界。
| 关键字 | 修饰对象 | 含义 | 风险点 |
|---|---|---|---|
| final(最终关键字) | 类 | 类不能被继承 | 扩展性降低 |
| final(最终关键字) | 方法 | 方法不能被重写 | 子类无法改变行为 |
| final(最终关键字) | 变量 | 引用不能重新指向 | 引用对象内部仍可能可变 |
| static(静态关键字) | 字段 | 类级别共享字段 | 共享可变状态容易引发并发问题 |
| static(静态关键字) | 方法 | 类级别方法 | 不依赖实例状态 |
| static(静态关键字) | 内部类 | 不持有外部类实例 | 常用于工具或节点结构 |
不可变对象常见设计:
- 类用 final(最终关键字)修饰,避免子类破坏不可变约束。
- 字段用 private(私有访问控制)和 final(最终关键字)修饰。
- 构造时完成所有字段赋值。
- 不暴露可变对象引用,必要时做 defensive copy(防御性拷贝)。
热门面试题
问题(基础题):final(最终关键字)修饰引用变量后,对象内容还能变吗?
- 考点:引用不可变和对象不可变的区别。
- 回答思路:final(最终关键字)只保证引用不能重新指向另一个对象,不保证对象内部状态不可变;如果引用指向 ArrayList(数组列表),仍然可以 add(添加)元素。
- 详细答案:final(最终关键字)约束的是变量槽位只能赋值一次。若槽位里保存基本类型,数值不能再变;若保存引用,引用地址不能换,但被引用对象仍可通过自身方法改变。真正不可变还要求类不能被子类破坏、字段私有、可变成员做防御性复制、不给外部修改入口。例如 final(最终关键字)的 ArrayList(数组列表)仍能添加元素,只是变量不能改指向另一个列表。
- 进阶追问:如何设计真正不可变的订单快照对象?
- 进阶回答:类和字段保持不可扩展与只读,构造器一次接收全部数据;金额、编号等字段本身使用不可变类型;集合在构造时复制,向外返回不可修改视图或副本;不提供 setter(设值方法)。如果订单包含可变明细对象,还必须逐项深复制,不能只复制集合外壳。
问题(原理题):static(静态关键字)共享字段为什么容易出并发问题?
- 考点:类级别共享、线程安全、可见性。
- 回答思路:static(静态关键字)字段属于类,被所有线程共享;如果是可变字段且没有并发控制,就可能出现竞态条件、可见性问题和状态串扰。
- 详细答案:static(静态关键字)字段随类加载存在,同一 ClassLoader(类加载器)范围内所有请求线程访问同一份数据。
count++之类操作包含读取、计算和写回,多线程会丢失更新;普通字段写入还可能不能及时被其他线程看见。若存放请求上下文,后一个请求可能覆盖前一个请求数据。静态常量通常安全,静态可变状态则需要并发容器、原子类、锁或更好的无共享设计。 - 进阶追问:在 Web(万维网)服务里用 static(静态关键字)保存当前用户会发生什么?
- 进阶回答:所有请求共享同一变量,用户甲和用户乙并发时会互相覆盖,导致越权和数据泄漏;多实例部署时每个实例的值又不一致。当前用户应从每次请求的认证上下文获取,异步线程需要显式传递且完成后清理。
问题(项目追问题):Runner(执行器)调度里哪些配置适合做不可变对象?
- 考点:配置快照、线程安全、任务一致性。
- 回答思路:任务超时时间、重试次数、限流阈值等适合构造成不可变配置快照,避免任务执行过程中配置被并发修改导致行为不一致。
- 详细答案:调度器接收任务时读取一次配置,生成包含版本号、超时、重试、并发限制和路由规则的不可变快照,并把快照版本记录到任务实例。任务执行期间始终使用该版本,避免重试到一半改变最大次数或超时语义。动态配置本身可以更新,但更新动作是用新对象原子替换旧对象,而不是逐字段修改共享对象。
- 进阶追问:如果配置中心动态更新,如何保证新任务用新配置、旧任务不被中途影响?
- 进阶回答:使用 AtomicReference(原子引用)保存当前不可变配置,新配置完整校验后一次替换。创建任务时读取当前引用并保存版本,新任务获得新对象,旧任务仍持有旧对象;对必须立即生效的安全开关,则单独设计可中断协议,不与普通参数热更新混在一起。
3.4 泛型、反射和注解
泛型让集合和组件具备类型约束,反射让程序运行时检查和操作类信息,注解则把元数据声明在代码结构上。三者在 Spring(Java 应用框架)、MyBatis(持久层框架)、序列化、参数校验、权限控制中非常常见。
| 机制 | 解决问题 | 工程场景 |
|---|---|---|
| 泛型 | 编译期类型约束,减少强制转换 | List<Order>、统一返回结果、仓储接口 |
| 反射 | 运行时获取类、字段、方法信息 | Spring(Java 应用框架)创建 Bean(对象实例)、MyBatis(持久层框架)映射字段 |
| 注解 | 在代码上声明元数据 | Controller(控制器)、事务、权限、校验、日志埋点 |
泛型的核心限制是 type erasure(类型擦除):Java(编程语言)泛型主要在编译期生效,运行期很多泛型信息会被擦除。因此 List<String> 和 List<Integer> 在运行期原始类型都接近 List(列表接口)。
反射的风险:
- 性能成本比直接调用高。
- 破坏封装,可能访问 private(私有访问控制)成员。
- 运行期才暴露错误,缺少编译期保护。
- 在高频路径使用反射要谨慎,通常需要缓存 Field(字段元信息)或 Method(方法元信息)。
热门面试题
问题(基础题):泛型解决了什么问题?
- 考点:类型安全、可读性、编译期约束。
- 回答思路:泛型让集合和方法在编译期带上类型信息,减少强制类型转换和 ClassCastException(类型转换异常),也让代码语义更清楚。
- 详细答案:没有泛型时集合只能按 Object(对象基类)接收元素,读取时必须手工强制转换,错误要到运行时才暴露。泛型把元素类型写入接口和方法签名,使编译器检查写入、读取和方法组合,并自动插入必要转换。泛型还支持复用同一算法,例如仓储接口可以用类型参数表达实体和主键类型,而不用为每种实体复制代码。
- 进阶追问:为什么泛型不能直接创建
new T()? - 进阶回答:type erasure(类型擦除)后,运行时通常不知道
T的具体类,也无法确定调用哪个构造器,因此不能直接实例化。可由调用方传入 Class(类元数据)、Supplier(对象提供器)或工厂函数来显式提供创建方式。
问题(原理题):什么是 type erasure(类型擦除)?
- 考点:编译期泛型、运行期类型、边界限制。
- 回答思路:Java(编程语言)为了兼容历史版本,泛型信息主要在编译期检查,编译后很多泛型信息被擦除成原始类型或上界类型,所以运行期不能简单判断
List<String>和List<Integer>。 - 详细答案:编译器先按泛型规则检查,再把类型参数替换为上界;没有显式上界时通常擦为 Object(对象基类),必要位置插入类型转换。这让新泛型代码能够与早期非泛型字节码兼容,也导致不能重载只在泛型参数上不同的方法、不能直接创建泛型数组。并非所有泛型痕迹都消失,类、字段和方法签名中的声明信息可以保存在元数据里供反射读取。
- 进阶追问:Spring(Java 应用框架)如何在某些场景下拿到泛型信息?
- 进阶回答:框架从类签名、字段、方法返回值和父类/接口声明的反射 Type(类型描述)中解析 ParameterizedType(参数化类型);如果调用处只剩运行时原始对象而声明信息也已擦除,就无法凭空恢复具体参数,因此框架常要求通过子类、字段签名或 TypeReference(类型引用)保留信息。
问题(项目追问题):为什么框架喜欢用注解和反射?
- 考点:声明式编程、解耦、运行时装配。
- 回答思路:注解让业务代码声明元数据,反射让框架运行时读取这些元数据并完成 Bean(对象实例)创建、依赖注入、事务增强、字段映射等逻辑,减少手写样板代码。
- 详细答案:注解只保存“这里需要什么能力”的元数据,本身不会执行。框架在启动期扫描类和方法,用反射读取注解,形成路由、BeanDefinition(Bean 定义)、映射和代理规则;运行期再按缓存好的元数据调用目标。优势是业务代码声明式、扩展点统一,代价是错误可能推迟到启动或运行期,调用关系也更隐式,因此框架必须做启动校验和元数据缓存。
- 进阶追问:如果反射扫描太慢,启动时间怎么优化?
- 进阶回答:缩小包扫描范围,避免扫描无关依赖;把反射结果缓存为可复用元数据;减少初始化阶段的网络和全表操作;必要时使用编译期索引、代码生成或 AOT(提前编译)生成提示。先用启动阶段耗时数据定位,不能把所有慢启动都归因于反射。
3.5 异常体系
Java(编程语言)异常体系分为 Throwable(可抛出对象)、Error(严重错误)和 Exception(异常)。面试重点是 checked exception(受检异常)和 unchecked exception(非受检异常)的边界,以及项目里如何设计业务异常。
flowchart TD
T["Throwable(可抛出对象)"] --> E1["Error(严重错误)"]
T --> E2["Exception(异常)"]
E2 --> CE["checked exception(受检异常)"]
E2 --> RE["RuntimeException(运行时异常)"]
RE --> NPE["NullPointerException(空指针异常)"]
RE --> IAE["IllegalArgumentException(非法参数异常)"]
E1 --> OOM["OutOfMemoryError(内存溢出错误)"]
E1 --> SOE["StackOverflowError(栈溢出错误)"]异常设计建议:
- 参数错误、状态错误、业务规则不满足,通常用业务异常或 RuntimeException(运行时异常)子类。
- 外部系统调用失败要保留 error code(错误码)、requestId(请求标识)、TraceId(链路标识)和原始异常。
- 不要吞异常;至少要记录上下文,必要时转换成业务可理解的错误。
- 事务方法里要注意异常类型和 rollback(回滚)规则,避免异常被捕获后事务误提交。
热门面试题
问题(基础题):Exception(异常)和 Error(严重错误)有什么区别?
- 考点:可恢复性、程序处理边界。
- 回答思路:Exception(异常)通常表示程序可处理或可恢复的问题,Error(严重错误)通常表示 JVM(Java 虚拟机)层面或系统级严重问题,例如 OOM(内存溢出)和 StackOverflowError(栈溢出错误),业务代码一般不应该捕获后继续运行。
- 详细答案:两者都继承 Throwable(可抛出对象)。Exception(异常)用于表达业务、参数、网络和持久化等可按策略处理的问题,其中 RuntimeException(运行时异常)通常不强制声明;Error(严重错误)表示运行环境已出现严重问题,例如内存耗尽或类链接失败,当前进程可能无法维持可靠状态。边界层可以记录或触发降级,但不应把 Error(严重错误)吞掉后假装请求成功。
- 进阶追问:线上出现 OOM(内存溢出)时为什么不能只 catch(捕获)后忽略?
- 进阶回答:内存已经无法满足分配,捕获异常所需的日志、响应和补偿对象也可能继续失败;部分线程和事务可能处于不完整状态。应提前启用 heap dump(堆转储),由容器重启故障实例并保留现场,再从引用链、批量大小、缓存和线程数修复根因。
问题(原理题):为什么事务方法里捕获异常后不抛出,可能导致事务不回滚?
- 考点:Spring(Java 应用框架)事务代理、异常传播、回滚规则。
- 回答思路:Spring(Java 应用框架)事务通常依赖代理捕获方法抛出的异常来决定 rollback(回滚);如果方法内部 catch(捕获)异常并返回成功,代理层认为业务成功,事务可能提交。
- 详细答案:外部调用经过事务代理后,代理先开启事务,再调用目标方法。只有异常继续穿过代理,代理才能按回滚规则标记并执行 rollback(回滚)。目标方法内部 catch(捕获)后正常返回,代理观察到的是成功结果,所以会 commit(提交)。此外默认回滚规则通常偏向 RuntimeException(运行时异常)和 Error(严重错误),受检异常需要显式配置。
- 进阶追问:如何在捕获异常后仍然让事务回滚?
- 进阶回答:优先转换并重新抛出符合回滚规则的异常;确需在当前方法返回统一结果时,可显式把当前事务标记为 rollback-only(仅允许回滚),但这增加了隐式行为。无论哪种方式,调用方都必须知道操作失败,不能返回业务成功。
问题(项目追问题):支付回调处理失败时,异常应该怎么设计?
- 考点:幂等、重试、可观测性、外部系统交互。
- 回答思路:区分参数非法、验签失败、订单不存在、状态冲突、数据库失败、第三方超时;可重试异常要保留上下文并进入重试或补偿,不可重试异常要落库并告警。
- 详细答案:先按是否可信和是否可重试分类。验签失败直接拒绝并记录安全审计;未知订单和非法状态要记录原始事件并告警;数据库瞬时失败可让渠道重试或进入内部重试;重复事件返回已处理结果。异常对象和日志要包含渠道、事件号、支付单号、请求摘要和 TraceId(链路标识),但不能打印密钥和完整敏感数据。
- 进阶追问:如果异常日志没有 TraceId(链路标识),排查会遇到什么困难?
- 进阶回答:无法把网关请求、支付服务、账务、MQ(消息队列)和第三方调用串成同一链路,只能按时间和业务号人工碰撞,容易漏掉并发请求。入口应生成或透传 TraceId(链路标识),异步消息把它写入消息头,并同时保留稳定业务主键用于跨天查询。
4. 底层原理
4.1 ArrayList(数组列表)和 LinkedList(链表列表)
ArrayList(数组列表)底层是动态数组,LinkedList(链表列表)底层是双向链表。面试官常问“插入删除是不是 LinkedList(链表列表)一定快”,答案不是绝对的。
| 维度 | ArrayList(数组列表) | LinkedList(链表列表) |
|---|---|---|
| 底层结构 | 连续数组 | 双向链表 |
| 随机访问 | 快,按下标 O(1)(常数复杂度) | 慢,需要遍历 O(n)(线性复杂度) |
| 尾部追加 | 通常快,扩容时有成本 | 快 |
| 中间插入 | 找位置快,移动元素有成本 | 找位置慢,改指针快 |
| 内存占用 | 相对低 | 每个节点有前后指针,额外成本高 |
| 实际项目常用度 | 高 | 较低 |
ArrayList(数组列表)扩容过程:
- 新增元素时发现数组容量不足。
- 创建更大的新数组。
- 把旧数组元素复制到新数组。
- 新元素放入新数组。
- 原数组等待 GC(垃圾回收)。
热门面试题
问题(基础题):ArrayList(数组列表)和 LinkedList(链表列表)怎么选?
- 考点:底层结构、访问模式、内存成本。
- 回答思路:读多、按下标访问多、尾部追加多选 ArrayList(数组列表);频繁在已定位节点附近插入删除才考虑 LinkedList(链表列表),但业务开发里 LinkedList(链表列表)使用频率并不高。
- 详细答案:ArrayList(数组列表)的连续数组具有良好缓存局部性,随机访问直接按下标计算,遍历和尾部追加通常更快。LinkedList(链表列表)每个节点要保存前后指针,访问第 n 个元素必须遍历;只有已经持有目标节点位置时,链接和删除动作本身才是常数成本。大多数业务只有下标和迭代器,没有长期持有节点,因此 ArrayList(数组列表)通常更合适。
- 进阶追问:为什么 LinkedList(链表列表)中间插入不一定快?
- 进阶回答:插入前通常要先从头或尾遍历找到位置,这一步是 O(n)(线性复杂度);数组虽然要移动元素,但底层连续内存批量复制很快。应按完整操作成本和真实数据量压测,而不是只比较“改指针”和“移动数组”。
问题(原理题):ArrayList(数组列表)扩容为什么有性能抖动?
- 考点:数组复制、内存分配、GC(垃圾回收)。
- 回答思路:扩容要分配新数组并复制旧元素,旧数组还会增加 GC(垃圾回收)压力;如果能预估容量,应使用初始容量降低扩容次数。
- 详细答案:容量不足时需要申请更大连续数组,再把全部引用复制过去。扩容瞬间新旧数组同时占内存,复制消耗 CPU(中央处理器)和内存带宽,旧数组随后成为垃圾;大列表频繁扩容会造成延迟尖峰。增长策略让追加的均摊复杂度仍接近 O(1)(常数复杂度),但单次扩容并不是常数成本。
- 进阶追问:导出订单列表时为什么要预估 List(列表接口)容量?
- 进阶回答:已知批次最多 5000 条时可一次设置接近容量,减少扩容和复制。不过更重要的是不要把全部订单放入一个列表;应分页或游标读取并流式写出,让内存上限由批次决定。
问题(项目追问题):WMS(仓储管理系统)批量出库生成拣货任务时,为什么常用 ArrayList(数组列表)?
- 考点:批量构建、顺序遍历、内存连续性。
- 回答思路:拣货任务通常一次批量生成后顺序遍历和批量入库,ArrayList(数组列表)内存连续、遍历快,设置初始容量能减少扩容。
- 详细答案:该场景主要是按出库明细顺序构造任务、校验、批量写库,很少在中间随机插入。ArrayList(数组列表)遍历局部性好、每个元素只有引用开销,适合批处理。创建时根据本批明细数设容量,完成入库后及时释放批次引用。
- 进阶追问:如果一次处理 50 万条任务,List(列表接口)会有什么风险?
- 进阶回答:任务对象、明细和关联字符串会形成巨大对象图,增加堆占用、GC(垃圾回收)停顿和事务时间,失败后重试成本也高。应拆成有检查点的小批次,限制并发,用唯一键保证批次重试幂等,并避免一个长事务覆盖全部任务。
4.2 HashMap(哈希映射)底层结构
HashMap(哈希映射)是面试最高频集合。它的核心结构是“数组 + 链表 + 红黑树”。数组负责快速定位 bucket(桶),链表或红黑树负责处理 hash collision(哈希冲突)。
flowchart LR
K["key(键)"] --> H["hashCode(哈希码方法)"]
H --> S["扰动函数"]
S --> I["定位 bucket(桶)"]
I --> B0["数组槽位"]
B0 --> N1["Node(节点)"]
N1 --> N2["链表节点"]
N2 --> T["冲突过多时树化为红黑树"]HashMap(哈希映射)put(写入)流程:
- 对 key(键)调用 hashCode(哈希码方法)。
- 通过扰动函数让高位参与运算,降低冲突概率。
- 用数组长度减一后按位与,定位 bucket(桶)。
- bucket(桶)为空时直接放入。
- bucket(桶)不为空时,先比较 hash(哈希)值,再用 equals(相等判断方法)判断是否同一个 key(键)。
- key(键)已存在则覆盖 value(值),不存在则追加到链表或红黑树。
- 元素数量超过 threshold(扩容阈值)时触发 resize(扩容)。
HashMap(哈希映射)线程不安全的原因:
- 并发 put(写入)可能导致数据覆盖。
- resize(扩容)期间多个线程同时迁移节点可能造成结构异常。
- 一个线程修改结构,另一个线程遍历,可能触发 ConcurrentModificationException(并发修改异常)。
热门面试题
问题(基础题):HashMap(哈希映射)为什么查询快?
- 考点:哈希定位、数组下标、冲突处理。
- 回答思路:HashMap(哈希映射)通过 hashCode(哈希码方法)和扰动函数快速定位数组 bucket(桶),理想情况下查找接近 O(1)(常数复杂度);冲突时再在链表或红黑树中比较。
- 详细答案:数组下标由处理后的哈希值与容量计算得到,理想分布下一个 bucket(桶)只有很少节点,查询只需一次数组定位和少量 equals(相等判断方法)比较。它的快依赖稳定且分布良好的 hashCode(哈希码方法)、合理负载因子和足够容量;极端冲突下仍需遍历链表或红黑树,不能把 O(1)(常数复杂度)理解成绝对保证。
- 进阶追问:如果 hashCode(哈希码方法)写得很差,会发生什么?
- 进阶回答:大量键进入同一 bucket(桶),查询和写入成本上升,可能频繁树化;若所有键哈希相同,哈希表优势显著下降。安全场景还要防恶意构造冲突导致 CPU(中央处理器)消耗。应让参与字段稳定并使结果分布均匀,不能简单返回常量。
问题(原理题):HashMap(哈希映射)为什么要树化?
- 考点:哈希冲突、链表退化、红黑树查询复杂度。
- 回答思路:当同一个 bucket(桶)冲突过多时,链表查询会退化成 O(n)(线性复杂度);树化后查询可接近 O(log n)(对数复杂度),降低极端冲突下的性能风险。
- 详细答案:链表长度增加后,每次查找都要逐节点比较。达到树化阈值且数组容量足够时,JDK(Java 开发工具包)8 的 HashMap(哈希映射)把该 bucket(桶)转换为红黑树,把极端查询复杂度降低到 O(log n)(对数复杂度)。如果整表容量还小,先扩容通常比树化更能通过重新分桶消除冲突。
- 进阶追问:为什么不是一开始就用红黑树?
- 进阶回答:绝大多数 bucket(桶)为空或只有一两个节点,链表节点更轻、插入和遍历更简单。红黑树节点字段更多,还要维护旋转和平衡,小规模下成本高于链表;树化是为极端冲突提供的保护,而不是默认结构。
问题(项目追问题):库存聚合时用 HashMap(哈希映射)统计 SKU(库存单位)数量,要注意什么?
- 考点:key(键)稳定性、容量预估、并发安全。
- 回答思路:key(键)要选稳定的 SKU(库存单位)编号和仓库编号组合;批量数据大时预估容量减少 resize(扩容);多线程聚合时不能直接共享普通 HashMap(哈希映射),应分片聚合后合并或使用 ConcurrentHashMap(并发哈希映射)。
- 详细答案:组合键必须包含真正决定库存范围的租户、仓库和 SKU(库存单位),并保持不可变。单线程批量聚合可预估键数量并使用 merge(合并)累加;并行处理优先让每个任务维护私有映射,最后归并,减少共享竞争。数量计算还要防空值、整数溢出和单位换算错误。
- 进阶追问:如果多个线程同时写同一个 HashMap(哈希映射),线上可能出现哪些现象?
- 进阶回答:可能丢失更新、读取到不一致结构、遍历抛 ConcurrentModificationException(并发修改异常),扩容期间还可能产生更复杂结构问题。不要通过“目前没复现”判断安全,应换并发容器或改变并行聚合方式。
4.3 LinkedHashMap(链式哈希映射)、TreeMap(树映射)和 HashSet(哈希集合)
LinkedHashMap(链式哈希映射)在 HashMap(哈希映射)基础上维护双向链表,所以可以保留插入顺序或访问顺序。TreeMap(树映射)基于红黑树,支持 key(键)有序。HashSet(哈希集合)底层通常借助 HashMap(哈希映射)实现,只关心 key(键)是否存在。
| 集合 | 底层重点 | 适用场景 |
|---|---|---|
| LinkedHashMap(链式哈希映射) | HashMap(哈希映射)+ 双向链表 | 保序、LRU(最近最少使用)缓存 |
| TreeMap(树映射) | 红黑树 | 范围查询、按 key(键)排序 |
| HashSet(哈希集合) | HashMap(哈希映射)的 key(键)集合 | 去重、存在性判断 |
热门面试题
问题(基础题):HashSet(哈希集合)如何保证元素不重复?
- 考点:HashMap(哈希映射)底层、equals(相等判断方法)、hashCode(哈希码方法)。
- 回答思路:HashSet(哈希集合)底层通常使用 HashMap(哈希映射),元素作为 key(键)存储;是否重复依赖 hashCode(哈希码方法)定位和 equals(相等判断方法)比较。
- 详细答案:add(添加)元素实际相当于把元素作为 HashMap(哈希映射)的键,并使用固定占位值。先按 hashCode(哈希码方法)定位 bucket(桶),再用 equals(相等判断方法)确认同一业务元素;已存在时不再增加集合大小。它保证的是按相等契约去重,不是按对象地址或数据库唯一性去重。
- 进阶追问:如果 equals(相等判断方法)相等但 hashCode(哈希码方法)不同,会怎样?
- 进阶回答:两个业务相等元素可能进入不同 bucket(桶),HashSet(哈希集合)会同时保留它们,破坏去重语义;查找和删除也可能失败。必须同步重写两个方法,并保持参与字段不可变。
问题(原理题):LinkedHashMap(链式哈希映射)如何实现 LRU(最近最少使用)?
- 考点:访问顺序、双向链表、淘汰策略。
- 回答思路:LinkedHashMap(链式哈希映射)可以按 accessOrder(访问顺序标志)维护链表,访问元素后移动到链表尾部,再通过 removeEldestEntry(移除最老条目方法)淘汰头部最久未访问元素。
- 详细答案:哈希表仍负责 O(1)(常数复杂度)附近的键定位,额外双向链表维护全局顺序。启用访问顺序后,读取命中也会把节点移到尾部,头部就是最久未访问节点;子类覆盖淘汰判断即可在新增后删除头部。该实现默认不是线程安全的,并且只能约束单进程内存。
- 进阶追问:为什么本地 LRU(最近最少使用)缓存不能替代 Redis(远程字典服务)缓存?
- 进阶回答:每个实例各自保存数据,命中和淘汰状态不一致;扩缩容和重启会丢失缓存,也无法提供跨实例原子操作。本地缓存适合小而热、允许短暂不一致的数据,分布式共享和协调仍需 Redis(远程字典服务)等外部系统,并设计多级缓存一致性。
问题(项目追问题):报警风暴治理里如何用集合去重?
- 考点:时间窗口、去重 key(键)、内存上限。
- 回答思路:可以用 HashSet(哈希集合)记录窗口内
deviceId(设备标识) + alarmCode(报警编码),避免同一设备同一报警重复刷屏;如果需要保留最近访问顺序,可用 LinkedHashMap(链式哈希映射)做窗口淘汰。 - 详细答案:先定义去重语义和窗口,例如同设备、同报警码在 60 秒内只保留首次并累计次数。键还可包含租户和规则版本;值记录首次、最近时间和次数。单实例可用有界本地结构,分布式场景用 Redis(远程字典服务)原子脚本或流式系统分区处理,并让原始报警持久化,不能因通知去重而丢审计数据。
- 进阶追问:如果报警量突然放大 100 倍,本地集合会有什么风险?
- 进阶回答:无界键数量会占满堆并触发频繁 GC(垃圾回收)甚至 OOM(内存溢出),单进程 CPU(中央处理器)也会被哈希和清理耗尽。必须设置最大容量、时间淘汰和入口限流,按设备或租户分片,并监控去重键数、输入速率和丢弃策略。
4.4 ConcurrentHashMap(并发哈希映射)
ConcurrentHashMap(并发哈希映射)用于并发场景,目标是在保证线程安全的同时降低锁竞争。和 Hashtable(哈希表)整表加锁不同,ConcurrentHashMap(并发哈希映射)会把锁粒度控制得更细。
| 版本 | 核心实现 | 特点 |
|---|---|---|
| JDK(Java 开发工具包)7 | Segment(分段)锁 | 多个 Segment(分段)降低整表锁竞争 |
| JDK(Java 开发工具包)8 | CAS(比较并交换)+ synchronized(同步锁)+ Node(节点) | 首节点加锁,结构更紧凑 |
JDK(Java 开发工具包)8 ConcurrentHashMap(并发哈希映射)写入主线:
- bucket(桶)为空时,用 CAS(比较并交换)尝试放入节点。
- bucket(桶)不为空时,对首节点使用 synchronized(同步锁)。
- 链表或红黑树内查找 key(键),存在则更新,不存在则新增。
- 扩容时多个线程可协助迁移,降低单线程扩容压力。
热门面试题
问题(基础题):ConcurrentHashMap(并发哈希映射)和 Hashtable(哈希表)有什么区别?
- 考点:锁粒度、并发性能、历史实现。
- 回答思路:Hashtable(哈希表)很多操作直接锁整张表,并发性能差;ConcurrentHashMap(并发哈希映射)通过更细粒度锁、CAS(比较并交换)和协助扩容提升并发性能。
- 详细答案:Hashtable(哈希表)是较早实现,主要公开方法使用 synchronized(同步锁)锁住整个实例,并发读写容易形成单点竞争。JDK(Java 开发工具包)8 的 ConcurrentHashMap(并发哈希映射)读取通常不锁,空桶写入使用 CAS(比较并交换),冲突桶只锁首节点,扩容可由多个线程协作。两者都不允许 null(空值)键和值,避免并发读取时无法区分不存在和显式空值。
- 进阶追问:ConcurrentHashMap(并发哈希映射)是不是所有操作都不加锁?
- 进阶回答:不是。空桶初始化等场景使用 CAS(比较并交换),冲突桶更新会使用 synchronized(同步锁),树结构和扩容也需要协调。它的目标是缩小竞争范围,不是消灭同步。
问题(原理题):ConcurrentHashMap(并发哈希映射)的 size(元素数)为什么不好统计?
- 考点:并发写入、分散计数、最终一致。
- 回答思路:多线程同时写入时,全局精确计数会成为竞争热点,所以 ConcurrentHashMap(并发哈希映射)采用分散计数思路,统计时汇总多个计数单元。
- 详细答案:每次写入若都争用一个全局计数器,会把并发映射退化为计数热点。实现把增量分散到基础计数和多个 CounterCell(计数单元),统计时求和。求和期间其他线程仍可能修改映射,所以 size(元素数)更适合监控和估算,不适合作为并发业务条件的原子快照。
- 进阶追问:高并发下依赖 size(元素数)做强一致判断有什么风险?
- 进阶回答:检查“size(元素数)小于上限”与随后写入不是一个原子操作,多个线程可同时通过检查并突破限制。应使用信号量、原子计数加回滚、队列容量或数据库约束表达强限制。
问题(项目追问题):异步任务执行状态能不能用 ConcurrentHashMap(并发哈希映射)保存?
- 考点:本地内存状态、分布式一致性、服务重启。
- 回答思路:单机临时状态可以用 ConcurrentHashMap(并发哈希映射),但不能作为最终状态来源;任务状态要落库或进入 Redis(远程字典服务)等共享存储,否则服务重启或多实例部署会丢状态。
- 详细答案:它适合保存本实例正在执行的任务句柄、取消标志等短期辅助状态,不适合保存唯一业务事实。任务的待执行、执行中、成功、失败和重试次数应持久化,并带版本号或租约;实例重启后可以扫描恢复。内存映射还要在完成时删除并设置容量监控,避免历史状态泄漏。
- 进阶追问:如果任务被多个实例同时调度,如何保证不重复执行?
- 进阶回答:通过数据库条件更新抢占任务,例如只有状态为待执行且版本匹配才能改为执行中,或使用带到期时间的租约;只有更新成功的实例获得执行权。业务副作用还要使用任务号做幂等,防止实例在执行成功但回写状态前宕机后被再次调度。
5. 架构图与流程图
5.1 集合选型流程
flowchart TD
A["需要存多个元素"] --> B{"是否需要 key(键)到 value(值)映射"}
B -->|是| C{"是否需要排序"}
C -->|是| D["TreeMap(树映射)"]
C -->|否| E{"是否需要保留顺序"}
E -->|是| F["LinkedHashMap(链式哈希映射)"]
E -->|否| G{"是否多线程共享写"}
G -->|是| H["ConcurrentHashMap(并发哈希映射)"]
G -->|否| I["HashMap(哈希映射)"]
B -->|否| J{"是否需要去重"}
J -->|是| K["HashSet(哈希集合)"]
J -->|否| L{"是否频繁随机访问"}
L -->|是| M["ArrayList(数组列表)"]
L -->|否| N["按场景评估 LinkedList(链表列表)或 Queue(队列接口)"]5.2 HashMap(哈希映射)扩容数据流
sequenceDiagram
participant App as 应用线程
participant Map as HashMap(哈希映射)
participant Old as 旧数组
participant New as 新数组
App->>Map: put(写入)新元素
Map->>Map: 判断 size(大小)是否超过 threshold(扩容阈值)
Map->>New: 创建更大数组
loop 迁移每个 bucket(桶)
Map->>Old: 读取旧节点
Map->>New: 重新分布节点
end
Map->>New: 写入新元素
Map-->>App: 返回写入结果6. 表格对比
| 场景 | 推荐结构 | 不推荐原因 |
|---|---|---|
| 批量订单顺序处理 | ArrayList(数组列表) | LinkedList(链表列表)随机访问慢、内存成本高 |
| SKU(库存单位)数量聚合 | HashMap(哈希映射) | TreeMap(树映射)排序成本不必要 |
| 支付流水去重 | HashSet(哈希集合)或数据库唯一索引 | 单靠 List(列表接口)遍历性能差 |
| 最近访问缓存 | LinkedHashMap(链式哈希映射) | HashMap(哈希映射)不维护访问顺序 |
| 排名或范围查询 | TreeMap(树映射) | HashMap(哈希映射)无序 |
| 多线程任务状态 | ConcurrentHashMap(并发哈希映射) | HashMap(哈希映射)线程不安全 |
7. 数据演绎
7.1 HashMap(哈希映射)扩容演绎
假设一个 HashMap(哈希映射)当前数组长度为 16,load factor(负载因子)为 0.75,那么 threshold(扩容阈值)是 12。
初始容量:16
扩容阈值:16 * 0.75 = 12
当前元素数:12
第 13 个元素写入 -> 触发 resize(扩容)
新容量:32
新阈值:32 * 0.75 = 24面试表达重点:
- 扩容不是只改一个数字,而是分配新数组并迁移节点。
- 如果批量导入 10 万条数据,不设置初始容量会多次扩容,带来数组复制和 GC(垃圾回收)压力。
- 项目里做库存聚合、订单分组、支付流水映射时,如果能预估数据量,应提前设置容量。
7.2 可变 key(键)导致查找失败
1. 创建 OrderKey(订单键):orderNo = A100, warehouseId = 8
2. 放入 HashMap(哈希映射),hashCode(哈希码方法)按 orderNo 和 warehouseId 计算
3. 业务代码把 warehouseId 改成 9
4. 再用同一个对象查询
5. 新 hashCode(哈希码方法)定位到另一个 bucket(桶)
6. 原来的 value(值)可能查不到项目风险:
- 支付幂等 key(键)可变,会导致重复回调被当成新请求。
- 库存聚合 key(键)可变,会导致同一个 SKU(库存单位)被拆成多个分组。
- 报警去重 key(键)可变,会导致重复报警无法合并。
8. 线上排查与实战经验
8.1 集合相关线上问题 SOP(标准操作流程)
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| CPU(中央处理器)飙高 | HashMap(哈希映射)冲突严重或循环处理大集合 | 看火焰图、jstack(线程栈工具)、热点方法 |
| OOM(内存溢出) | List(列表接口)无限增长、大对象缓存未释放 | jmap(内存映射工具)导出 HeapDump(堆转储),看集合持有链 |
| 去重失效 | equals(相等判断方法)/hashCode(哈希码方法)错误 | 构造相同业务对象做单元验证,检查 key(键)字段是否可变 |
| 并发异常 | 多线程修改普通集合 | 搜索共享 HashMap(哈希映射)/ArrayList(数组列表),替换为线程安全方案或分片合并 |
| 缓存命中异常 | key(键)拼接规则不一致 | 统一 key(键)生成器,日志打印关键字段 |
排查口诀:
- 先看集合是否无限增长。
- 再看 key(键)是否稳定。
- 再看是否多线程共享修改。
- 最后看是否需要换数据结构,而不是只加机器。
9. 项目落地话术
9.1 WMS(仓储管理系统)库存聚合
可以这样回答:
在 WMS(仓储管理系统)里,库存聚合经常要按 SKU(库存单位)和仓库维度做分组。我一般不会直接用可变对象当 HashMap(哈希映射) key(键),而是抽取稳定的
skuId + warehouseId作为业务 key(键)。如果是单线程批处理,用 HashMap(哈希映射)预估容量减少 resize(扩容);如果是多线程分片处理,我会让每个线程维护本地 HashMap(哈希映射),最后归并,或者用 ConcurrentHashMap(并发哈希映射)但注意热点 key(键)竞争。
9.2 支付回调幂等
可以这样回答:
支付回调里我不会依赖整个请求对象做去重,因为对象字段可能变化。幂等核心是稳定业务唯一键,比如支付通道事件 ID(标识)或支付流水号。内存里的 HashSet(哈希集合)只能做短时间保护,真正兜底要靠数据库唯一索引或 Redis(远程字典服务)原子写入,并且把重复请求、状态冲突、验签失败分别记录。
9.3 IoT(物联网)报警风暴治理
可以这样回答:
报警风暴治理里,集合的价值主要是窗口去重和聚合。我会按
deviceId(设备标识) + alarmCode(报警编码)做 key(键),在时间窗口内用 HashSet(哈希集合)去重;如果要控制内存上限,可以用 LinkedHashMap(链式哈希映射)按访问顺序淘汰最老报警。这个方案还要配合 Redis(远程字典服务)或消息队列做跨实例一致性,否则本地集合只能保证单实例内有效。
10. 高频面试题与追问
为什么 equals(相等判断方法)和 hashCode(哈希码方法)要一起重写?
- 回答思路:先讲相等契约,再讲哈希容器的两阶段查找,最后用可变键说明项目风险。
- 详细答案:HashMap(哈希映射)先通过 hashCode(哈希码方法)缩小到一个 bucket(桶),只在该 bucket(桶)内使用 equals(相等判断方法)判断业务相等。若两个 equals(相等判断方法)相等的对象哈希不同,它们会进入不同 bucket(桶),导致查找、删除和去重失效。反过来哈希相同不要求对象相等,因为 equals(相等判断方法)会继续解决冲突。
- 进阶追问:哪些字段适合参与这两个方法?
- 进阶回答:选择稳定、能够定义业务身份的字段,例如不可变订单号;不要使用状态、更新时间或可变集合。对象作为键后修改参与字段,会使节点留在旧 bucket(桶)而查询去新 bucket(桶)。
HashMap(哈希映射)的 put(写入)流程是什么?
- 回答思路:按哈希、定位、比较、插入或覆盖、树化、扩容六步回答。
- 详细答案:先取得键的 hashCode(哈希码方法)并扰动,让高位也参与下标计算;以容量减一和哈希值按位与得到 bucket(桶)。空桶直接写入,非空桶先比较哈希再比较 equals(相等判断方法),命中则覆盖值,否则追加链表或红黑树;链表过长且容量达到条件时树化,元素数超过阈值时扩容迁移。
- 进阶追问:为什么容量通常设计为二的幂?
- 进阶回答:这样下标可用
(n - 1) & hash(哈希值)高效计算,扩容翻倍后节点只需根据新增高位判断留在原位置还是移动固定偏移,不必重新做通用取模。
HashMap(哈希映射)为什么线程不安全?
- 回答思路:分别说明普通写、复合操作和结构变更没有同步保护。
- 详细答案:两个线程可能同时判断 bucket(桶)为空并相互覆盖,也可能在更新链表、树化和 resize(扩容)时读取到中间状态。即使单个 get(读取)暂时看似可用,“先检查再写入”也不是原子操作。遍历期间结构变化还可能抛 ConcurrentModificationException(并发修改异常)。
- 进阶追问:给 HashMap(哈希映射)外面加 synchronized(同步锁)可以吗?
- 进阶回答:能保证正确性,但所有操作争用同一锁,组合操作还必须始终遵守同一锁约定。一般优先使用 ConcurrentHashMap(并发哈希映射)及其原子方法,或让每个线程私有聚合后合并。
ConcurrentHashMap(并发哈希映射)为什么并发性能更好?
- 回答思路:对比整表锁,说明读取、空桶写、冲突桶写和扩容分别如何协调。
- 详细答案:JDK(Java 开发工具包)8 中读取通常不加互斥锁,空桶通过 CAS(比较并交换)插入,冲突桶只锁当前首节点,竞争限制在少数 bucket(桶)。扩容时线程还可以协助迁移,避免一个线程独占全部工作。它仍有同步成本,只是把粒度和热点缩小。
- 进阶追问:computeIfAbsent(不存在则计算)里的函数可以做远程调用吗?
- 进阶回答:不推荐。计算函数可能在桶级协调期间执行,长时间远程调用会扩大锁持有和竞争,还要面对重复计算或异常。应把远程加载、超时和缓存写入设计为明确流程。
ArrayList(数组列表)扩容有什么成本?
- 回答思路:说明新数组分配、引用复制、新旧共存和 GC(垃圾回收)四项成本。
- 详细答案:容量不足时要申请更大连续数组并复制已有引用,扩容瞬间新旧数组同时占内存,复制占用 CPU(中央处理器)和内存带宽,旧数组随后等待 GC(垃圾回收)。因此追加的均摊成本虽低,单次扩容仍可能形成延迟尖峰。
- 进阶追问:初始容量是不是越大越好?
- 进阶回答:不是。过大容量会浪费堆并加重并发任务内存峰值。应根据单批上限合理估算,并用分页或流式处理限制总量。
LinkedList(链表列表)插入删除一定比 ArrayList(数组列表)快吗?
- 回答思路:区分“已经持有节点”和“只有下标”两种前提。
- 详细答案:已经拿到目标节点时,LinkedList(链表列表)修改前后指针是常数成本;但按下标插入前必须遍历定位,完整成本仍是 O(n)(线性复杂度)。ArrayList(数组列表)需要移动元素,但连续内存批量复制和缓存局部性通常很好,实际业务里往往更快且更省内存。
- 进阶追问:频繁从队头进出应该用什么?
- 进阶回答:使用 ArrayDeque(数组双端队列)通常比 LinkedList(链表列表)更紧凑、更快;并发生产消费则根据阻塞和容量需求选择 BlockingQueue(阻塞队列)。
String(字符串)为什么能作为 HashMap(哈希映射)常用 key(键)?
- 回答思路:从不可变、哈希稳定、相等语义和跨系统表达回答。
- 详细答案:String(字符串)创建后内容不变,因此 hashCode(哈希码方法)可缓存且不会因字段修改导致键失联;equals(相等判断方法)按字符内容比较,语义清晰。它也便于序列化、配置和日志检索,适合订单号和组合业务键。前提是键格式统一且长度受控。
- 进阶追问:超长 String(字符串)键有什么问题?
- 进阶回答:会增加内存、哈希和比较成本,进入 Redis(远程字典服务)后还增加网络与存储开销。可用稳定摘要缩短,但要保留原始业务号映射并评估碰撞风险。
StringBuilder(可变字符串构建器)和 StringBuffer(线程安全字符串构建器)怎么选?
- 回答思路:先看是否共享,再看数据规模和输出方式。
- 详细答案:StringBuilder(可变字符串构建器)没有同步开销,适合方法内部单线程拼接;StringBuffer(线程安全字符串构建器)在方法上同步,只有多个线程必须操作同一缓冲区时才有意义。更常见的设计是每个线程独立构建后合并,避免共享可变对象。
- 进阶追问:超大文件导出用 StringBuilder(可变字符串构建器)就足够吗?
- 进阶回答:不够。超大构建器仍会把全部内容留在堆里,应分页读取并通过 BufferedWriter(缓冲字符输出流)流式写出,仅让一个批次驻留内存。
final(最终关键字)能保证对象不可变吗?
- 回答思路:区分变量不可重新赋值和对象状态不可改变。
- 详细答案:final(最终关键字)基本类型值不能重赋,引用不能改指向,但引用对象仍可能改变。不可变类还要封闭继承、私有字段、构造期赋值、无修改方法,并对数组和集合做深层防御性处理。
- 进阶追问:返回 Collections.unmodifiableList(不可修改列表包装方法)就完全不可变了吗?
- 进阶回答:它通常只是禁止通过该视图增删,底层原列表若被其他引用修改,视图仍会变化;列表元素本身也可能可变。真正快照需要复制容器并保证元素不可变或深复制。
static(静态关键字)字段有什么风险?
- 回答思路:从共享、生命周期、并发和多实例不一致四方面回答。
- 详细答案:静态可变字段被进程内所有线程共享,易出现竞态、可见性和请求数据串扰;它通常与 ClassLoader(类加载器)同生命周期,错误引用还会阻止对象回收。分布式部署时每个实例又各有一份,不能代表全局状态。
- 进阶追问:哪些 static(静态关键字)用法相对安全?
- 进阶回答:真正不可变常量、无状态纯函数和经过严格并发设计的共享基础设施较安全。任何按请求变化的用户、订单和临时结果都不应放静态字段。
泛型的 type erasure(类型擦除)是什么?
- 回答思路:说明编译期检查、运行期替换和由此产生的限制。
- 详细答案:编译器检查类型参数后,把它们替换为上界或 Object(对象基类),并在读取位置插入类型转换,以兼容早期字节码。由此不能直接
new T()、不能创建普通泛型数组,也不能只按参数类型重载方法。声明签名中的部分泛型元数据仍可供反射读取。 - 进阶追问:什么是 heap pollution(堆污染)?
- 进阶回答:当参数化类型变量实际引用了不符合其声明类型的对象时就发生堆污染,常见来源是原始类型、未经检查转换和可变参数泛型数组,最终可能在远处读取时抛 ClassCastException(类型转换异常)。
反射为什么慢?
- 回答思路:从元数据查找、访问检查、参数包装和优化难度回答,同时避免夸大。
- 详细答案:反射调用比静态调用多了成员查找、访问控制、参数数组和类型转换等路径,JIT(即时编译)也更难像固定调用点那样内联优化。现代运行时会做一定优化,所以低频配置和启动扫描通常不是问题;高频序列化或映射应缓存 Field(字段元信息)、Method(方法元信息)和转换器,必要时使用代码生成。
- 进阶追问:优化前应该先做什么?
- 进阶回答:用启动分析或性能剖析确认反射确实占主要耗时,再优化扫描范围和缓存。数据库、网络或错误批次设计往往才是更大瓶颈。
注解本身会执行逻辑吗?
- 回答思路:说明声明与执行分离,并举事务注解为例。
- 详细答案:注解只把结构化元数据放在类、方法、字段或参数上;运行时框架必须扫描并解释它,编译期注解处理器也可以生成代码。Transactional(事务注解)之所以生效,是容器创建代理并由事务拦截器读取规则,不是注解自己开启事务。
- 进阶追问:为什么自己 new(创建)的对象上事务注解常不生效?
- 进阶回答:对象没有经过 Spring(Java 应用框架)容器和后置处理器,不会被包装为事务代理,外部调用自然没有拦截器。
checked exception(受检异常)和 unchecked exception(非受检异常)区别是什么?
- 回答思路:从编译器约束、调用方恢复能力和接口污染讨论。
- 详细答案:受检异常要求调用方 catch(捕获)或 throws(抛出声明),适合调用方确实能采取恢复策略的情况;非受检异常不强制声明,常表达编程错误、参数错误和统一处理的业务异常。选择不应只为少写代码,而要看调用方是否能够有意义地恢复。
- 进阶追问:第三方受检异常要原样向上抛吗?
- 进阶回答:通常在基础设施边界转换为领域可理解异常,并保留原异常作为 cause(原因),附上渠道、操作和错误码,避免业务层耦合具体客户端类型。
为什么业务异常常继承 RuntimeException(运行时异常)?
- 回答思路:说明统一异常边界、事务默认规则和不能滥用的边界。
- 详细答案:业务规则失败通常由统一异常处理器映射为错误码,调用链各层并不都能恢复,使用 RuntimeException(运行时异常)可减少机械声明,也默认触发常见 Spring(Java 应用框架)事务回滚。异常仍需有明确类型、稳定错误码和安全信息,不能所有情况都抛一个模糊异常。
- 进阶追问:业务校验失败一定要回滚吗?
- 进阶回答:如果校验发生在任何写入之前,可以直接失败;若事务内已修改数据,通常应回滚。审计失败记录若必须保留,可通过独立事务或可靠事件记录,但要防止连接池和一致性问题。
HashSet(哈希集合)底层是什么?
- 回答思路:解释元素作为键、占位值和去重契约。
- 详细答案:HashSet(哈希集合)内部通常维护 HashMap(哈希映射),add(添加)相当于把元素写成 key(键),value(值)使用共享占位对象。是否已存在由 hashCode(哈希码方法)和 equals(相等判断方法)共同决定,因此元素相等契约错误会直接导致去重失败。
- 进阶追问:HashSet(哈希集合)能保证遍历顺序吗?
- 进阶回答:普通 HashSet(哈希集合)不保证稳定顺序;需要插入顺序使用 LinkedHashSet(链式哈希集合),需要排序使用 TreeSet(树集合),并明确比较器与 equals(相等判断方法)的一致性。
TreeMap(树映射)适合什么场景?
- 回答思路:从有序能力和 O(log n)(对数复杂度)成本回答。
- 详细答案:TreeMap(树映射)基于红黑树,按自然顺序或 Comparator(比较器)维护键,适合范围查询、邻近键、最大最小键和持续有序迭代。其查找写入通常为 O(log n)(对数复杂度),若只需要等值查找,HashMap(哈希映射)通常更快。
- 进阶追问:比较器返回零但 equals(相等判断方法)不相等会怎样?
- 进阶回答:TreeMap(树映射)会把比较结果为零的键视为同一个排序位置,后写值可能覆盖前值,导致与其他集合的相等语义不一致。比较器应与业务身份保持一致。
LinkedHashMap(链式哈希映射)为什么能做 LRU(最近最少使用)?
- 回答思路:哈希表负责定位,双向链表负责顺序,访问后移动并淘汰头部。
- 详细答案:启用 accessOrder(访问顺序标志)后,每次命中把节点移到链表尾部,头部始终是最久未访问节点;新增后通过 removeEldestEntry(移除最老条目方法)判断是否淘汰头部。它适合小型单机缓存,但默认不线程安全,也不具备过期、权重和分布式一致性。
- 进阶追问:生产本地缓存还应考虑什么?
- 进阶回答:容量、按大小权重、过期时间、并发、加载穿透、统计和淘汰回调。通常优先采用成熟缓存库,而不是只继承 LinkedHashMap(链式哈希映射)。
为什么不能把所有缓存都放到本地 Map(映射接口)?
- 回答思路:说明内存上限、实例隔离、生命周期和一致性。
- 详细答案:本地 Map(映射接口)占用应用堆,无界增长会与业务对象争夺内存;每个实例各有副本,扩缩容、重启和请求路由都会改变命中;数据更新还要广播失效。它适合小规模、允许短暂不一致的热点数据,不能承担共享锁、全局幂等和最终业务状态。
- 进阶追问:多级缓存如何处理一致性?
- 进阶回答:数据库更新成功后发布版本化失效事件,各实例清理本地缓存,分布式缓存采用旁路更新;事件可能丢失时还需较短本地过期、版本校验和监控兜底。
项目里如何避免集合导致 OOM(内存溢出)?
- 回答思路:控制进入速率、驻留数量、单对象大小和生命周期,并建立监控。
- 详细答案:查询和导出按游标分页,处理后立即释放批次;缓存、去重集、重试队列都设容量和过期;线程池使用有界队列,ThreadLocal(线程本地变量)在 finally(最终清理)中清除;大文件采用流式读写。监控集合条目、任务堆积、堆占用、分配速率和 GC(垃圾回收)回收后基线,避免只等 OOM(内存溢出)告警。
- 进阶追问:已经发生 OOM(内存溢出)如何定位到具体集合?
- 进阶回答:先保留 heap dump(堆转储),用 MAT(内存分析工具)查看支配树和可疑泄漏报告,找到占用最大的集合及其到垃圾回收根的路径,再结合任务、缓存和线程池指标确认谁在持续写入;止血后修容量和生命周期,而不是只增加堆。
11. 本模块复习清单
- 能讲清 equals(相等判断方法)和 hashCode(哈希码方法)的约束。
- 能讲清 String(字符串)不可变的原因和项目价值。
- 能讲清 final(最终关键字)引用不可变和对象不可变的区别。
- 能讲清泛型、反射、注解分别解决什么问题。
- 能讲清 Java(编程语言)异常体系和事务回滚关系。
- 能画出 HashMap(哈希映射)put(写入)和 resize(扩容)流程。
- 能对比 ArrayList(数组列表)、LinkedList(链表列表)、HashMap(哈希映射)、TreeMap(树映射)、HashSet(哈希集合)。
- 能说明 ConcurrentHashMap(并发哈希映射)的并发优势和使用边界。
- 能把集合选择绑定到 WMS(仓储管理系统)、支付幂等、IoT(物联网)报警治理项目。
