Java 业务开发中的十类隐蔽陷阱:从对象判等到 OOM

Java 业务代码真正难排查的 Bug,往往不是复杂算法写错,而是一些“看起来理所当然”的语义在边界条件下突然失效:== 偶尔能正确比较包装类型、BigDecimal.equals 不认为 1.01 相等、subList 只是原 List 的视图、异步日志会丢数据、submit 会把线程池异常藏进 Future,甚至一个看似只是“放大限制”的 Tomcat 参数也可能把堆内存吃光。本文把对象判等、数值、集合、空值、异常、日志、文件 IO、序列化、日期时间和 OOM 十类问题重新组织成一套面向真实业务开发的防错模型,并结合现代 Java 的实践说明哪些旧习惯已经应该被替换。

为什么这些问题总是在“看起来没问题”时出问题

这批问题表面上分散在 Java 语法、集合、Spring、Redis、Jackson、Tomcat 和 JVM 中,实际上都指向同一件事:代码依赖了一个没有被明确写出来的隐含契约

领域 常见错误 真正被忽略的契约
对象判等 IntegerString== 引用相等与值相等不是一回事
数值计算 double 算金额、整数静默溢出 数字在机器中的表示范围与精度有限
集合 asListsubList 当独立 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 都背下来,而是在看到“缓存、视图、自动转换、默认值、异步、序列化、时间、限制参数”这些词时,主动追问:谁拥有数据、谁负责释放、比较的到底是什么、默认行为是什么、异常去了哪里,以及这项配置会按什么维度放大资源消耗。

对象判等:==equalshashCodecompareTo 必须是一套语义

基本类型比较值,引用类型的 == 比较身份

Java 中 == 对基本类型比较数值,对引用类型比较两个引用是否指向同一个对象。业务代码通常关心的是“内容是不是一样”,因此引用类型的内容比较应该使用 equals

1
2
3
4
5
6
7
8
int a = 128;
int b = 128;
System.out.println(a == b); // true

Integer x = 128;
Integer y = 128;
System.out.println(x == y); // 不应依赖这个结果
System.out.println(x.equals(y)); // true

最危险的地方不是 == 永远错误,而是它有时正确。自动装箱会调用 Integer.valueOf,而 Integer 默认至少缓存 [-128, 127] 范围内的对象:

1
2
3
4
5
6
7
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // 通常 true,命中了 IntegerCache

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // 通常 false

这也是这类 Bug 隐蔽的原因。状态码最初只有 100、101、102,== 似乎一直工作;状态增长到 128 以后,逻辑突然失效。JVM 还可以通过参数调整部分装箱缓存范围,但业务代码绝不能建立在这种实现细节上。

自动拆箱还会制造另一种错觉:

1
2
3
Integer boxed = 128;
int primitive = 128;
System.out.println(boxed == primitive); // true,boxed 被拆箱后比较 int

所以代码审查时看到包装类型参与 ==,最好的反应不是分析“这次到底会不会命中缓存”,而是直接消除这种依赖。

String 常量池也不能成为业务判等规则

字符串字面量会进入字符串常量池:

1
2
3
4
5
6
7
8
String a = "hello";
String b = "hello";
System.out.println(a == b); // true

String c = new String("hello");
String d = new String("hello");
System.out.println(c == d); // false
System.out.println(c.equals(d)); // true

String.intern() 可以把字符串驻留到字符串表中,但它是内存优化机制,不是内容判等 API。大量、低重复率的动态字符串全部 intern,会把压力转移到 StringTable。资料中的实验在字符串表桶数量很少时驻留 1000 万个字符串耗时超过 40 秒,调大 StringTableSize 后才明显改善;这个数字与 JDK、机器和参数有关,但结论不变:不要为了省一次 equals 或做普通去重而滥用 intern

需要分析字符串表时,可以使用对应 JDK 支持的统计参数,例如:

1
2
-XX:+PrintStringTableStatistics
-XX:StringTableSize=<bucket-count>

参数名和行为需要以当前 JDK 为准,尤其不要把某次压测得到的桶数量直接当生产推荐值。

自定义值对象必须同时设计 equalshashCode

如果一个点只由 xy 决定身份,那么 desc 不应该参与相等判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final class Point {
private final int x;
private final int y;
private final String desc;

public Point(int x, int y, String desc) {
this.x = x;
this.y = y;
this.desc = desc;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point that = (Point) o;
return x == that.x && y == that.y;
}

@Override
public int hashCode() {
return Objects.hash(x, y);
}
}

只重写 equals 而不重写 hashCode 会直接破坏 HashSetHashMap 的语义:两个 equalstrue 的对象如果 hash code 不一致,会被分配到不同桶,containsget 等操作可能找不到“逻辑上相等”的对象。

核心契约是:

1
2
如果 a.equals(b) == true
那么 a.hashCode() 必须等于 b.hashCode()

反过来不成立,不同对象允许 hash 冲突。

对于真正的不可变值对象,现代 Java 还可以优先考虑 record,它会基于组件自动生成 equalshashCodetoString

1
public record Point(int x, int y) {}

前提仍然是:record 中声明的组件正好就是你的“值语义”。

