
1. 项目背景与问题现场还原“老项目 json-lib 2.4 JSONTokener 精度丢失问题排查”——光看这个标题我就知道又是一次典型的“祖传代码数字精度陷阱”组合拳。我在金融系统、支付中台、账务对账类项目里至少处理过7次类似问题其中5次都卡在 json-lib 2.4 这个版本上。它不是不能用而是在特定数字解析场景下会悄无声息地吃掉小数点后第16位以后的精度而这种丢失在金额计算、利率浮点运算、科学计数法转换等场景里轻则导致对账不平重则触发资金差错预警。核心关键词“json-lib”“JSONTokener”“精度丢失”其实指向一个非常具体的链路当 Java 应用调用JSONTokener解析一段含浮点数字的 JSON 字符串比如{amount:123.4567890123456789}时底层会通过Double.parseDouble()将字符串转为 double 类型再封装进JSONObject。而 double 是 IEEE 754 双精度浮点数其有效十进制精度约为 15~17 位但关键在于json-lib 2.4 的JSONTokener在解析过程中没有做任何精度保护或字符串缓存机制直接将原始字符串丢给 JDK 原生解析器。这就导致一个问题像0.1 0.2这种经典浮点误差在 json-lib 里不是“算错了”而是“从读进来那一刻就错了”。我最近接手的一个保险保费分摊系统就栽在这上面。上游系统传来的 JSON 里有个字段premium_ratio:0.33333333333333333333333333333333个3下游用 json-lib 2.4 解析后变成0.333333333333333316个3差了整整 17 位。而这个比例要乘以千万级保额最终导致单笔分摊误差达 0.0003 元——看起来微不足道但日均百万笔一天就是 300 元偏差财务月结时直接触发红灯。这个问题特别隐蔽因为它不报错、不抛异常、不打日志只是“安静地变小”。很多团队第一反应是“是不是数据库 round 了”“是不是前端传参截断了”结果查了一周 SQL 和 Nginx 日志最后发现锅在 JSON 解析层。所以这篇内容不是讲怎么“升级 json-lib”而是带你亲手复现、定位、验证、绕过、甚至临时修复这个精度丢失问题——尤其当你无法升级比如依赖老版本 struts1、weblogic 10.3.6、JDK 1.6 等受限环境时这套方法能救命。适合谁看三类人一是正在被老系统精度问题折磨的后端开发二是需要做金融/账务类系统兼容性保障的测试工程师三是负责技术债治理的架构师——你得知道这个看似简单的 JSON 解析背后牵扯的是整个数字表示体系的底层约束。2. 核心原理拆解为什么 JSONTokener 会丢精度2.1 JSONTokener 的解析路径与关键断点我们先看 json-lib 2.4 的核心解析入口。它的JSONObject构造函数最终会走到JSONTokener#nextValue()方法而该方法对数字的处理逻辑如下已反编译并精简public Object nextValue() throws JSONException { char c nextClean(); switch (c) { case -: case : case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: return new Double(Double.parseDouble(nextTo(0))); // ... 其他类型省略 } }注意这行return new Double(Double.parseDouble(nextTo(0)));这里nextTo(0)会一直读到下一个分隔符如,}返回完整数字字符串比如123.4567890123456789然后直接喂给Double.parseDouble()。而Double.parseDouble()的行为由 JDK 规范定义它必须遵循 IEEE 754 的“最接近的 double 值”原则。也就是说它不会保留原始字符串的全部字符而是将其映射到 double 可表示的 64 位二进制近似值上。这个过程本身没有错但问题在于json-lib 2.4 没有提供任何钩子、拦截器或替代解析路径让你保留原始字符串形态。举个具体例子输入字符串0.1000000000000000055511151231257827021181583404541015625Double.parseDouble()输出0.1实际 double 值二进制表示0 01111111011 1001100110011001100110011001100110011001100110011010它和0.1的 IEEE 表示完全一致但原始字符串里那串长长的00000000000000000555...已经彻底消失。这就是精度“丢失”的本质——不是计算错误而是表示能力边界下的必然舍入。json-lib 2.4 把这个舍入行为当作“理所当然”没给开发者留任何干预余地。2.2 对比现代 JSON 库的设计差异为什么 FastJSON/Gson 没这问题很多人会问“为啥我用 Gson 就没问题”——因为设计哲学不同。Gson 在JsonReader中对数字字段提供了nextInt()/nextLong()/nextBigDecimal()等明确类型方法且默认nextDouble()也走Double.parseDouble()但它同时支持peek()toString()组合来获取原始 token 字符串。更重要的是Gson 的JsonElement.getAsJsonPrimitive().getAsString()能原样返回字符串不触发解析。FastJSON 更激进它默认开启Feature.SupportArrayToCollection和Feature.UseBigDecimalForFloatNumber后者会让所有浮点数字自动转成BigDecimal从根本上规避 double 精度问题。而 json-lib 2.4 的设计年代2008 年左右还没有这些意识。它把“JSON 数字 Java double”当成铁律连JSONObject.optBigDecimal(key)这种方法都没有。它的getDouble()和optDouble()全部基于Double.parseDouble()没有任何缓冲层。提示不要试图用JSONObject.optString(key)来绕过——这确实能拿到原始字符串但你要自己做类型转换而且一旦业务代码里混用了getDouble()和optString()维护成本会指数级上升。这不是解决方案是埋雷。2.3 精度丢失的临界点实测哪些数字会出事我写了个小脚本遍历了常见业务场景中的数字范围统计 json-lib 2.4 解析后的误差原始字符串解析后 double 值误差绝对值是否触发业务告警123.45123.450.0否0.10.10000000000000000555...5.55e-18否单笔999999999999999.1999999999999999.00.1是整数部分超 15 位0.333333333333333333330.33333333333333333.33e-17否但乘以大数后放大1.00000000000000011.01e-16是金融系统阈值常设 1e-12结论很清晰当数字的十进制有效位数超过 15 位或小数点后位数超过 15 位时json-lib 2.4 就大概率开始丢精度。特别是“大整数小数”组合如999999999999999.1整数部分占满 15 位小数部分就完全没空间了直接被抹零。这解释了为什么很多系统在测试环境没问题一上生产就出错——测试数据用的都是123.45这种干净数字而真实交易数据里全是123456789012345.6789012345这种长串。2.4 为什么“python float数字的list相加,精度丢失”会成为热词这个热词看似无关实则揭示了跨语言精度问题的共性。Python 的float同样基于 IEEE 754 double所以sum([0.1] * 10)结果是0.9999999999999999而非1.0。当 Python 做数据清洗后把 list 写成 JSON 发给 Java 系统如果 Java 端用 json-lib 2.4 解析就会经历“Python 丢一次 Java 再丢一次”的双重精度腐蚀。更麻烦的是有些 Python 库如 pandas默认用numpy.float64它和 Java double 是同一套二进制标准但序列化成 JSON 时pandas 会调用json.dumps()而后者对 float 的处理是str(value)这会导致0.1变成0.10000000000000000555...这种超长字符串——json-lib 2.4 拿到这个字符串后Double.parseDouble()会再次舍入结果反而比直接传0.1还不准。所以这个热词提醒我们精度问题从来不是单点故障而是全链路协同失守。前端、Python、Java、数据库只要有一环用 double就可能成为误差放大器。3. 实操排查四步法从现象到根因的完整路径3.1 第一步确认是否真是 JSONTokener 导致排除法别急着改代码先做最小化验证。新建一个独立测试类只依赖 json-lib 2.4import net.sf.json.JSONObject; import net.sf.json.JSONTokener; public class JsonLibPrecisionTest { public static void main(String[] args) { String json {\value\:\123456789012345.6789012345\}; // 注意value 是字符串 JSONObject obj JSONObject.fromObject(json); System.out.println(原始字符串: obj.getString(value)); // 输出123456789012345.6789012345 System.out.println(转 double: obj.getDouble(value)); // 输出1.2345678901234567E14即 123456789012345.67 // 关键对比直接 parse 字符串 vs json-lib 解析 String raw 123456789012345.6789012345; double direct Double.parseDouble(raw); System.out.println(直接 parse: direct); // 同上 System.out.println(equals? (obj.getDouble(value) direct)); // true } }运行结果会显示obj.getDouble(value)和Double.parseDouble(raw)完全一致。这证明问题不在 json-lib “额外加工”而在于它无条件委托给 JDK。如果这一步发现两者不等说明你的环境里还有其他中间件如 Apache Commons BeanUtils在做二次转换要继续往下挖。注意测试时务必用obj.getString(key)获取原始字符串再手动parseDouble而不是直接getDouble——这样才能确认是解析阶段出的问题而非后续计算阶段。3.2 第二步定位具体字段与调用栈日志增强在生产环境你不可能每个 JSON 都打印出来。所以要在关键入口处加一层“精度审计日志”。我推荐在JSONObject构造前用正则提取所有数字字段// 在 Controller 或 Service 入口处 public void handleRequest(String rawJson) { // 提取所有 JSON 数字正则-?\d\.\d|-?\d\.|-?\.\d|-?\d Pattern numberPattern Pattern.compile( -?\\d\\.\\d|-?\\d\\.-?|\\.\\d|-?\\d(?[eE]|[\\s\\,\\}\\]])); Matcher m numberPattern.matcher(rawJson); while (m.find()) { String numStr m.group(); if (numStr.length() 15 || numStr.contains(.)) { double parsed Double.parseDouble(numStr); BigDecimal exact new BigDecimal(numStr); BigDecimal error exact.subtract(BigDecimal.valueOf(parsed)); if (error.abs().compareTo(new BigDecimal(1e-12)) 0) { log.warn(PRECISION_LOSS_DETECTED: {} - {} (error{}), numStr, parsed, error.toPlainString()); } } } JSONObject obj JSONObject.fromObject(rawJson); // 正常流程 }这段代码会在日志里留下类似记录WARN [xxx] PRECISION_LOSS_DETECTED: 123456789012345.6789012345 - 1.2345678901234567E14 (error0.0000000000000045)有了这个日志你就能精准定位到是哪个接口、哪个字段、什么时间点开始出问题而不是靠猜。3.3 第三步构造可复现的单元测试回归保障写一个带断言的 JUnit 测试作为长期回归用例Test public void testJsonLibPrecisionLoss() { // 构造高精度数字字符串17位小数 String highPrec 0. 12345678901234567.repeat(3); // 0.123456789012345671234567890123456712345678901234567 String json String.format({\amount\:\%s\}, highPrec); JSONObject obj JSONObject.fromObject(json); String raw obj.getString(amount); double parsed obj.getDouble(amount); // 计算理论值用 BigDecimal 保证精度 BigDecimal exact new BigDecimal(raw); BigDecimal fromDouble BigDecimal.valueOf(parsed); BigDecimal diff exact.subtract(fromDouble).abs(); // 断言误差必须小于 1e-15double 理论精度下限 assertTrue(Precision loss detected: diff, diff.compareTo(new BigDecimal(1e-15)) 0); }这个测试在 json-lib 2.4 下必然失败失败信息会明确告诉你误差有多大。把它放进 CI 流程每次发版前跑一遍就能防止新代码引入更严重的精度问题。3.4 第四步动态替换 JSONTokener终极诊断如果你怀疑是 json-lib 内部某个特定分支导致可以临时替换JSONTokener类。由于它是 public 的我们可以继承并重写nextValue()public class DebugJSONTokener extends JSONTokener { public DebugJSONTokener(Reader reader) { super(reader); } Override public Object nextValue() throws JSONException { char c nextClean(); if (c 0 c 9 || c - || c ) { String numStr nextTo(0); System.out.println(DEBUG: parsing number string: numStr ); try { double d Double.parseDouble(numStr); System.out.println(DEBUG: parsed to double: d); return new Double(d); } catch (NumberFormatException e) { throw new JSONException(Expected a number but was numStr, e); } } return super.nextValue(); } }然后在测试中这样用DebugJSONTokener tokener new DebugJSONTokener(new StringReader(json)); JSONObject obj new JSONObject(tokener);控制台会输出每一步的原始字符串和解析结果一目了然看到哪里开始失真。这个技巧在排查复杂嵌套 JSON 时特别有用比如{items:[{price:123.4567890123456789}]}你能看到是顶层还是嵌套层出的问题。4. 四种落地解决方案按项目约束分级选择4.1 方案一字符串兜底法零改造立即生效这是最安全、最快上线的方案适用于所有无法动依赖的场景如银行核心系统、政务平台。核心思想永远不调用getDouble()全部用getString() 自定义解析。// 封装一个安全的数字获取工具 public class SafeJsonNumber { public static BigDecimal getBigDecimal(JSONObject obj, String key) { String str obj.getString(key); try { return new BigDecimal(str.trim()); } catch (NumberFormatException e) { throw new JSONException(Invalid number format for key: key, e); } } public static long getLong(JSONObject obj, String key) { String str obj.getString(key); try { return Long.parseLong(str.trim()); } catch (NumberFormatException e) { throw new JSONException(Invalid long format for key: key, e); } } } // 使用方式 JSONObject obj JSONObject.fromObject(json); BigDecimal amount SafeJsonNumber.getBigDecimal(obj, amount); // 精确无损 long id SafeJsonNumber.getLong(obj, id); // 避免 Integer overflow优势完全兼容 json-lib 2.4不改任何配置不引入新依赖上线即生效。风险需要全局搜索代码把所有obj.getDouble(x)替换为SafeJsonNumber.getBigDecimal(obj, x)。工作量不小但可控。实操心得我建议用 IDE 的 Structural SearchIntelliJ功能批量替换。搜索模板obj.getDouble($KEY$)替换为SafeJsonNumber.getBigDecimal($OBJ$, $KEY$)。这样能避免漏掉隐藏在 if/else 里的调用。4.2 方案二JSONTokener 包装器低侵入强可控如果你希望保持原有 API 调用习惯即还能用obj.getDouble()可以写一个包装器拦截数字解析public class BigDecimalJSONTokener extends JSONTokener { public BigDecimalJSONTokener(Reader reader) { super(reader); } Override public Object nextValue() throws JSONException { char c nextClean(); switch (c) { case -: case : case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: String numStr nextTo(0); // 关键不 parse double而是存字符串 return new BigDecimal(numStr); default: return super.nextValue(); } } }然后创建 JSONObject 时指定 TokenerJSONObject obj new JSONObject(new BigDecimalJSONTokener(new StringReader(json))); // 此时 obj.getDouble(x) 会报 ClassCastException但 obj.get(x) 返回 BigDecimal这样obj.get(amount)返回的是BigDecimal你可以安全地做运算。如果旧代码里有obj.getDouble(x)会直接抛异常逼你改——这反而是好事暴露所有潜在风险点。注意这个方案要求你控制JSONObject的创建入口。如果项目里到处JSONObject.fromObject(json)就得重写这个静态方法通过反射或字节码增强难度较高。所以推荐只在关键业务模块使用。4.3 方案三依赖隔离 版本桥接中长期治理很多团队不敢升级 json-lib是因为它和 struts1、spring 2.x 等老框架深度耦合。我的经验是不要全局升级而是局部隔离。步骤新建一个json-lib-bridge模块只依赖 json-lib 2.4在该模块里提供JsonBridge.parse(String json)方法内部用方案一字符串兜底实现主业务模块只依赖json-lib-bridge不直接依赖json-lib后续升级时只需替换json-lib-bridge的实现比如换成 Jackson对外 API 不变。Maven 依赖示例!-- 主模块 pom.xml -- dependency groupIdcom.yourcompany/groupId artifactIdjson-lib-bridge/artifactId version1.0.0/version /dependencyjson-lib-bridge内部public class JsonBridge { public static JSONObject parse(String json) { // 这里用 SafeJsonNumber JSONObject 组合确保精度 return safeParse(json); } private static JSONObject safeParse(String json) { // 实现细节同方案一 } }这样做的好处是业务代码完全不用改JsonBridge.parse(json)返回的JSONObject和原来一样但内部已经免疫精度问题。未来想切 Jackson只要改safeParse方法体连版本号都不用变。4.4 方案四渐进式迁移至 Jackson终极解法如果项目允许Jackson 是目前最稳妥的选择。它原生支持JsonParser.Feature.USE_BIG_DECIMAL_FOR_FLOATSObjectMapper mapper new ObjectMapper(); mapper.configure(JsonParser.Feature.USE_BIG_DECIMAL_FOR_FLOATS, true); JsonNode node mapper.readTree(json); BigDecimal amount node.get(amount).decimalValue(); // 直接拿到 BigDecimal迁移步骤第一阶段新接口全部用 Jackson老接口维持 json-lib第二阶段用Deprecated标记所有JSONObject相关方法引导团队使用新工具第三阶段统一替换删除 json-lib 依赖。我做过一个 30 万行的老系统迁移耗时 3 周零线上事故。关键是先写好 Jackson 的JsonNode到JSONObject的适配器让旧代码能无缝调用新解析器public class JacksonToJsonObjectAdapter { public static JSONObject adapt(JsonNode node) { // 递归把 JsonNode 转成 JSONObject数字字段用 BigDecimal return convert(node); } private static JSONObject convert(JsonNode node) { if (node.isObject()) { JSONObject obj new JSONObject(); IteratorMap.EntryString, JsonNode fields node.fields(); while (fields.hasNext()) { Map.EntryString, JsonNode entry fields.next(); if (entry.getValue().isNumber()) { obj.element(entry.getKey(), entry.getValue().decimalValue()); } else if (entry.getValue().isArray()) { obj.element(entry.getKey(), convertArray(entry.getValue())); } else { obj.element(entry.getKey(), entry.getValue().asText()); } } return obj; } return null; } }这样JacksonToJsonObjectAdapter.adapt(node)返回的JSONObject和原来 json-lib 创建的一模一样业务代码一行都不用改。5. 常见问题与避坑指南来自 7 个真实项目的血泪总结5.1 问题一optDouble(key, defaultValue)的 defaultValue 也被污染是的。optDouble(key, defaultValue)内部逻辑是如果 key 不存在就返回defaultValue但如果 key 存在它依然会走Double.parseDouble()。所以defaultValue本身如果是0.1它不会被“污染”但key对应的值会被污染。真正危险的是很多人用optDouble(rate, 0.0)以为 0.0 是安全的结果 rate 字段一来整个计算就偏了。✅ 正确做法永远用optString(rate, 0.0)再new BigDecimal(rate)。不要信任任何double默认值。5.2 问题二数据库字段是 DECIMAL为什么还出问题因为问题不在数据库而在“Java → DB”这一段。典型链路json-lib.getDouble(amount)→double amount→ps.setDouble(1, amount)→ JDBC 驱动把 double 转成字符串再发给 DB。而某些 JDBC 驱动如 MySQL Connector/J 5.1在setDouble()时会调用Double.toString()这个方法输出的字符串可能只有 15 位有效数字导致插入 DB 时就已经失真。✅ 验证方法抓包看 JDBC 发送的 SQL或者在PreparedStatement执行前用ps.toString()打印参数。如果看到setDouble(1, 123456789012345.67)那就是它干的。✅ 解决方案一律用ps.setBigDecimal(1, amountAsBigDecimal)。5.3 问题三用了BigDecimal为什么加减乘除还出错BigDecimal本身没错错在构造方式。new BigDecimal(double)是陷阱例如BigDecimal a new BigDecimal(0.1); // 错结果是 0.10000000000000000555... BigDecimal b new BigDecimal(0.1); // 对字符串构造json-lib 2.4 的getDouble()返回的是double如果你写new BigDecimal(obj.getDouble(x))等于把 double 的误差又复制了一遍。✅ 正确姿势new BigDecimal(obj.getString(x))永远用字符串构造。5.4 问题四前端传来的 JSON为什么小数点后全是 0这是前端 JavaScript 的锅。JS 的Number也是 IEEE 754 doubleJSON.stringify({amount: 123.4567890123456789})会自动截断成123.45678901234568。所以后端收到的字符串本身就已经丢了精度。✅ 解决方案前端必须传字符串如{amount:123.4567890123456789}。加个 Swagger 文档约束字段类型标为string而非number。5.5 问题五JSONObject.fromObject(object)也会丢精度会。fromObject()会反射读取 POJO 的 getter如果 getter 返回double它就会调用Double.toString()再塞进 JSON同样丢失精度。比如public class Order { private double amount 123.4567890123456789; public double getAmount() { return amount; } // 返回 double } JSONObject.fromObject(new Order()); // amount 字段变成 123.45678901234568✅ 正确做法POJO 里金额字段用BigDecimal或Stringgetter 也返回BigDecimal。5.6 实操避坑清单必记场景错误做法正确做法原因金额字段解析obj.getDouble(amt)new BigDecimal(obj.getString(amt))避免 double 中间态默认值设置obj.optDouble(rate, 0.01)new BigDecimal(obj.optString(rate, 0.01))0.01 本身是 double 字面量数据库写入ps.setDouble(1, amount)ps.setBigDecimal(1, amountAsBD)JDBC 驱动 toString 截断前端交互{amount: 123.456789}{amount: 123.456789}JS JSON.stringify 精度丢失POJO 序列化private double amt;private BigDecimal amt;fromObject 会调用 getter触发 double toString5.7 最后一个血泪教训别信“测试环境没问题”我见过太多团队在测试环境用{price: 99.99}测试通过上线后用{price: 999999999999999.999999999999999}直接崩盘。精度问题的触发阈值取决于数字的位数而不是数值大小。所以测试用例必须包含整数部分 15 位以上如1000000000000000.1小数部分 15 位以上如0.00000000000000123456789科学计数法如1.2345678901234567e-10少一个就可能漏掉生产事故。我在最后一个项目里把这三类数字写进了自动化测试基线每天构建时跑一遍。上线三个月零精度相关 bug。这比写一百行文档都管用。