Ohhnews

分类导航

$ cd ..
foojay原文

Java精确处理价格:十进制字符串与BigDecimal陷阱

#java#bigdecimal#价格处理#数值精度#jackson

价格看起来是最简单的数据:一个带几位小数的数字。然后一个加密货币价格出现了十五位小数,一个代币的交易价格为 0.000012341873905,屏幕上的数字开始对不上。本文探讨了从 JSON 到屏幕之间出了什么问题,以及如何用 BigDecimal 和 Jackson 保留每一位数字。关于数字类型之间更广泛的权衡,请参阅 foojay 上的 double、BigDecimal 还是定点数?;本文跟踪一个价格从 JSON 到屏幕的旅程。

为什么 double 是价格的错误类型

double 存储的是二进制小数,而大多数十进制小数没有精确的二进制形式。用三行代码就能看到:

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);
        System.out.println(63520.780763913 * 0.75);
        System.out.println(new BigDecimal("63520.780763913").multiply(new BigDecimal("0.75")));
    }
}
0.30000000000000004
47640.585572934746
47640.58557293475

第二行是 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 系列):

ObjectMapper mapper = new ObjectMapper();
JsonNode p = mapper.readTree("{\"p\": 63520.780763913123456}").get("p");

p.getClass();       // DoubleNode
p.decimalValue();   // 63520.78076391312

在你的代码看到这些数字之前,它们就已经丢失了,而 decimalValue() 只是包装了四舍五入后的 double。直接绑定到 BigDecimal 字段是精确的;丢失发生在树中和无类型映射中。如果你能控制解析器,DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS 也能在那里保留完整值,但树会去除末尾的零(1200.00 返回为 1.2E+3),所以请用 toPlainString() 打印这些值。

然而,你通常无法控制每一个客户端,所以许多市场数据 API 改为将价格作为字符串发送。本文示例使用的 Bitculator Data API 会将每个币种返回为如下的一行(有删节;Bitculator Data API 文档列出了每个字段):

{
  "slug": "bitcoin",
  "symbol": "BTC",
  "price": "63520.780763913",
  "marketcap": 1251359381049.09,
  "change_24h": 1.35
}

价格是一个十进制字符串,末尾的零被去除,且从不使用 E 记数法。对于四舍五入的 double 可以接受的值,例如市值和百分比变化,仍保留为 JSON 数字。

使用 Jackson 读取十进制字符串

字符串价格到达时是一个 TextNode,这就是第一个陷阱所在:

JsonNode price = mapper.readTree("{\"price\": \"63520.780763913\"}").get("price");

price.decimalValue();             // 0
price.asDouble();                 // 63520.780763913, but now a double
new BigDecimal(price.asText());   // 63520.780763913

decimalValue() 定义在 JsonNode 上,对于任何不是数字节点的内容都返回 BigDecimal.ZERO。没有异常,没有警告,只有一个零价格。asDouble() 解析文本,这又带回了 double 的问题。new BigDecimal(node.asText()) 才是你想要的转换。不要在这里使用 node.toString():它返回带引号的 JSON 形式,而 BigDecimal 构造函数会拒绝引号。

如果你将响应映射到 record,Jackson 会为你转换字符串,因为它默认将 JSON 字符串强制转换为 BigDecimal 字段:

@JsonIgnoreProperties(ignoreUnknown = true)
record Quote(String symbol, BigDecimal price) {}

Quote btc = mapper.readValue(
        "{\"symbol\":\"BTC\",\"price\":\"63520.780763913\",\"rank\":1}", Quote.class);
// Quote[symbol=BTC, price=63520.780763913]

这个注解在处理真实 API 负载时很重要,因为真实负载携带的字段比你的 record 多。Jackson 2 默认在遇到未知属性时失败。

Jackson 3(包名 tools.jackson)改变了一些行为。字符串节点是 StringNode,decimalValue() 会抛出 JsonNodeException 而不是返回零,新的 asDecimal() 将文本解析为精确的 BigDecimal。Jackson 3 也不再默认在未知属性上失败。

BigDecimal 的五个陷阱

一旦值成为 BigDecimal,这个类仍然有几个惊喜。

1. double 构造函数

new BigDecimal(0.1);       // 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1);   // 0.1
new BigDecimal("0.1");     // 0.1

new BigDecimal(double) 忠实地复制了二进制误差。BigDecimal.valueOf(double) 看起来更好,因为它通过 Double.toString,但它无法找回 double 从未拥有的数字:BigDecimal.valueOf(63520.780763913123456) 是 63520.78076391312。请从字符串构建价格。

2. equals 不是 compareTo

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");

a.equals(b);       // false, the scales differ (1 and 2)
a.compareTo(b);    // 0

