当前位置 : 主页 > 编程语言 > 其它开发 >

Java数值计算精度、舍入于溢出问题

来源:互联网 收集:自由互联 发布时间:2022-06-13
本文摘录总结于极客时间——《java业务开发常见错误 100 例》   数值计算也是业务开发常见的一个环节,这基本是初入职场小白们最经常犯得错误之一,比如说是金额类型用 Double 来

本文摘录总结于极客时间——《java业务开发常见错误 100 例》

  数值计算也是业务开发常见的一个环节,这基本是初入职场小白们最经常犯得错误之一,比如说是金额类型用 Double 来计算。接下来,我们来具体聊聊。

Double

  我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除:

System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);

double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05)
    System.out.println("OK");

  结果是:

0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999

  结果可能跟我们预期的不一样,比如 0.1+0.2 返回的结果是 0.30000000000000004,而不是 0.3。
  其实对于计算机来说, 0.1 无法精确表达,这是浮点数计算造成的精度损失的根源。你可能会说,以 0.1 为例,其十进制和二进制间转换后相差非常小,不会对计算产生什么影响。但,所谓积土成山,如果大量使用 double 来作大量的金钱计算,最终损失的精度就是大量的资金出入(比如,我们公司的一些老项目依旧使用 Double 来处理重量)。
  想避免以上这种情况,推荐使用 BigDecimal 来计算浮点型的计算,但是需要注意的是,务必使用字符串的构造方式来初始化 BigDecimal,否则仅仅只是提高了浮点型计算返回结果的精确度。

// 不用字符串
System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));

  返回如下:

0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875
考虑浮点数舍入和格式化的方式

  除了使用 Double 保存浮点数可能带来精度问题外,更匪夷所思的是这种精度问题,加上 String.format 的格式化舍入方式,可能得到让人摸不着头脑的结果。
  看到例子:

double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1));//四舍五入
System.out.println(String.format("%.1f", num2));

  得到的结果居然是 3.4 和 3.3。这就是由精度问题和舍入方式共同导致的,double 和 float 的 3.35 其实相当于 3.350xxx 和 3.349xxx:

3.350000000000000088817841970012523233890533447265625
3.349999904632568359375

  String.format 采用四舍五入的方式进行舍入,取 1 位小数,double 的 3.350 四舍五入为 3.4,而 float 的 3.349 四舍五入为 3.3。
  我们看一下 Formatter 类的相关源码,可以发现使用的舍入模式是 HALF_UP(代码 11 行):

else if (c == Conversion.DECIMAL_FLOAT) {
    // Create a new BigDecimal with the desired precision.
    int prec = (precision == -1 ? 6 : precision);
    int scale = value.scale();

    if (scale > prec) {
        // more "scale" digits than the requested "precision"
        int compPrec = value.precision();
        if (compPrec <= scale) {
            // case of 0.xxxxxx
            value = value.setScale(prec, RoundingMode.HALF_UP);
        } else {
            compPrec -= (scale - prec);
            value = new BigDecimal(value.unscaledValue(),
                                   scale,
                                   new MathContext(compPrec));
        }
    }

  如果我们希望使用其他舍入方式来格式化字符串的话,可以设置 DecimalFormat,如下代码所示:

double num1 = 3.35;
float num2 = 3.35f;
DecimalFormat format = new DecimalFormat("#.##");
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num1));
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num2));

  这就引出了我们的第二个避坑原则:浮点数的字符串格式化也要通过 BigDecimal 进行。

用 equals 做判等就一定对吗?

  经过前面的提示,这次我们直接使用 BigDecimal 来进行判等,那么两个 BigDecimal 对象进行判等,结果就一定相同吗?
  我们来看下面的例子。使用 equals 方法比较 1.0 和 1 这两个 BigDecimal:

System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")))

  你可能已经猜到我想说什么了,结果当然是 false。BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,1.0 的 scale 是 1,1 的 scale 是 0,所以结果一定是 false。
  如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法,修改后代码如下:

System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);

  牵扯到 equals 和 compareTo 两个方法时,你可能会意识到 hashCode 方法也是顾及 value 跟 scale 的。这时候,,我们把值为 1.0 的 BigDecimal 加入 HashSet,然后判断其是否存在值为 1 的 BigDecimal,得到的结果是 false:

Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false

  解决方案有两个:

  1. 第一个方法是,使用 TreeSet 替换 HashSet。TreeSet 不使用 hashCode 方法,也不使用 equals 比较元素,而是使用 compareTo 方法,所以不会有问题。
  2. 第二个方法是,把 BigDecimal 存入 HashSet 或 HashMap 前,先使用 stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的 BigDecimal,scale 也是一致的。

  当然我相信这种情况也是蛮少见的。

小心数值溢出

  这也是一个比较容易忽略也不经常出现的一个点,因为溢出是默默的溢出,不会有任何异常,比如下面这段代码:

long l = Long.MAX_VALUE;
System.out.println(l + 1);
System.out.println(l + 1 == Long.MIN_VALUE);

// 结果是
-9223372036854775808
true

  改进方案是以下几点:

  1. 方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。我们来测试一下,使用 Math.addExact 对 Long 最大值做 +1 操作:
try {
    long l = Long.MAX_VALUE;
    System.out.println(Math.addExact(l, 1));
} catch (Exception ex) {
    ex.printStackTrace();
}

  你会得到一个 RuntimeException——ArithmeticException。
2. 方法二是,使用大数类 BigInteger。BigDecimal 是处理浮点数的专家,而 BigInteger 则是对大数进行科学计算的专家。
  如下代码,使用 BigInteger 对 Long 最大值进行 +1 操作;如果希望把计算结果转换一个 Long 变量的话,可以使用 BigInteger 的 longValueExact 方法,在转换出现溢出时,同样会抛出 ArithmeticException:

BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString());

try {
    long l = i.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
    ex.printStackTrace();
}

  今天学习的内容其实在生产上都是不易发生的场景,基本上我们处理浮点型数据时坚持使用 BigDecimal 等对象且注意判等等情况就能避免大部分问题。
  总之,对于金融、科学计算等场景,请尽可能使用 BigDecimal 和 BigInteger,避免由精度和溢出问题引发难以发现,但影响重大的 Bug。

上一篇:MybatisPlus
下一篇:没有了
网友评论