compareTo == 0 最好与 equals == true 保持一致

一个更难发现的问题出现在有序集合和二分搜索中。

假设 Student.equalsid + name 决定,但 compareTo 只比较 id

1
2
3
public int compareTo(Student other) {
return Integer.compare(this.id, other.id);
}

那么:

  • ArrayList.indexOf 通过 equals 搜索;
  • Collections.binarySearch 通过排序规则搜索;
  • HashSet.contains 依赖 hashCode + equals
  • TreeSet.contains 依赖 Comparator / compareTo

于是 id=2,name=wangid=2,name=liequals 来说不是同一个学生,对排序规则来说却是“相等”的。代码从线性搜索优化为二分搜索后,就可能出现业务行为变化。

更安全的实现是让排序规则覆盖同一组身份字段:

1
2
3
4
5
6
@Override
public int compareTo(Student other) {
return Comparator.comparingInt(Student::getId)
.thenComparing(Student::getName)
.compare(this, other);
}

严格来说,Comparable 规范允许“自然顺序与 equals 不一致”的类型,BigDecimal 就是著名例子;但业务实体如果同时进入 Hash 容器和 Tree 容器,最好不要制造两套“相等”定义。

getClass()instanceof 是两种不同的继承语义

equals 中常见两种类型判断:

1
if (o == null || getClass() != o.getClass()) return false;

以及:

1
if (!(o instanceof Point)) return false;

getClass() 要求运行时类型完全相同,父类和子类不会相等;instanceof 允许子类型通过判断。两者没有绝对的“正确答案”,关键是你的值语义是否允许跨继承层级相等,并且必须保证对称性、传递性。

热加载、插件系统和自定义 ClassLoader 还会引入另一层边界:在 JVM 中,类身份由“类的全限定名 + 定义它的 ClassLoader”共同决定。名字相同但由两个不同 ClassLoader 加载的类,运行时并不是同一个类型。这也是热部署环境里某些 equals、强制类型转换问题特别诡异的原因。

Lombok 不是免思考按钮

@Data 会生成 equalshashCode,但“生成了”不代表“业务语义正确”。默认把不该参与身份的字段加入比较,会让对象看起来总不相等;继承层级中忽略父类字段,则可能把完全不同的两个子类对象判断为相等。

可以显式控制字段:

1
2
3
4
5
6
7
8
@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Person {
private String name;

@EqualsAndHashCode.Include
private String identity;
}

子类确实需要把父类语义纳入判等时:

1
2
3
4
5
@Data
@EqualsAndHashCode(callSuper = true)
public class Employee extends Person {
private String company;
}

Lombok、IDE 代码生成和 record 都只是减少模板代码。真正需要设计的,始终是“什么字段定义对象身份”。

数值计算:精度、舍入、比较和溢出是四个不同问题

double 的问题不是“Java 算错了”

1
2
System.out.println(0.1 + 0.2);
// 0.30000000000000004

Java 浮点数遵循 IEEE 754。十进制 0.1 在二进制中是无限循环小数,只能保存为最接近的可表示值。因此浮点误差不是某次加法造成的偶然问题,而是表示层就已经存在。

对于科学计算、统计等容忍误差的场景,double 完全合理;对于金额、利率、税额、结算等要求十进制定点语义的业务,应该使用 BigDecimal 或明确的最小货币单位模型。

BigDecimal 最大的坑发生在构造之前

错误写法:

1
BigDecimal a = new BigDecimal(0.1);

传进去的 0.1 已经是一个近似的二进制浮点值,BigDecimal 只是忠实地把这个近似值展开出来,并不会“修复精度”。

优先写成:

1
2
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = BigDecimal.valueOf(0.1);

如果原始数据本来就是字符串、数据库 DECIMAL、接口中的十进制文本,应该从源头保持十进制表示,不要先转 double 再转回来。

scaleprecision 会影响表现形式与相等性

BigDecimal 不只有 value,还有 scale:

1
2
3
4
5
BigDecimal a = new BigDecimal("1.0"); // scale = 1
BigDecimal b = new BigDecimal("1"); // scale = 0

System.out.println(a.equals(b)); // false
System.out.println(a.compareTo(b) == 0); // true

这意味着两个概念必须分开:

  • “表示是否完全相同”使用 equals
  • “数值是否相同”使用 compareTo

这对集合尤其重要:

1
2
3
Set<BigDecimal> set = new HashSet<>();
set.add(new BigDecimal("1.0"));
System.out.println(set.contains(new BigDecimal("1"))); // false

如果业务只关心数值,可在进入 Hash 容器前规范化:

1
BigDecimal normalized = value.stripTrailingZeros();

或者使用基于自然顺序判断的 TreeSet,但要明确接受它的集合相等语义来自 compareTo

舍入模式必须是业务规则,不应该藏在格式化里

现代代码应使用 RoundingMode,而不是旧版 BigDecimal.ROUND_* 常量:

1
2
3
BigDecimal price = new BigDecimal("3.35");
System.out.println(price.setScale(1, RoundingMode.DOWN)); // 3.3
System.out.println(price.setScale(1, RoundingMode.HALF_UP)); // 3.4

