☰
Java空串转数字:NumberFormatException排查与防御
2026/11/3 16:09:09 网站建设 项目流程

凌晨两点,一条告警弹出来,堆栈第一行就是java.lang.NumberFormatException: empty String。很多人的第一反应是去搜这条消息,然后照着改Integer.parseInt,改完发现根本不报这个错——因为Integer.parseInt("")抛出来的消息压根不是这行字。这条 NumberFormatException 的文案,其实是一条“指路牌”,它能直接把排查范围从几十个数字转换入口收窄到寥寥几个。这篇东西就是想把这条异常拆开给你看:它到底由谁抛出、为什么会走到那一步、从请求入口到落库的整条链路上有哪些“变成空串”的姿势,以及我常用的四层防御改造和几套现成的排查手段。不管你是刚接手线上告警的新手,还是已经写了不少年业务代码、但每次看到空串转换还是习惯性加个!= null的老手,下面的内容都能直接用上。

1. 先搞清楚这行异常到底是谁抛的

1.1 从堆栈第一行读起,别急着改代码

排查任何NumberFormatException,第一步永远是看堆栈最顶上那几行 frame,而不是看消息。消息只是结果,frame 才是来源。我习惯把堆栈分成三段来看:最上面 3 到 5 行是“出事地点”,告诉你具体调用了哪个 parse 方法;中间一段是“路过的地方”,通常能看到 Spring 的TypeConverterDelegate、Jackson 的StdDeserializer、MyBatis 的TypeHandler这类框架类;最下面是“发车地点”,往往是 Controller 方法或者某个定时任务的入口。

这条消息之所以有价值,是因为不同转换入口抛出的文案是不一样的。Integer.parseInt("")给你的是For input string: "",Long.parseLong("")也一样;但Double.parseDouble("")、Float.parseFloat("")、new BigDecimal("")抛出来的恰恰就是empty String。所以一旦你在堆栈里看到empty String这个原文,基本可以把 Integer、Long 这条线先放一放,重点怀疑浮点数、BigDecimal、BigInteger 相关的路径,或者某个框架内部先做了 trim 再转数字。

这里有个容易被忽略的细节:Short.parseShort("")和Byte.parseByte("")走的也是NumberFormatException.forInputString,文案同样是For input string: ""。所以“消息文案”这把尺子虽然好用,但它只帮你排除,不帮你锁定,真正的定位还是要靠堆栈里的类名和方法名。

1.2 源码级对照:同一句空串,七种不同死法

为了把这件事说清楚,我专门把 JDK 里几个高频转换方法对空串的处理逐个走了一遍。这张表建议存下来,下次看到异常消息,先对照一下就能省半小时。

调用方式空串""的异常消息关键源码位置
Integer.parseInt("")/Integer.valueOf("")For input string: ""Integer.parseInt里len == 0走forInputString
Long.parseLong("")/Long.valueOf("")For input string: ""Long.parseLong同样的分支
Short.parseShort("")/Byte.parseByte("")For input string: ""同上,复用forInputString
Integer.decode("")Zero length stringInteger.decode开头单独判断nm.length() == 0
Double.parseDouble("")/Double.valueOf("")empty StringFloatingDecimal.readJavaFormatString里l == 0分支
Float.parseFloat("")/Float.valueOf("")empty String同样复用FloatingDecimal的读取逻辑
new BigDecimal("")empty StringBigDecimal(char[], int, int, MathContext)里len == 0
new BigInteger("")Zero length BigIntegerBigInteger(String, int)里len == 0分支

从这张表能读出两个关键信息。第一,empty String这个文案集中在浮点数和BigDecimal上,说明你遇到的极大概率是“价格、金额、比率、坐标、评分”这类带小数的字段,而不是分页页码这种整型字段。第二,Double.parseDouble在读取之前会先做一次in.trim(),也就是说传入" "(纯空格)和传入""会抛完全一样的empty String。这一点在排查时特别要命——日志里看着明明有内容,实际上是一串空格。

