Java浮点数误差与BigDecimal原理:从二进制存储到精确计算实践

Java浮点数误差与BigDecimal原理:从二进制存储到精确计算实践 在很多后端研发和技术面试场景里单问“浮点数为什么会有误差”很容易讲出几个关键词二进制、无限小数、舍入误差。但真正容易被追问也更容易暴露理解深度的是后面的那一层BigDecimal 到底是怎么把误差解决掉的它是把所有二进制浮点数都换成了十进制字符串重算一遍还是在内部用了某种精确保存机制如果只是简单回答“BigDecimal 用字符串表示数字”那在 Java 面试里基本只能拿一个基础分。这篇文章不准备停留在“记住结论”的层面而是从 Java 浮点数的存储结构开始把误差产生的底层原因拆开再一步步看到 BigDecimal 的源码设计和运算逻辑然后把加减乘除、比较、舍入、格式化这些高频使用点逐个跑一遍。最后还会整理面试官最喜欢追问的边界场景以及自己在真实项目里遇到过的一些坑。1. 先理解 Java 浮点数为什么要用二进制存储1.1 二进制与十进制的“数值表示”差异计算机内部没有“十进制”这个概念所有数据最终都要落到二进制电路上。CPU 里的浮点数运算单元不管是单精度 float 还是双精度 double都遵循 IEEE 754 标准用二进制科学计数法保存一个数。一个十进制数字在小数部分能否被精确表示取决于这个十进制小数能不能写成一个有限二进制小数。简单说二进制小数只能表示分母为2^k的十进制小数比如0.5、0.25、0.125。原因很简单二进制的每一位权重都是 2 的负整数次幂小数点后第一位是2^-1第二位是2^-2以此类推。所以十进制里的0.1看起来是一个干净的一位小数但在二进制里等于0.1 1/10 1/(2 * 5)因为分母里含有一个质因数 5它无法用若干 2 的负次幂相加得到。无论二进制位扩展到多少位也只能无限接近0.1无法精确等于0.1。这就是浮点数误差的第一层来源十进制小数转换为二进制小数时本身就是近似值。1.2 double 的存储布局符号位、指数位、尾数位在 Java 中double占用 64 位它的存储布局如下组成部分位数作用符号位 sign1 位0 表示正数1 表示负数指数位 exponent11 位保存经过偏移后的指数尾数位 fraction52 位保存二进制科学计数法的小数部分float则是 32 位布局相同但指数位只有 8 位尾数位只有 23 位。IEEE 754 为了表示更大范围的数据还给指数引入了偏移量。double的指数偏移量是1023float是127。也就是说真实指数E存储时会被写成E 1023。尾数部分有一个隐藏规则二进制科学计数法下1 M 2所以整数部分一定是 1。基于这个规律IEEE 754 标准不存储这个 1只存小数点后面的部分因此实际精度比明面上多了一位。double的有效二进制精度是 53 位。可以写一段代码实际看0.1在double里离真实值有多远public class FloatPrecisionDemo { public static void main(String[] args) { double value 0.1; System.out.println(value); System.out.println(new BigDecimal(value)); } }运行结果0.1 0.1000000000000000055511151231257827021181583404541015625第一行是打印时被格式化后的显示结果第二行是double内部真正持有的十进制展开值。这个值比0.1大一点点误差在 20 位小数之后出现。所以面试官问“浮点误差产生的原因”时先不要急着答“精度不够”更准确的表达是十进制小数先转换为二进制近似值double 再用有限的 52 位尾数保存这个近似值两次近似叠加后误差就出现了。1.3 为什么连续运算会放大误差单个数字的误差可能很小但误差会随着运算累积。乘法会把误差倍数放大加减法在数值量级差距悬殊时也会丢失低位信息。看一个最容易在面试中出现的例子public class AccumulateErrorDemo { public static void main(String[] args) { double total 0; for (int i 0; i 10; i) { total 0.1; } System.out.println(total 1.0); System.out.println(total); } }运行结果false 0.999999999999999910 次累加之后误差没有消失反而被累积成了可见的结果差异。这个例子可以非常直观地说明浮点数不是不能做运算而是它的运算结果天然带有近似性。业务场景一旦要求“计算结果必须精确”直接使用double就会出问题。2. BigDecimal 不是“神仙方案”它解决的是十进制精确表示问题2.1 BigDecimal 的核心设计思路BigDecimal位于java.math包下它没有把数字保存成二进制浮点数而是用一个无限精度的整数配合一个表示小数位数的标度来保存十进制数。可以这样理解它的数据结构intVal无限精度的整数保存所有有效数字。scale小数位数决定小数点从哪里切开。precision有效数字总位数由intVal和scale计算得出。例如数字123.45在BigDecimal内部会被保存成整数12345和标度2。123.450也是整数123450和标度3两者数值相同但 scale 不同因此打印结果也不同。看下面这段代码import java.math.BigDecimal; public class BigDecimalStructureDemo { public static void main(String[] args) { BigDecimal a new BigDecimal(123.45); BigDecimal b new BigDecimal(123.450); System.out.println(a a); System.out.println(b b); System.out.println(a.scale() a.scale()); System.out.println(b.scale() b.scale()); System.out.println(a.equals(b) a.equals(b)); System.out.println(a.compareTo(b) a.compareTo(b)); } }运行结果a 123.45 b 123.450 a.scale() 2 b.scale() 3 a.equals(b) false a.compareTo(b) 0这里有两个值得向面试官强调的点equals方法会同时比较数值和scale所以123.45和123.450不相等。compareTo只比较数值大小所以123.45和123.450大小相等。实际业务中判断两个BigDecimal是否相等应该优先使用compareTo避免因为小数位不同导致误判。2.2 从 double 构造和从 String 构造结果是完全不同的这是 BigDecimal 最常见的坑也是面试中几乎必问的细节。BigDecimal d1 new BigDecimal(0.1); BigDecimal d2 new BigDecimal(0.1); System.out.println(d1); System.out.println(d2);运行结果0.1000000000000000055511151231257827021181583404541015625 0.1new BigDecimal(0.1)接收的是double类型的参数而0.1这个double本身就是近似值。BigDecimal会忠实保存这个近似值对应的十进制展开因此得到一大串数字。new BigDecimal(0.1)接收的是字符串字符串解析过程严格按照十进制小数处理因此结果干净。所以项目中推荐统一使用new BigDecimal(String value)或者使用BigDecimal.valueOf(double value)。valueOf内部会先调用Double.toString把double转成十进制字符串再走字符串构造逻辑。BigDecimal d3 BigDecimal.valueOf(0.1); System.out.println(d3);运行结果0.1注意BigDecimal.valueOf(0.1)能显示成0.1不代表0.1这个double没有误差而是valueOf主动做了字符串转换只取了十进制输出结果。2.3 BigDecimal 的“精确”边界BigDecimal 解决的是十进制小数在十进制体系内的精确保存与运算问题。它不是魔法也不能改变“0.1 作为 double 本身是近似值”这一事实。如果业务数据来源就是double那么误差在进入 BigDecimal 之前已经存在。一个典型的错误写法是BigDecimal result new BigDecimal(0.1).add(new BigDecimal(0.2));这样得到的不是0.3而是两个近似值相加后的近似展开。正确写法是BigDecimal result new BigDecimal(0.1).add(new BigDecimal(0.2));因此在设计接口、数据库字段、JSON 序列化结构时金额、利率、计数这类数据应该从一开始就使用字符串或 BigDecimal 语义而不是先存成double再转换。3. 用加减乘除和舍入操作理解 BigDecimal 的运算规则3.1 加减法的结果标度规则BigDecimal.add和subtract的结果标度取两个操作数中较大的scale。BigDecimal a new BigDecimal(1.20); BigDecimal b new BigDecimal(0.3); BigDecimal sum a.add(b); BigDecimal diff a.subtract(b); System.out.println(sum); System.out.println(diff);运行结果1.50 0.90这个结果和直觉一致1.20 加 0.3保留两位小数结果是 1.50而不是 1.5。如果需要去掉末尾的 0可以使用stripTrailingZeros()。System.out.println(sum.stripTrailingZeros());输出1.53.2 乘法的结果标度规则乘法结果的scale是操作数scale之和。BigDecimal a new BigDecimal(1.20); BigDecimal b new BigDecimal(0.30); System.out.println(a.multiply(b));1.20的 scale 是 20.30的 scale 是 2乘法结果 scale 是 4输出0.3600从数学角度看0.3600和0.36数值相等从业务展示角度看可能需要统一格式。所以乘法之后是否调用stripTrailingZeros()取决于下游展示和存储要求。3.3 除法的无穷小数问题除法是 BigDecimal 运算里最容易抛出异常的场景。当除不尽时divide方法无法用有限位数表达结果会抛出ArithmeticExceptionBigDecimal one new BigDecimal(1); BigDecimal three new BigDecimal(3); System.out.println(one.divide(three));运行时会看到异常Exception in thread main java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.正确的做法是传入舍入模式和保留位数BigDecimal result one.divide(three, 4, RoundingMode.HALF_UP); System.out.println(result);输出0.3333这里的参数含义是保留 4 位小数使用四舍五入规则。3.4 常用舍入模式速查java.math.RoundingMode提供了多种舍入规则业务中高频使用的是HALF_UP、HALF_EVEN、UP、DOWN、CEILING、FLOOR。舍入模式含义示例2.5 保留 0 位小数示例-2.5 保留 0 位小数UP远离零方向舍入3-3DOWN向零方向舍入2-2CEILING向正无穷方向舍入3-2FLOOR向负无穷方向舍入2-3HALF_UP四舍五入3-3HALF_EVEN银行家舍入看相邻位2-2HALF_DOWN大于 5 才进 12-2HALF_EVEN是会计和统计中常用的“银行家舍入”它在 5 的前一位是偶数时舍去奇数时进位从而减少大量数据累计时的系统性偏差。普通业务如果没有特殊要求一般使用HALF_UP。3.5 BigDecimal 除法重载方法的区分面试中经常出现这样的题目one.divide(three)和one.divide(three, RoundingMode.HALF_UP)有什么区别。简单回答divide(BigDecimal divisor)要求结果必须能精确表示否则抛异常。divide(BigDecimal divisor, RoundingMode roundingMode)如果除不尽会使用默认结果标度并应用给定的舍入模式。divide(BigDecimal divisor, int scale, RoundingMode roundingMode)可以直接指定结果保留的小数位数最常用于金额计算。实际开发里更推荐写全参数版本避免不同 JDK 版本对默认 scale 的行为产生差异。4. 比较、格式化和不可变性BigDecimal 使用中的三个关键细节4.1 比较大小应该用 compareTo而不是 equals前面已经看到equals会受scale影响compareTo只比较数值大小。业务中判断金额是否相等例如判断订单应付金额和实付金额是否一致应该使用compareToBigDecimal dueAmount new BigDecimal(100.00); BigDecimal paidAmount new BigDecimal(100.0); if (dueAmount.compareTo(paidAmount) 0) { System.out.println(金额一致); }如果使用equals会输出“金额不一致”因为scale一个是 2一个是 1。这类问题在数据库返回DECIMAL(10,2)、JSON 序列化又自动调整小数位之后很容易出现。4.2 BigDecimal 对象是不可变的BigDecimal的运算方法不会修改原对象而是返回一个新的BigDecimal实例。很多初学者会写出这种代码BigDecimal amount new BigDecimal(10); amount.add(new BigDecimal(5)); System.out.println(amount);运行结果是10原因就是add的结果没有接收。正确写法是amount amount.add(new BigDecimal(5));这也意味着在循环中累加金额时要注意对象的创建和回收BigDecimal total BigDecimal.ZERO; for (BigDecimal item : itemList) { total total.add(item); }BigDecimal.ZERO和BigDecimal.ONE是内部缓存对象可以作为累加初始值使用避免额外创建对象。4.3 格式化和转换输出金额展示时经常需要保留两位小数并加上千分位分隔符。BigDecimal本身不负责格式化需要借助DecimalFormat或者String.format。import java.math.BigDecimal; import java.math.RoundingMode; import java.text.DecimalFormat; public class FormatDemo { public static void main(String[] args) { BigDecimal amount new BigDecimal(12345.6789); BigDecimal rounded amount.setScale(2, RoundingMode.HALF_UP); DecimalFormat df new DecimalFormat(#,##0.00); System.out.println(df.format(rounded)); } }输出12,345.68注意setScale本身也是返回新对象不会修改原amount。另外如果amount本身有多位小数setScale(2, RoundingMode.HALF_UP)会四舍五入如果amount只有一位小数setScale(2)不指定舍入模式也不会抛异常因为位数是变多而不是变少。但如果把setScale(1)放在一个两位小数的数字上不指定舍入模式就会抛ArithmeticExceptionBigDecimal a new BigDecimal(1.25); System.out.println(a.setScale(1));运行结果Exception in thread main java.lang.ArithmeticException: Rounding necessary这也是一个容易忽略的细节。5. 实际项目中的金额计算从数据库字段到 JSON 输出的完整链路5.1 数据库字段推荐使用 DECIMAL金额字段在数据库中优先使用DECIMAL类型而不是FLOAT或DOUBLE。例如CREATE TABLE order ( id BIGINT PRIMARY KEY, order_no VARCHAR(64), amount DECIMAL(12, 2) NOT NULL, paid_amount DECIMAL(12, 2) NOT NULL DEFAULT 0.00, created_at DATETIME );DECIMAL(12, 2)表示总位数 12小数 2 位整数部分最多 10 位。具体位数要根据业务单笔金额上限、累计金额上限来定不能照搬。5.2 Java 实体使用 BigDecimalimport java.math.BigDecimal; import java.time.LocalDateTime; public class Order { private Long id; private String orderNo; private BigDecimal amount; private BigDecimal paidAmount; private LocalDateTime createdAt; public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount amount; } public BigDecimal getPaidAmount() { return paidAmount; } public void setPaidAmount(BigDecimal paidAmount) { this.paidAmount paidAmount; } }5.3 JSON 序列化时的小数位数控制使用 Jackson 序列化时默认会输出BigDecimal的原始scale。如果数据库字段是DECIMAL(12, 2)Java 实体也是BigDecimal一般输出是两位小数。但为了避免不同库版本和配置带来意外可以局部使用注解import com.fasterxml.jackson.annotation.JsonFormat; public class OrderResponse { JsonFormat(shape JsonFormat.Shape.STRING, pattern 0.00) private BigDecimal amount; }这里把金额序列化成字符串是为了避免前端 JavaScript 处理超长数字时丢失精度。实际项目中金额字段序列化为字符串是常见做法特别是订单号、金额同时存在时。5.4 统计汇总时避免反复转换如果有金额累加需求推荐在一个地方统一处理import java.math.BigDecimal; import java.util.List; public class OrderAmountCalculator { public BigDecimal sumAmounts(ListOrder orders) { BigDecimal total BigDecimal.ZERO; for (Order order : orders) { if (order ! null order.getAmount() ! null) { total total.add(order.getAmount()); } } return total; } }注意两点累加前判空避免NullPointerException。循环内必须重新赋值给total因为add返回新对象。如果订单数据量大考虑使用parallelStream后使用reduce但要确保合并函数不会丢失精度。5.5 一个最小可运行的计算示例下面把完整流程跑一遍输入两个字符串金额执行折扣计算最后格式化成两位小数。import java.math.BigDecimal; import java.math.RoundingMode; import java.text.DecimalFormat; public class AmountCalculateDemo { public static void main(String[] args) { BigDecimal price new BigDecimal(199.90); BigDecimal quantity new BigDecimal(3); BigDecimal discountRate new BigDecimal(0.85); BigDecimal originalAmount price.multiply(quantity); BigDecimal payableAmount originalAmount.multiply(discountRate) .setScale(2, RoundingMode.HALF_UP); System.out.println(原价合计 originalAmount); System.out.println(折扣后应付 payableAmount); // 格式化输出 DecimalFormat df new DecimalFormat(#,##0.00); System.out.println(格式化后 df.format(payableAmount)); } }运行结果原价合计599.70 折扣后应付509.745 格式化后509.75这里的计算链路是先精确乘出原价再进行折扣乘法最后统一舍入到两位小数。不要在中间步骤过早舍入否则误差会在后续计算中累积。6. 面试高频追问BigDecimal 的表示原理与极限边界6.1 BigInteger 和 BigDecimal 的关系BigDecimal底层保存整数部分的类型是BigInteger。BigInteger可以保存任意长度的整数不受long的 64 位上限约束。为了直观理解可以写代码看BigDecimal是怎么“拆开”数字的import java.math.BigDecimal; import java.math.BigInteger; public class InnerDemo { public static void main(String[] args) throws Exception { BigDecimal value new BigDecimal(123.4500); System.out.println(scale value.scale()); System.out.println(precision value.precision()); } }运行结果scale 4 precision 7数字123.4500有 7 位有效数字1、2、3、4、5、0、0。如果把末尾两个 0 去掉precision会变成 5。6.2 为什么 BigDecimal 不是线程安全的BigDecimal是不可变对象理论上不可变对象是线程安全的。但这里必须区分“对象本身”和“引用”。如果多个线程共享同一个BigDecimal引用并且线程内执行的是value value.add(...)那么赋值过程存在竞态条件因为其他线程可能读到旧值。如果只是读取同一个BigDecimal不会出现数据错乱。实际并发场景中更推荐使用AtomicReferenceBigDecimal或者使用不可变对象后不断替换引用import java.math.BigDecimal; import java.util.concurrent.atomic.AtomicReference; public class SafeTotal { private final AtomicReferenceBigDecimal total new AtomicReference(BigDecimal.ZERO); public void add(BigDecimal value) { total.accumulateAndGet(value, BigDecimal::add); } public BigDecimal getTotal() { return total.get(); } }6.3 BigDecimal 的精度上限BigDecimal理论上没有精度上限但会受到内存限制。BigInteger内部用int[]保存数据每个int占 32 位数值越大占用内存越多。创建超大BigDecimal会导致内存压力甚至OutOfMemoryError。正常业务中不会出现天文数字级别的高精度运算。如果确实需要大量计算要考虑运算性能不能把BigDecimal用在每秒百万次的高频实时计算链路上。6.4 BigDecimal 性能对比 double可以做一个小型基准测试对比double和BigDecimal但不需要严谨的 JMH 报告只需要说明量级差异long start System.nanoTime(); double sum 0; for (int i 0; i 1000000; i) { sum 0.1d; } long doubleTime System.nanoTime() - start; start System.nanoTime(); BigDecimal total BigDecimal.ZERO; BigDecimal step new BigDecimal(0.1); for (int i 0; i 1000000; i) { total total.add(step); } long bigDecimalTime System.nanoTime() - start; System.out.println(double time: doubleTime ns); System.out.println(BigDecimal time: bigDecimalTime ns);BigDecimal的耗时通常比double高数十倍。原因很简单BigDecimal需要创建不可变对象、维护BigInteger数组、处理进位退位逻辑。所以性能敏感、精度要求低的场景比如图形渲染、物理引擎、统计指标近似计算继续使用double是合理的金额计算、余额扣减、费率计算则必须使用BigDecimal。7. 常见问题排查从报错到正确用法7.1 除不尽抛出 ArithmeticException现象java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.原因调用divide时没有指定舍入模式而除法结果的小数部分无限循环。解决BigDecimal result value.divide(divisor, 2, RoundingMode.HALF_UP);预防所有涉及除法的BigDecimal运算统一封装工具方法强制传入精度和舍入模式。7.2 new BigDecimal(0.1) 得到超长小数现象0.1000000000000000055511151231257827021181583404541015625原因double入参本身是二进制近似值。解决BigDecimal safeValue new BigDecimal(0.1);或者BigDecimal safeValue BigDecimal.valueOf(0.1);预防项目代码规范中强制要求高精度数值必须从字符串或数据库DECIMAL字段读取避免在 Java 层把double直接传入构造器。7.3 setScale 缩小位数据抛异常现象java.lang.ArithmeticException: Rounding necessary原因setScale从多位数缩小到少位数时必须明确舍入规则。解决value.setScale(2, RoundingMode.HALF_UP);预防统一使用工具方法封装setScale并在 code review 阶段检查是否传了舍入模式。7.4 equals 判断金额相等返回 false现象两个值看起来相同但equals返回false。原因scale不同例如100.00和100.0。解决value1.compareTo(value2) 0预防业务比较金额时只用compareTo。7.5 JSON 输出金额变成科学计数法或丢失小数位现象BigDecimal序列化后变成1.2345E3或者小数位被截断。原因JSON 库默认处理方式不同或者BigDecimal的scale为负数。当scale为负数时例如new BigDecimal(1.23E3).stripTrailingZeros()可能输出科学计数法。解决BigDecimal value new BigDecimal(1234.00) .setScale(2, RoundingMode.HALF_UP);并使用 Jackson 注解统一输出格式。7.6 排查清单检查项检查内容处理建议构造入参是否直接传入double改为字符串或valueOf除法调用是否指定 scale 和舍入模式强制指定金额比较是否使用equals改为compareTo累加赋值是否忘记接收返回值重新赋值给变量舍入调用setScale缩小位数时是否传模式统一封装JSON 展示小数位是否符合预期使用注解固定格式8. 最佳的实践规则让项目少出现“精度事故”8.1 统一使用 BigDecimal 工具类项目里建议封装一个金额工具类把构造、加法、减法、乘法、除法、舍入、比较统一收口避免团队成员各自实现逻辑。import java.math.BigDecimal; import java.math.RoundingMode; public final class AmountUtils { private static final int DEFAULT_SCALE 2; private AmountUtils() { } public static BigDecimal of(String value) { return new BigDecimal(value); } public static BigDecimal add(BigDecimal a, BigDecimal b) { return a.add(b); } public static BigDecimal subtract(BigDecimal a, BigDecimal b) { return a.subtract(b); } public static BigDecimal multiply(BigDecimal a, BigDecimal b) { return a.multiply(b); } public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, DEFAULT_SCALE, RoundingMode.HALF_UP); } public static BigDecimal scale(BigDecimal value) { return value.setScale(DEFAULT_SCALE, RoundingMode.HALF_UP); } public static int compare(BigDecimal a, BigDecimal b) { return a.compareTo(b); } }8.2 数据库字段、接口入参、日志记录统一规范数据库金额字段使用DECIMAL不要使用DOUBLE。对外接口的金额字段使用字符串或者 JSON 序列化时格式化为字符串。打印日志时不直接拼接BigDecimal先转成字符串避免输出不稳定的科学计数法。从数据库读取值时不要先转换成double再做运算。8.3 学习环境与生产环境学习阶段可以用double和BigDecimal反复对比观察误差如何产生、舍入模式如何影响结果。生产环境则必须按照以下顺序落地确认数据源头类型是字符串或DECIMAL。统一使用new BigDecimal(String)或BigDecimal.valueOf。所有计算和展示使用同一个scale和RoundingMode。金额比较只使用compareTo。除法必须指定精度和舍入模式。对金额结果做单元测试覆盖边界值比如0.00、负数、大额数字、9999999999.99。8.4 面试回答参考如果面试官问“BigDecimal 如何解决浮点误差”可以按以下结构回答浮点数误差来自十进制小数到二进制小数的转换。因为二进制只能精确表示可拆分为2^-n组合的小数0.1这类常见十进制小数只能无限近似表示。BigDecimal 不采用二进制科学计数法而是使用 BigInteger 保存所有有效数字用 scale 记录小数位数从而在十进制体系内精确表达一个数。它的运算规则也完全面向十进制整数实现所以加减乘除不会产生二进制舍入误差。真正要注意的部分是构造方式必须优先从字符串构造否则传入 double 后误差已经存在BigDecimal 只能忠实保存这个误差值。这段回答既讲清楚了原理又说明了自己知道 BigDecimal 的使用边界比直接背八股效果更好。9. 从面试题到工程能力的延伸方向9.1 浮点数在非 Java 语言中的表现如果只学 Java很容易误以为只有 Java 有浮点误差。实际上 C、Python、Go、JavaScript、Rust 等语言遵循 IEEE 754 标准时都会有类似问题。只是不同语言提供了不同的封装或运算符重载。理解这一点面试时才能把问题回答得足够通用。9.2 与数据库计算的一致性数据库中的DECIMAL计算由数据库自己完成Java 中的BigDecimal计算由 JVM 完成。两者要提前约定小数位和舍入方式否则同一笔订单在应用层计算应收金额和数据库触发器等场景中可能得到不同结果。项目中可以把所有舍入规则统一记录在技术文档中。9.3 测试策略金额计算模块建议至少覆盖正数、负数、零。整数与多位小数。极大金额和极小金额。末位为 5 的舍入场景。多次累加的精度稳定性。除以 3 这类无限小数场景。JSON 序列化与反序列化的往返一致性。单元测试代码示例import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.math.RoundingMode; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class AmountUtilsTest { Test void testDivideWithRounding() { BigDecimal result new BigDecimal(1) .divide(new BigDecimal(3), 2, RoundingMode.HALF_UP); assertEquals(0, new BigDecimal(0.33).compareTo(result)); } Test void testCompareToIgnoresScale() { BigDecimal a new BigDecimal(100.00); BigDecimal b new BigDecimal(100.0); assertTrue(a.compareTo(b) 0); } Test void testStringConstructorIsPrecise() { BigDecimal fromString new BigDecimal(0.1); BigDecimal fromDouble new BigDecimal(0.1); assertTrue(fromString.compareTo(fromDouble) ! 0); } }9.4 建议的练习路径如果想把这块知识掌握得更扎实可以按以下顺序练习写代码打印0.1、0.2、0.3的double内部展开值。用BigDecimal分别从double和String构造比较输出差异。实现一个简单的金额计算工具类包含加减乘除、舍入、比较。用DecimalFormat处理千分位输出。在 Spring Boot 项目里把金额字段从double改成BigDecimal观察 JSON 输出变化。编写单元测试覆盖除不尽和边界场景。浮点精度问题看起来是一个很小的知识点但它背后涉及 IEEE 754、数据结构设计、运算规则、序列化、数据库字段选型是一条能串联很多基础的完整链路。面试时能把它讲出层次说明不仅会背结论也理解计算机底层表示和业务建模之间的平衡。