BigDecimal 与精确小数运算 | JavaSE

BigDecimal 与精确小数运算

一、学习目标

完成本章后,你应该能够:

  1. 解释为什么 doublefloat 在某些十进制小数运算中会出现精度误差。
  2. 理解 BigDecimal 的核心定位:任意精度的十进制数值运算
  3. 正确使用 new BigDecimal(String)BigDecimal.valueOf(double) 创建 BigDecimal。
  4. 明确为什么通常不推荐直接使用 new BigDecimal(0.1)
  5. 熟练使用 add()subtract()multiply()divide() 完成四则运算。
  6. 理解除法中的 scaleRoundingMode
  7. 熟练使用 setScale() 处理金额保留小数位数。
  8. 准确区分 equals()compareTo() 的比较语义。
  9. 理解 BigDecimal 的不可变性。
  10. 能够使用 BigDecimal 完成商品价格、折扣、税费等实际金额计算。

二、核心知识

2.1 为什么 0.1 + 0.2 不一定等于我们看到的 0.3

先观察:

public class DoubleDemo {
    public static void main(String[] args) {
        double a = 0.1;
        double b = 0.2;

        System.out.println(a + b);
    }
}

你可能认为输出:

0.3

实际常见结果是:

0.30000000000000004

这并不是 Java 把小学数学算错了。

真正的问题在于:

计算机中的 floatdouble 使用二进制浮点表示,而很多有限十进制小数无法用有限长度的二进制小数精确表示。


2.2 十进制无法有限表示与二进制无法有限表示

十进制中:

1 / 3
=
0.333333333...

无法使用有限位十进制小数精确表示。

类似地,在二进制中:

0.1

也不能用有限长度的二进制小数精确表示。

因此计算机保存的实际上只能是:

非常接近 0.1 的某个二进制浮点数

于是:

近似值
+
近似值
=
另一个近似值

最终就可能出现:

0.30000000000000004

2.3 double 是不是不能用

不是。

这是学习 BigDecimal 时非常容易走向的另一个极端。

double 非常适合:

  • 科学计算
  • 图形计算
  • 物理模拟
  • 统计计算
  • 对微小浮点误差能够容忍的计算

例如:

double temperature = 36.5;
double height = 1.78;
double distance = 125.67;

完全正常。

真正需要格外谨慎的是:

业务规则要求十进制结果精确、舍入规则明确的场景。

例如:

商品金额
账单金额
利息
税费
折扣
财务计算

这就是 BigDecimal 最典型的应用领域。


2.4 BigDecimal 是什么

BigDecimal 位于:

java.math

包中。

需要导包:

import java.math.BigDecimal;

它可以理解为:

Java 提供的任意精度十进制数值类型。

例如:

BigDecimal price =
        new BigDecimal("19.99");

此时我们能够精确表达:

19.99

而不需要依赖二进制浮点数对十进制小数进行近似。


2.5 BigDecimal 的核心结构:数值 + scale

BigDecimal 可以从概念上理解为:

unscaled value × 10^(-scale)

例如:

19.99

可以理解成:

unscaled value = 1999
scale          = 2

所以:

1999 × 10^-2
=
19.99

再例如:

2.00

可以理解成:

unscaled value = 200
scale          = 2

这解释了后面一个非常重要的现象:

new BigDecimal("2.0")

与:

new BigDecimal("2.00")

虽然数值大小一样,但是内部表示中的:

scale

并不一样。


2.6 BigDecimal 是不可变对象

BigDecimal 与前面学习过的:

String
LocalDate
LocalDateTime

一样,也属于不可变对象。

例如:

BigDecimal price =
        new BigDecimal("100");

price.add(
        new BigDecimal("20")
);

System.out.println(price);

输出仍然是:

100

因为:

price.add(...)

并没有修改 price 本身。

而是:

根据原 BigDecimal 计算并返回一个新的 BigDecimal。

因此应该:

BigDecimal result =
        price.add(
                new BigDecimal("20")
        );

或者:

price =
        price.add(
                new BigDecimal("20")
        );

三、使用方法

3.1 创建 BigDecimal:字符串构造器

最直观的方式:

BigDecimal price =
        new BigDecimal("19.99");

注意参数:

"19.99"

是一个:

String

而不是:

double

这样 BigDecimal 可以直接按照字符串中的十进制数字建立精确的十进制数值。

例如:

BigDecimal a =
        new BigDecimal("0.1");

