Java高频隐蔽Bug:包装类型比较、集合删除与金额精度
上周半夜在一个技术社区的问答区闲逛,刷到一条提问:标题只有一行“Java 求看下”,正文贴了二十来行代码和半屏红色的堆栈,没有 JDK 版本,没有输入数据,也没有说明“我期望什么、实际发生了什么”。底下三条回复,一条让他贴完整代码,一条让他自己 debug,还有一条直接贴了段疑似能跑的改法。这类帖子每天都在出现,但真正有意思的地方在于:它们背后指向的那几个技术点,翻来覆去就是那么几个——包装类型的==、集合遍历时删元素、finally里的return、金额计算的精度。这几个点有个共同特征:写出来的代码看上去完全合理,编译器不拦,静态检查不报,甚至跑一百次有九十次是对的,剩下十次在线上某个特定数据下才炸。
这篇就把这类“看着对、实则坑”的 Java 问题拆开讲。适合已经能写业务代码、但还没有系统整理过这些边界情况的朋友,也适合那些经常在社区里帮人看代码、想把“这次为什么错”讲清楚的人。我会先把拆题的方法摆出来,再逐个把高频坑的根因、正确写法、排查手段讲透,中间穿插我自己踩过的和帮别人定位过的经验。代码都能直接拷到本地跑,建议边看边动手验证,这类知识光看记不住。
1. 只贴半屏报错栈的提问,暴露的是拆题方法的问题
先把话说直白一点:社区里绝大多数“求看下”的 Java 提问,问题不在代码本身,而在提问者还没完成“把问题缩小到一个可验证单元”这一步。他手里有的是一个功能、一段业务逻辑、一个跑不通的流程,而回答者需要的是最小复现。这两者之间的落差,就是所有来回拉扯的来源。我在问答区见过最典型的一种情况:楼主贴了一段三百行的 Service 方法,说“这里有时候会抛空指针”,下面有人问哪一行,他说“日志里没打行号”。这种提问基本注定沉底。
我自己总结过一套拆题顺序,用在帮别人定位问题时命中率挺高。第一步是把预期和实际写成两句话,比如“我预期这个if判断为 false,实际进去了一百次里有三次为 true”。很多提问者写不出这两句话,因为他对预期本身就是模糊的,而这往往才是 bug 的真正来源。第二步是砍到最小,把业务字段换成1、"a"这种常量,把三层嵌套的调用压成一行,直到代码短到你能一眼扫完。第三步是问“这条路径上,哪个假设最脆弱”,通常就是类型转换、相等判断、集合结构变化、异常处理这几个位置。
我把这几年在社区里反复见到的 Java 高频问题归了下类,大致分布是这样的:
| 问题类别 | 典型表象 | 触发条件 | 定位难度 |
|---|---|---|---|
| 包装类型相等判断 | 判断时灵时不灵,换数据就变 | 数值跨过缓存区间 | 中,需要知道装箱机制 |
| 集合遍历中修改 | 有时抛异常,有时不抛但少数据 | 元素数量、删除位置 | 中高,异常具有欺骗性 |
| 异常处理与返回值 | 方法莫名返回旧值,异常消失 | finally里有return | 高,日志上看不出痕迹 |
| 浮点与金额计算 | 差一分钱、对账不平 | 十进制小数参与运算 | 中,但影响面大 |
| 并发共享状态 | 偶发、无法稳定复现 | 多线程同时读写 | 高,需理解内存模型 |
这张表里,前四类占了我在社区里看到的 Java 逻辑类问题的绝大部分。它们的共同点是:错误不发生在语法层,而发生在语义层的边界上。编译器只保证你写的东西合语法,不保证它符合你的意图;而边界条件恰恰是意图和实现分叉的地方。
还有一个细节值得说。很多人提问时会附上一句“这段代码在我本地是好的”,这句话其实信息量极大——它说明问题依赖于环境或数据,而不依赖代码结构。依赖数据的问题,多半和取值范围、类型、精度有关;依赖环境的问题,多半和版本、并发、时序有关。顺着这个分叉去查,能省掉大量时间。下面几个案例,基本都是从这个分叉口切进去的。
2. 包装类型的==为什么时灵时不灵
2.1 一个“有时进有时不进”的判断
这是社区里出现频率最高的一类问题,代码通常长这样:
Integer a = 100; Integer b = 100; System.out.println(a == b); // true Integer c = 1000; Integer d = 1000; System.out.println(c == d); // false提问者往往已经发现了规律:“小于某个数就是 true,大了就是 false”,但说不清为什么。有人会猜“是不是和内存有关”,方向对了,但没落到具体机制上。这里真正发生的事是:Integer a = 100这种写法叫自动装箱,编译器会把它翻译成Integer.valueOf(100)。而Integer.valueOf里有一段缓存逻辑:
public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }IntegerCache.low固定是 -128,high默认是 127(可以通过启动参数调整上界,但不建议依赖)。落在这个区间内的值,返回的是缓存数组里的同一个对象;超出区间,每次new一个新对象。==比较的是引用,所以前者相等、后者不等。Long、Short、Byte、Character都有类似的缓存,Double和Float没有。
2.2 缓存设计背后的取舍
为什么只缓存 -128 到 127?这不是随手定的。这个区间的依据是 JLS 里对常量池的要求(-128~127是必须支持的),同时它覆盖了绝大多数计数、状态码、小范围枚举值的场景。缓存的对象驻留在堆里长期存活,缓存范围越大,常驻内存越多;而超过这个范围的数值,通常是业务数据,重复率低,缓存收益不明显。所以这是一个“用极小的内存换掉大量小整数对象创建”的工程折中。
理解了这一层,就能反推出一堆相关问题。比如Integer和int比较时会自动拆箱,变成基本类型比较,所以1000 == integerValue是 true,跟缓存无关。而Integer和Long用equals比较,永远返回 false,因为Integer.equals里第一句就是instanceof Integer,类型不对直接返回 false。这一类“跨类型 equals 恒为 false”的问题在社区里也极其常见,尤其是从 Map 里取值、参数从上游传过来类型不明的时候。
2.3 拆箱带来的空指针
比相等判断更隐蔽的是自动拆箱引发的空指针。看这段:
Map<String, Integer> map = new HashMap<>(); Integer v = map.get("notExist"); // null int result = v; // 这里抛 NPEmap.get返回 null 是完全合法的,赋值给int时编译器插入了v.intValue(),于是空指针。报错行号指向赋值那一行,看起来莫名其妙——因为那一行既没有方法调用也没有解引用。三元表达式里的坑更隐蔽:
Integer x = null; boolean flag = true; int y = flag ? x : 0; // 抛 NPE原因是三元表达式两个分支一个是Integer一个是int,会发生二元数值提升,Integer被拆箱成int,null 就炸了。这种代码 review 时极难发现,我见过一次线上事故就是这行引起的,排查了两个小时才定位到。
2.4 我自己的编码约定
踩过几次之后,我给自己定了几条硬规矩,多年下来基本没再出过这类问题。第一,凡是包装类型比较,一律用Objects.equals(a, b)或先判空再equals,不给自己留“这里肯定是小数字”的侥幸。第二,方法返回值优先用基本类型,只有在需要表达“可能没有值”时才用包装类型,而且调用方拿到后第一件事是判空。第三,集合里的包装类型取出来,不要直接参与算术运算,先转成局部变量并判空。第四,在 IDE 里把“包装类型用 == 比较”开成警告级别,配合静态检查工具,能挡掉大部分低级失误。
有个容易被忽略的细节:
Integer的缓存只影响==,不影响equals,也不影响hashCode。所以用Integer做 Map 的 key 是完全安全的,100和new Integer(100)作为 key 是同一个键。别被==的表现误导,以为它们在集合里也不相等。
3. 循环里删元素,最危险的不是抛异常
3.1 for-each 的语法糖拆开是什么
有一段代码我几乎每个月都能在问答区见到:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (String s : list) { if ("b".equals(s)) { list.remove(s); } }跑起来抛ConcurrentModificationException。很多人第一反应是“我明明没有并发啊,哪来的并发修改”,这个异常的命名确实容易误导,它跟多线程没关系,是结构性修改的检测机制。for-each走的是Iterator,编译器展开后大致是:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) list.remove(s); }ArrayList.Itr内部维护了一个expectedModCount,在创建迭代器时等于列表的modCount。每次调用next()会先检查两者是否一致,不一致就抛异常。list.remove会让modCount++,下一次next()立刻发现对不上。注意关键词是“下一次”——如果删的是最后一个元素,next()不再被调用,异常就不会抛。
3.2 不抛异常才是真的可怕
这就是这类问题最阴的地方。改成用下标遍历:
for (int i = 0; i < list.size(); i++) { if (list.get(i).startsWith("a")) { list.remove(i); } }这段代码不会抛任何异常,但它会跳过元素。删掉下标 0 之后,原来下标 1 的元素挪到了 0,而循环变量i已经加到 1,于是这个元素被跳过了。如果列表里有两个相邻的待删元素,只会删掉第一个。这种 bug 不会报错,只会在某个数据组合下少处理一条记录,等到对账时才发现,排查成本极高。我在实际项目里遇到过一次批量状态更新的漏处理,根因就是这个,日志上看一切正常,就是有几条记录状态没变。
正确的写法有几种,按场景选:
// 方案一:显式使用迭代器,这是最通用的 Iterator<String> it = list.iterator(); while (it.hasNext()) { if (it.next().startsWith("a")) { it.remove(); // 迭代器自己的 remove,会同步 expectedModCount } } // 方案二:Java 8+ 的 removeIf,语义最清晰 list.removeIf(s -> s.startsWith("a")); // 方案三:倒序遍历,只适用于按下标删除 for (int i = list.size() - 1; i >= 0; i--) { if (list.get(i).startsWith("a")) { list.remove(i); } }方案一的关键在于必须用it.remove()而不是list.remove(),前者会同步更新expectedModCount,后者不会。方案三之所以安全,是因为倒序删除时被删元素后面的下标都更小,已经遍历过的部分不受影响。这三种方式我会优先选removeIf,可读性最好,内部实现也做了优化。
3.3Arrays.asList和subList的两个连带坑
顺着这个问题往下,还有两个非常高频的连带坑。第一个是Arrays.asList返回的是固定长度的列表,add和remove会抛UnsupportedOperationException:
List<String> list = Arrays.asList("a", "b", "c"); list.remove("a"); // UnsupportedOperationException它返回的是Arrays内部的一个私有类,底层直接引用原数组,没有扩容能力,所以不支持结构性修改。想改就包一层new ArrayList<>(Arrays.asList(...))。顺带一提,用List.of(...)创建的不可变列表也是同样的表现,而且连set都不允许。
第二个是subList返回的是视图而不是副本。对原列表做结构性修改,视图会失效并抛ConcurrentModificationException;反过来通过视图修改,会影响原列表。我见过有人用subList做分页处理后直接返回给调用方,调用方又往原列表里加数据,结果在序列化时炸掉。稳妥的做法是分页之后立刻new ArrayList<>(subList)拷一份出来。
3.4 排查这类问题的顺序
如果线上真出现了“少处理了几条”的现象,我的排查顺序是这样的:先确认数据结构是不是ArrayList或LinkedList,再看遍历方式是不是按下标且正向、循环体内有没有修改操作。这两条同时满足,基本就锁定嫌疑了。然后去查那条漏掉的数据在集合里的位置,如果是紧跟着另一条被删元素,基本可以确认。这个判断链条很短,但前提是你得先想到这一层,否则会一直在业务逻辑里绕圈。
4.finally里的return把异常吃掉了
4.1 一段看不出问题的代码
这个问题在问答区出现的频率不如前两个,但一旦出现,定位难度直接拉满。典型代码:
public int parse(String s) { try { return Integer.parseInt(s); } finally { return -1; } }无论传什么,永远返回 -1。更麻烦的是如果parseInt抛了NumberFormatException,这个异常会彻底消失,调用方看到的是正常的 -1,没有任何异常信号。我在社区里见过一个更隐蔽的版本:方法里try块中调用了一个远程接口,finally里写了段“兜底返回默认值”的逻辑,结果接口超时的异常被静默吞掉,上游一直以为业务正常,直到数据不一致才暴露。
4.2 执行顺序和“吞异常”的原理
这里要分清两个层面。第一层是执行顺序:try块里执行return时,会先把返回值算出来暂存,然后执行finally,最后才真正返回。如果finally里也有return,它会覆盖暂存的那个值。第二层是异常:try块抛出异常后,finally照常执行;如果finally里有return,那么异常就被丢弃了,方法正常返回。编译器不会拦这个写法,因为语法上完全合法。
我自己验证这个行为时习惯看字节码,javap -c出来能清楚看到finally的代码块被复制到了正常路径和异常路径上,异常路径里的athrow被finally里的ireturn截断。不过日常排查不用这么麻烦,记住一句话就够:finally里除了关闭资源,什么都别写。
4.3 资源关闭的正确姿势
那“关闭资源”这件事本身也有讲究。老写法是finally里close(),但close()自己也可能抛异常,同样会掩盖主异常:
InputStream in = null; try { in = open(); // 业务逻辑 } finally { if (in != null) in.close(); // 这里抛异常,主异常就没了 }从 JDK 7 开始有try-with-resources,它会自动处理这个层级关系:
try (InputStream in = open()) { // 业务逻辑 } catch (IOException e) { // 如果 close 也抛了异常,会被挂到 e.getSuppressed() 上 for (Throwable t : e.getSuppressed()) { log.warn("关闭资源时出错", t); } }s getSuppressed()这个方法知道的人不多,但很好用。主异常保持抛出状态,关闭阶段的异常作为“抑制异常”附加在后面,既不干扰主流程,也不丢信息。我在自己的项目里会把所有资源类操作统一改成try-with-resources,顺手在 catch 里打一遍抑制异常,日志里能完整还原当时的现场。
4.4 检查一下你的代码里有没有这种写法
有一个很实用的自查方法:全局搜finally,逐个看里面有没有return、break、continue或者可能抛异常且没有 try 包裹的调用。我在接手一个历史项目时做过这个扫描,一共搜出十一处finally,其中三处有return,两处直接close没有捕获。那三处里有一处直接导致了接口返回值的错误,业务方反馈“退款状态一直是未处理”,查了两天才定位到。
记住一个判断标准:
finally块的职责应该只有“清理”,一旦它开始“决定返回值”或者“改变流程走向”,就说明设计跑偏了。这种代码往往能通过所有测试,因为测试用例很少覆盖异常路径。
5. 金额差一分钱的根源在构造器选错
5.1double存不下 0.1
这一类问题在电商、财务、结算场景里出现得最多。经典的一行:
System.out.println(0.1 + 0.2); // 0.30000000000000004这不是 Java 的 bug,是 IEEE 754 双精度浮点的固有特性。double用 64 位表示一个二进制浮点,64 位里 52 位是尾数,只能精确表示形如“2 的负整数次幂之和”的十进制数。0.1在二进制里是无限循环小数,只能截断存储,误差就产生了。float的尾数只有 23 位,误差更大,0.1 + 0.2出来是0.3但后面还拖着精度损失。
这类误差在单次运算里小到看不见,但在累加、比较、取整时会放大。我在社区里见过最典型的一个案例:一个购物车按商品单价乘数量再求和,用的double,两万条数据累加下来误差到了0.03,前端展示时四舍五入,出现了“总价和明细对不上”的问题。
5.2BigDecimal用错构造器等于白用
很多人的第一个反应是“那就用BigDecimal”,但这里还有一个二次陷阱:
BigDecimal a = new BigDecimal(0.1); System.out.println(a); // 0.1000000000000000055511151231257827...new BigDecimal(double)这个构造器会把double的精确二进制值完整展开,误差原封不动带进来。正确做法是用字符串构造或valueOf:
BigDecimal correct1 = new BigDecimal("0.1"); BigDecimal correct2 = BigDecimal.valueOf(0.1); // 内部走 Double.toStringBigDecimal.valueOf(double)内部实际上调用了Double.toString(d)再转成BigDecimal,所以能得到人类预期的0.1。这两个写法我在项目里统一约定成只用valueOf和字符串构造,代码评审时看到new BigDecimal(0.xxx)直接打回。
5.3divide的精度参数不是可选项
另一个高频报错是ArithmeticException: Non-terminating decimal expansion,通常来自这种写法:
BigDecimal r = a.divide(b); // 除不尽就抛异常1除以3在十进制下是无限循环小数,BigDecimal不知道该保留多少位,只能抛异常。必须显式指定精度和舍入模式:
BigDecimal r = a.divide(b, 2, RoundingMode.HALF_UP);RoundingMode有八种,业务里常用的是HALF_UP(四舍五入)和HALF_EVEN(银行家舍入,也叫四舍六入五成双)。金融场景很多规范要求用HALF_EVEN,因为它在大批量累加时统计偏差更小。这个选型不是随便定的,我遇到过对账差几毛钱的情况,最后查到是一处用了HALF_UP、另一处用了默认行为,两边舍入规则不一致。所以我的建议是在项目里定义一个常量ROUNDING = RoundingMode.HALF_EVEN,全局统一引用。
5.4equals和compareTo的区别
还有一个极其隐蔽的坑:BigDecimal的equals会同时比较数值和scale(小数位数):
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0也就是说,1.0和1.00在HashSet里是两个不同的元素,在HashMap里是两个不同的 key。我见过一个去重逻辑用了HashSet<BigDecimal>,结果重复的金额没被去掉,导致后续计算翻倍。判断数值相等一律用compareTo(...) == 0,需要放进Set或当作Mapkey 时,先统一setScale到业务约定的小数位再放,比如统一保留两位:value.setScale(2, RoundingMode.HALF_EVEN)。
把这几条整理成一张对照表,贴在项目组的规范文档里挺实用的:
| 场景 | 错误写法 | 正确写法 | 后果 |
|---|---|---|---|
| 构造 | new BigDecimal(0.1) | BigDecimal.valueOf(0.1) | 带入二进制误差 |
| 除法 | a.divide(b) | a.divide(b, 2, HALF_EVEN) | 除不尽抛异常 |
| 比较 | a.equals(b) | a.compareTo(b) == 0 | 因 scale 不同判为不等 |
| 累加 | 用double求和 | 全程BigDecimal | 误差随数量放大 |
| 取整后入库 | 不指定 scale | 入库前统一setScale | 展示值与存储值不一致 |
6. 把社区里的提问变成自己的排查清单
6.1 建立一个“看起来对但会翻车”的清单
我这些年最大的收获不是记住了某个 API,而是养成了一个习惯:每次在社区里看到一个自己没立刻想明白的问题,就把它写进一个本地文档,标明触发条件、根因、正确写法。攒到几十条之后,你会发现它们高度集中在几个主题上——类型与相等、集合结构变化、异常与流程控制、数值精度、并发可见性。这五个主题基本能覆盖日常开发中八成的逻辑类 bug。
这个清单的价值在于前置判断。写代码时脑子里有这张表,遇到包装类型比较会本能地找Objects.equals,写循环删除会本能地想到removeIf,写finally会本能地只放关闭资源。这比事后排查省太多时间。我现在的习惯是每完成一个模块,拿清单过一遍,重点看有没有踩中那五类边界。
6.2 用最小复现替代“我本地是好的”
另一个被我反复强调的是写最小复现的能力。前面那几个例子,其实每一个都能砍成十行以内的独立片段。养成这个习惯之后,你会发现很多问题在“砍”的过程中自己就暴露了——因为砍的时候你必须明确每个变量的类型和取值范围,而答案往往就藏在那里。那个Integer三元表达式拆箱的空指针,如果只贴业务代码,别人看一小时也未必能找到;换成三行独立代码,一眼就看出来了。
具体做法是新建一个临时类,把可疑逻辑复制进去,所有外部依赖替换成常量,然后逐个改常量看行为变化。如果问题依赖特定数据,就写一个小循环遍历边界值(-129、-128、127、128、0、null),通常几次就命中。这个方法我在社区里回复别人时用得最多,也是最能帮到人的一种回复方式——直接给出可运行的最小复现,比文字描述有用十倍。
6.3 偶尔看看字节码,比读十篇博客有用
最后分享一个我自己很受用的方法:遇到“为什么实际行为和文档描述不一致”的时候,用javap -c -p 类名看一眼字节码。自动装箱、字符串拼接、finally复制、三元表达式的类型提升,这些机制在字节码层面都是明摆着的。第一次看可能有点懵,看多了会发现编译器做的事其实很简单,关键在于它会插入哪些你没写的指令。
比如Integer a = 100编译出来是invokestatic Integer.valueOf,一眼就能确认装箱发生了;finally的处理会体现为代码块在两个路径上的重复;字符串在循环里用+拼接会看到每次循环里都有StringBuilder的创建和toString。这些东西文档里都写,但只有自己看过一遍,才会真正建立起“我写的代码和最终执行的指令不是一回事”的直觉。
我个人在实际操作中的体会是,Java 这门语言表面上语法友好,自动装箱、自动拆箱、增强 for、try-with-resources都是在替你省事,但每一次“替你省事”都对应着一层你可能不知道的隐式行为。上面这几个案例,本质上都是隐式行为和你的显式意图不一致。把这些隐式行为挨个摸清楚一遍,比记一堆 API 有用得多。