Java浮点数Infinity排查与防御:从Double.isInfinite到JSON序列化兜底 上周三下午某项目组的一位同学在群里甩了张截图接口返回的 JSON 里有个字段长这样avgLatency: Infinity。前端直接白屏控制台一行Unexpected token I in JSON at position ...。后端日志里只留下一句isInfinitetrue没堆栈、没异常日志级别 INFO看起来一切“正常”。这个场景我太熟了——浮点运算不会因为算出个天文数字就抛异常它只会安静地把结果变成正无穷然后沿着调用链一路往下传直到某个环节受不了为止。先把标题里的字纠正一下Doube 是手滑正确拼写是 Double。这篇东西不讲语法入门讲的是当一个 double或者 Double、float、Number 类型的参数被判定为无穷大的时候这个值到底怎么来的、为什么没人报错、以及怎么在最短时间内把它摁住。写业务代码一两年的同学可以直接抄走排查清单和防御写法写了几年的人也不妨对照看看项目里那几个除法是不是还裸着。下面所有代码示例都跑了实测结论以 JDK 8 和 JDK 17 两套环境为准行为一致。1. isInfinite 判断的根本不是“数值大小”而是浮点数的特殊状态1.1 Infinity 是一个编码状态不是一个“大到没边的数”很多人第一次看到Double.isInfinite下意识理解成“这个数字是不是特别大”。这个理解偏差是后面所有误判的源头。按 IEEE 754 双精度标准一个 double 占 64 位1 位符号位、11 位指数位、52 位尾数位。指数位全部为 1 的时候这个编码就进入了特殊区间——尾数全 0 表示正无穷或负无穷尾数非 0 表示 NaNNot a Number。正无穷的具体位模式是0x7FF0000000000000负无穷是0xFFF0000000000000它们是确定的、可复现的编码不是“算着算着跑飞了”。换个生活化的说法它类似表格软件里的#DIV/0!。你看到的不是一个大数字而是一个单元格状态标记意思是“这一步运算没有合法的数值结果”。区别在于表格软件会把这个状态显示出来提醒你而浮点数会把它当成一个合法值继续参与后续运算还能往下传、能写进对象、能序列化全程不吭声。这也解释了为什么Double.POSITIVE_INFINITY Double.MAX_VALUE返回 true但Double.POSITIVE_INFINITY - Double.MAX_VALUE还是Infinity——无穷大参与加减乘除之后除了少数几种情况比如Infinity - Infinity得 NaN、Infinity / Infinity得 NaN结果仍然是无穷。它会自我复制。1.2 isInfinite、isNaN、isFinite 三个方法的分工这三个方法经常被混用实际职责完全不同列个表对照一下更清楚方法返回 true 的条件典型误用Double.isInfinite(v)v 是正无穷或负无穷以为它能判断“数值超出业务范围”Double.isNaN(v)v 是 NaN用v Double.NaN比较永远 falseDouble.isFinite(v)v 既不是无穷也不是 NaN不知道这是 JDK 8 才有的方法老项目里还在手写双重判断isFinite是最省事的一个一行顶两行。老项目如果还卡在 JDK 7只能写成!Double.isInfinite(v) !Double.isNaN(v)顺序无所谓但两个都不能少——这是新手最容易漏的地方只判了 infinite 忘了 NaN。还有一点值得说这三个都是Double类的静态方法参数是基本类型double所以对基本类型也能直接调不需要先装箱。反过来如果你手上的变量是Double包装类型且可能为 null直接传进去会触发自动拆箱抛 NPE。这个坑在第 5 节还会展开。1.3 从“出现了 Infinity”能反推出什么这条经验是我踩过几次之后总结的只要链路上出现了 Infinity那就一定存在一次浮点运算、一次浮点字符串解析或者一次向 double 的溢出转换。整型和 BigDecimal 都走不到这个状态。int和long溢出是回绕Integer.MAX_VALUE 1直接变成最小值静默但不会变无穷。BigDecimal做除法除不尽或者除零会抛ArithmeticException这是显式失败。BigDecimal.doubleValue()超出 double 表示范围时返回的是 Infinity不抛异常——这是最隐蔽的一条后面单独讲。这个反向推导的价值在于缩小排查范围。看到isInfinitetrue你不需要满世界找 bug只需要问自己一句这条数据从入库到返回中间哪几个地方碰过 double 或 float答案通常就那么两三处。2. 参数值变成 Infinity 的五条来路2.1 除以 0.0分母是“零值”不等于分母是整数 0这是出现频率最高的一条也是误解最多的一条。Java 里1 / 0会抛ArithmeticException: / by zero但1.0 / 0.0返回Infinity-1.0 / 0.0返回-Infinity0.0 / 0.0返回NaN。三种结果没有一种报错。真正阴险的地方在于自动类型提升。看这段代码int a 100; int b 0; double result a / b; // 抛 ArithmeticException因为先做整数除法 double result2 (double) a / b; // Infinity因为 a 先被提升成 double double result3 a / (double) b; // Infinitya / b在赋值给 double 之前就已经按 int 算完了所以抛异常。但只要其中任何一侧是 double 或 float整个表达式就按浮点算结果就是无穷。业务代码里最常见的写法是total / count两个都是 int 的时候反而安全抛异常至少能被发现一旦某个字段被定义成Double或者方法签名返回 double除零就变成了静默的 Infinity。我的做法很简单所有除法在写之前先问一句“分母有没有可能是 0”可能的话就在前面加一个判断返回业务上合理的默认值通常是 0 或者 null而不是让 Infinity 流出去。这不是防御性编程洁癖是省自己后面的排查时间。public static double safeDivide(double numerator, double denominator, double fallback) { if (denominator 0.0) { return fallback; } double result numerator / denominator; return Double.isFinite(result) ? result : fallback; }注意最后那行isFinite判断不能省。分母不为 0 也可能出问题比如两个都是极大值相乘导致的溢出虽然除法场景下少见但把这个兜底加上几乎零成本。2.2 溢出越过 1.7976931348623157E308 那条线Double.MAX_VALUE的值是1.7976931348623157E308大约 1.8 乘以 10 的 308 次方。任何超过它的运算结果都会直接变成 Infinity同样不抛异常。这条来路在业务系统里比想象中常见尤其是金融、统计、指标聚合这几类场景。几个我实际见过触发溢出的写法// 连续乘法复利、概率连乘、权重累乘 double rate 1.05; double total 1.0; for (int i 0; i 20000; i) { total * rate; // 大约 1000 多次之后就 Infinity 了 } // Math.pow 的参数没做边界检查 double big Math.pow(10, 400); // Infinity // 指数函数在异常输入下的表现 double exp Math.exp(1000); // Infinity复利那个例子值得单独说。1.05的 20000 次方听着夸张但代码里没人会写 20000 这个常量通常是循环次数来自于数据条数。某次数据量从几百涨到几万循环还在结果就从有限值变成了 Infinity。这种问题的特点是代码没改过数据量涨了就出问题所以排查的时候容易怀疑到别的地方去。概率连乘更隐蔽。一堆小于 1 的数连乘结果会无限趋近于 0最后变成0.0下溢这个不产生 Infinity 但会产生精度丢失属于另一类问题。而权重连乘、贝叶斯公式里的似然计算往往是大数相乘往上溢出的概率更高。实际处理办法不是加 try-catchcatch 不到而是在循环里定期检查for (int i 0; i n; i) { total * factor; if (!Double.isFinite(total)) { // 记录是第几次迭代炸的输入数据是什么 log.warn(累乘在第 {} 次迭代溢出当前因子 {}, i, factor); break; } }这个日志非常关键。它把“一个莫名其妙的 Infinity”变成了“第 N 次迭代开始出问题”排查时间能省掉一大半。2.3 字符串解析与 JSON 反序列化Double.parseDouble(Infinity)是合法的返回正无穷不抛异常。Double.parseDouble(-Infinity)和Double.parseDouble(NaN)同样合法。这意味着如果一个 double 值来自外部输入表单、配置文件、消息队列、第三方接口攻击者或者上游系统只要传个Infinity进来你的对象里就会安安静静地躺着一个无穷大。Double.valueOf(Infinity)行为一致。真正需要注意的是Integer.parseInt不会接受这种字符串会抛NumberFormatException所以问题只出在浮点解析上。序列化方向的坑更实际。用 Jackson 把一个含无穷大的对象转成 JSON默认行为是输出字符串Infinity带引号因为标准 JSON 不支持无穷大这种字面量。前端拿到之后JSON.parse直接报语法错误整个响应体解析失败表现出来就是白屏或者“接口返回格式异常”跟真正的错误原因隔了十万八千里。反序列化方向Jackson 默认是拒绝Infinity这种非标准数字的会抛InvalidFormatException。但很多项目为了兼容性开了ALLOW_NON_NUMERIC_NUMBERS这个开关在JsonReadFeature里叫ALLOW_NON_NUMERIC_NUMBERS开了之后就放行了无穷大顺利进入内存。这个开关在解析第三方数据时确实有用但代价是失去了这道天然屏障。我的建议是出口侧一定要配置成遇到非有限值就报错或者转成 null而不是输出Infinity入口侧除非有明确理由不要开那个放行开关。下面第 4 节会给出具体配置。2.4 BigDecimal 转 double 这类“静默转换”这条是我认为最该被写进团队规范的一条因为几乎没人会主动想到。BigDecimal.doubleValue()在数值超出 double 表示范围时返回Double.POSITIVE_INFINITY或Double.NEGATIVE_INFINITY不抛异常不报警。而new BigDecimal(Double.POSITIVE_INFINITY)反过来会抛NumberFormatException。一个是静默一个是显式很容易让人以为双向都安全。BigDecimal huge new BigDecimal(1E400); double d huge.doubleValue(); // Infinity静默 BigDecimal back new BigDecimal(d); // NumberFormatException显式金融系统里 BigDecimal 用得极多通常用于金额计算然后为了绘图、为了调外部接口、为了塞进某个只接受 double 的 SDK会做一次doubleValue()。大部分金额不会溢出所以这段代码跑几年都没事直到某天有人传了一个异常大的值进来。这类问题在灰度环境几乎不可能复现因为测试数据都是正常量级。同类还有Number.doubleValue()、Long到double的隐式转换long 最大值约 9.22e18远没到 double 上限安全、Float到Double的转换float 溢出成 Infinity 之后转 double 还是 Infinity。后一条要注意float 的表示上限只有约 3.4e38比 double 小得多一段用 float 做的中间计算变成 Infinity 之后转成 double 依然是 Infinity而且你从 double 的角度看不出到底是哪一步出的问题。2.5 数学函数、统计计算与表达式引擎Math包里一批函数在输入异常时返回无穷Math.pow、Math.exp、Math.log(0.0)返回-Infinity这个特别容易忘log 的 0 点是负无穷、Math.tan在某些角度附近会产生极大值。统计类计算里方差公式如果用朴素写法中间项可能出现极大值和极小值相减导致数值不稳定。还有一类来源是表达式引擎。规则引擎、报表工具、有些低代码平台内部都有自己的一套表达式求值器用户配一个公式a / b运行的时候 b 恰好是 0.0结果就是 Infinity然后这个值被塞进结果集里返回。这类问题的排查难点在于代码不在你手里你只能从输出反推。所以在把外部配置的表达式接入系统时一定要在结果出口做一次有限性校验别指望表达式引擎帮你把关。3. 一次图表 Y 轴消失的完整排查链路光讲原理不够我把去年碰到的一次完整过程拆开写重点在排查顺序和每一步的验证方式因为大部分人卡住不是因为不懂原理而是不知道先看哪儿。3.1 现象接口没报错前端白屏图表 Y 轴刻度全空某监控面板突然出问题。现象有三个图表能渲染出外框但 Y 轴刻度数字全部消失浏览器控制台报 JSON 解析错误后端接口日志显示 HTTP 200响应时间正常没有任何异常记录。第一反应是前端版本更新导致的渲染问题。回滚了前端版本问题还在。这时候才去看接口原始响应发现里面有个字段是errorRate: Infinity没带引号——这说明不是 Jackson 序列化的Jackson 会输出带引号的形式而是某个环节手写字符串拼接或者用了别的序列化方式。这个细节很关键它直接排除了“Jackson 配置问题”这条线。3.2 第一轮怀疑除零加日志确认分母确实是 0errorRate的算法很直白errorCount / totalCount。第一反应就是除零。代码里errorCount和totalCount都是long按理说除零应该抛ArithmeticException但代码写成了(double) errorCount / totalCount——那个强制转换把整个表达式拉进了浮点域。加了一行日志把两个值打出来errorCount0, totalCount0。确认了。但这只是表象还得回答一个问题为什么总数会是 0这个面板的数据来自一张按小时聚合的统计表按理说每小时都应该有数据。3.3 第二轮回溯数据源发现是“无数据”被当成了“数据为 0”继续往上游查。统计任务的逻辑是从原始日志表按小时分组统计写入聚合表。写聚合表之前会先查一次某个配置表拿采样率然后做除法。问题就在这里。某天配置表发生了一次数据迁移迁移后有个字段从“有值”变成了空字符串。代码里用Optional.ofNullable(...).orElse(0)兜底空字符串在后续Double.parseDouble的时候变成 0.0采样率成了 0。采样率是分母除零得到 Infinity。Infinity 被写进了聚合表的 DOUBLE 字段JDBC 驱动对 Infinity 的处理是写进去不同数据库行为不一样MySQL 存进去读出来还是 Infinity然后一路传到接口。这里有个很典型的思维误区空值和 0 是两种不同的语义用 0 兜底看起来安全实际上把“我不知道”变成了“我知道是零”。如果兜底成 null 或者直接让任务失败问题当天就会以任务异常的形式暴露而不是变成图表上的一个空白刻度。3.4 第三轮真正的修复点不在计算那一行定位到根因之后很多人会直接在除法那行加个判断然后收工。我觉得这不够因为同类问题还会再来。当时的处理是三处一起改位置改法目的配置读取空字符串不再兜底成 0改为抛配置异常让错误在源头暴露计算处分母为 0 时返回 null不返回 0 也不返回 Infinity区分“无数据”和“值为 0”序列化出口遇到非有限值转 null并打 WARN 日志最后一道兜底保证响应体永远合法另外补了一条监控每小时跑一次扫描查聚合表里有没有非有限的数值不同数据库判断方式不一样可以在应用层写个小任务遍历最近的数据。这条监控保留到现在后来真的又抓到过一次同类问题来自另一个没用同一个工具类的模块。3.5 验证方式怎么确认真的修好了验证不能只靠“刷新页面看图表正常了”。当时做了三步验证第一步手工构造测试数据让采样率为 0跑一遍任务确认任务失败并且错误日志里能看出是哪个配置项的问题而不是一个孤零零的 Infinity。第二步构造一个分母为 0 的查询请求直接调接口确认返回的是errorRate: null而不是Infinity前端拿到 null 之后显示为--图表正常。第三步把序列化出口的兜底逻辑单独写单测构造一个含Double.POSITIVE_INFINITY、Double.NEGATIVE_INFINITY、Double.NaN的对象验证三种情况都输出 null 且都打了日志。这三步做完才算收工。尤其是第三步后面别的模块复用了这个序列化器等于一次性把坑填了一片。4. 把 Infinity 拦在哪一层最省事4.1 计算入口把分母检查做成工具方法别靠自觉靠每个人写代码时想起来判断是靠不住的。团队里十几个人每个人的边界意识不一样。可靠的做法是提供一个工具类把常用的除法、百分比、增长率计算全部收口代码评审的时候看到直接写的除法就要求改。public final class DoubleGuards { private DoubleGuards() {} /** 安全除法分母为 0 或结果非有限时返回 fallback */ public static Double divide(Double a, Double b, Double fallback) { if (a null || b null || b 0.0d) { return fallback; } double r a / b; return Double.isFinite(r) ? r : fallback; } /** 计算过程中出现非有限值立刻抛异常用于必须精确的场合 */ public static double requireFinite(double v, String scene) { if (!Double.isFinite(v)) { throw new IllegalStateException(非有限数值出现于: scene , value v); } return v; } }这里有个取舍要说清楚divide返回 fallback 是“宽容模式”适合指标、展示类数据requireFinite抛异常是“严格模式”适合金额、结算这类不能含糊的场景。同一个系统里两种模式可以并存关键是要在代码里明确表达意图而不是含糊地返回一个 Infinity 让下游猜。4.2 序列化出口让响应体永不出现 Infinity出口这一层是性价比最高的。不管上游有多少条路径能产生无穷大只要出口统一拦一次前端就不会挂。用 Jackson 的话可以不写自定义序列化器通过模块注册一个全局的序列化处理Configuration public class JacksonFiniteConfig { Bean public Jackson2ObjectMapperBuilderCustomizer finiteCustomizer() { return builder - { SimpleModule module new SimpleModule(); module.addSerializer(Double.class, new JsonSerializerDouble() { Override public void serialize(Double value, JsonGenerator gen, SerializerProvider sp) throws IOException { if (value null || !Double.isFinite(value)) { gen.writeNull(); } else { gen.writeNumber(value); } } }); module.addSerializer(Double.TYPE, new JsonSerializerDouble() { Override public void serialize(Double value, JsonGenerator gen, SerializerProvider sp) throws IOException { if (value null || !Double.isFinite(value)) { gen.writeNull(); } else { gen.writeNumber(value); } } }); builder.modules(module); }; } }两个addSerializer都得注册一个针对包装类型Double.class一个针对基本类型Double.TYPE。只注册前者的话DTO 里声明成double的字段不受影响照样输出 Infinity这个坑我踩过排查了半天才发现是注册漏了一个。Float 同理也要单独注册一份因为 Jackson 的序列化器是按类型精确匹配的不会自动往上找。如果不想改全局配置改了影响面大要评估也可以用注解的方式在特定字段上加JsonSerialize(using FiniteDoubleSerializer.class)粒度更细但要每个字段都标容易漏。4.3 存储层别指望数据库帮你把关不同数据库对 Infinity 的态度差别很大。有的驱动在写入时直接报错有的能存进去、读出来还是 Infinity有的会转成 NULL 或者最大值。这意味着同一个问题在不同环境表现完全不同测试环境看着正常生产环境崩掉很容易把人带偏。我的做法是凡是可能产生非有限值的字段写入之前先校验一次非有限就写 null同时在应用日志里留一条带业务主键的记录。这样数据库层面永远是干净的排查时不需要考虑数据库的差异。这条规则听起来啰嗦但比事后对着一堆不同表现的数据库调驱动参数要省心得多。4.4 前端一行判断能救一次事故前端这层不是必须的但加上成本极低。JSON.parse 遇到Infinity这种裸字面量会直接抛错整个响应体报废但如果后端输出的是Infinity字符串解析是能过的只是后续计算会出各种诡异结果字符串参与运算、图表库拿到 NaN 之类的。function toFiniteNumber(value, fallback null) { const n typeof value number ? value : Number(value); return Number.isFinite(n) ? n : fallback; }Number.isFinite和全局的isFinite有个关键区别全局isFinite(123)返回 true因为它会先做类型转换Number.isFinite(123)返回 false不做转换。图表数据处理的时候用Number.isFinite更稳妥避免把字符串当成数字放进去。图表库拿到数据数组之后加一句.filter(Number.isFinite)能挡掉绝大部分渲染异常。5. 几个真踩过的细节比较、打印、以及 NaN 那个亲戚5.1 NaN 和 Infinity 是伴生关系只防一个不够出现 Infinity 的地方附近极大概率同时存在 NaN。因为Infinity - Infinity、Infinity / Infinity、0.0 / 0.0都产生 NaN。如果你的防御代码只写了isInfiniteNaN 会从旁边溜过去。而 NaN 比 Infinity 更阴险的一点是它参与任何比较都返回 false包括NaN NaN。所以用if (value Double.NaN)判断是永远不成立的必须用Double.isNaN。还有一个更隐蔽的Double.NaN 0是 falseDouble.NaN 0也是 falseDouble.NaN 0还是 false。如果你写的是if (value threshold) { ... } else { 正常处理 }NaN 会掉进 else 分支被当成正常值处理。这种代码看一百遍也看不出问题。5.2 equals、 和 compare 对特殊值的态度不一样这三个的差异在特殊值上体现得特别明显比较方式POSITIVE_INFINITY与自身NaN与自身-0.0与0.0基本类型truefalsetrueDouble.equalstruetruefalseDouble.compare00返回 -1Double.equals认为两个 NaN 相等因为它比较的是位模式而认为不等。Double.equals认为-0.0和0.0不相等位模式不同而认为相等。Double.compare把-0.0排在0.0前面但对 NaN 的处理是把它当成最大的值。这些差异在业务代码里通常碰不到但如果你在写去重逻辑、写缓存 key、写集合的contains判断就可能踩上。曾经有个功能是用SetDouble做数据去重结果-0.0和0.0被当成两个不同的值数据库里出现了两条看起来一样的记录。原因就是HashSet用的是equals加hashCode不是。5.3 包装类型拆箱null 和 Infinity 是两种不同的崩Double类型的参数传进Double.isInfinite(x)的时候如果 x 是 null会自动拆箱成基本类型抛NullPointerException。这个和 Infinity 完全是两码事但日志里经常混在一起看容易误判。Double value null; Double.isInfinite(value); // NullPointerException写判断的时候先判 null 再判有限性顺序不能反。另外Double和double混用的方法签名要注意参数声明成double的方法传 null 进去在调用处就崩了声明成Double的方法内部做运算时才崩。崩溃位置不一样排查路径也不一样看堆栈的时候要留意。5.4 日志里打印出来的样子和 JSON 里的样子不一样String.valueOf(Double.POSITIVE_INFINITY)是InfinityDouble.toString也是Infinity。但 JSON 里可能是带引号的Infinity也可能是裸的Infinity取决于序列化实现也可能是null如果你做了兜底。这三者在日志里的样子完全不同排查的时候容易对不上。我现在的习惯是日志里打印 double 类型的关键值一律用String.format(%.6f, value)或者显式加上isFinite的标记例如rateInfinity(finitefalse)。这样一眼就能区分是“value 本身是无穷大”还是“value 是字符串 Infinity”还是“value 是 null 被格式化成了 Infinity”。多打几个字符省下来的是半小时的怀疑人生。5.5 顺带说个常见误解Double.MIN_VALUE 不是最小负数Double.MIN_VALUE的值是4.9E-324这是最小的正数不是最小的负数。最小负数要靠-Double.MAX_VALUE。这个误解导致很多人写的范围校验逻辑实际是错的比如if (v Double.MIN_VALUE)本意是判断“太小了”实际上负数全部满足这个条件。配合上前面说的 Infinity一段校验代码可能同时放过无穷大和放过负数看起来还挺严谨。正确的范围校验应该是显式的上下界加上有限性判断public static boolean inRange(double v, double min, double max) { return Double.isFinite(v) v min v max; }写完这个工具方法之后原来散落各处的if (v 0 v 100)全都替换掉了。改动不大但把“NaN 掉进 else”和“Infinity 通过上界判断”这两类问题一次性堵住了。现在但凡写除法我都会先在脑子里过一遍分母能不能是 0但凡从BigDecimal转double都会顺手加一个isFinite检查。这两个动作花不了几秒钟但去年到今年项目里再没出现过因为无穷大导致的白屏。真正值得记住的可能就一句浮点数出错不会喊疼它只会安静地把错误值传下去等到有人发现的时候已经离现场很远了。