常见模式含义如下:

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
2
long value = Long.MAX_VALUE;
System.out.println(value + 1); // Long.MIN_VALUE

默认整数运算采用补码环绕,不会因为溢出自动抛异常。对需要安全边界的计算,可使用:

1
2
long result = Math.addExact(a, b);
long result2 = Math.multiplyExact(x, y);

超出范围时会得到 ArithmeticException

真正的大整数运算使用 BigInteger,转换回基础类型时也应使用带 Exact 的方法:

1
2
BigInteger big = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE);
long value = big.longValueExact(); // 超出 long 范围时抛异常

金额落库时不要混入二进制浮点语义

资料中的实践建议优先使用数据库 DECIMAL(p, s) 保存金额,例如 DECIMAL(13,2)DECIMAL(13,4) 只是示例,真实 precision/scale 必须根据最大金额、币种和业务精度确定。

使用 long 保存“分”也能避免浮点误差,但它把单位换算的正确性推给了应用:一次漏乘 100 或漏除 100 就可能产生严重事故。无论选择哪种模型,都要让“单位 + 精度 + 舍入”成为类型或领域对象的一部分,而不是依赖开发者记忆。

集合:很多 List API 返回的不是你以为的那个 List

Arrays.asList 有三个典型陷阱

第一个是基本类型数组:

1
2
3
int[] array = {1, 2, 3};
List<int[]> list = Arrays.asList(array);
System.out.println(list.size()); // 1

int[] 自身被当成一个对象传给了泛型可变参数。需要 List<Integer> 时:

1
2
3
List<Integer> list = Arrays.stream(array)
.boxed()
.collect(Collectors.toCollection(ArrayList::new));

第二个是它返回的不是普通 java.util.ArrayList,而是 Arrays 内部的定长 List。可以 set,不能改变大小:

1
2
List<String> list = Arrays.asList("a", "b", "c");
list.add("d"); // UnsupportedOperationException

第三个是它与原数组共享底层存储:

1
2
3
4
String[] array = {"a", "b", "c"};
List<String> list = Arrays.asList(array);
array[1] = "x";
System.out.println(list); // [a, x, c]

需要独立、可变的 List 时,明确复制:

1
List<String> list = new ArrayList<>(Arrays.asList(array));

现代 Java 的 List.of(...) 更明确地表达“不可变集合”,但它同样不能拿来当可变 ArrayList 使用。

subList 是视图,不是切出来的新数组

1
2
3
4
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
List<Integer> sub = list.subList(1, 4);
sub.remove(1);
System.out.println(list); // 原 list 也发生变化

SubList 保存着对原始 List 的引用,结构修改会互相影响。更隐蔽的是内存问题:即使只保存一个元素的 subList,只要这个视图仍然引用着一个巨大的原 List,原 List 也可能无法回收。

如果想得到独立切片:

1
List<Integer> copy = new ArrayList<>(list.subList(from, to));

原始 List 在创建 SubList 后发生结构性修改,还可能因为 modCount 不一致触发 ConcurrentModificationException。这个异常名字里虽然有 “Concurrent”,但并不要求真的存在多线程,单线程边遍历边错误修改同样会触发。

remove(1) 到底是删索引还是删数字 1

对于 List<Integer>,下面两个调用语义完全不同:

1
2
list.remove(1);                  // 调用 remove(int index)
list.remove(Integer.valueOf(1)); // 调用 remove(Object o)

自动装箱和重载结合在一起,很容易把“删除值”写成“删除下标”。

遍历时删除也不要直接修改原集合:

1
2
3
4
5
6
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
if (it.next() == 2) {
it.remove();
}
}

Java 8 以后更简单:

1
list.removeIf(i -> i == 2);

迭代器自己的 remove 会同步维护 expectedModCount,直接调用 list.remove 则破坏了迭代器看到的结构版本。

数据结构选型必须同时看时间和空间

对百万级 List 反复按 ID 搜索,线性扫描的累计成本很高:

1
2
3
4
Order order = list.stream()
.filter(x -> x.id() == targetId)
.findFirst()
.orElse(null);

如果业务本质就是按 ID 随机查找,应该在构建阶段建立索引:

1
2
3
4
Map<Long, Order> byId = orders.stream()
.collect(Collectors.toMap(Order::id, Function.identity()));

Order order = byId.get(targetId);

资料中的实验里,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
2
Integer count = null;
int next = count + 1; // 自动拆箱 NPE
1
2
3
4
String status = null;
status.equals("OK"); // NPE
"OK".equals(status); // 安全
Objects.equals(a, b); // 两边都允许为 null
1
2
Map<String, String> map = new ConcurrentHashMap<>();
map.put(null, "value"); // NPE
1
foo.getBar().doSomething(); // foo 或 bar 任一为空都可能 NPE
1
2
List<Item> items = remoteCall();
items.size(); // 远程方法如果返回 null,直接 NPE

ConcurrentHashMap 不支持 null 并不是简单“少一个功能”。并发 Map 如果允许 value 为 null,当 get(key) 返回 null 时无法判断“key 不存在”还是“key 显式映射到 null”;再用 containsKey 二次确认时,Map 又可能已经被其他线程修改。因此它通过 API 约束直接消除这种歧义。