顺带说一句new BigDecimal("")的调用链:BigDecimal(String val)会先执行val.toCharArray(),如果 val 是 null,你拿到的是NullPointerException而不是NumberFormatException。所以看到empty String反而可以放心:说明入参不是 null,是一个长度为 0 或者 trim 后长度为 0 的字符串。这个信息对定位很关键,它把“没传参数”和“传了空参数”两种情况彻底区分开了。

注意:不要因为Double.parseDouble会 trim 就以为所有转换都容错空格。Integer.parseInt(" 1 ")是会直接抛For input string: " 1 "的,它只处理正负号,不处理空格。所以“加个 trim 就万事大吉”这个想法在整型路径上不成立。

2. 为什么空字符串会一路走到转换这一步

2.1 数据在链路上“变空”的五种常见姿势

空串不会凭空出现在 parse 方法里,它一定是在前面某一环被制造出来、或者被放行进来的。我在实际项目里总结下来,主要就这五种来源。

第一种是表单默认值。前端表单初始化时把数字输入框统一设成"",用户没填就直接提交,后端拿到的是一个空字符串而不是null。这在新增和编辑共用一个表单页面的场景里特别常见,因为编辑时字段有值,新增时是空串,两套逻辑混在一起,很容易漏掉某一分支。

第二种是前端序列化约定。有些前端工具或者封装的请求库,会把undefined和null统一转成空串再发出去,理由是这样对后端更“友好”。结果就是后端永远收不到 null,只能收到空串,所有!= null判断全部失效。

第三种是Excel 或 CSV 导入。表格里某一列没填,读出来的单元格值是空串;如果这一列中间还夹杂着用户从别处粘贴过来的全角空格或者不换行空格,那就是“看着像空的,实际有两个字符”。这类问题在批量导入功能上线后集中爆发,而且很难复现,因为同一个模板不同人填出来的脏数据完全不一样。

第四种是数据库里的空串与 NULL 混用。某些数据库对空串和 NULL 的处理并不一致,字段允许为空但写入时用了空串,查询出来就是""。ORM 框架把""映射到一个Double或者BigDecimal属性时,就会在类型转换这一步炸掉。而且这种问题往往只在存量数据上出现,新数据因为加了校验反而是好的,排查时容易误判成“偶发”。

第五种是字符串拼接和占位符。日志里为了格式整齐,用String.format拼接参数,某个参数本身是 null,格式化后变成了字面量"null"再被送进转换方法。这时候报的通常是For input string: "null",但如果拼接后是"",就会走空串这条路。我见过一个坑:配置中心里某个比例参数的值为空白(不是删除,是把值清空了但保留了配置项),服务启动读取配置后直接new BigDecimal(configValue),启动即失败。

2.2 哪些框架会替你悄悄调用转换方法

真正让人困惑的地方在于:很多时候你自己代码里根本没写parseDouble,异常却从框架深处冒出来。所以有必要把“谁在替你转换”这件事捋清楚。

Spring MVC 的参数绑定是头号嫌疑人。当你在 Controller 上写@RequestParam Double rate或者在一个 DTO 里定义private BigDecimal amount;,请求参数是空串时,WebDataBinder会去找对应的PropertyEditor或Converter,CustomNumberEditor内部调用的就是Double.valueOf或BigDecimal的构造。这时候堆栈里出现的会是TypeMismatchException或者MethodArgumentTypeMismatchException,往 cause 里翻一层才看到NumberFormatException: empty String。很多人只看了最外层的异常就以为是参数校验问题,其实根子在转换。

Jackson 这类 JSON 库是第二号嫌疑人。反序列化时把 JSON 里的""赋给一个double基本类型字段,不同版本的默认行为不完全一样:有的版本会尝试强制转换然后失败,有的会先转成 null 再因为基本类型不能为 null 而报错。这块行为在不同小版本之间确实存在差异,我的建议是不要依赖默认值,而是显式配置反序列化规则,把行为固定下来。

