<导航

使用BigDecimal的注意事项

一、BigDecimal 使用不当,造成 P0 事故

https://mp.weixin.qq.com/s/ubztl7G9GUe9TNqKZt3JuQ

01、背景

  我们在使用金额计算或者展示金额的时候经常会使用BigDecimal,也是涉及金额时非常推荐的一个类型,BigDecimal自身也提供了很多构造器方法,这些构造器方法使用不当可能会造成不必要的麻烦甚至是金额损失,从而引起事故资损。

接下来我们看下收银台出的一起事故。

02、问题描述

  • 收银台计算商品金额报错,导致订单无法支付。

03、故障原因

BigDecimal在金额计算中丢失精度

04、原因分析

首先我们先用一段代码复现问题根源,如下所示:

public static void main(String[] args) {
    BigDecimal bigDecimal=new BigDecimal(88);
    System.out.println(bigDecimal);
    bigDecimal=new BigDecimal("8.8");
    System.out.println(bigDecimal);
    bigDecimal=new BigDecimal(8.8);
    System.out.println(bigDecimal);
}

执行结果如下:

通过测试发现,当使用double或者float这些浮点数据类型时,会丢失精度,String、int则不会;这是为什么呢?

我们点开构造器方法看下源码:

public static long doubleToLongBits(double value) {
    long result = doubleToRawLongBits(value);
    // Check for NaN based on values of bit fields, maximum
    // exponent and nonzero significand.
    if ( ((result & DoubleConsts.EXP_BIT_MASK) ==
          DoubleConsts.EXP_BIT_MASK) &&
         (result & DoubleConsts.SIGNIF_BIT_MASK) != 0L)
        result = 0x7ff8000000000000L;
    return result;
}

  问题就处在 doubleToRawLongBits 这个方法上,在jdk中double类(float与int对应)中提供了double与long转换,doubleToRawLongBits就是将double转换为long,这个方法是原始方法(底层不是java实现,是c++实现的)。double之所以会出问题,是因为小数点转二进制丢失精度。BigDecimal在处理的时候把十进制小数扩大N倍让它在整数上进行计算,并保留相应的精度信息。

  • float和double类型,主要是为了科学计算和工程计算而设计的,之所以执行二进制浮点运算,是为了在广泛的数值范围上提供较为精确的快速近和计算。
  • 并没有提供完全精确的结果,所以不应该被用于精确的结果的场合。
  • 当浮点数达到一定大的数,就会自动使用科学计数法,这样的表示只是近似真实数而不等于真实数。
  • 当十进制小数位转换二进制的时候也会出现无限循环或者超过浮点数尾数的长度。

05、总结

所以,在涉及到精度计算的过程中,我们尽量使用String类型来进行转换,正确用法如下:

//或者使用BigDecimal.valueOf()
BigDecimal bigDecimal2=new BigDecimal("8.8");
BigDecimal bigDecimal3=new BigDecimal("8.812");
System.out.println( bigDecimal2.compareTo(bigDecimal3)); System.out.println( bigDecimal2.add(bigDecimal3));

  BigDecimal创建出来的是对象,我们不能用传统的加减乘除对其进行运算,必须使用他的方法,在我们数据库存储里,如果我们使用的是double或者float类型,需要进行来回的转换后进行计算,非常不方便。


二、金额计算用 BigDecimal 就万无一失了?看看这五个坑吧

https://mp.weixin.qq.com/s/OCBlnLuu3bTOb2ixWRcTIg

看到一篇因为在金额计算中没有使用BigDecimal而导致故障的文章,但是除非在一些非常简单的场景,结算汇金类的业务也不会直接用BigDecimal来计算金额,原因有两点:

  1. BigDecimal里面还是有很多隐蔽的坑的
  2. BigDecimal没有提供金额的单位

BigDecimal中的五个容易踩的坑

1、 new BigDecimal()还是BigDecimal#valueOf()

先看下面这段代码

BigDecimal bd1 = new BigDecimal(0.01);
BigDecimal bd2 = BigDecimal.valueOf(0.01);
System.out.println("bd1 = " + bd1);
System.out.println("bd2 = " + bd2);

输出到控制台的结果是:

bd1 = 0.01000000000000000020816681711721685132943093776702880859375
bd2 = 0.01

造成这种差异的原因是0.1这个数字计算机是无法精确表示的,送给BigDecimal的时候就已经丢精度了,而BigDecimal#valueOf的实现却完全不同。

public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO.  This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
}

它使用了浮点数相应的字符串来构造BigDecimal对象,因此避免了精度问题。所以大家要尽量要使用字符串而不是浮点数去构造BigDecimal对象,如果实在不行,就使用BigDecimal#valueOf()方法吧。

2、等值比较

BigDecimal bd1 = new BigDecimal("1.0");
BigDecimal bd2 = new BigDecimal("1.00");
System.out.println(bd1.equals(bd2));
System.out.println(bd1.compareTo(bd2));

控制台的输出将会是:

false
0

究其原因是,BigDecimalequals方法的实现会比较两个数字的精度,而compareTo方法则只会比较数值的大小。

3、BigDecimal并不代表无限精度

先看这段代码

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b) // results in the following exception.

结果会抛出异常:

java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

关于这个异常,Oracle的官方文档有具体说明

If the quotient has a nonterminating decimal expansion and the operation is specified to return an exact result, an ArithmeticException is thrown. Otherwise, the exact result of the division is returned, as done for other operations.

大意是,如果除法的商的结果是一个无限小数但是我们期望返回精确的结果,那程序就会抛出异常。回到我们的这个例子,我们需要告诉JVM我们不需要返回精确的结果就好了

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b, 2, RoundingMode.HALF_UP)// 0.33

4、BigDecimal转回String要小心

BigDecimal d = BigDecimal.valueOf(12334535345456700.12345634534534578901);
String out = d.toString(); // Or perform any formatting that needs to be done
System.out.println(out); // 1.23345353454567E+16

可以看到结果已经被转换成了科学计数法,可能这个并不是预期的结果BigDecimal有三个方法可以转为相应的字符串类型,切记不要用错:

String toString();     // 有必要时使用科学计数法
String toPlainString();   // 不使用科学计数法
String toEngineeringString();  // 工程计算中经常使用的记录数字的方法,与科学计数法类似,但要求10的幂必须是3的倍数

5、执行顺序不能调换(乘法交换律失效)

乘法满足交换律是一个常识,但是在计算机的世界里,会出现不满足乘法交换律的情况

BigDecimal a = BigDecimal.valueOf(1.0);
BigDecimal b = BigDecimal.valueOf(3.0);
BigDecimal c = BigDecimal.valueOf(3.0);
System.out.println(a.divide(b, 2, RoundingMode.HALF_UP).multiply(c)); // 0.990
System.out.println(a.multiply(c).divide(b, 2, RoundingMode.HALF_UP)); // 1.00

别小看这这0.01的差别,在汇金领域,会产生非常大的金额差异。

三、最佳实践

  关于金额计算,很多业务团队会基于BigDecimal再封装一个Money类,其实我们直接可以用一个半官方的Money类:JSR 354 ,虽然没能在Java 9中成为Java标准,很有可能集成到后续的Java版本中成为官方库。

1、maven坐标

<dependency>
    <groupId>org.javamoney</groupId>
    <artifactId>moneta</artifactId>
    <version>1.1</version>
</dependency>

2、新建Money

CurrencyUnit cny = Monetary.getCurrency("CNY");
Money money = Money.of(1.0, cny); 
// 或者 Money money = Money.of(1.0, "CNY");
//System.out.println(money);

3、金额运算

CurrencyUnit cny = Monetary.getCurrency("CNY");
Money oneYuan = Money.of(1.0, cny);
Money threeYuan = oneYuan.add(Money.of(2.0, "CNY")); //CNY 3
Money tenYuan = oneYuan.multiply(10); // CNY 10
Money fiveFen = oneYuan.divide(2); //CNY 0.5

4、比较相等

Money fiveFen = Money.of(0.5, "CNY"); //CNY 0.5
Money anotherFiveFen = Money.of(0.50, "CNY"); // CNY 0.50
System.out.println(fiveFen.equals(anotherFiveFen)); // true

可以看到,这个类对金额做了显性的抽象,增加了金额的单位,也避免了直接使用BigDecimal的一些坑。


四、区分BigDecimal的toString()和toPlainString()

https://blog.csdn.net/weixin_43896318/article/details/104630775

求值语句

为了能表示出科学计数法,我们得让数小一点:

BigDecimal num = new BigDecimal(0.2333).pow(20);
System.out.println(num);

打印结果

2.28189037167401120969664016458058704445821447776153697856333722521431496331550551213930891906957487435112104808347862419891420033162248142531663995155772279146232315410060206987151178648426698855546081020777110688844170619669193518453985280966919855312604013430815833563803446476005419063873594466971526322215177750276141448913214334808975521236293623113834028980688572444044246581431849340824046219938314943100698334559217720778436613420267026603092024290999790614071820410967350767900092513471798538644190754888725556844272602124114705649986642100894001740207945946131697703242257894368252834211931168108684805772879141748782976829469911671275574176661389794305239001858006008883235021362729155223312931934657673225453759580180785170264139962848926240405883081486320326522336957649286875366094925648693739077878530704550560566747517994737139860236090744552245807066682260105331670524231711427517062910685789869039060383532495613756103646363461215507160773269224241213935868860166499926337883991102182710785104859748632046196649414415702494807192124426364898681640625E-13

toString()打印结果

结果是一样的,因为我们其实也知道在打印num的时候其实默认就是打印num.toString()啦!

System.out.println(num.toString());

