金额、字符串和日期都属于“看起来简单,出了错很难查”的类型。它们的坑通常藏在精度、正则语义、索引边界和时区这些细节里。

BigDecimal 与金额

不要用 new BigDecimal(double)

现象

金额计算出现 0.30000000000000004 这类结果,或者对账时少一分钱。

1
2
3
BigDecimal amount = new BigDecimal(0.1);
System.out.println(amount);
// 0.1000000000000000055511151231257827021181583404541015625

根因

double 本身不能精确表示很多十进制小数。new BigDecimal(double) 会保留 double 的二进制近似值,所以精度并没有变好。

正确写法

优先用字符串构造,或者用 BigDecimal.valueOf:

1
2
BigDecimal amount = new BigDecimal("0.1");
BigDecimal tax = BigDecimal.valueOf(0.1);

BigDecimal.valueOf(double) 内部先转成字符串,再构造,适合从 double 来源安全转换。金额存储最好从源头就用 DECIMAL,不要让它先经过 double。

equals 和 compareTo 不是一回事

现象

用 BigDecimal 作为 Map 的 key,1.0 和 1.00 有时取不到同一个值;但业务上明明都是 1 元。

根因

BigDecimal.equals 既比较数值,也比较 scale。1.0 和 1.00 数值相等,但 scale 不同,所以 equals 返回 false。compareTo 则只比较数值,返回 0。

1
2
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0

正确写法

金额比较用 compareTo:

1
2
3
if (a.compareTo(b) == 0) {
// 金额相同
}

如果要把金额放进 HashMap,先统一 scale,例如都用 setScale(2, RoundingMode.HALF_UP),避免同一个金额有多个 key。

除法必须指定舍入规则

现象

两个 BigDecimal 相除,突然抛 ArithmeticException: Non-terminating decimal expansion。

1
BigDecimal result = new BigDecimal("10").divide(new BigDecimal("3"));

根因

BigDecimal 除法如果结果是无限小数,不指定精度和舍入模式时,无法表示结果,于是抛异常。

正确写法

除法时显式指定 scale 和 RoundingMode:

1
2
BigDecimal result = new BigDecimal("10")
.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);

金额舍入规则要在业务里统一,不能有的地方向上取整,有的地方向下取整。

String 的细节

split 用的是正则,不是普通分隔符

现象

想按点号拆分文件名,结果得到一个空数组:

1
2
String[] parts = "a.b.txt".split(".");
System.out.println(parts.length); // 0

根因

String.split 的参数是正则表达式,. 在正则里表示任意字符,所以 "a.b.txt" 被拆成了空数组。类似 |、+、*、? 这些字符也需要转义。

正确写法

简单分隔符可以转义,或者用 Pattern.quote:

1
2
3
4
String[] parts = "a.b.txt".split("\\.");

String delimiter = ".";
String[] parts2 = "a.b.txt".split(Pattern.quote(delimiter));

如果只是按单个固定字符拆分,Splitter 等工具类通常比手写正则更不容易错。

末尾空串默认会被丢掉

现象

按逗号拆分 "a,b,",期望得到三个元素,结果只有两个:

1
2
String[] parts = "a,b,".split(",");
System.out.println(parts.length); // 2

根因

split 默认会移除结果末尾的空字符串。

正确写法

需要保留末尾空串时,传入负数 limit:

1
2
String[] parts = "a,b,".split(",", -1);
System.out.println(parts.length); // 3

这个坑在解析 CSV、配置文件、消息字段时很常见,因为空字段往往有业务含义。

substring 的下标语义和 SQL 不同

现象

从 Java 里截取字符串时越界,或者截出来的长度和 SQL SUBSTRING 对不上。

根因

Java 的 substring(beginIndex, endIndex) 是左闭右开,endIndex 不包含在内,且索引从 0 开始。很多数据库的 SUBSTRING(str, start, length) 第三个参数是长度,语义完全不同。

1
2
String s = "abcdef";
s.substring(1, 3); // "bc"

正确写法

跨语言处理字符串时,统一在边界做转换,并给 endIndex 加显式校验。需要按长度截取时:

1
2
3
int begin = 1;
int length = 2;
String result = s.substring(begin, Math.min(begin + length, s.length()));

不要凭记忆在两个系统之间复用下标。

日期、时间与时区

LocalDateTime 不代表一个绝对时间点

现象

服务跨时区部署后,用户看到的创建时间和数据库里的时间差了几个小时。

根因

LocalDateTime 只包含年月日时分秒,没有时区信息。同一串 2026-09-07 10:00:00 在 UTC 和上海时区代表不同的时刻。拿它跨系统传输时,接收方不知道它到底是哪个时区。

正确写法

需要表示绝对时间点时使用 Instant 或带时区的 ZonedDateTime:

1
2
Instant now = Instant.now();
ZonedDateTime shanghaiTime = now.atZone(ZoneId.of("Asia/Shanghai"));

LocalDateTime 适合表达“本地钟表时间”,例如“每天上午 9 点提醒”,而不是用来记录跨系统事件时间。接口返回时间最好同时带时区,或者约定统一使用 UTC。

Jackson 不配置无法处理 LocalDateTime

现象

升级到 Java 8 时间类型后,接口返回 500,日志报 InvalidDefinitionException 或日期被序列化成奇怪格式。

根因

Jackson 默认不识别 LocalDateTime 等 java.time 类型,需要注册 JavaTimeModule。如果只加了模块,还可能出现时间戳数组或时区偏移问题。

正确写法

配置 ObjectMapper:

1
2
3
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

对字段单独指定格式:

1
2
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createdAt;

接口时间格式和时区要全局统一,避免不同模块返回不同格式。数据库字段最好也明确使用 DATETIME 或 TIMESTAMP 的语义,并约定是否存储 UTC。

排查清单

这类问题先看四个点:

  1. 金额是否从 double 进入 BigDecimal,除法是否指定了 scale 和舍入模式。
  2. split 参数是否被当成了正则,是否在意末尾空串。
  3. 字符串索引是否混用了“长度”和“结束下标”。
  4. 时间是否带时区,LocalDateTime 和 Instant 是否用在了正确场景。