Java精确处理价格:十进制字符串与BigDecimal陷阱
价格看起来是最简单的数据:一个带几位小数的数字。然后一个加密货币价格出现了十五位小数,一个代币的交易价格为 0.000012341873905,屏幕上的数字开始对不上。本文探讨了从 JSON 到屏幕之间出了什么问题,以及如何用 BigDecimal 和 Jackson 保留每一位数字。关于数字类型之间更广泛的权衡,请参阅 foojay 上的 double、BigDecimal 还是定点数?;本文跟踪一个价格从 JSON 到屏幕的旅程。
为什么 double 是价格的错误类型
double 存储的是二进制小数,而大多数十进制小数没有精确的二进制形式。用三行代码就能看到:
第二行是 0.75 BTC 在价格为 63,520.780763913 USD 时的价值,而 double 已经弄错了最后几位数字。误差还会累积:将一聪(0.00000001)相加一亿次,作为 double 得到 1.0000000022898672,而作为 BigDecimal 则精确得到 1.00000000。
为显示而进行的四舍五入掩盖了错误,这就是它能在代码审查中存活的原因。一旦你对这些值进行求和、比较或存储,它就会变成一份错误报告。
为什么价格 API 发送字符串
JSON 只有一种数字类型,大多数解析器会将带小数的数字转换为 double。Jackson 的树模型(readTree)和像 Map<String, Object> 这样的无类型目标正是这样做的(示例使用 Jackson 2,即下面 SDK 依赖的 2.17 系列):
在你的代码看到这些数字之前,它们就已经丢失了,而 decimalValue() 只是包装了四舍五入后的 double。直接绑定到 BigDecimal 字段是精确的;丢失发生在树中和无类型映射中。如果你能控制解析器,DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS 也能在那里保留完整值,但树会去除末尾的零(1200.00 返回为 1.2E+3),所以请用 toPlainString() 打印这些值。
然而,你通常无法控制每一个客户端,所以许多市场数据 API 改为将价格作为字符串发送。本文示例使用的 Bitculator Data API 会将每个币种返回为如下的一行(有删节;Bitculator Data API 文档列出了每个字段):
价格是一个十进制字符串,末尾的零被去除,且从不使用 E 记数法。对于四舍五入的 double 可以接受的值,例如市值和百分比变化,仍保留为 JSON 数字。
使用 Jackson 读取十进制字符串
字符串价格到达时是一个 TextNode,这就是第一个陷阱所在:
decimalValue() 定义在 JsonNode 上,对于任何不是数字节点的内容都返回 BigDecimal.ZERO。没有异常,没有警告,只有一个零价格。asDouble() 解析文本,这又带回了 double 的问题。new BigDecimal(node.asText()) 才是你想要的转换。不要在这里使用 node.toString():它返回带引号的 JSON 形式,而 BigDecimal 构造函数会拒绝引号。
如果你将响应映射到 record,Jackson 会为你转换字符串,因为它默认将 JSON 字符串强制转换为 BigDecimal 字段:
这个注解在处理真实 API 负载时很重要,因为真实负载携带的字段比你的 record 多。Jackson 2 默认在遇到未知属性时失败。
Jackson 3(包名 tools.jackson)改变了一些行为。字符串节点是 StringNode,decimalValue() 会抛出 JsonNodeException 而不是返回零,新的 asDecimal() 将文本解析为精确的 BigDecimal。Jackson 3 也不再默认在未知属性上失败。
BigDecimal 的五个陷阱
一旦值成为 BigDecimal,这个类仍然有几个惊喜。
1. double 构造函数
new BigDecimal(double) 忠实地复制了二进制误差。BigDecimal.valueOf(double) 看起来更好,因为它通过 Double.toString,但它无法找回 double 从未拥有的数字:BigDecimal.valueOf(63520.780763913123456) 是 63520.78076391312。请从字符串构建价格。
2. equals 不是 compareTo
equals 和 hashCode 包含标度,所以 HashSet 会保留两个值,而 HashMap.get(b) 会错过以 a 存储的键。TreeSet 和 TreeMap 使用 compareTo 并视为一个值。请用 compareTo 比较价格,用 signum() == 0 测试是否为零,如果必须使用映射键,请用 stripTrailingZeros() 规范化它们。
3. divide 需要规则
没有标度或 MathContext 时,divide 只有在结果精确时才会成功;否则会抛出 Non-terminating decimal expansion; no exact representable decimal result.。请决定结果的含义:八位小数向下舍入得到完整的聪,并且永远不会超过 100 USD 能买的 BTC 数量。MathContext 计算的是有效数字,而不是小数位数。还要注意没有标度的 divide(divisor, RoundingMode.HALF_UP):它保留被除数的标度,所以 BigDecimal.ONE.divide(new BigDecimal("3"), RoundingMode.HALF_UP) 返回 0。
4. toString 会切换到科学记数法
一旦值低于 0.000001(一聪打印为 1E-8),或者去除零后留下负标度,toString() 就会使用 E 记数法。字符串拼接、String.valueOf 和 "%s" 都会调用 toString()。对于任何人阅读或其他系统解析的内容,请使用 toPlainString()。Jackson 也用 toString() 写入 BigDecimal,除非你启用 StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN,例如使用 JsonMapper.builder().enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN).build()。
5. 不同工具的四舍五入模式不同
HALF_EVEN 将平局送到偶数邻居,这使许多四舍五入值的求和无偏。NumberFormat 默认使用它,而 String.format 使用四舍五入,所以同一个值在一个页面上可能显示为两种不同的样子。请有意选择一种模式,并只在最后舍入一次。
综合应用:为一个小型投资组合估值
下面是一个程序中的完整路径:获取三种币的价格,为持仓估值并显示每种币的份额。它使用 Bitculator Java SDK(GitHub 上的源代码),需要 Java 17,并将行返回为 Jackson 2 的 JsonNode:
在运行之前,将 BITCULATOR_API_KEY 设置为 Data API 密钥;免费层密钥就足够了。
使用这些示例价格(BTC 为 63,520.780763913,ETH 为 2,431.5527310904,SHIB 为 0.000012341873905),它打印:
该程序在每次乘法和求和过程中保持完整精度,只在人们阅读结果的地方进行舍入:在 NumberFormat 和唯一一次除法中。SHIB 的价格刚好高于 toString() 切换到 E 记数法的点,所以无论如何都会以普通形式打印;一个更便宜的代币价格为 0.00000065231842 时会打印为 6.5231842E-7。程序不依赖这一点,因为人们看到的每个数字都经过 NumberFormat 或 toPlainString()。最后一行的 stripTrailingZeros() 是因为乘积的标度是两个标度之和,而 add() 保留较大的标度:SHIB 值有 15 位小数,所以精确总和在去除零之前是 53750.322085423250000。循环后的检查也很重要:API 会省略它不认识的 slug,而静默缺失的持仓与静默错误的数字是同一种 bug。## 简明清单
- 绝不要用
double存储价格,哪怕“只是用于显示”也不行。 - 用
new BigDecimal(node.asText())读取十进制字符串,绝不要用decimalValue()或asDouble()(在 Jackson 3 中应使用node.asDecimal())。 - 如果价格以 JSON 数字形式传入,而你用
readTree读取它们,或读入Map,请在解析前启用USE_BIG_DECIMAL_FOR_FLOATS。 - 用字符串构造
BigDecimal,而不是用 double。 - 用
compareTo进行比较,用signum()判断是否为零。 - 为每个
divide指定标度和舍入模式,或提供MathContext。 - 输出时使用
toPlainString(),写入 JSON 时使用WRITE_BIGDECIMAL_AS_PLAIN。 - 有意识地选择舍入模式,并且只在最后舍入一次。
披露:作者构建了示例中使用的 Bitculator Data API 与 Java SDK。
分享此页面
发现错误或有内容要补充?在 GitHub 上编辑此页面
[LOADING...]
作者
Christian Lauridsen
Christian Lauridsen 构建了 Bitculator:一个提供公开 REST API、面向八种语言的开源 SDK,以及一个 MCP 服务器的加密市场数据平台。
相关文章
[LOADING...]Stefano Fago2026年6月24日 476 次浏览
double、BigDecimal 还是定点数?Java 中数字的精度、性能与合理选择
参与讨论
[LOADING...]Stefano Fago2026年6月24日 476 次浏览