数值计算

363 阅读9分钟

一、危险的Double


1、Double参与运算存在的问题

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

System.out.println(0.1+0.2);//todo 0.30000000000000004
System.out.println(1.0-0.8);//todo 0.19999999999999996
System.out.println(4.015*100);//todo 401.49999999999994
System.out.println(123.3/100);//todo 1.2329999999999999 

可以看到,输出结果和我们预期的很不一样。比如,0.1+0.2输出的不是0.3而是0.30000000000000004

2、出现精读损失的根源

出现上述问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外。Java 采用了IEEE 754标准实现浮点数的表达和运算。

比如,0.1的二进制表示为0.0 0011 0011 0011 ...(0011 无限循环),再转换为十进制就是0.1000000000000000055511151231257827021181583404541015625对于计算机而言,0.1无法精确表达,这是浮点数计算造成精度损失的根源。

你可能会说,以0.1为例,其十进制和二进制间转换后相差非常小,不会对计算产生什么影响。但,所谓积土成山,如果大量使用double来作大量的金钱计算,最终损失的精度就是大量的资金出入。比如,每天有一百万次交易,每次交易都差一分钱,一个月下来就差30万。这就不是小事儿了。那,如何解决这个问题呢?

3、解决方案

我们大都听说过BigDecimal类型,浮点数精确表达和运算的场景,一定要使用这个类型。不过,在使用BigDecimal时有几个坑需要避开。我们用BigDecimal把之前的四则运算改一下:

System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));//todo 0.3000000000000000166533453693773481063544750213623046875
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));//todo 0.1999999999999999555910790149937383830547332763671875
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));//todo 401.49999999999996802557689079549163579940795898437500
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));//todo 1.232999999999999971578290569595992565155029296875

可以看到,运算结果还是不精确,只不过是精度高了而已。这里给出浮点数运算避坑第一原则:使用BigDecimal表示和计算浮点数,且务必使用字符串的构造方法来初始化BigDecimal

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));//todo 0.3
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));//todo 0.2
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));//todo 401.500
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));//todo 1.233

既然不能调用BigDecimal传入Double的构造方法,但手头只有一个Double,如何转换为精确表达的BigDecimal呢?

System.out.println(new BigDecimal("4.015").multiply(new BigDecimal(Double.toString(100))));//todo 401.5000

输出为401.5000。与上面字符串初始化1004.015相乘得到的结果401.500相比,这里为什么多了10呢?原因就是,BigDecimalscaleprecision的概念,scale表示小数点右边的位数,而precision表示精度,也就是有效数字的长度。

通过调试可以发现,new BigDecimal(Double.toString(100))得到的BigDecimalscale=1precision=4,而new BigDecimal("100")得到的BigDecimal的scale=0precision=3。对于BigDecimal乘法操作,返回值的scale是两个数的scale相加。所以,初始化100的两种不同方式,导致最后结果的scale分别是43

private static void testScale() {
    BigDecimal bigDecimal1 = new BigDecimal("100");
    BigDecimal bigDecimal2 = new BigDecimal(String.valueOf(100));
    BigDecimal bigDecimal3 = new BigDecimal(String.valueOf(100d));
    BigDecimal bigDecimal4 = BigDecimal.valueOf(100d);
    BigDecimal bigDecimal5 = new BigDecimal(Double.toString(100));

    print(bigDecimal1); //todo scale 0 precision 3 result 401.500
    print(bigDecimal2); //todo scale 0 precision 3 result 401.500
    print(bigDecimal3); //todo scale 1 precision 4 result 401.5000
    print(bigDecimal4); //todo scale 1 precision 4 result 401.5000
    print(bigDecimal5); //todo scale 1 precision 4 result 401.5000
}

private static void print(BigDecimal bigDecimal) {
    System.out.println("scale = " + bigDecimal.scale()+",precision = " + bigDecimal.precision() + ",result = " + bigDecimal.multiply(new BigDecimal("4.015")));
}

BigDecimaltoString方法得到的字符串和scale相关,又会引出了另一个问题:对于浮点数的字符串形式输出和格式化,我们应该考虑显式进行,通过格式化表达式或格式化工具来明确小数位数和舍入方式。

二、考虑浮点数舍入和格式化的方式


1、浮点数舍入与格式化存在的问题

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

2、浮点数舍入与格式化案例

double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1));//3.4
System.out.println(String.format("%.1f", num2));//3.3

这就是由精度问题和舍入方式共同导致的,doublefloat3.35其实相当于3.350xxx3.349xxx

//todo double的3.35
3.350000000000000088817841970012523233890533447265625
//todo float的3.35
3.349999904632568359375

String.format采用四舍五入的方式进行舍入,取1位小数,double3.350四舍五入为3.4,而float3.349四舍五入为3.3

通过Formatter类的相关源码,可以发现使用的舍入模式是HALF_UP(代码第11行):

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));//todo 3.35
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num2));//todo 3.34

当我们把这2个浮点数向下舍入取2位小数时,输出分别是3.353.34,还是我们之前说的浮点数无法精确存储的问题。

因此,即使通过DecimalFormat来精确控制舍入方式,doublefloat的问题也可能产生意想不到的结果。

3、解决方案

浮点数避坑第二原则:浮点数的字符串格式化也要通过BigDecimal进行。

比如下面这段代码,使用BigDecimal来格式化数字3.35,分别使用向下舍入和四舍五入方式取1位小数进行格式化:

BigDecimal num1 = new BigDecimal("3.35");
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
System.out.println(num2);//todo 3.3
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
System.out.println(num3);//todo 3.4

这次得到的结果是3.33.4,符合预期。