Optional 能减少判空代码,但不能替你定义业务规则

可以把级联访问写得更安全:

1
2
3
4
5
Optional.ofNullable(fooService)
.map(FooService::getBarService)
.map(BarService::bar)
.filter("OK"::equals)
.ifPresent(v -> log.info("OK"));

也可以为“可能返回 null 的集合”提供空集合兜底:

1
2
List<Item> items = Optional.ofNullable(loadItems())
.orElseGet(Collections::emptyList);

但这里有一个非常重要的边界:没有 NPE 不代表程序是对的。 如果 barService 按设计必须存在,那么把它静默过滤掉,只会把初始化 Bug 变成一个“什么也没发生”的业务 Bug。

因此遇到 null 要先回答:

  • 这是正常的“无数据”吗?
  • 是调用方参数缺失吗?
  • 是业务状态不允许吗?
  • 是系统初始化或远程依赖故障吗?
  • 应该返回默认值、跳过、告警,还是立即失败?

更新接口最怕“未传”和“显式传 null”混为一谈

假设客户端只想修改用户名:

1
2
3
4
{
"id": 1,
"name": null
}

如果直接把同一个 User 同时当 Request DTO 和 JPA Entity,再 save(user),没有出现在请求里的 agecreateTime 等字段也可能被覆盖。

真正需要建模的是三态:

1
2
3
字段缺失       -> 不修改
字段存在且为 null -> 显式清空 / 重置
字段存在且有值 -> 更新为新值

资料中的做法是让 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
SELECT SUM(score) FROM user;

没有可参与聚合的值时,结果可能是 NULL,需要按业务决定是否 COALESCE/IFNULL 为 0:

1
SELECT COALESCE(SUM(score), 0) FROM user;
1
SELECT COUNT(score) FROM user;

只统计 score IS NOT NULL 的行。统计记录数通常应使用:

1
SELECT COUNT(*) FROM user;

查询 NULL 不能写:

1
WHERE score = NULL

而要写:

1
WHERE score IS NULL

因为 SQL 的 NULL 代表 unknown,普通比较运算得到的仍是 unknown,而不是 true。

生产 NPE 不好复现时,先观察现场而不是疯狂加日志

资料中使用 Arthas 的 watchstack 来观察线上方法入参和调用路径:

1
2
watch com.example.UserService update params -x 2
stack com.example.UserService update

对只在特定参数、特定分支下发生的问题,这比本地盲猜更高效。线上诊断同样要注意访问控制、脱敏和执行开销,避免把工具本身变成新的风险。

异常处理:异常不是“统一 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestControllerAdvice
public class ApiExceptionHandler {

@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException ex) {
return ResponseEntity.badRequest()
.body(new ApiError(ex.getCode(), ex.getMessage()));
}

@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> system(Exception ex) {
log.error("unhandled system exception", ex);
return ResponseEntity.internalServerError()
.body(new ApiError("SERVER_ERROR", "服务器忙,请稍后再试"));
}
}

真实系统还应记录 traceId、请求路径和必要的脱敏上下文,而不是把敏感请求体原样打入日志。

最糟糕的异常处理是“生吞”

1
2
3
4
5
try {
readFile();
} catch (IOException e) {
// 什么都不做
}

这比不捕获更难排查,因为故障已经发生,唯一的证据却被主动删除了。

只记录 message 也不够:

1
log.error("read failed: {}", e.getMessage());

它丢掉了异常类型、cause 和 stack trace。应该记录 Throwable:

1
log.error("read failed, path={}", path, e);

如果要转换异常,把原异常作为 cause:

1
throw new FileProcessException("读取对账文件失败: " + path, e);

一个好的异常至少保留三件事:发生了什么、在哪个业务上下文发生、原始根因是什么。

捕获异常后通常只有三类合法动作

  • 转换:把底层异常转成上层能理解的异常,同时保留 cause;
  • 恢复:使用缓存、默认值、降级路径继续工作;
  • 重试:仅对可恢复、通常幂等的瞬时失败进行,并配合退避、次数上限和熔断。

“远程调用失败就无限重试”会把下游故障放大成雪崩。

finally 自己抛异常会覆盖真正的根因

1
2
3
4
5
try {
throw new RuntimeException("business failed");
} finally {
throw new RuntimeException("close failed");
}

最后看到的只会是 close failed。这就是为什么资源类型应该优先使用 try-with-resources:

1
2
3
try (InputStream in = Files.newInputStream(path)) {
// read
}

如果业务逻辑和 close() 都抛异常,try-with-resources 会保留业务异常作为主异常,并把关闭异常作为 suppressed exception 挂在上面。

1
2
PrimaryException
Suppressed: CloseException

不要在 finallyreturn,它同样可能覆盖 try/catch 的返回值或异常,使控制流极难理解。

不要把 Exception 对象定义成 static 常量

下面这种“统一管理异常”的写法会制造假现场:

1
2
public static final BusinessException ORDER_NOT_FOUND =
new BusinessException("订单不存在");

Throwable 的栈信息与它被创建的时刻有关。重复抛同一个实例,后续请求看到的 stack trace 可能始终指向第一次初始化的位置。