第三号是模板引擎和表达式语言。在页面渲染层做数字格式化时,如果传入的是空串,格式化函数内部会走数字转换,这时候抛出的异常被包装成渲染异常,堆栈会很长,第一眼根本看不到NumberFormatException。类似的还有各类规则引擎、报表引擎,它们在处理单元格数据时经常会“尽力而为”地转数字,转不动就抛。

提示:判断是不是框架在替你转换,看堆栈里有没有Converter、Editor、Deserializer、TypeHandler这类字眼。有的话,去查框架的转换配置,比逐行改业务代码更省事。

3. 五类高频触发场景与最小复现代码

3.1 接口参数绑定:空串是怎么绕过非空校验的

先说最常见的一种。前端提交{"amount": ""},后端 DTO 里写的是private BigDecimal amount;,还加了@NotNull注解。很多人以为这样就安全了,实际上@NotNull只拦 null,不拦空串,参数校验这一关直接放行,等到框架做类型转换的时候才炸。

下面这段是最小复现,你可以直接拿去跑:

public class AmountDemo { public static void main(String[] args) { String raw = ""; // 前端传来的空串 BigDecimal amount = new BigDecimal(raw); // 这一行直接抛 System.out.println(amount); } }

控制台输出就是我们要找的那句:

Exception in thread "main" java.lang.NumberFormatException: empty String at java.math.BigDecimal.<init>(BigDecimal.java:491) at java.math.BigDecimal.<init>(BigDecimal.java:597) at AmountDemo.main(AmountDemo.java:3)

注意堆栈里两个BigDecimal.<init>的顺序,第一个是char[]版本,第二个是String版本,这跟上一节讲的调用链完全对得上。看到这个堆栈形状,就可以确定是直接构造而不是框架转换。

那怎么防?第一层当然是入口拦。但别用@NotNull,要用能识别空串的注解。参数校验规范里@NotBlank是给字符串用的,对BigDecimal类型字段本身不生效,正确的做法是把接收参数写成String,用@NotBlank校验,再在业务层显式转换;或者保持BigDecimal类型,但把请求参数的默认值约定成“不传该字段”而不是“传空串”。这两种做法各有代价,我后面第 4 节会展开讲取舍。

3.2 JSON 反序列化:默认值和基本类型的坑

第二种场景是 JSON 反序列化。这里最隐蔽的一种是:字段声明成double基本类型(不是Double包装类型),JSON 里给的是空串。基本类型不能为 null,所以框架没法用“转成 null”这个兜底方案,只能硬转,然后就抛了。

public class RateConfig { private double rate; // 基本类型,不能为 null // getter / setter 省略 }

如果一定要在反序列化层做兼容,我一般会写一个专门的反序列化器,把空串统一映射成 null,然后再由业务层决定 null 是否合法:

public class BlankToNullDoubleDeserializer extends JsonDeserializer<Double> { @Override public Double deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String text = p.getValueAsString(); if (text == null) { return null; } String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } try { return Double.valueOf(trimmed); } catch (NumberFormatException e) { // 抛框架自己的异常类型,保留字段名等上下文,方便前端定位 throw InvalidFormatException.from(p, "字段值不是合法数字: [" + text + "]", text, Double.class); } } }

这个写法的好处是异常信息里带上了原始值,而且用方括号包住,空格、换行、制表符一眼就能看出来。我踩过一次坑:日志里打印值不是合法数字: 1.5,看着完全正常,实际上是"1.5\u00A0"后面跟了一个不换行空格,加方括号之后就现形了。

注意:给Double注册反序列化器的时候,如果字段是double基本类型,反序列化器返回 null 之后还会再多一层错误,需要在字段上配合默认值处理或者在业务层判空。别指望一个反序列化器解决所有问题。

3.3 数据库与 ORM 之间:NULL 和空串不是一回事

第三种场景在数据访问层。有些团队在表设计时把数值列定义成字符串类型(比如为了兼容超长小数或者历史遗留),ORM 映射时又声明成BigDecimal,读写两侧的类型对不上,就会出现“库里存的是空串,代码里当数字用”的情况。

更麻烦的是空串和 NULL 在业务语义上经常被混着用。比如“折扣率”字段,NULL 表示没设置,空串表示设置成了空——这两种在业务上可能都该当作“未设置”处理,但代码里如果不统一,就会出现同一个页面有的数据正常、有的数据报错。我的建议是在数据访问层做一次统一收口,把空串归一成 null,别让空串流到业务层。

public class QuantityRowMapper implements RowMapper<Quantity> { @Override public Quantity mapRow(ResultSet rs, int rowNum) throws SQLException { Quantity q = new Quantity(); q.setId(rs.getLong("id")); q.setAmount(parseAmount(rs.getString("amount"))); return q; } private BigDecimal parseAmount(String raw) { if (raw == null) { return null; } String trimmed = raw.trim(); if (trimmed.isEmpty()) { return null; // 空串当成未设置,和 NULL 语义对齐 } return new BigDecimal(trimmed); } }

这里用rs.getString而不是rs.getBigDecimal是刻意的。用getString能拿到原始文本,这样空串和 NULL 的差异才可见;用getBigDecimal的话,驱动可能已经替你做了隐式转换,返回 0 或者 null,问题就被掩盖了,等哪天上游数据变了才爆出来。

3.4 批量导入与外部数据:看不见的字符才是主角

第四种场景是 Excel、CSV、接口对接这类外部数据源。这类数据的共同特点是“你无法控制输入”,用户从各种地方复制粘贴,带进来的字符五花八门。

我见过的高频隐形字符有这么几个:不换行空格\u00A0(从网页复制最常见)、零宽空格\u200B、字节顺序标记\uFEFF(从某些文本编辑器导出的 CSV 开头会带)、全角空格\u3000、以及制表符和换行符。这些字符在trim()眼里都不算空白,所以" "经过 trim 之后长度可能还是 2。

排查这类问题,光靠打印字符串不够用,要把它转成可见形式:

public static String visible(String s) { if (s == null) { return "null"; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); boolean invisible = c < 0x20 || c == 0x7F || c == '\u00A0' || c == '\u200B' || c == '\uFEFF' || c == '\u3000'; if (invisible) { sb.append(String.format("\\u%04X", (int) c)); } else { sb.append(c); } } return "[" + sb + "]"; }

把这段工具方法挂到日志里,从visible("1.5")打印出[1.5],从visible("1.5\u00A0")打印出[1.5\u00A0],问题立刻清晰。这套方法我在三个项目里都用过,几乎每次都能在十分钟内锁定脏字符。

处理方式我一般分两步:先做一次全面的字符清洗,把常见隐形字符替换成普通空格或者直接删掉;再做一次严格的合法性校验,清洗之后还不合法的直接拒绝并记录原始值,别让它继续往下走。清洗用正则比较省事:

private static final Pattern INVISIBLE = Pattern.compile("[\\u00A0\\u200B\\uFEFF\\u3000\\t\\r\\n]"); public static String clean(String raw) { if (raw == null) { return null; } String result = INVISIBLE.matcher(raw).replaceAll(" ").trim(); return result.isEmpty() ? null : result; }

3.5 前端心智模型错位:Number("")是 0,parseDouble("")不是

第五种场景是我觉得最值得单独拎出来说的:前后端对“空串转数字”的认知完全不一样。

在 JavaScript 里,Number("")的结果是0,Number(" ")也是0,parseInt("")是NaN,parseFloat("")也是NaN。也就是说,前端开发者很容易形成“空串就是 0”的直觉,写代码时随手就Number(input)了,然后把这个值提交给后端。后端拿到的如果是数字 0,一切正常;但如果中间某一环把 0 又转回了空串(比如表单清空、序列化时把 0 当作 falsy 值丢弃),后端就会拿到空串。

反过来,Java 侧是严格抛异常,两者行为差异巨大。这种差异导致的问题是:前端本地测试一切正常,联调时偶发报错,而且很难解释为什么。我在排查这类问题时,第一件事就是让前端把实际发出的请求体完整打出来,很多时候一看就发现是某个字段被序列化成了""。

规避办法是约定清楚:数字字段要么不传(字段缺失),要么传合法数字,绝不允许传空串。前端提交前做一次显式的空值判断,把空串转成null或者干脆删掉这个 key。后端则在入口做兜底,收到空串当作缺失处理。两边各退一步,问题就收敛了。

4. 从源头到出口的四层防御改造

4.1 第一层:入口转换,把空串挡在类型系统之外

最省事的做法是在参数进入业务代码之前就转换掉。Spring MVC 提供了Converter机制,可以注册一个全局的String到数字类型的转换器,把空串和纯空白统一处理成 null,让后续的校验注解和业务逻辑只需要面对 null 这一种“缺失”状态。

@Configuration public class NumberConvertConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(String.class, Integer.class, source -> { if (source == null) { return null; } String trimmed = source.trim(); return trimmed.isEmpty() ? null : Integer.valueOf(trimmed); }); registry.addConverter(String.class, BigDecimal.class, source -> { if (source == null) { return null; } String trimmed = source.trim(); return trimmed.isEmpty() ? null : new BigDecimal(trimmed); }); } }

这个方案的优点是改动集中、影响面可控,所有走 Spring MVC 参数绑定的入口都能受益。缺点有两个:一是全局注册转换器会覆盖默认行为,如果有地方依赖“空串转换失败”来触发异常,行为会变;二是它管不到 JSON body 的反序列化,因为 body 是 Jackson 在处理,不经过FormatterRegistry。

所以我一般会把这个改造和参数校验配合用:转换器负责把空串变成 null,DTO 上的@NotNull负责把 null 拦下来,两层职责分明。这样前端传空串时会得到一条清晰的参数校验提示,而不是一个带堆栈的 500 错误。

提示:注册全局转换器前,先在测试环境把核心接口跑一遍,重点看那些“参数可选”的接口。空串变 null 之后,如果业务逻辑里有value != null就当作“已传值”的判断,可能会出现逻辑偏差。

4.2 第二层:工具方法收口,让每个 parse 都有兜底

第二层是业务代码里的工具方法。我不太喜欢在每个调用点写 try-catch,一来啰嗦,二来容易漏,三来 catch 块里写什么全靠当时心情。更好的做法是写一组语义明确的转换工具,把“怎么处理异常”这件事变成一个有明确命名的选择。

public final class Numbers { private Numbers() { } /** 解析失败或为空时返回给定的默认值,适合有兜底语义的配置项 */ public static int toIntOr(String raw, int fallback) { String cleaned = clean(raw); if (cleaned == null) { return fallback; } try { return Integer.parseInt(cleaned); } catch (NumberFormatException e) { return fallback; } } /** 解析失败时抛出带原始值的业务异常,适合必须校验的用户输入 */ public static BigDecimal requireDecimal(String raw, String fieldName) { String cleaned = clean(raw); if (cleaned == null) { throw new IllegalArgumentException(fieldName + " 不能为空"); } try { return new BigDecimal(cleaned); } catch (NumberFormatException e) { throw new IllegalArgumentException(fieldName + " 不是合法数字: [" + cleaned + "]", e); } } /** 解析失败时返回 null,把决策权交给调用方 */ public static Double toDoubleOrNull(String raw) { String cleaned = clean(raw); if (cleaned == null) { return null; } try { return Double.valueOf(cleaned); } catch (NumberFormatException e) { return null; } } }

这三个方法覆盖了我遇到的绝大多数场景:配置项用toIntOr,用户输入用requireDecimal,可选字段用toDoubleOrNull。命名本身就是文档,看一眼就知道失败之后会发生什么,比满地 try-catch 可读性强得多。

注意:clean方法要放在工具类内部或者单独抽出来,别让调用方自己 trim。我见过有人在调用处写toIntOr(raw.trim(), 0),结果 raw 是 null 时直接 NPE,反而引入了新问题。

4.3 第三层:序列化容错,把行为固定下来

第三层是序列化层。前面说过,JSON 库对空串转数字的默认行为在不同版本之间有差异,最稳妥的办法是显式配置,别依赖默认值。

如果你的项目里数字字段尤其是金额类字段比较多,我建议直接注册一个全局的反序列化器,把空串和纯空白统一映射成 null,同时对非法值抛出带上下文的异常。这样前端调试的时候能直接从响应里看到是哪个字段、原始值是什么,而不是一个笼统的“请求参数错误”。

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer numberTolerance() { return builder -> { SimpleModule module = new SimpleModule(); module.addDeserializer(Double.class, new BlankToNullDoubleDeserializer()); module.addDeserializer(BigDecimal.class, new BlankToNullBigDecimalDeserializer()); builder.modules(module); }; } }

这里有个取舍要讲清楚:把空串当 null 处理,等于承认“空串是合法的缺失表达”。如果你的接口规范里明确要求数字字段不许传空串,那就不要做这个容错,直接让它报错,靠校验提示引导前端改。容错和严格,选一个就行,别两边都做一半——既容错又在业务层判空,最后谁也不知道某个字段到底是必填还是可选。

4.4 第四层:数据与约定,从根上减少空串产生

前三层都是代码层面的防御,第四层是约定层面的。我越来越觉得,很多空串问题的根源不在于代码写得好不好,而在于上下游对“空”的表达没有统一。

数据库层面,我的习惯是数值字段一律不允许存空串,要么有值要么 NULL。如果字段是字符串类型但业务上是数字,就在写入前做校验,非法值直接拒绝,别让它进库。表结构上加约束虽然会带来一些改造成本,但比起事后排查数据问题,这点成本很值。

接口层面,数字字段的统一约定是:缺少该 key 表示未传,null表示显式清空,空串一律视为非法。这条约定要写进接口文档,前端做统一处理,后端做统一拦截。我见过一个团队把这条写进了联调检查清单,之后这类报错几乎绝迹。

配置层面,配置中心里的数值项建议加上类型约束,或者在启动时做一次全量校验,把空值、纯空白、非法字符在启动阶段就打出来。启动失败总比运行到一半出事强,尤其是那种“只在某个特定配置组合下才触发”的问题。

5. 排查现场实录:三个典型案例的完整思路

5.1 偶发的对账解析失败

某公司的对账服务每隔几天就会报一次empty String,频率很低,但每次都要人工介入。最初的排查方向是怀疑上游数据有问题,加了一堆日志还是定位不到。

转折点是把原始字符串用可见化方法打印出来。日志显示原始值是[1.0050\u00A0],末尾跟了一个不换行空格。上游是从网页系统导出的对账文件,某一列的格式在不同浏览器下导出结果不一样,只有用某类浏览器导出时才会带这个字符。因为Double.parseDouble会先 trim,而trim()不处理\u00A0,所以 trim 之后长度不为 0,继续往下解析,解析到那个字符时失败。

这里有个细节值得说:为什么报的是empty String而不是常见的格式错误消息?因为这批数据里有一部分行的该列完全是空的(只有一个\u00A0),trim 之后长度确实为 0,直接命中空串分支;另一部分带实际数字的行,报的是For input string: "1.0050\u00A0"。两种错误混在一起,日志分类没做好,看起来就像是同一种错误偶发。

解决办法是在读取环节统一做字符清洗,同时把错误日志按异常消息分类统计,这样一眼就能看出是两类问题。改完之后,这类告警归零。

5.2 线上 5% 请求报错的隐藏字符

第二个案例是接口报错率稳定在 5% 左右,不高不低,持续了好几天。抓了几个请求的入参,肉眼看着完全正常。

最后是在反序列化器里加了一行原始字节打印才发现的:出问题的请求体里,数字字段的值是"1.5",但字符串后面跟了一个零宽空格\u200B。这个字符在大多数编辑器里完全不可见,长度也不为 0,trim()不掉,Double.valueOf直接失败。

问题来源是某个运营后台的输入框,用户从聊天工具里复制数字粘贴进去,带上了零宽字符。前端没有任何校验,原样提交。这个案例的教训是:隐形字符的清洗不能只做在后端,前端提交前也应该做一次归一化,两边都做才稳。

5.3 定时任务里的空单元格

第三个案例最简单也最典型。一个每天凌晨跑的导入任务,读取表格后直接new BigDecimal(cell.getStringValue()),某天开始每天失败。查下来是上游改了模板,新增了一列可选的“调整系数”,大部分行是空的,代码没有判空直接转。

这类问题的修复很简单,但我想强调的不是修复本身,而是为什么它上线前没被发现。原因是测试数据里这一列全都填了值,空值分支从来没被执行过。所以后面我在做导入类功能时,会专门准备一份“全空模板”和一份“部分为空模板”作为固定测试用例,这个习惯帮我拦下过不少类似问题。

6. 常见问题速查表与踩坑心得

6.1 问题定位速查表

现象最可能的原因快速验证方式
消息是empty String浮点数或 BigDecimal 转换,输入为空或纯空白在转换前打印可见化字符串
消息是For input string: ""整型转换,输入为空串检查Integer/Long相关调用
消息是For input string: " 1 "整型转换带空格,parseInt不 trim确认是否漏了清洗
消息是For input string: "null"字符串拼接把 null 拼成了字面量检查String.format或+拼接
只在高并发或特定时段出现数据源差异,而非代码逻辑问题按异常消息分类统计,看是否混了两类错误
本地无法复现测试数据没有空值分支补一份全空和部分为空的用例
前端说传了值序列化把 falsy 值丢了或转了抓完整请求体比对
堆栈里有Converter/Deserializer框架在替你转换查框架转换配置,别改业务代码

6.2 几条踩出来的经验

第一条,别用!= null判断“有没有值”。空串、纯空白、隐形字符都能通过这个判断,然后在后面某一刻炸掉。判断字符串有没有实际内容,用trim之后判长度,或者直接用现成的空白判断工具。这条看起来是常识,但我 review 代码时还是经常看到。

第二条,错误日志里永远打印原始值的可见形式。打包成方括号、转义隐形字符,多写这几行代码,能省掉后面几小时的排查。我在所有涉及外部输入解析的地方都加了这个处理,回报率极高。

第三条,Double.parseDouble会 trim,Integer.parseInt不会。这个差异导致同一个空串在不同类型上表现不同,写通用清洗逻辑的时候一定要统一处理,别指望 parse 方法自己兜。

第四条,别在 catch 块里写空实现。我见过太多catch (NumberFormatException e) { },问题被吞掉,等到数据错乱才发现。要么给默认值并打日志,要么包装成带上下文的异常往上抛,总之要让问题可见。

第五条,修复要修在产生空串的那一环,而不是消费它的那一环。在 parse 前面加判空只能止血,真正的问题是上游为什么会产生空串。把上游改掉,下游的判空就变成了防御性冗余,这才是健康的状态。

6.3 一个容易被忽略的扩展方向

如果你的系统里数字转换点特别多,可以考虑在编译期做一层检查,或者用自定义的静态分析规则扫描所有parseXxx和数字构造调用,把没有做空值保护的调用点列出来。这件事听起来麻烦,但一次性投入之后,新代码的问题能在提交阶段就被拦住,比等到线上报警强太多。

我个人的体会是,NumberFormatException: empty String这类异常本身并不难修,难的是搞清楚“为什么会有空串流到这里”。每次遇到,我都会顺手问一句:这个空串是从哪来的,上游有没有可能产生别的脏数据?往往顺着这条线查下去,会发现一批同类问题。修一个异常,顺手把一整类隐患堵住,这才是排查的真正价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询