三、包装类用equals做判等,就一定是对的吗


1、包装类用equals做判等案例

包装类的比较要通过equals进行,而不能使用==。那么,使用equals方法对两个BigDecimal判等,一定能得到我们想要的结果吗?

//todo 使用equals方法比较1.0和1这两个BigDecimal
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));//todo false

结果当然是falseBigDecimalequals方法的注释中说明了原因,equals比较的是BigDecimalvaluescale1.0scale11scale0,所以结果一定是false

/**
 * Compares this {@code BigDecimal} with the specified
 * {@code Object} for equality.  Unlike {@link
 * #compareTo(BigDecimal) compareTo}, this method considers two
 * {@code BigDecimal} objects equal only if they are equal in
 * value and scale (thus 2.0 is not equal to 2.00 when compared by
 * this method).
 *
 * @param  x {@code Object} to which this {@code BigDecimal} is
 *         to be compared.
 * @return {@code true} if and only if the specified {@code Object} is a
 *         {@code BigDecimal} whose value and scale are equal to this
 *         {@code BigDecimal}'s.
 * @see    #compareTo(java.math.BigDecimal)
 * @see    #hashCode
 */
@Override
public boolean equals(Object x)

2、改进方式一

如果我们希望只比较BigDecimal的value,可以使用compareTo方法,修改后代码如下:

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

3、hashSet类型是BigDecimal存在的问题

BigDecimalequalshashCode方法会同时考虑valuescale,如果结合HashSetHashMap使用的话就可能会出现麻烦。比如,我们把值为1.0BigDecimal加入HashSet,然后判断其是否存在值为1BigDecimal,得到的结果是false

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

4、解决方案一

使用TreeSet替换HashSetTreeSet不使用hashCode方法,也不使用equals比较元素,而是使用compareTo方法,所以不会有问题

Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1")));//todo 返回true

5、解决方案二

BigDecimal存入HashSetHashMap前,先使用stripTrailingZeros方法去掉尾部的零,比较的时候也去掉尾部的0,确保value相同的BigDecimalscale也是一致的

Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//todo 返回true

四、小心数值溢出问题


1、什么是数值溢出

数值溢出: 不管是int还是long,所有的基本数值类型都有超出表达范围的可能性

2、数值溢出案例

//todo 对Long的最大值进行+1操作:
long temp = Long.MAX_VALUE;
System.out.println(temp + 1); //todo -9223372036854775808
//todo 输出结果是一个负数,因为 Long 的最大值 +1 变为了 Long 的最小值:
System.out.println(temp + 1 == Long.MIN_VALUE); // true

**显然这是发生了数值溢出,而且是默默的溢出,并没有任何异常。**这类问题非常容易被忽略

3、改进方式一

考虑使用Math类的addExactsubtractExactxxxExact方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。我们来测试一下,使用Math.addExactLong最大值做+1操作:

try {
    long temp = Long.MAX_VALUE;
    System.out.println(Math.addExact(temp, 1));
} catch (Exception e) {
    e.printStackTrace();
}

//todo 执行后,可以得到ArithmeticException,这是一个 RuntimeException
java.lang.ArithmeticException: long overflow
	at java.lang.Math.addExact(Math.java:809)

3、改进方式二

使用大数类BigIntegerBigDecimal是处理浮点数的专家,而BigInteger则是对大数进行科学计算的专家

如下代码,使用BigIntegerLong最大值进行+1操作;如果希望把计算结果转换一个Long变量的话,可以使用BigIntegerlongValueExact方法,在转换出现溢出时,同样会抛出ArithmeticException

BigInteger temp = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(temp.add(BigInteger.ONE).toString());//todo 9223372036854775808
try {
    long l = temp.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
    ex.printStackTrace();
}

//todo 转换后,同样可以得到ArithmeticException
java.lang.ArithmeticException: BigInteger out of long range
	at java.math.BigInteger.longValueExact(BigInteger.java:4632)

可以看到,通过BigIntegerLong的最大值加1一点问题都没有,当尝试把结果转换为Long类型时,则会提示BigInteger out of long range

五、总结


1、计算器或计算机会得到反直觉的计算结果的原因

  • 在人看来,浮点数只是具有小数点的数字,0.11都是一样精确的数字。但,计算机其实无法精确保存浮点数,因此浮点数的计算结果也不可能精确。
  • 在人看来,一个超大的数字只是位数多一点而已,多写几个1并不会让大脑死机。但,计算机是把数值保存在了变量中,不同类型的数值变量能保存的数值范围不同,当数值超过类型能表达的数值上限则会发生溢出问题。

2、重点回顾

  • 第一,切记,要精确表示浮点数应该使用BigDecimal。并且,使用BigDecimalDouble入参的构造方法同样存在精度丢失问题,应该使用String入参的构造方法或者BigDecimal.valueOf方法来初始化。
  • 第二,对浮点数做精确计算,参与计算的各种数值应该始终使用BigDecimal,所有的计算都要通过BigDecimal的方法进行,切勿只是让BigDecimal来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差。
  • 第三,对于浮点数的格式化,如果使用String.format的话,需要认识到它使用的是四舍五入,可以考虑使用DecimalFormat来明确指定舍入方式。但考虑到精度问题,我更建议使用BigDecimal来表示浮点数,并使用其setScale方法指定舍入的位数和方式。
  • 第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完全错误的。我们考虑使用Math.xxxExact方法来进行运算,在溢出时能抛出异常,更建议对于可能会出现溢出的大数运算使用BigInteger类。

总之,对于金融、科学计算等场景,请尽可能使用BigDecimalBigInteger,避免由精度和溢出问题引发难以发现,但影响重大的Bug