统一管理的应该是错误码和消息模板,而不是 Throwable 实例:

1
2
3
public static BusinessException orderNotFound(long id) {
return new BusinessException("ORDER_NOT_FOUND", "订单不存在: " + id);
}

线程池里的异常会因为提交方式不同而表现完全不同

execute 提交的 Runnable 如果抛出未捕获 RuntimeException,工作线程会结束,并由线程池按需补充新 worker;异常通常交给 UncaughtExceptionHandler

因此任务内部应明确处理异常,并为线程工厂设置兜底 handler。

submit 则不同:任务会被包装成 FutureTask,异常被保存在 Future 中,工作线程不会因为这次任务异常退出,UncaughtExceptionHandler 也通常看不到它。

1
2
3
4
5
6
7
8
9
Future<?> future = pool.submit(() -> {
throw new RuntimeException("boom");
});

try {
future.get();
} catch (ExecutionException e) {
log.error("async task failed", e.getCause());
}

如果你根本不关心结果,却用 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
2
3
4
5
6
7
<logger name="com.example" level="DEBUG">
<appender-ref ref="CONSOLE"/>
</logger>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

如果只是想让某个包开启 DEBUG,不要重复挂 Appender:

1
<logger name="com.example" level="DEBUG"/>

如果它确实要走独立 Appender,则关闭继承:

1
2
3
<logger name="com.example" level="DEBUG" additivity="false">
<appender-ref ref="APP_FILE"/>
</logger>

日志重复不仅浪费磁盘,还会在 ELK、Loki 一类集中式系统中放大存储、索引和告警成本。

LevelFilterThresholdFilter 不是一回事

ThresholdFilter(WARN) 的语义是允许 WARN 及以上级别。

LevelFilter(INFO) 则是“匹配 INFO 后怎么处理,不匹配又怎么处理”。如果只配置 level 而不配置 onMatch/onMismatch,默认都可能继续交给后续过滤器,结果并不等于“只收 INFO”。

只记录 INFO 可以明确写:

1
2
3
4
5
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

AsyncAppender 的性能来自把成本移到队列,不是把 IO 变没了

异步日志的核心是生产线程把事件放入队列,后台线程再写 Appender。于是你必须同时决定:

  • 队列可以占多少内存;
  • 队列快满时能否丢 DEBUG/INFO;
  • 队列满时业务线程能否阻塞;
  • 是否需要 caller data;
  • 应用退出时能等待多久完成 flush。

资料所对应的 Logback 版本中,AsyncAppender 的关键默认行为包括:

参数 当时默认行为 风险
queueSize 256 突发日志非常容易把队列打满
discardingThreshold 队列容量的 1/5 剩余空间不足时可丢弃较低级别日志
neverBlock false 队列满时可能反向阻塞业务线程
includeCallerData false 行号、方法等调用方信息可能缺失

这些默认值属于版本实现细节,升级 Logback 时应重新检查当前文档。真正的取舍只有三个:

  • 绝对不阻塞:就必须允许某些日志丢失;
  • 绝对不丢:队列满时就必须等待,日志系统可能拖慢业务;
  • 队列无限大:最终只是把日志问题变成 OOM。

所以异步日志参数必须经过峰值流量压测,不能从别的项目复制一个 queueSize 就结束。

{} 只延迟 toString,不会阻止你先执行昂贵的方法

1
log.debug("result={}", slowQuery());

即使 DEBUG 关闭,Java 也会先执行 slowQuery() 再调用 debug。占位符省掉的是字符串拼接和部分 toString 成本,不是参数计算本身。

传统写法:

1
2
3
if (log.isDebugEnabled()) {
log.debug("result={}", slowQuery());
}

原资料写作时的 SLF4J API 还没有现代 fluent logging。SLF4J 2.x 已支持 Supplier 风格的延迟参数,可在当前依赖版本支持时使用:

1
2
3
log.atDebug()
.addArgument(() -> slowQuery())
.log("result={}");

对于大对象 JSON 化、数据库查询、复杂集合展开等日志参数,这一点非常重要。

文件 IO:编码、句柄和缓冲区缺一个都会出事故

字符流必须明确字符集

文件本质上只有字节。只有把字节解释成字符时,字符集才参与进来。

如果文件按 GBK 写入,却依赖机器默认字符集读取:

1
2
3
try (FileReader reader = new FileReader("hello.txt")) {
// 使用默认 charset,环境一变就可能乱码
}

同一份代码在旧服务器正常、新服务器乱码,往往不是“机器有问题”,而是代码偷偷依赖了 Charset.defaultCharset()

应显式指定:

1
2
3
4
Charset charset = Charset.forName("GBK");
try (BufferedReader reader = Files.newBufferedReader(path, charset)) {
// read
}

更理想的是协议层统一 UTF-8;如果必须对接历史系统,也应把对方编码当接口协议字段管理,而不是猜。

readAllLinesFiles.lines 解决的是两种问题

1
List<String> all = Files.readAllLines(path, charset);

会把所有行装进内存,适合确定很小的文件,不适合 GB 级文件。

大文件更适合流式消费:

1
2
3
4
try (Stream<String> lines = Files.lines(path, charset)) {
lines.limit(200_000)
.forEach(this::process);
}

关键是 Stream 必须关闭。Files.lines 背后持有 BufferedReader 和文件描述符,如果每次调用后都不 close,最终会出现:

1
Too many open files

在 Linux 上可以辅助检查:

1
lsof -p <pid> | grep '<filename>' | wc -l

凡是返回 StreamDirectoryStream 或其他 AutoCloseable 资源的文件 API,都要先确认它的关闭语义,不能因为“这是个静态方法”就默认资源会在方法返回后释放。

逐字节 IO 的问题是系统调用次数

这种代码功能没错,性能却可能差几个数量级:

1
2
3
4
int b;
while ((b = in.read()) != -1) {
out.write(b);
}

改为块读写:

1
2
3
4
5
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}

资料中的 35MB 文件实验,从逐字节的百秒级下降到使用缓冲区的百毫秒级;具体数据受磁盘、OS、缓存和 JDK 影响,但差异的来源很明确:减少跨 Java/OS 边界的 IO 调用次数。

BufferedInputStream/BufferedOutputStream 自身已有缓冲,但如果外层仍然逐字节调用 Java 方法,方法调用次数依然巨大。业务通常应让调用粒度本身也是块状的。