2.28189037167401120969664016458058704445821447776153697856333722521431496331550551213930891906957487435112104808347862419891420033162248142531663995155772279146232315410060206987151178648426698855546081020777110688844170619669193518453985280966919855312604013430815833563803446476005419063873594466971526322215177750276141448913214334808975521236293623113834028980688572444044246581431849340824046219938314943100698334559217720778436613420267026603092024290999790614071820410967350767900092513471798538644190754888725556844272602124114705649986642100894001740207945946131697703242257894368252834211931168108684805772879141748782976829469911671275574176661389794305239001858006008883235021362729155223312931934657673225453759580180785170264139962848926240405883081486320326522336957649286875366094925648693739077878530704550560566747517994737139860236090744552245807066682260105331670524231711427517062910685789869039060383532495613756103646363461215507160773269224241213935868860166499926337883991102182710785104859748632046196649414415702494807192124426364898681640625E-13

toPlainString()打印结果

这次不同!

System.out.println(num.toPlainString());

0.000000000000228189037167401120969664016458058704445821447776153697856333722521431496331550551213930891906957487435112104808347862419891420033162248142531663995155772279146232315410060206987151178648426698855546081020777110688844170619669193518453985280966919855312604013430815833563803446476005419063873594466971526322215177750276141448913214334808975521236293623113834028980688572444044246581431849340824046219938314943100698334559217720778436613420267026603092024290999790614071820410967350767900092513471798538644190754888725556844272602124114705649986642100894001740207945946131697703242257894368252834211931168108684805772879141748782976829469911671275574176661389794305239001858006008883235021362729155223312931934657673225453759580180785170264139962848926240405883081486320326522336957649286875366094925648693739077878530704550560566747517994737139860236090744552245807066682260105331670524231711427517062910685789869039060383532495613756103646363461215507160773269224241213935868860166499926337883991102182710785104859748632046196649414415702494807192124426364898681640625

总结

使用toString()可能科学计数法表示,而使用toPlainString()才能打印完整的数值。

说明

例子的结果不能不够小,否则可能误认为三者一样。

比如最早我测的是这个:

import java.math.BigDecimal;

public class BigDecimalTest {
    public static void main(String[] args) {
        BigDecimal num = new BigDecimal(0.66).pow(10);
        System.out.println(num);
        System.out.println(num.toString());
        System.out.println(num.toPlainString());
    }
}
>java BigDecimalTest
0.01568336880910796514692485387380358722432090658674607795185185558463356941593280438333875994123155412243735195834705140991618787447848128774946858219534786702323782812590762928242630260751971484116602400344065713771468254349752623195976497313013575753886310637762485552492886146251661530527797289372428420293315321412564193962492477328625977419079453856979274761289232833572242408303626740680597755011834774326410628545344183904639899408459342093849694664301730220414247643012754939939265054482664396573454723693430423736572265625
0.01568336880910796514692485387380358722432090658674607795185185558463356941593280438333875994123155412243735195834705140991618787447848128774946858219534786702323782812590762928242630260751971484116602400344065713771468254349752623195976497313013575753886310637762485552492886146251661530527797289372428420293315321412564193962492477328625977419079453856979274761289232833572242408303626740680597755011834774326410628545344183904639899408459342093849694664301730220414247643012754939939265054482664396573454723693430423736572265625
0.01568336880910796514692485387380358722432090658674607795185185558463356941593280438333875994123155412243735195834705140991618787447848128774946858219534786702323782812590762928242630260751971484116602400344065713771468254349752623195976497313013575753886310637762485552492886146251661530527797289372428420293315321412564193962492477328625977419079453856979274761289232833572242408303626740680597755011834774326410628545344183904639899408459342093849694664301730220414247643012754939939265054482664396573454723693430423736572265625

五、BigDecimal 转字符串,并去掉尾部的0

实际开发当中,常常会遇到一些金额数,想去除小数点后多余的0,下面使用BigDecimal原生方法stripTrailingZeros实现末尾去0。

比如: 原始金额 amount = 0.998元

将元转化为分: amount.multiply(BigDecimal.valueOf(100)) 结果:99.800

去除末尾多余的0,利用stripTrailingZeros方法 amount.multiply(BigDecimal.valueOf(100)).stripTrailingZeros()

要注意的是,当金额比较大且是整数的时候,使用stripTrailingZeros方法,金额最终会以科学计数法的形式进行显示 比如金额300元,amount.multiply(BigDecimal.valueOf(100)).stripTrailingZeros() 转化为分为30000,最终显示结果为3E+4;

如果不想以科学计数法的形式展现,可以使用toPlainString方法 amount.multiply(BigDecimal.valueOf(100)).stripTrailingZeros().toPlainString() 最终结果显示为30000

//stripTrailingZeros() 去掉尾部的0;
//toPlainString() 转化字符串,非科学计数法。
BigDecimal target = new BigDecimal("5375130.000000");
String str = target.stripTrailingZeros().toPlainString();//5375130

 

代码测试示例:

 

执行结果:

 

posted @ 2022-10-18 10:19  字节悦动  阅读(873)  评论(0编辑  收藏  举报