equals 和 hashCode 包含标度,所以 HashSet 会保留两个值,而 HashMap.get(b) 会错过以 a 存储的键。TreeSet 和 TreeMap 使用 compareTo 并视为一个值。请用 compareTo 比较价格,用 signum() == 0 测试是否为零,如果必须使用映射键,请用 stripTrailingZeros() 规范化它们。

3. divide 需要规则

BigDecimal usd = new BigDecimal("100");
BigDecimal btcPrice = new BigDecimal("63520.780763913");

usd.divide(btcPrice);                          // ArithmeticException
usd.divide(btcPrice, 8, RoundingMode.DOWN);    // 0.00157428
usd.divide(btcPrice, MathContext.DECIMAL64);   // 0.001574287954231371

没有标度或 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 会切换到科学记数法

BigDecimal tiny = new BigDecimal("0.0000000123");

String s = "price: " + tiny;   // price: 1.23E-8
tiny.toPlainString();           // 0.0000000123

new BigDecimal("1200").stripTrailingZeros();                   // 1.2E+3
new BigDecimal("1200").stripTrailingZeros().toPlainString();   // 1200

一旦值低于 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. 不同工具的四舍五入模式不同

new BigDecimal("2.345").setScale(2, RoundingMode.HALF_UP);     // 2.35
new BigDecimal("2.345").setScale(2, RoundingMode.HALF_EVEN);   // 2.34

NumberFormat.getCurrencyInstance(Locale.US).format(new BigDecimal("2.345"));   // $2.34
String.format(Locale.ROOT, "%.2f", new BigDecimal("2.345"));                  // 2.35

HALF_EVEN 将平局送到偶数邻居,这使许多四舍五入值的求和无偏。NumberFormat 默认使用它,而 String.format 使用四舍五入,所以同一个值在一个页面上可能显示为两种不同的样子。请有意选择一种模式,并只在最后舍入一次。

综合应用:为一个小型投资组合估值

下面是一个程序中的完整路径:获取三种币的价格,为持仓估值并显示每种币的份额。它使用 Bitculator Java SDK(GitHub 上的源代码),需要 Java 17,并将行返回为 Jackson 2 的 JsonNode:

<dependency>
  <groupId>com.bitculator</groupId>
  <artifactId>bitculator</artifactId>
  <version>1.1.0</version>
</dependency>

在运行之前,将 BITCULATOR_API_KEY 设置为 Data API 密钥;免费层密钥就足够了。

import com.bitculator.Bitculator;
import com.bitculator.Params;
import com.fasterxml.jackson.databind.JsonNode;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

public class PortfolioValue {

    public static void main(String[] args) {
        Map<String, BigDecimal> holdings = Map.of(
                "bitcoin", new BigDecimal("0.75"),
                "ethereum", new BigDecimal("2.5"),
                "shiba-inu", new BigDecimal("2500000"));

        Bitculator client = new Bitculator(System.getenv("BITCULATOR_API_KEY"));
        JsonNode rows = client.prices().list(Params.of().set("slugs", holdings.keySet()));

        Map<String, BigDecimal> values = new LinkedHashMap<>();
        for (JsonNode row : rows) {
            JsonNode price = row.get("price");
            if (price == null || price.isNull()) {
                continue; // no current price for this coin
            }
            BigDecimal amount = holdings.get(row.get("slug").asText());
            values.put(row.get("symbol").asText(), new BigDecimal(price.asText()).multiply(amount));
        }
        if (values.size() < holdings.size()) {
            System.err.println("No price for " + (holdings.size() - values.size()) + " holding(s); the total leaves them out");
        }

        BigDecimal total = values.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        NumberFormat usd = NumberFormat.getCurrencyInstance(Locale.US);

        values.forEach((symbol, value) -> {
            BigDecimal share = value.multiply(BigDecimal.valueOf(100))
                    .divide(total, 2, RoundingMode.HALF_EVEN);
            System.out.printf("%-5s %14s %7s%%%n", symbol, usd.format(value), share.toPlainString());
        });
        System.out.printf("%-5s %14s%n", "Total", usd.format(total));
        System.out.println("Exact total: " + total.stripTrailingZeros().toPlainString());
    }
}

使用这些示例价格(BTC 为 63,520.780763913,ETH 为 2,431.5527310904,SHIB 为 0.000012341873905),它打印:

BTC       $47,640.59   88.63%
ETH        $6,078.88   11.31%
SHIB          $30.85    0.06%
Total     $53,750.32
Exact total: 53750.32208542325

该程序在每次乘法和求和过程中保持完整精度,只在人们阅读结果的地方进行舍入:在 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 服务器的加密市场数据平台。

相关文章

Java [LOADING...]

[LOADING...]Stefano Fago2026年6月24日 476 次浏览

double、BigDecimal 还是定点数?Java 中数字的精度、性能与合理选择

Java

参与讨论

Java [LOADING...]

[LOADING...]Stefano Fago2026年6月24日 476 次浏览

double、BigDecimal 还是定点数?Java 中数字的精度、性能与合理选择

Java