文件复制可以考虑 FileChannel,但要正确管理资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try (FileChannel in = FileChannel.open(src, StandardOpenOption.READ);
FileChannel out = FileChannel.open(dst,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {

long position = 0;
long size = in.size();
while (position < size) {
long transferred = in.transferTo(position, size - position, out);
if (transferred <= 0) break;
position += transferred;
}
}

在支持的操作系统上,transferTo 可以利用更高效的数据传输路径,减少用户态数据搬运。不要把“零拷贝”理解成绝对没有任何复制,它通常指减少 CPU/用户态参与的数据拷贝。

文件系统操作默认不是事务

复制一半失败、重命名跨文件系统、删除失败,都不会像数据库事务一样自动回滚。Files.move 可以请求 ATOMIC_MOVE,但是否支持取决于文件系统和具体场景;跨文件系统通常无法保证。

需要业务原子性的场景,常见策略是:写临时文件 -> fsync/校验 -> 在同一文件系统内原子 rename -> 再更新元数据。不要把多步 File/Files 调用想象成天然事务。

序列化:一来一回仍然是同一个对象,需要协议双方共同保证

Redis 的“乱码”通常只是序列化协议不一致

RedisTemplateStringRedisTemplate 不是简单的“一个返回 Object、一个返回 String”。资料对应的 Spring Data Redis 默认行为中:

  • RedisTemplate 默认使用 JDK 序列化处理 key/value;
  • StringRedisTemplate 使用字符串序列化处理 key/value。

于是同样写入 key user:1,JDK 序列化后在 redis-cli 看到的是二进制转义串,而不是可读文本。更关键的是:两种 Template 按各自算法重新序列化 key 后,连同一个 key 都可能互相找不到。

工程上更常见的是明确 key 和 value 协议:

1
2
3
4
5
6
7
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
template.setValueSerializer(RedisSerializer.json());
template.setHashValueSerializer(RedisSerializer.json());
template.afterPropertiesSet();

具体 serializer API 随 Spring Data Redis 版本可能变化,但原则应该固定下来并写进架构规范:

1
2
key: UTF-8 String
value: JSON(带明确类型策略/Schema 版本)

否则 Redis 升级、服务拆分、多语言接入时都会把“框架默认值”变成兼容性债务。

JSON 带类型信息很方便,但不要无边界开启多态反序列化

资料中通过 Jackson default typing 把 Java 类型信息一起写入 Redis,从而避免反序列化后只得到 LinkedHashMap。这个思路能解决类型恢复问题,但现代工程必须额外考虑安全边界。

不要对不可信输入开放“任意类名 -> 任意 Java 类型”的多态反序列化。更稳妥的做法是:

  • 缓存数据只由受信任服务生产;
  • 使用明确的目标类型 serializer;
  • 多态场景配置允许列表或受限的 PolymorphicTypeValidator
  • 不把外部 HTTP JSON 与内部 Redis 类型元数据协议混用;
  • 缓存 schema 变更时设计版本和迁移策略。

“反序列化方便”不能以扩大反序列化攻击面为代价。

不要为了改一个 Jackson 特性就替换整个 ObjectMapper

Spring Boot 会为默认 ObjectMapper 配置一组 Web 友好的行为。如果直接:

1
2
3
4
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}

很可能把框架自动配置全部覆盖掉。原资料中的事故就是为了修改枚举序列化方式,自定义 Bean 后意外恢复了 FAIL_ON_UNKNOWN_PROPERTIES,客户端多传一个字段就开始大量 400。

更合理的是在现有 builder 上做增量定制:

1
2
3
4
5
@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder.featuresToDisable(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

或者使用 spring.jackson.* 配置。核心思想是:增量修改框架默认配置,不要为一个小需求重新造完整基础设施 Bean。

反序列化不会自动走你“希望它走”的业务构造器

1
2
3
4
5
6
7
8
9
public class ApiResult {
private boolean success;
private int code;

public ApiResult(int code) {
this.code = code;
this.success = code == 2000;
}
}

如果 Jackson 反序列化时使用无参构造 + setter/字段赋值,上面构造器里的业务逻辑可能根本没有执行。

需要构造器创建语义时显式声明:

1
2
3
4
5
@JsonCreator
public ApiResult(@JsonProperty("code") int code) {
this.code = code;
this.success = code == 2000;
}

不过更值得反思的是:success 如果完全可以由 code 推导,是否应该被反序列化为独立状态?派生字段越多,越容易出现“一来一回以后内部状态不一致”。

API 边界不要直接暴露 Java 枚举的 ordinal

客户端有 4 个状态,服务端新增第 5 个状态时,旧客户端直接反序列化枚举就可能失败。用 ordinal() 更危险:只要枚举插入、重排,原来的数字含义就改变。

更稳妥的 API 是稳定 code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public enum OrderStatus {
CREATED(1000),
PAID(1001),
DELIVERED(1002),
FINISHED(1003),
UNKNOWN(-1);

private final int code;

OrderStatus(int code) {
this.code = code;
}

public int code() {
return code;
}

public static OrderStatus fromCode(int code) {
return Arrays.stream(values())
.filter(x -> x.code == code)
.findFirst()
.orElse(UNKNOWN);
}
}

更彻底的做法是在 DTO 中直接传 statusCode,Java 枚举只存在于服务内部,通过显式 mapper 转换。这样客户端协议不会被 Java 枚举实现细节绑架。

还在使用 JDK Serializable 时,serialVersionUID 是兼容性协议的一部分

现代 REST/RPC 场景更多使用 JSON、Protobuf 等跨语言格式,但某些历史缓存、文件或 Java 内部通信仍可能使用 Serializable。这时应显式声明:

1
private static final long serialVersionUID = 1L;

否则编译器会根据类结构计算默认值,不同编译器或类结构变化可能导致反序列化时出现 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
Instant createdAt = Instant.now();

展示给东京用户:

1
ZonedDateTime tokyo = createdAt.atZone(ZoneId.of("Asia/Tokyo"));

把一个本地时间转换成时间点,必须提供时区:

1
2
LocalDateTime local = LocalDateTime.of(2026, 9, 8, 13, 0);
Instant instant = local.atZone(ZoneId.of("Asia/Shanghai")).toInstant();

这不是 API 设计得麻烦,而是物理事实:没有时区就没有唯一时间点。

同一个字面时间在不同时区解析,本来就应该得到不同 Instant

2020-01-02 22:00:00 如果解释为上海时间和纽约时间,代表两个不同的瞬间;反过来,同一个 Instant 在纽约和上海显示出来的本地时钟时间也必然不同。

不要遇到“数据库时间差了 8 小时”就直接在代码里 plusHours(8)。应该沿链路检查:

1
2
3
4
5
6
7
客户端时区
-> JSON 时区/offset
-> JVM ZoneId
-> JDBC 驱动配置
-> 数据库 session time_zone
-> 数据库存储类型
-> 输出格式化时区

人为补 8 小时通常只是把配置错误永久写进业务逻辑。

YYYYyyyy 不是大小写风格问题

Y 表示 week-based-year,y 表示 year-of-era。年末几天可能已经属于下一“周序年”:

1
2
DateTimeFormatter wrong = DateTimeFormatter.ofPattern("YYYY-MM-dd");
DateTimeFormatter normal = DateTimeFormatter.ofPattern("yyyy-MM-dd");

如果只是普通日历日期,不要用 YYYY

现代代码做严格解析时,可以进一步显式指定 resolver style,并优先用 uuuu 表示 proleptic year:

1
2
3
DateTimeFormatter strict = DateTimeFormatter
.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);

这比依赖 SimpleDateFormat 默认宽松解析可靠得多。

SimpleDateFormat 的最大问题是“可变且共享”

SimpleDateFormat 内部持有可变 Calendar,多线程共享一个 static 实例会互相修改解析状态,出现随机错误甚至荒谬年份。

旧代码如果不能迁移,只能线程隔离或加锁;新代码应直接使用线程安全、不可变的 DateTimeFormatter

1
2
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");

时间计算不要手写毫秒乘法

1
new Date(System.currentTimeMillis() + 30 * 24 * 60 * 60 * 1000);

这段代码中常量表达式先按 int 计算,可能在转换为 long 之前就已经溢出。即使改成 30L,手工毫秒运算遇到夏令时、月长度等日历规则仍然容易表达错业务语义。

Java Time 直接写业务意图:

1
2
3
4
5
LocalDateTime after30Days = LocalDateTime.now().plusDays(30);
LocalDate firstDay = LocalDate.now()
.with(TemporalAdjusters.firstDayOfMonth());
LocalDate lastFriday = LocalDate.now()
.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));

计算两个日期的“2 个月 11 天”与“总共 72 天”也是两种不同问题:

1
2
Period period = Period.between(start, end);            // P2M11D
long days = ChronoUnit.DAYS.between(start, end); // 72

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
2
3
4
5
6
7
8
9
10
11
Set<UserDTO> users = entities.stream()
.map(this::toDto)
.collect(Collectors.toSet());

