Java 业务开发中的十类隐蔽陷阱:从对象判等到 OOM
Java 业务代码真正难排查的 Bug,往往不是复杂算法写错,而是一些“看起来理所当然”的语义在边界条件下突然失效:== 偶尔能正确比较包装类型、BigDecimal.equals 不认为 1.0 和 1 相等、subList 只是原 List 的视图、异步日志会丢数据、submit 会把线程池异常藏进 Future,甚至一个看似只是“放大限制”的 Tomcat 参数也可能把堆内存吃光。本文把对象判等、数值、集合、空值、异常、日志、文件 IO、序列化、日期时间和 OOM 十类问题重新组织成一套面向真实业务开发的防错模型,并结合现代 Java 的实践说明哪些旧习惯已经应该被替换。
为什么这些问题总是在“看起来没问题”时出问题
这批问题表面上分散在 Java 语法、集合、Spring、Redis、Jackson、Tomcat 和 JVM 中,实际上都指向同一件事:代码依赖了一个没有被明确写出来的隐含契约。
| 领域 | 常见错误 | 真正被忽略的契约 |
|---|---|---|
| 对象判等 | Integer、String 用 == |
引用相等与值相等不是一回事 |
| 数值计算 | double 算金额、整数静默溢出 |
数字在机器中的表示范围与精度有限 |
| 集合 | 把 asList、subList 当独立 ArrayList |
很多集合 API 返回的是视图或受限实现 |
| 空值 | 到处判空但业务仍然错 | null 的业务含义没有定义清楚 |
| 异常 | 全局 catch Exception、只记 message |
异常既是控制流,也是故障现场 |
| 日志 | 重复日志、异步丢日志 | Logger、Appender、队列之间存在传播与背压 |
| 文件 IO | 乱码、句柄泄漏、逐字节读写 | 字符集、资源生命周期和系统调用成本必须显式管理 |
| 序列化 | Redis 乱码、JSON 一来一回类型变了 | 序列化协议必须在生产者和消费者之间稳定一致 |
| 日期时间 | 时区错乱、跨年格式错误 | “时间点”和“本地时间表示”是两个概念 |
| OOM | 有 GC 就认为不会泄漏 | GC 只能回收不可达对象,不能替你控制容量 |
可以把这些问题归纳成四种边界:
flowchart LR
A[语言与类型语义] --> A1[判等]
A --> A2[数值]
A --> A3[null]
B[容器与资源生命周期] --> B1[集合视图]
B --> B2[文件句柄]
B --> B3[线程池与日志队列]
C[系统边界与协议] --> C1[JSON / Redis]
C --> C2[数据库 NULL]
C --> C3[时间与时区]
D[容量与可达性] --> D1[缓存]
D --> D2[对象副本]
D --> D3[Tomcat / JVM 参数]
真正稳定的业务代码,不是把每一个 API 都背下来,而是在看到“缓存、视图、自动转换、默认值、异步、序列化、时间、限制参数”这些词时,主动追问:谁拥有数据、谁负责释放、比较的到底是什么、默认行为是什么、异常去了哪里,以及这项配置会按什么维度放大资源消耗。
对象判等:==、equals、hashCode 和 compareTo 必须是一套语义
基本类型比较值,引用类型的 == 比较身份
Java 中 == 对基本类型比较数值,对引用类型比较两个引用是否指向同一个对象。业务代码通常关心的是“内容是不是一样”,因此引用类型的内容比较应该使用 equals。
1 | |
最危险的地方不是 == 永远错误,而是它有时正确。自动装箱会调用 Integer.valueOf,而 Integer 默认至少缓存 [-128, 127] 范围内的对象:
1 | |
这也是这类 Bug 隐蔽的原因。状态码最初只有 100、101、102,== 似乎一直工作;状态增长到 128 以后,逻辑突然失效。JVM 还可以通过参数调整部分装箱缓存范围,但业务代码绝不能建立在这种实现细节上。
自动拆箱还会制造另一种错觉:
1 | |
所以代码审查时看到包装类型参与 ==,最好的反应不是分析“这次到底会不会命中缓存”,而是直接消除这种依赖。
String 常量池也不能成为业务判等规则
字符串字面量会进入字符串常量池:
1 | |
String.intern() 可以把字符串驻留到字符串表中,但它是内存优化机制,不是内容判等 API。大量、低重复率的动态字符串全部 intern,会把压力转移到 StringTable。资料中的实验在字符串表桶数量很少时驻留 1000 万个字符串耗时超过 40 秒,调大 StringTableSize 后才明显改善;这个数字与 JDK、机器和参数有关,但结论不变:不要为了省一次 equals 或做普通去重而滥用 intern。
需要分析字符串表时,可以使用对应 JDK 支持的统计参数,例如:
1 | |
参数名和行为需要以当前 JDK 为准,尤其不要把某次压测得到的桶数量直接当生产推荐值。
自定义值对象必须同时设计 equals 和 hashCode
如果一个点只由 x、y 决定身份,那么 desc 不应该参与相等判断:
1 | |
只重写 equals 而不重写 hashCode 会直接破坏 HashSet、HashMap 的语义:两个 equals 为 true 的对象如果 hash code 不一致,会被分配到不同桶,contains、get 等操作可能找不到“逻辑上相等”的对象。
核心契约是:
1 | |
反过来不成立,不同对象允许 hash 冲突。
对于真正的不可变值对象,现代 Java 还可以优先考虑 record,它会基于组件自动生成 equals、hashCode 和 toString:
1 | |
前提仍然是:record 中声明的组件正好就是你的“值语义”。
compareTo == 0 最好与 equals == true 保持一致
一个更难发现的问题出现在有序集合和二分搜索中。
假设 Student.equals 由 id + name 决定,但 compareTo 只比较 id:
1 | |
那么:
ArrayList.indexOf通过equals搜索;Collections.binarySearch通过排序规则搜索;HashSet.contains依赖hashCode + equals;TreeSet.contains依赖Comparator / compareTo。
于是 id=2,name=wang 与 id=2,name=li 对 equals 来说不是同一个学生,对排序规则来说却是“相等”的。代码从线性搜索优化为二分搜索后,就可能出现业务行为变化。
更安全的实现是让排序规则覆盖同一组身份字段:
1 | |
严格来说,Comparable 规范允许“自然顺序与 equals 不一致”的类型,BigDecimal 就是著名例子;但业务实体如果同时进入 Hash 容器和 Tree 容器,最好不要制造两套“相等”定义。
getClass() 和 instanceof 是两种不同的继承语义
equals 中常见两种类型判断:
1 | |
以及:
1 | |
getClass() 要求运行时类型完全相同,父类和子类不会相等;instanceof 允许子类型通过判断。两者没有绝对的“正确答案”,关键是你的值语义是否允许跨继承层级相等,并且必须保证对称性、传递性。
热加载、插件系统和自定义 ClassLoader 还会引入另一层边界:在 JVM 中,类身份由“类的全限定名 + 定义它的 ClassLoader”共同决定。名字相同但由两个不同 ClassLoader 加载的类,运行时并不是同一个类型。这也是热部署环境里某些 equals、强制类型转换问题特别诡异的原因。
Lombok 不是免思考按钮
@Data 会生成 equals 和 hashCode,但“生成了”不代表“业务语义正确”。默认把不该参与身份的字段加入比较,会让对象看起来总不相等;继承层级中忽略父类字段,则可能把完全不同的两个子类对象判断为相等。
可以显式控制字段:
1 | |
子类确实需要把父类语义纳入判等时:
1 | |
Lombok、IDE 代码生成和 record 都只是减少模板代码。真正需要设计的,始终是“什么字段定义对象身份”。
数值计算:精度、舍入、比较和溢出是四个不同问题
double 的问题不是“Java 算错了”
1 | |
Java 浮点数遵循 IEEE 754。十进制 0.1 在二进制中是无限循环小数,只能保存为最接近的可表示值。因此浮点误差不是某次加法造成的偶然问题,而是表示层就已经存在。
对于科学计算、统计等容忍误差的场景,double 完全合理;对于金额、利率、税额、结算等要求十进制定点语义的业务,应该使用 BigDecimal 或明确的最小货币单位模型。
BigDecimal 最大的坑发生在构造之前
错误写法:
1 | |
传进去的 0.1 已经是一个近似的二进制浮点值,BigDecimal 只是忠实地把这个近似值展开出来,并不会“修复精度”。
优先写成:
1 | |
如果原始数据本来就是字符串、数据库 DECIMAL、接口中的十进制文本,应该从源头保持十进制表示,不要先转 double 再转回来。
scale 和 precision 会影响表现形式与相等性
BigDecimal 不只有 value,还有 scale:
1 | |
这意味着两个概念必须分开:
- “表示是否完全相同”使用
equals; - “数值是否相同”使用
compareTo。
这对集合尤其重要:
1 | |
如果业务只关心数值,可在进入 Hash 容器前规范化:
1 | |
或者使用基于自然顺序判断的 TreeSet,但要明确接受它的集合相等语义来自 compareTo。
舍入模式必须是业务规则,不应该藏在格式化里
现代代码应使用 RoundingMode,而不是旧版 BigDecimal.ROUND_* 常量:
1 | |
常见模式含义如下:
| RoundingMode | 方向 |
|---|---|
UP |
远离 0 |
DOWN |
向 0 截断 |
CEILING |
向正无穷 |
FLOOR |
向负无穷 |
HALF_UP |
0.5 时向远离 0 的方向,最接近日常“四舍五入” |
HALF_DOWN |
恰好 0.5 时向 0 |
HALF_EVEN |
0.5 时舍入到相邻偶数,常称银行家舍入 |
UNNECESSARY |
断言无需舍入,否则抛异常 |
金额精度、价格精度、数量精度和舍入模式最好成为领域配置,而不是散落在 Controller、SQL、报表代码中的 setScale(2)。
整数溢出更危险,因为默认不会报错
1 | |
默认整数运算采用补码环绕,不会因为溢出自动抛异常。对需要安全边界的计算,可使用:
1 | |
超出范围时会得到 ArithmeticException。
真正的大整数运算使用 BigInteger,转换回基础类型时也应使用带 Exact 的方法:
1 | |
金额落库时不要混入二进制浮点语义
资料中的实践建议优先使用数据库 DECIMAL(p, s) 保存金额,例如 DECIMAL(13,2) 或 DECIMAL(13,4) 只是示例,真实 precision/scale 必须根据最大金额、币种和业务精度确定。
使用 long 保存“分”也能避免浮点误差,但它把单位换算的正确性推给了应用:一次漏乘 100 或漏除 100 就可能产生严重事故。无论选择哪种模型,都要让“单位 + 精度 + 舍入”成为类型或领域对象的一部分,而不是依赖开发者记忆。
集合:很多 List API 返回的不是你以为的那个 List
Arrays.asList 有三个典型陷阱
第一个是基本类型数组:
1 | |
int[] 自身被当成一个对象传给了泛型可变参数。需要 List<Integer> 时:
1 | |
第二个是它返回的不是普通 java.util.ArrayList,而是 Arrays 内部的定长 List。可以 set,不能改变大小:
1 | |
第三个是它与原数组共享底层存储:
1 | |
需要独立、可变的 List 时,明确复制:
1 | |
现代 Java 的 List.of(...) 更明确地表达“不可变集合”,但它同样不能拿来当可变 ArrayList 使用。
subList 是视图,不是切出来的新数组
1 | |
SubList 保存着对原始 List 的引用,结构修改会互相影响。更隐蔽的是内存问题:即使只保存一个元素的 subList,只要这个视图仍然引用着一个巨大的原 List,原 List 也可能无法回收。
如果想得到独立切片:
1 | |
原始 List 在创建 SubList 后发生结构性修改,还可能因为 modCount 不一致触发 ConcurrentModificationException。这个异常名字里虽然有 “Concurrent”,但并不要求真的存在多线程,单线程边遍历边错误修改同样会触发。
remove(1) 到底是删索引还是删数字 1
对于 List<Integer>,下面两个调用语义完全不同:
1 | |
自动装箱和重载结合在一起,很容易把“删除值”写成“删除下标”。
遍历时删除也不要直接修改原集合:
1 | |
Java 8 以后更简单:
1 | |
迭代器自己的 remove 会同步维护 expectedModCount,直接调用 list.remove 则破坏了迭代器看到的结构版本。
数据结构选型必须同时看时间和空间
对百万级 List 反复按 ID 搜索,线性扫描的累计成本很高:
1 | |
如果业务本质就是按 ID 随机查找,应该在构建阶段建立索引:
1 | |
资料中的实验里,100 万元素、1000 次随机搜索,List 需要数秒而 Map 只需要百毫秒量级;同时 HashMap 占用内存显著高于 ArrayList。这里真正要记住的不是某个毫秒数,而是:索引是用空间换时间的结构。
同理,大 List 去重不要循环 contains,应考虑 HashSet;固定区间查询可以提前构造分组索引。
不要把“大 O”脱离实际调用路径
教科书说:
- ArrayList 随机访问
O(1),中间插入O(n); - LinkedList 随机访问
O(n),已知节点后的插入O(1)。
问题出在“已知节点”四个字。linkedList.add(index, value) 为了找到第 index 个节点,本身就要遍历,因此整个操作仍然可能是 O(n)。此外 ArrayList 的连续内存访问更符合 CPU cache,链表每个节点还有对象和指针开销。
所以“插入多就用 LinkedList”不是一个可靠的工程规则。真正的规则是:按实际调用方式和数据规模做 benchmark。 在大多数普通业务 List 场景中,ArrayList 是更稳妥的默认选择。
null:最难的不是避免 NPE,而是定义“空”到底是什么意思
最常见的五类 NullPointerException
业务代码中最常见的空指针来源可以归为几类:
1 | |
1 | |
1 | |
1 | |
1 | |
ConcurrentHashMap 不支持 null 并不是简单“少一个功能”。并发 Map 如果允许 value 为 null,当 get(key) 返回 null 时无法判断“key 不存在”还是“key 显式映射到 null”;再用 containsKey 二次确认时,Map 又可能已经被其他线程修改。因此它通过 API 约束直接消除这种歧义。
Optional 能减少判空代码,但不能替你定义业务规则
可以把级联访问写得更安全:
1 | |
也可以为“可能返回 null 的集合”提供空集合兜底:
1 | |
但这里有一个非常重要的边界:没有 NPE 不代表程序是对的。 如果 barService 按设计必须存在,那么把它静默过滤掉,只会把初始化 Bug 变成一个“什么也没发生”的业务 Bug。
因此遇到 null 要先回答:
- 这是正常的“无数据”吗?
- 是调用方参数缺失吗?
- 是业务状态不允许吗?
- 是系统初始化或远程依赖故障吗?
- 应该返回默认值、跳过、告警,还是立即失败?
更新接口最怕“未传”和“显式传 null”混为一谈
假设客户端只想修改用户名:
1 | |
如果直接把同一个 User 同时当 Request DTO 和 JPA Entity,再 save(user),没有出现在请求里的 age、createTime 等字段也可能被覆盖。
真正需要建模的是三态:
1 | |
资料中的做法是让 DTO 字段使用 Optional<T>,配合 Jackson JDK8 module 区分“字段缺失”与“字段显式为 null”:字段缺失时 Optional 字段本身仍为 null,显式 null 则反序列化为 Optional.empty()。这是一种可行技巧,但它依赖序列化配置,也会增加团队理解成本。
现代接口更重要的是把三态契约显式化:可以使用专门的 Patch DTO、显式字段状态包装类型,或者采用 JSON Merge Patch 一类有明确语义的协议。不要靠“Entity 中 null 的偶然含义”完成部分更新。
同时应把 DTO 和 Entity 分离:
- 客户端能修改的字段才进入 DTO;
- 数据库生成字段不要暴露给请求;
- 先读取现有 Entity,再按 patch 规则修改;
- ORM 的动态更新能力只能减少 SQL 列,不会自动替你定义 patch 语义。
SQL 中的 NULL 遵循三值逻辑
三个经典坑:
1 | |
没有可参与聚合的值时,结果可能是 NULL,需要按业务决定是否 COALESCE/IFNULL 为 0:
1 | |
1 | |
只统计 score IS NOT NULL 的行。统计记录数通常应使用:
1 | |
查询 NULL 不能写:
1 | |
而要写:
1 | |
因为 SQL 的 NULL 代表 unknown,普通比较运算得到的仍是 unknown,而不是 true。
生产 NPE 不好复现时,先观察现场而不是疯狂加日志
资料中使用 Arthas 的 watch、stack 来观察线上方法入参和调用路径:
1 | |
对只在特定参数、特定分支下发生的问题,这比本地盲猜更高效。线上诊断同样要注意访问控制、脱敏和执行开销,避免把工具本身变成新的风险。
异常处理:异常不是“统一 catch 掉”,而是逐层决定是否还能处理
Controller、Service、Repository 对异常的责任不同
典型三层系统可以这样理解:
flowchart TB
Client[调用方] --> Controller
Controller --> Service
Service --> Repository
Repository --> DB[(Database)]
Service --> Remote[外部服务 / MQ / Cache]
Repository -. 原始技术异常 .-> Service
Service -. 转换 / 降级 / 回滚 / 继续抛出 .-> Controller
Controller -. API 错误模型 .-> Client
Repository 层的数据库异常,有时需要重试,有时要转换,有时应该直接向上传播;Service 层承载事务和业务分支,过早 catch 可能让本该回滚的事务继续提交;Controller 层更适合把最终仍未处理的异常转换成稳定的 API 响应。
所以全局异常处理器应该是兜底和协议转换层,不是把所有业务方法统一包上 try/catch 的地方。
1 | |
真实系统还应记录 traceId、请求路径和必要的脱敏上下文,而不是把敏感请求体原样打入日志。
最糟糕的异常处理是“生吞”
1 | |
这比不捕获更难排查,因为故障已经发生,唯一的证据却被主动删除了。
只记录 message 也不够:
1 | |
它丢掉了异常类型、cause 和 stack trace。应该记录 Throwable:
1 | |
如果要转换异常,把原异常作为 cause:
1 | |
一个好的异常至少保留三件事:发生了什么、在哪个业务上下文发生、原始根因是什么。
捕获异常后通常只有三类合法动作
- 转换:把底层异常转成上层能理解的异常,同时保留 cause;
- 恢复:使用缓存、默认值、降级路径继续工作;
- 重试:仅对可恢复、通常幂等的瞬时失败进行,并配合退避、次数上限和熔断。
“远程调用失败就无限重试”会把下游故障放大成雪崩。
finally 自己抛异常会覆盖真正的根因
1 | |
最后看到的只会是 close failed。这就是为什么资源类型应该优先使用 try-with-resources:
1 | |
如果业务逻辑和 close() 都抛异常,try-with-resources 会保留业务异常作为主异常,并把关闭异常作为 suppressed exception 挂在上面。
1 | |
不要在 finally 中 return,它同样可能覆盖 try/catch 的返回值或异常,使控制流极难理解。
不要把 Exception 对象定义成 static 常量
下面这种“统一管理异常”的写法会制造假现场:
1 | |
Throwable 的栈信息与它被创建的时刻有关。重复抛同一个实例,后续请求看到的 stack trace 可能始终指向第一次初始化的位置。
统一管理的应该是错误码和消息模板,而不是 Throwable 实例:
1 | |
线程池里的异常会因为提交方式不同而表现完全不同
execute 提交的 Runnable 如果抛出未捕获 RuntimeException,工作线程会结束,并由线程池按需补充新 worker;异常通常交给 UncaughtExceptionHandler。
因此任务内部应明确处理异常,并为线程工厂设置兜底 handler。
submit 则不同:任务会被包装成 FutureTask,异常被保存在 Future 中,工作线程不会因为这次任务异常退出,UncaughtExceptionHandler 也通常看不到它。
1 | |
如果你根本不关心结果,却用 submit 后把 Future 扔掉,就等于主动隐藏异常。CompletableFuture、虚拟线程任务同样需要明确消费失败结果;并发模型变新了,异常传播契约并没有消失。
日志:能打出来不等于日志系统配置正确
SLF4J 是门面,桥接方向不能形成环
Java 生态中曾长期并存 Log4j、Log4j2、JUL、Commons Logging、Logback。SLF4J 的价值是把“业务代码调用的 API”和“最终日志实现”解耦:
flowchart TB
App[业务代码] --> SLF4J[SLF4J API]
JCL[Commons Logging API] --> Bridge1[jcl-over-slf4j] --> SLF4J
JUL[java.util.logging] --> Bridge2[jul-to-slf4j] --> SLF4J
L4J2[Log4j2 API] --> Bridge3[log4j-to-slf4j] --> SLF4J
SLF4J --> Logback[Logback]
桥接是有方向的。不要同时引入“Log4j -> SLF4J”和“SLF4J -> Log4j”这种相反方向的桥,最终会形成调用循环。排查日志冲突时第一步不是改 XML,而是先看 Maven/Gradle 依赖树。
日志重复往往来自 Logger 继承
Logback 中子 Logger 默认具有 additivity,会把事件继续交给父 Logger。下面的配置会让同一条日志同时经过自定义 logger 和 root 中的 CONSOLE:
1 | |
如果只是想让某个包开启 DEBUG,不要重复挂 Appender:
1 | |
如果它确实要走独立 Appender,则关闭继承:
1 | |
日志重复不仅浪费磁盘,还会在 ELK、Loki 一类集中式系统中放大存储、索引和告警成本。
LevelFilter 和 ThresholdFilter 不是一回事
ThresholdFilter(WARN) 的语义是允许 WARN 及以上级别。
LevelFilter(INFO) 则是“匹配 INFO 后怎么处理,不匹配又怎么处理”。如果只配置 level 而不配置 onMatch/onMismatch,默认都可能继续交给后续过滤器,结果并不等于“只收 INFO”。
只记录 INFO 可以明确写:
1 | |
AsyncAppender 的性能来自把成本移到队列,不是把 IO 变没了
异步日志的核心是生产线程把事件放入队列,后台线程再写 Appender。于是你必须同时决定:
- 队列可以占多少内存;
- 队列快满时能否丢 DEBUG/INFO;
- 队列满时业务线程能否阻塞;
- 是否需要 caller data;
- 应用退出时能等待多久完成 flush。
资料所对应的 Logback 版本中,AsyncAppender 的关键默认行为包括:
| 参数 | 当时默认行为 | 风险 |
|---|---|---|
queueSize |
256 | 突发日志非常容易把队列打满 |
discardingThreshold |
队列容量的 1/5 | 剩余空间不足时可丢弃较低级别日志 |
neverBlock |
false | 队列满时可能反向阻塞业务线程 |
includeCallerData |
false | 行号、方法等调用方信息可能缺失 |
这些默认值属于版本实现细节,升级 Logback 时应重新检查当前文档。真正的取舍只有三个:
- 绝对不阻塞:就必须允许某些日志丢失;
- 绝对不丢:队列满时就必须等待,日志系统可能拖慢业务;
- 队列无限大:最终只是把日志问题变成 OOM。
所以异步日志参数必须经过峰值流量压测,不能从别的项目复制一个 queueSize 就结束。
{} 只延迟 toString,不会阻止你先执行昂贵的方法
1 | |
即使 DEBUG 关闭,Java 也会先执行 slowQuery() 再调用 debug。占位符省掉的是字符串拼接和部分 toString 成本,不是参数计算本身。
传统写法:
1 | |
原资料写作时的 SLF4J API 还没有现代 fluent logging。SLF4J 2.x 已支持 Supplier 风格的延迟参数,可在当前依赖版本支持时使用:
1 | |
对于大对象 JSON 化、数据库查询、复杂集合展开等日志参数,这一点非常重要。
文件 IO:编码、句柄和缓冲区缺一个都会出事故
字符流必须明确字符集
文件本质上只有字节。只有把字节解释成字符时,字符集才参与进来。
如果文件按 GBK 写入,却依赖机器默认字符集读取:
1 | |
同一份代码在旧服务器正常、新服务器乱码,往往不是“机器有问题”,而是代码偷偷依赖了 Charset.defaultCharset()。
应显式指定:
1 | |
更理想的是协议层统一 UTF-8;如果必须对接历史系统,也应把对方编码当接口协议字段管理,而不是猜。
readAllLines 和 Files.lines 解决的是两种问题
1 | |
会把所有行装进内存,适合确定很小的文件,不适合 GB 级文件。
大文件更适合流式消费:
1 | |
关键是 Stream 必须关闭。Files.lines 背后持有 BufferedReader 和文件描述符,如果每次调用后都不 close,最终会出现:
1 | |
在 Linux 上可以辅助检查:
1 | |
凡是返回 Stream、DirectoryStream 或其他 AutoCloseable 资源的文件 API,都要先确认它的关闭语义,不能因为“这是个静态方法”就默认资源会在方法返回后释放。
逐字节 IO 的问题是系统调用次数
这种代码功能没错,性能却可能差几个数量级:
1 | |
改为块读写:
1 | |
资料中的 35MB 文件实验,从逐字节的百秒级下降到使用缓冲区的百毫秒级;具体数据受磁盘、OS、缓存和 JDK 影响,但差异的来源很明确:减少跨 Java/OS 边界的 IO 调用次数。
BufferedInputStream/BufferedOutputStream 自身已有缓冲,但如果外层仍然逐字节调用 Java 方法,方法调用次数依然巨大。业务通常应让调用粒度本身也是块状的。
文件复制可以考虑 FileChannel,但要正确管理资源
1 | |
在支持的操作系统上,transferTo 可以利用更高效的数据传输路径,减少用户态数据搬运。不要把“零拷贝”理解成绝对没有任何复制,它通常指减少 CPU/用户态参与的数据拷贝。
文件系统操作默认不是事务
复制一半失败、重命名跨文件系统、删除失败,都不会像数据库事务一样自动回滚。Files.move 可以请求 ATOMIC_MOVE,但是否支持取决于文件系统和具体场景;跨文件系统通常无法保证。
需要业务原子性的场景,常见策略是:写临时文件 -> fsync/校验 -> 在同一文件系统内原子 rename -> 再更新元数据。不要把多步 File/Files 调用想象成天然事务。
序列化:一来一回仍然是同一个对象,需要协议双方共同保证
Redis 的“乱码”通常只是序列化协议不一致
RedisTemplate 与 StringRedisTemplate 不是简单的“一个返回 Object、一个返回 String”。资料对应的 Spring Data Redis 默认行为中:
RedisTemplate默认使用 JDK 序列化处理 key/value;StringRedisTemplate使用字符串序列化处理 key/value。
于是同样写入 key user:1,JDK 序列化后在 redis-cli 看到的是二进制转义串,而不是可读文本。更关键的是:两种 Template 按各自算法重新序列化 key 后,连同一个 key 都可能互相找不到。
工程上更常见的是明确 key 和 value 协议:
1 | |
具体 serializer API 随 Spring Data Redis 版本可能变化,但原则应该固定下来并写进架构规范:
1 | |
否则 Redis 升级、服务拆分、多语言接入时都会把“框架默认值”变成兼容性债务。
JSON 带类型信息很方便,但不要无边界开启多态反序列化
资料中通过 Jackson default typing 把 Java 类型信息一起写入 Redis,从而避免反序列化后只得到 LinkedHashMap。这个思路能解决类型恢复问题,但现代工程必须额外考虑安全边界。
不要对不可信输入开放“任意类名 -> 任意 Java 类型”的多态反序列化。更稳妥的做法是:
- 缓存数据只由受信任服务生产;
- 使用明确的目标类型 serializer;
- 多态场景配置允许列表或受限的
PolymorphicTypeValidator; - 不把外部 HTTP JSON 与内部 Redis 类型元数据协议混用;
- 缓存 schema 变更时设计版本和迁移策略。
“反序列化方便”不能以扩大反序列化攻击面为代价。
不要为了改一个 Jackson 特性就替换整个 ObjectMapper
Spring Boot 会为默认 ObjectMapper 配置一组 Web 友好的行为。如果直接:
1 | |
很可能把框架自动配置全部覆盖掉。原资料中的事故就是为了修改枚举序列化方式,自定义 Bean 后意外恢复了 FAIL_ON_UNKNOWN_PROPERTIES,客户端多传一个字段就开始大量 400。
更合理的是在现有 builder 上做增量定制:
1 | |
或者使用 spring.jackson.* 配置。核心思想是:增量修改框架默认配置,不要为一个小需求重新造完整基础设施 Bean。
反序列化不会自动走你“希望它走”的业务构造器
1 | |
如果 Jackson 反序列化时使用无参构造 + setter/字段赋值,上面构造器里的业务逻辑可能根本没有执行。
需要构造器创建语义时显式声明:
1 | |
不过更值得反思的是:success 如果完全可以由 code 推导,是否应该被反序列化为独立状态?派生字段越多,越容易出现“一来一回以后内部状态不一致”。
API 边界不要直接暴露 Java 枚举的 ordinal
客户端有 4 个状态,服务端新增第 5 个状态时,旧客户端直接反序列化枚举就可能失败。用 ordinal() 更危险:只要枚举插入、重排,原来的数字含义就改变。
更稳妥的 API 是稳定 code:
1 | |
更彻底的做法是在 DTO 中直接传 statusCode,Java 枚举只存在于服务内部,通过显式 mapper 转换。这样客户端协议不会被 Java 枚举实现细节绑架。
还在使用 JDK Serializable 时,serialVersionUID 是兼容性协议的一部分
现代 REST/RPC 场景更多使用 JSON、Protobuf 等跨语言格式,但某些历史缓存、文件或 Java 内部通信仍可能使用 Serializable。这时应显式声明:
1 | |
否则编译器会根据类结构计算默认值,不同编译器或类结构变化可能导致反序列化时出现 InvalidClassException。这再次说明:任何序列化都不是“把对象写出去”这么简单,而是在建立版本兼容协议。
日期时间:先区分“时间点”和“本地时间表示”,时区问题就清楚了一半
Date 不是 LocalDateTime
理解 Java 时间 API,最重要的是这张概念表:
| 类型 | 表达的概念 |
|---|---|
Instant |
UTC 时间线上的一个确定时间点 |
LocalDate |
无时区的日期 |
LocalTime |
无时区的时间 |
LocalDateTime |
无时区的日期 + 时间,只是本地表示 |
OffsetDateTime |
日期时间 + UTC offset |
ZonedDateTime |
日期时间 + ZoneId,包含时区规则 |
Duration |
基于秒/纳秒的时间量 |
Period |
基于年/月/日历日的日期量 |
java.util.Date 内部本质上是 epoch 毫秒时间点,它自己不携带“上海/纽约”这种时区属性。Date.toString() 之所以会打印 CST 等字样,是格式化展示时使用了 JVM 默认时区,不代表 Date 内部存了这个时区。
LocalDateTime 刚好相反:2026-09-08 13:00:00 只是一组本地字段。如果不知道它是上海时间、东京时间还是纽约时间,就无法唯一定位到 UTC 时间线。
存储和传输优先使用时间点,展示时再应用时区
如果业务事件是“订单在某一瞬间创建”,更清晰的模型是保存 Instant:
1 | |
展示给东京用户:
1 | |
把一个本地时间转换成时间点,必须提供时区:
1 | |
这不是 API 设计得麻烦,而是物理事实:没有时区就没有唯一时间点。
同一个字面时间在不同时区解析,本来就应该得到不同 Instant
2020-01-02 22:00:00 如果解释为上海时间和纽约时间,代表两个不同的瞬间;反过来,同一个 Instant 在纽约和上海显示出来的本地时钟时间也必然不同。
不要遇到“数据库时间差了 8 小时”就直接在代码里 plusHours(8)。应该沿链路检查:
1 | |
人为补 8 小时通常只是把配置错误永久写进业务逻辑。
YYYY 和 yyyy 不是大小写风格问题
Y 表示 week-based-year,y 表示 year-of-era。年末几天可能已经属于下一“周序年”:
1 | |
如果只是普通日历日期,不要用 YYYY。
现代代码做严格解析时,可以进一步显式指定 resolver style,并优先用 uuuu 表示 proleptic year:
1 | |
这比依赖 SimpleDateFormat 默认宽松解析可靠得多。
SimpleDateFormat 的最大问题是“可变且共享”
SimpleDateFormat 内部持有可变 Calendar,多线程共享一个 static 实例会互相修改解析状态,出现随机错误甚至荒谬年份。
旧代码如果不能迁移,只能线程隔离或加锁;新代码应直接使用线程安全、不可变的 DateTimeFormatter:
1 | |
时间计算不要手写毫秒乘法
1 | |
这段代码中常量表达式先按 int 计算,可能在转换为 long 之前就已经溢出。即使改成 30L,手工毫秒运算遇到夏令时、月长度等日历规则仍然容易表达错业务语义。
Java Time 直接写业务意图:
1 | |
计算两个日期的“2 个月 11 天”与“总共 72 天”也是两种不同问题:
1 | |
period.getDays() 只返回 Period 中“日”这一部分,不是总天数。
MySQL DATETIME 和 TIMESTAMP 要按语义选,不要背旧版本字节数
资料评论区基于较老 MySQL 版本讨论过 DATETIME/TIMESTAMP 的存储字节数和自动初始化规则,这些实现细节随版本变化,不应该直接照搬到现代 MySQL。
长期稳定的概念区别是:
DATETIME更像“日历字段的字面值”,本身不做 session time zone 的 UTC 往返转换;TIMESTAMP写入和读取时会受到连接/session 时区影响,适合表达时间线上的时间点,但范围和行为需要按当前 MySQL 版本确认;- JDBC 驱动、JVM 时区、数据库 session 时区必须一起测试;
- 如果应用本身使用
Instant,要明确 ORM/JDBC 最终如何映射,而不是只看 Java 字段类型名字。
跨时区系统最好把这些选择写成数据库设计规范和集成测试,而不是依赖服务器默认时区。
OOM:GC 是自动回收,不是自动容量管理
一份业务数据,在堆里可能有很多份对象
资料中的自动补全案例只有约 1 万用户,但为了给用户名的每个前缀建立索引,每个前缀 List 都重新 new 一份完整 UserDTO,最终生成约 6 万份大对象,MAT 中占用超过 1GB。
问题不是 HashMap 本身,而是“索引引用同一对象”被误写成了“索引复制对象”:
1 | |
这类问题在导出、ORM 映射、DTO 转换、批量聚合中非常常见:数据库看起来只有 100MB,不代表 JVM 只需要 100MB。ResultSet、框架中间对象、Entity、DTO、序列化缓冲区可能同时存在。
容量评估应该用真实数据做 heap dump 或 profiler 验证,而不是简单用“数据库字节数 × 1”。
对于前缀自动补全本身,Trie/前缀树往往比“所有前缀字符串 -> List”更贴近问题结构,但无论用什么结构,都要关注对象副本和索引膨胀。
WeakHashMap 的 key 是弱引用,不代表 Entry 一定能被回收
考虑:
1 | |
引用关系实际上是:
flowchart LR
M[WeakHashMap] --> E[Entry]
E -. weak reference .-> K[User key]
E --> V[UserProfile value]
V --> K
虽然 Entry 到 Key 是弱引用,但 Value 又强引用 Key,于是仍然存在:
1 | |
这是一条完整强引用路径,Key 当然不会被 GC。
如果确实使用弱引用结构,要检查整个对象图,而不只是 Map 的 key 类型。资料中通过把 Value 包装为 WeakReference<UserProfile>,或让 Value 不再反向引用同一个 Key 来打断强引用路径。
生产缓存更推荐有界缓存:最大条目数/权重、过期策略、命中率、淘汰统计都可观测,而不是把缓存生命周期完全交给 GC 的软/弱引用。GC 驱动的缓存容量很难和业务 SLA 对齐。
ThreadLocal 也有类似的“弱 key、强 value”风险
ThreadLocalMap 的 Entry key 是弱引用,但 value 是强引用。线程池线程长期存活时,如果业务忘记 remove(),即使 ThreadLocal key 被回收,value 仍可能在线程下一次清理前长期滞留。
1 | |
这和 WeakHashMap 案例本质相同:不要只看某一条引用是 weak 还是 strong,要看 GC Root 到对象是否仍存在完整强引用路径。
容量参数会被并发数乘起来
资料中的 Tomcat 事故把:
1 | |
设置到了约 10MB,只是为了绕过一次 “Request header is too large”。当时对应 Tomcat 实现会按这个上限为请求/响应头缓冲分配空间,工作线程一多,单请求的十几/几十 MB 级别缓冲乘以并发数,很快吃满 2GB 堆。
这个案例的通用公式是:
1 | |
线程栈、HTTP header buffer、上传缓冲、日志队列、数据库连接池、批量查询 page size 都遵循类似规律。
资料中的 Spring Boot 配置名属于当时版本;新版本 Spring Boot/Tomcat 的请求头/响应头配置键和限制语义可能变化,升级时必须查当前版本文档。不要把旧配置名或旧默认值当永久 API。
“限制参数调得足够大就不会报错”通常是错误思路。限制的本质是给资源加边界,应该根据真实请求分布留合理余量,并追查为什么客户端会发送异常大的 header。
动态脚本还可能把 OOM 打到 Metaspace
如果每次请求都 new GroovyShell().evaluate(script),可能持续生成大量动态类。即使堆没有泄漏,Metaspace 也会因为类和 ClassLoader 生命周期失控而出现压力。
更合理的思路是:
1 | |
缓存同样必须有界,并确保废弃脚本关联的 ClassLoader 可以卸载。动态表达式引擎的资源模型不能按普通字符串工具类理解。
OOM 发生后先保留现场
生产环境至少应该让 OOM 自动产生 heap dump:
1 | |
JDK 8 常见 GC 日志参数是:
1 | |
JDK 9+ 已统一到 Unified Logging,现代 JDK 更常用:
1 | |
排查路径通常是:
1 | |
命令行也可以辅助:
1 | |
现代 JDK 还可以使用 jcmd 触发 heap dump。注意 heap dump、jmap -histo:live 等操作可能带来停顿或额外压力,生产执行前要评估风险和磁盘空间。
最重要的是不要看到 OOM 就先把 -Xmx 翻倍。加内存可以缓解容量不足,却无法修复无界缓存、重复对象、强引用泄漏或错误的“每请求资源 × 并发”配置。
把十类问题收敛成一套代码审查习惯
这些 Bug 很难靠“记住 100 个坑”彻底解决,更有效的方式是把它们变成固定的审查问题。
看到比较代码时
- 这是身份比较还是值比较?
- 包装类型/String 是否错误使用
==? equals、hashCode、compareTo的字段集合是否一致?- 对象会不会进入 Hash/Tree 容器?
- Lombok/record 生成的值语义真的是业务需要的吗?
看到金额和数字时
- 原始值是否经过
double才进入BigDecimal? - scale、precision、单位、舍入模式在哪里定义?
- 判等需要
equals还是compareTo? - 运算有没有整数溢出风险?
- 转换回 int/long 是否应该用
xxxValueExact?
看到集合 API 时
- 返回的是独立集合、只读集合还是原对象视图?
- 是否意外共享 backing array/list?
- 遍历期间有没有结构修改?
- 搜索场景是否应该提前建立 Map/Set 索引?
- 性能判断有没有真实 benchmark,而不是只背大 O?
看到 null 时
- null 是“缺失”“未知”“清空”还是“系统错误”?
- API 是否需要区分字段缺失和显式 null?
- DTO 和 Entity 是否不当地共用?
- 数据库 NULL 是否会影响聚合、统计和条件判断?
- Optional 是在表达语义,还是只是在隐藏错误?
看到 catch 时
- 这一层真的有能力处理这个异常吗?
- 原始 cause 和 stack trace 是否保留?
- 是转换、恢复还是重试?
- catch 后事务还会按预期回滚吗?
- 线程池 Future 的异常有没有被消费?
看到日志配置时
- 依赖树中是否存在多套日志绑定或桥接环?
- logger additivity 是否导致重复?
- filter 是精确等级还是阈值?
- AsyncAppender 满了以后是丢、等还是撑内存?
- DEBUG 参数计算是否本身很昂贵?
看到文件、流和资源时
- 字符集是否显式?
- 资源是否
AutoCloseable,谁负责 close? - 是否把大文件一次性读入堆?
- IO 粒度是不是太小?
- 多步文件操作失败后是否有中间状态?
看到 JSON、Redis、RPC 时
- 两端序列化器是否真的一致?
- 类型信息/schema 是否有版本策略?
- ObjectMapper 是增量定制还是整 Bean 覆盖?
- 枚举是否把 name/ordinal 暴露成了外部协议?
- 多态反序列化是否限制了允许类型?
看到时间字段时
- 它表示时间点还是本地日历时间?
- 时区/offset 在哪一层确定?
- 是否错误使用
YYYY? - 是否还在共享
SimpleDateFormat? - “差几天”需要日历 Period 还是总 elapsed days?
看到缓存和容量参数时
- 缓存是否有最大容量和淘汰策略?
- 一份业务数据在 JVM 中到底有几份副本?
- value 有没有反向强引用 weak key?
- 参数的单实例成本乘以并发后是多少?
- OOM 时是否有 GC 日志和 heap dump 能保留现场?
结语
这十类错误有一个共同特点:单看某一行代码几乎都“说得过去”。== 在 127 以内确实能工作,subList 确实是一个 List,Optional 确实能避免 NPE,AsyncAppender 确实能让请求线程更快,调大 header size 也确实能让“大请求头”不再报错。
问题发生在这些局部正确的行为进入真实系统以后:对象会放进不同集合,数据会跨进程和跨版本,线程会复用,时区会变化,队列会积压,文件会变大,缓存会一直增长,并发会把单次几 MB 的分配放大成几 GB。
所以业务开发里真正值得培养的不是“遇到坑再记一个 API”,而是对默认行为保持敏感:值与引用要分开,数据与视图要分开,缺失与空值要分开,时间点与本地时间要分开,异常与日志要保留上下文,序列化和数据库要有稳定协议,所有缓存、队列和容量参数都必须有边界。 当这些边界在设计阶段就被明确写出来,很多原本需要线上事故才能学会的经验,就可以在代码提交之前解决。