BigDecimal b =
        new BigDecimal("0.2");

BigDecimal result =
        a.add(b);

System.out.println(result);

输出:

0.3

3.2 不推荐这样创建小数 BigDecimal

下面这行代码虽然能编译:

BigDecimal number =
        new BigDecimal(0.1);

但它通常不是我们想要的效果。

原因:

0.1

在传入构造器之前,就已经先成为了一个:

double

而这个 double 并不能精确表示十进制 0.1。

因此:

new BigDecimal(0.1)

得到的是:

这个 double 二进制浮点值对应的精确十进制表示。

而不是你肉眼理解的“数学上的精确 0.1”。


3.3 对比实验

import java.math.BigDecimal;

public class BigDecimalCreateDemo {

    public static void main(String[] args) {

        BigDecimal a =
                new BigDecimal(0.1);

        BigDecimal b =
                new BigDecimal("0.1");

        System.out.println(a);
        System.out.println(b);
    }
}

第二个结果就是我们真正希望表示的:

0.1

而第一个会带有大量额外十进制位。

所以请建立非常重要的习惯:

明确写死的十进制值
→ new BigDecimal("0.1")

而不是:

new BigDecimal(0.1)

3.4 BigDecimal.valueOf()

如果手中已经是:

double

通常可以:

BigDecimal.valueOf(doubleValue)

例如:

double price = 0.1;

BigDecimal value =
        BigDecimal.valueOf(price);

valueOf(double) 会基于该 double 的规范字符串表示进行转换。

例如:

BigDecimal a =
        BigDecimal.valueOf(0.1);

BigDecimal b =
        BigDecimal.valueOf(0.2);

System.out.println(
        a.add(b)
);

得到:

0.3

3.5 到底使用 String 构造还是 valueOf

可以建立下面的实用规则。

程序中明确写一个精确十进制值

优先:

new BigDecimal("19.99")

例如:

BigDecimal taxRate =
        new BigDecimal("0.13");

数据已经是 double

使用:

BigDecimal.valueOf(value)

例如:

double value = 19.99;

BigDecimal number =
        BigDecimal.valueOf(value);

但如果整个业务一开始就要求精确十进制:

最好不要先把金额保存成 double,再“补救”成 BigDecimal。

更合理的是从数据进入系统时就选择正确的数据模型。


3.6 BigDecimal 常用四则运算

BigDecimal 不能直接写:

a + b

进行 BigDecimal 数值相加。

它通过方法完成运算。

主要包括:

| 运算 | API | | ---- | ------------ | | 加法 | add() | | 减法 | subtract() | | 乘法 | multiply() | | 除法 | divide() |


3.7 add():加法

BigDecimal price =
        new BigDecimal("99.90");

BigDecimal shipping =
        new BigDecimal("8.00");

BigDecimal total =
        price.add(shipping);

System.out.println(total);

结果:

107.90

思维:

price + shipping

转换成 BigDecimal API:

price.add(shipping)

3.8 subtract():减法

BigDecimal original =
        new BigDecimal("100.00");

BigDecimal discount =
        new BigDecimal("20.50");

BigDecimal result =
        original.subtract(discount);

System.out.println(result);

结果:

79.50

注意方向:

a.subtract(b)

表示:

a - b

3.9 multiply():乘法

例如商品单价:

19.90

购买:

3件

代码:

BigDecimal price =
        new BigDecimal("19.90");

BigDecimal count =
        new BigDecimal("3");

BigDecimal total =
        price.multiply(count);

System.out.println(total);

得到:

59.70

3.10 divide():除法

简单情况:

BigDecimal a =
        new BigDecimal("10");

BigDecimal b =
        new BigDecimal("2");

BigDecimal result =
        a.divide(b);

System.out.println(result);

得到:

5

但是除法有一个特殊问题。


3.11 为什么 1 / 3 会出现问题

例如:

BigDecimal one =
        new BigDecimal("1");

BigDecimal three =
        new BigDecimal("3");

BigDecimal result =
        one.divide(three);

数学结果是:

0.333333333333...

这是无限循环小数。

BigDecimal 的核心目标恰恰是:

精确十进制运算。

但是:

1 / 3

无法用有限位十进制小数精确表示。

此时如果你没有告诉 BigDecimal:

到底保留几位?
如何舍入?

它无法凭空猜测你的业务规则。

因此会抛出:

ArithmeticException

3.12 指定 scale 和 RoundingMode

例如:

BigDecimal result =
        BigDecimal.ONE.divide(
                new BigDecimal("3"),
                2,
                RoundingMode.HALF_UP
        );

结果:

0.33

参数:

divide(divisor, scale, roundingMode)

这里:

divisor
→ 除数

scale
→ 小数点后保留的位数

roundingMode
→ 舍入模式

3.13 RoundingMode.HALF_UP

需要:

import java.math.RoundingMode;

常见:

RoundingMode.HALF_UP

它接近我们在基础数学中熟悉的:

四舍五入。

例如:

BigDecimal number =
        new BigDecimal("3.145");

BigDecimal result =
        number.setScale(
                2,
                RoundingMode.HALF_UP
        );

System.out.println(result);

得到:

3.15

3.14 不是所有业务都应该 HALF_UP

这一点非常重要。

BigDecimal 提供多种:

RoundingMode

例如:

UP
DOWN
CEILING
FLOOR
HALF_UP
HALF_DOWN
HALF_EVEN
UNNECESSARY

不同场景的规则不同。

例如:

银行规则
财务规则
税务规则
支付规则
统计规则

可能明确要求某一种舍入策略。

因此不要形成错误思维:

BigDecimal 舍入
=
永远 HALF_UP

正确原则是:

舍入模式属于业务规则的一部分。


3.15 setScale()

金额系统中经常需要:

保留两位小数

例如:

BigDecimal price =
        new BigDecimal("19.999");

BigDecimal result =
        price.setScale(
                2,
                RoundingMode.HALF_UP
        );

System.out.println(result);

得到:

20.00

注意:

setScale()

依然不会修改原 BigDecimal。


3.16 BigDecimal 转换成 double

原始教学案例中:

BigDecimal result =
        BigDecimal.valueOf(0.1)
                .add(
                        BigDecimal.valueOf(0.2)
                );

double value =
        result.doubleValue();

可以通过:

doubleValue()

转成 double

BigDecimal 作为 Number 的子类,还提供:

intValue()
longValue()
floatValue()
doubleValue()

等转换。

但要注意:

一旦重新转换成浮点类型,又重新进入浮点表示体系。

因此精确金额计算链路中,不应该毫无必要地频繁:

BigDecimal
→ double
→ BigDecimal
→ double

3.17 常量 ZERO、ONE、TEN

BigDecimal 提供常用常量:

BigDecimal.ZERO
BigDecimal.ONE
BigDecimal.TEN

例如:

BigDecimal balance =
        BigDecimal.ZERO;

比:

new BigDecimal("0")

更清晰。

JDK 21 还提供:

BigDecimal.TWO

代表数值 2。


四、原理与进阶

4.1 BigDecimal 不是“无限精度计算机”

BigDecimal 常被描述为:

高精度小数。

更严谨地说,它能够表示:

任意精度的有符号十进制数。

但现实计算资源仍然有限。

例如:

  • 内存有限
  • CPU 时间有限
  • scale 有范围
  • 运算可能要求舍入

所以“任意精度”描述的是数据模型能力,而不是:

机器真的拥有无限内存和无限计算能力

4.2 precision 与 scale

两个非常重要的概念:

precision

有效数字总位数。

例如:

123.45

precision:

5

scale

小数点右侧位数。

123.45

scale:

2

例如:

BigDecimal value =
        new BigDecimal("123.45");

System.out.println(
        value.precision()
);

System.out.println(
        value.scale()
);

得到:

5
2

不要把:

precision

和:

scale

混为一谈。


4.3 equals() 的陷阱

观察:

BigDecimal a =
        new BigDecimal("2.0");

BigDecimal b =
        new BigDecimal("2.00");

System.out.println(
        a.equals(b)
);

结果:

false

很多初学者会疑惑:

2.0 明明等于 2.00

为什么 false?

因为 BigDecimal 的:

equals()

不仅考虑:

数值

还要求:

scale

一致。

这里:

2.0
scale = 1

2.00
scale = 2

因此:

a.equals(b)

为 false。


4.4 compareTo() 比较数值

如果业务真正想问:

这两个金额数值上是否一样?

通常更适合:

a.compareTo(b)

规则:

< 0
→ a < b

= 0
→ a 与 b 数值相等

> 0
→ a > b

例如:

BigDecimal a =
        new BigDecimal("2.0");

BigDecimal b =
        new BigDecimal("2.00");

System.out.println(
        a.compareTo(b)
);

结果:

0

因为从数值意义:

2.0 == 2.00

4.5 BigDecimal 数值比较推荐模式

判断相等:

a.compareTo(b) == 0

判断大于:

a.compareTo(b) > 0

判断小于:

a.compareTo(b) < 0

例如判断余额是否足够:

if (balance.compareTo(price) >= 0) {
    System.out.println("余额充足");
}

4.6 为什么不能使用 > 和 <

BigDecimal 是引用类型对象。

不能写:

if (a > b) {
}

>< 不是 BigDecimal 对象之间的数值比较方案。

应该:

a.compareTo(b)

4.7 BigDecimal 与金额建模

即使使用 BigDecimal,也不意味着:

金融计算自动绝对正确。

例如金额计算还涉及:

货币单位
人民币还是美元
税率
折扣顺序
舍入时机
小数位规则
中间步骤是否舍入
最终步骤是否舍入

例如:

先折扣后税

和:

先税后折扣

甚至可能产生不同业务结果。

BigDecimal 解决的是:

十进制表示与运算工具。

业务正确性仍然需要正确的业务规则。


五、实践应用

5.1 商品金额计算

商品:

单价:19.90
数量:3

代码:

BigDecimal price =
        new BigDecimal("19.90");

BigDecimal quantity =
        new BigDecimal("3");

BigDecimal total =
        price.multiply(quantity);

System.out.println(total);

结果:

59.70

5.2 折扣计算

假设:

原价:299.00
折扣:85%

可以:

BigDecimal price =
        new BigDecimal("299.00");

BigDecimal discountRate =
        new BigDecimal("0.85");

BigDecimal finalPrice =
        price.multiply(discountRate)
             .setScale(
                     2,
                     RoundingMode.HALF_UP
             );

System.out.println(finalPrice);

5.3 商品结算完整案例

import java.math.BigDecimal;
import java.math.RoundingMode;

public class PaymentDemo {

    public static void main(String[] args) {

        BigDecimal unitPrice =
                new BigDecimal("39.90");

        BigDecimal quantity =
                new BigDecimal("3");

        BigDecimal discount =
                new BigDecimal("0.85");

        BigDecimal subtotal =
                unitPrice.multiply(quantity);

        BigDecimal afterDiscount =
                subtotal.multiply(discount);

        BigDecimal finalAmount =
                afterDiscount.setScale(
                        2,
                        RoundingMode.HALF_UP
                );

        System.out.println(
                "原金额:" + subtotal
        );

        System.out.println(
                "折后金额:" + finalAmount
        );
    }
}

核心数据流:

单价
 ×
数量
 ↓
原始金额
 ×
折扣率
 ↓
折后金额
 ↓
按照业务规则舍入
 ↓
最终金额

六、常见问题

6.1 BigDecimal 是不是专门给钱用的

不是。

它是:

任意精度十进制运算类型。

金额只是非常典型的应用场景。

凡是要求精确十进制运算的业务都可能使用。


6.2 为什么不能直接 new BigDecimal(0.1)

因为:

0.1

首先是一个不能精确表示十进制 0.1 的 double

BigDecimal 构造器得到的是:

这个 double 实际二进制值的精确十进制展开。

不是数学意义上的精确 0.1


6.3 new BigDecimal("0.1") 和 valueOf(0.1) 怎么选

两者都可以解决常见的:

想表达十进制 0.1

问题。

经验规则:

原始数据就是十进制文本
→ new BigDecimal(String)

手里已经是 double
→ BigDecimal.valueOf(double)

6.4 BigDecimal 为什么不能直接用 + - * /

因为:

BigDecimal

是对象。

常用:

add
subtract
multiply
divide

进行运算。


6.5 divide 为什么有时候报 ArithmeticException

例如:

1 / 3

结果无法使用有限十进制精确表示。

如果没有提供舍入策略,BigDecimal 不会自行猜测该保留多少位,因此可能抛出:

ArithmeticException

6.6 BigDecimal.equals 为什么会出现 2.0 不等于 2.00

因为:

equals()

同时比较:

数值
+
scale

而:

2.0  → scale 1
2.00 → scale 2

如果业务只比较数值大小,应考虑:

compareTo()

6.7 setScale 是否修改原对象

不会。

BigDecimal 是不可变对象。

错误:

price.setScale(
        2,
        RoundingMode.HALF_UP
);

然后还以为:

price

已经变化。

应该:

price =
        price.setScale(
                2,
                RoundingMode.HALF_UP
        );