Map<String, List<UserDTO>> index = new HashMap<>();
for (UserDTO user : users) {
for (String prefix : prefixes(user.getName())) {
index.computeIfAbsent(prefix, k -> new ArrayList<>())
.add(user); // 引用同一份 DTO
}
}

这类问题在导出、ORM 映射、DTO 转换、批量聚合中非常常见:数据库看起来只有 100MB,不代表 JVM 只需要 100MB。ResultSet、框架中间对象、Entity、DTO、序列化缓冲区可能同时存在。

容量评估应该用真实数据做 heap dump 或 profiler 验证,而不是简单用“数据库字节数 × 1”。

对于前缀自动补全本身,Trie/前缀树往往比“所有前缀字符串 -> List”更贴近问题结构,但无论用什么结构,都要关注对象副本和索引膨胀。

WeakHashMap 的 key 是弱引用,不代表 Entry 一定能被回收

考虑:

1
2
3
4
Map<User, UserProfile> cache = new WeakHashMap<>();

User user = new User("mario");
cache.put(user, new UserProfile(user, "Tokyo"));

引用关系实际上是:

flowchart LR
    M[WeakHashMap] --> E[Entry]
    E -. weak reference .-> K[User key]
    E --> V[UserProfile value]
    V --> K

虽然 Entry 到 Key 是弱引用,但 Value 又强引用 Key,于是仍然存在:

1
WeakHashMap -> Entry -> Value -> Key

这是一条完整强引用路径,Key 当然不会被 GC。

如果确实使用弱引用结构,要检查整个对象图,而不只是 Map 的 key 类型。资料中通过把 Value 包装为 WeakReference<UserProfile>,或让 Value 不再反向引用同一个 Key 来打断强引用路径。

生产缓存更推荐有界缓存:最大条目数/权重、过期策略、命中率、淘汰统计都可观测,而不是把缓存生命周期完全交给 GC 的软/弱引用。GC 驱动的缓存容量很难和业务 SLA 对齐。

ThreadLocal 也有类似的“弱 key、强 value”风险

ThreadLocalMap 的 Entry key 是弱引用,但 value 是强引用。线程池线程长期存活时,如果业务忘记 remove(),即使 ThreadLocal key 被回收,value 仍可能在线程下一次清理前长期滞留。

1
2
3
4
5
6
try {
context.set(value);
// business
} finally {
context.remove();
}

这和 WeakHashMap 案例本质相同:不要只看某一条引用是 weak 还是 strong,要看 GC Root 到对象是否仍存在完整强引用路径。

容量参数会被并发数乘起来

资料中的 Tomcat 事故把:

1
server.max-http-header-size=10000000

设置到了约 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
2
3
4
script content/hash
-> compile/parse once
-> cache compiled Script/Class
-> subsequent invocation reuses compiled result

缓存同样必须有界,并确保废弃脚本关联的 ClassLoader 可以卸载。动态表达式引擎的资源模型不能按普通字符串工具类理解。

OOM 发生后先保留现场

生产环境至少应该让 OOM 自动产生 heap dump:

1
2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof

JDK 8 常见 GC 日志参数是:

1
2
3
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
-Xloggc:/var/log/app/gc.log

JDK 9+ 已统一到 Unified Logging,现代 JDK 更常用:

1
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags

排查路径通常是:

1
2
3
4
5
6
异常信息确认 OOM 区域
-> GC 日志确认回收是否失效
-> jstat / JFR 观察趋势
-> heap dump
-> MAT / VisualVM / profiler 看 dominator tree 与 retained size
-> 顺着 GC Root 找“为什么还可达”

命令行也可以辅助:

1
2
jstat -gcutil <pid> 1000
jmap -histo:live <pid> | head -50

现代 JDK 还可以使用 jcmd 触发 heap dump。注意 heap dump、jmap -histo:live 等操作可能带来停顿或额外压力,生产执行前要评估风险和磁盘空间。

最重要的是不要看到 OOM 就先把 -Xmx 翻倍。加内存可以缓解容量不足,却无法修复无界缓存、重复对象、强引用泄漏或错误的“每请求资源 × 并发”配置。

把十类问题收敛成一套代码审查习惯

这些 Bug 很难靠“记住 100 个坑”彻底解决,更有效的方式是把它们变成固定的审查问题。

看到比较代码时

  • 这是身份比较还是值比较?
  • 包装类型/String 是否错误使用 ==
  • equalshashCodecompareTo 的字段集合是否一致?
  • 对象会不会进入 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”,而是对默认行为保持敏感:值与引用要分开,数据与视图要分开,缺失与空值要分开,时间点与本地时间要分开,异常与日志要保留上下文,序列化和数据库要有稳定协议,所有缓存、队列和容量参数都必须有边界。 当这些边界在设计阶段就被明确写出来,很多原本需要线上事故才能学会的经验,就可以在代码提交之前解决。


Java 业务开发中的十类隐蔽陷阱:从对象判等到 OOM
https://allendericdalexander.github.io/2026/09/08/geeker/088java100error/2java-business-common-mistakes/
作者
AtLuoFu
发布于
2026年9月8日
许可协议