6.8 doubleValue 后是不是还保持 BigDecimal 的十进制模型

不是。

转换成:

double

后,就重新遵循 double 的二进制浮点表示规则。

因此不要没有必要地转换。


七、练习与验收

7.1 知识问答

  1. 为什么 0.1 + 0.2 使用 double 可能不是精确的 0.3
  2. BigDecimal 主要解决什么问题?
  3. BigDecimal 位于哪个包?
  4. new BigDecimal("0.1")new BigDecimal(0.1) 有什么区别?
  5. BigDecimal.valueOf(double) 的作用是什么?
  6. add()subtract()multiply()divide() 分别对应什么运算?
  7. 为什么 1 / 3 使用无参数舍入策略的 divide() 可能抛出异常?
  8. scale 是什么?
  9. precision 是什么?
  10. RoundingMode 是什么?
  11. HALF_UP 表示什么基本舍入思想?
  12. 为什么舍入模式应该由业务规则决定?
  13. BigDecimal 是否可变?
  14. equals()compareTo() 有什么差异?
  15. 为什么 2.02.00equals() 可能为 false?
  16. 如何判断两个 BigDecimal 数值是否相等?
  17. 为什么金融业务即使使用 BigDecimal 仍然需要明确业务规则?

7.2 代码阅读

不运行代码,预测结果:

import java.math.BigDecimal;

public class BigDecimalRead01 {

    public static void main(String[] args) {

        BigDecimal a =
                new BigDecimal("0.1");

        BigDecimal b =
                new BigDecimal("0.2");

        BigDecimal c =
                a.add(b);

        System.out.println(a);
        System.out.println(b);
        System.out.println(c);
    }
}

回答:

  1. 三行分别输出什么?
  2. a 是否发生变化?
  3. 为什么?

继续阅读:

BigDecimal a =
        new BigDecimal("2.0");

BigDecimal b =
        new BigDecimal("2.00");

System.out.println(
        a.equals(b)
);

System.out.println(
        a.compareTo(b)
);

回答:

  1. 两个结果分别是什么?
  2. 为什么两个 API 会产生不同结果?

7.3 手写代码

任务一:基本四则运算

创建:

10.50
3.20

使用 BigDecimal 分别计算:

加
减
乘
除

除法保留:

2 位小数

任务二:商品结算

给定:

单价:29.90
数量:5
折扣:88%

计算最终金额。

要求:

  • 全程使用 BigDecimal
  • 最终保留两位小数
  • 明确舍入模式

任务三:数值比较

创建:

100.0
100.00
99.99

分别使用:

equals()
compareTo()

进行比较,并解释结果。


7.4 Debug

下面代码存在精度设计问题:

BigDecimal price =
        new BigDecimal(0.1);

BigDecimal count =
        new BigDecimal(3);

BigDecimal total =
        price.multiply(count);

System.out.println(total);

要求:

  1. 找出最值得修改的位置。
  2. 解释问题为什么在进入 BigDecimal 之前就已经产生。
  3. 修改代码。

继续 Debug:

BigDecimal a =
        new BigDecimal("1");

BigDecimal b =
        new BigDecimal("3");

System.out.println(
        a.divide(b)
);

回答:

  1. 为什么可能出现异常?
  2. 如何通过明确精度和舍入模式解决?

7.5 综合训练

实现一个控制台购物结算程序。

已知:

商品A:19.90 × 3
商品B:39.50 × 2

会员折扣:90%
税率:6%

要求:

  1. 所有金额、比例使用 BigDecimal。
  2. 计算每类商品小计。
  3. 计算商品总价。
  4. 应用会员折扣。
  5. 根据明确规则计算税费。
  6. 计算最终金额。
  7. 最终金额保留两位小数。
  8. 使用 compareTo() 判断最终金额是否超过 100 元。
  9. 说明每一步是否应该立即舍入,以及你的理由。

7.6 本章验收

能够闭卷写出:

new BigDecimal("0.1")
BigDecimal.valueOf(0.1)

add()
subtract()
multiply()
divide()

setScale()
compareTo()

能够解释:

double 为什么可能产生小数误差

能够解释:

为什么不要随便 new BigDecimal(0.1)

能够解释:

1 / 3 为什么需要舍入规则

能够准确区分:

equals()
vs
compareTo()

并能够独立完成:

商品金额
+
数量
+
折扣
+
税费
+
舍入

这一完整 BigDecimal 运算链,则本章核心能力基本达标。