一、问题描述

Java8中提供Stream流式计算和Lambda表达式,极大的简化了对集合对象的一些处理操作。但通过Stream流式计算对Double浮点类型的数据进行计算时,经常会出现精度丢失的问题。

 @Test
 public void testDoubleSum() {
     List<Double> list = Arrays.asList(6.6, 1.3);
     double result  = list.stream().mapToDouble(Double::new).sum();
     System.out.println("mapToDouble(Double::new).sum的值:" + result);

     result  =list.stream().collect(Collectors.summingDouble(Double::new));
     System.out.println("Collectors.summingDouble的值:" + result);

     result  = list.stream().reduce((a,b)-> a + b).get();
     System.out.println("reduce方式的值:" + result);
 }    

计算结果为:

mapToDouble(Double::new).sum的值:7.8999999999999995
Collectors.summingDouble的值:7.8999999999999995
reduce方式的值:7.8999999999999995

二、原因分析:

上面的三种写法,本质上都是直接对double浮点数据类型进行计算,而在Java中对浮点数进行计算的时候,计算机会先将会先将输入的十进制的 double 类型的数据转换为二进制数据,然后再进行相关的运算。

然而在十进制转二进制的过程中,有些十进制数是无法使用一个有限的二进制数来表达的,换言之就是转换的时候出现了精度的丢失问题,所以导致最后在运算的过程中,自然就出现了我们看到的一幕。

Collectors.summingDouble()的源码分析:
其计算逻辑是:直接采用double数据进行累加运算

public static <T> Collector<T, ?, Double>
    summingDouble(ToDoubleFunction<? super T> mapper) {
        /*
         * In the arrays allocated for the collect operation, index 0
         * holds the high-order bits of the running sum, index 1 holds
         * the low-order bits of the sum computed via compensated
         * summation, and index 2 holds the simple sum used to compute
         * the proper result if the stream contains infinite values of
         * the same sign.
         */
        return new CollectorImpl<>(
                () -> new double[3],
                //直接通过double进行累加运算
                (a, t) -> { sumWithCompensation(a, mapper.applyAsDouble(t));
                            a[2] += mapper.applyAsDouble(t);},
                (a, b) -> { sumWithCompensation(a, b[0]);
                            a[2] += b[2];
                            return sumWithCompensation(a, b[1]); },
                a -> computeFinalSum(a),
                CH_NOID);
    }

三、解决方案:

Java 语言中最经典的便是使用 BigDecimal 来解决。

整体思路是先将 double 类型的数据转换成 BigDecimal 来进行运算,最后再转换成 double 类型的数据。

方式一:将集合中Double对象转为BigDecimal对象在进行计算

    @Test
    public void testDoubleSum2() {
        List<Double> list = Arrays.asList(6.6, 1.3);
        //写法一
        double result  = list.stream().map(e->new BigDecimal(String.valueOf(e))).reduce(BigDecimal::add).get().doubleValue();
        System.out.println("先转BigDecimal再采用reduce方式的值:" + result);

       //写法二
        result = list.stream().map(e->new BigDecimal(String.valueOf(e))).collect(Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)).doubleValue();
        System.out.println("Collectors.reducing计算的值:" + result);
    }

方式二:自定义收集器Collector处理double计算

1、自定义收集器Collector

public static Collector<Double, ?, Double> summingDouble() {
        return Collector.of(
                //1、结果容器
                ()-> new BigDecimal[1],
                //2、累加器
                (result, item) -> {
                    if(item != null && item !=0){
                        if(result[0] != null){
                            result[0] = result[0].add(new BigDecimal(String.valueOf(item)));
                        }else{
                            result[0] = new BigDecimal(String.valueOf(item));
                        }
                    }
                },
                //3、并行计算时的合并器
                (result1, result2) -> {
                    result1[0] = result1[0].add(result2[0]);
                    return result1;
                },
                //4、结果转换
                total -> total[0].doubleValue()
        );
    }

2、通过自定义收集器计算

 @Test
 public void testDoubleSum3() {
     List<Double> list = Arrays.asList(6.6, 1.3);
     double result  = list.stream().collect(summingDouble());
     System.out.println("通过自定义收集器summingDouble计算的值:" + result);
 }

3、计算结果

通过自定义收集器summingDouble计算的值:7.9

四、补充介绍:如何自定义收集器Collector

可用使用Collector.of()静态方法创建自定义Collector。

不仅是为了更精简和增强可读性,还因为这种方法可以忽略部分不必要的实现。实际上,Collector接口仅需要4个必须部分————提供者(supplier), 累加器(accumulator) ,合并器(combiner)以及类型转换器。

Collector接口说明:

public interface Collector<T, A, R> {
    /**
     * 提供者:创建并返回新的可变结果容器的函数.
     */
    Supplier<A> supplier();

    /**
     * 累加器:将值累加到结果容器中
     */
    BiConsumer<A, T> accumulator();

    /**
     * 合并器:在并行环境下,流被分为多个部分,每个部分被并行累加
     */
    BinaryOperator<A> combiner();

    /**
     * 结尾函数:执行从中间累积类型A到最终结果类型R的最终转换。
     */
    Function<A, R> finisher();

    
    /**
     * 特征集:指示Collector属性的特征,可用于优化缩减实现。
     */
    Set<Characteristics> characteristics();

1、结果容器提供者(supplier)

实现Collector,必须提供结果容器,即累加值存储的地方,需要注意数组元素的类型,这里我们使用的BigDecimal类型,只用存一个结果元素,所以数组长度为1。

下面代码提供了结果容器:

() -> new BigDecimal[1]

2、计算逻辑:累加元素(accumulator)

接下来,我们需要创建函数实现增加元素至结果容器。在我们的示例中,需要将元素item依次累加到result结果元素中:

(result, item) -> {
                    if(item != null && item !=0){
                        if(result[0] != null){
                            result[0] = result[0].add(new BigDecimal(String.valueOf(item)));
                        }else{
                            result[0] = new BigDecimal(String.valueOf(item));
                        }
                    }
}

该函数是Consumer类型,其不返回任何值,仅以可变的方式更新结果容器————即数组中的第一个元素。

3、合并器(combiner)

在reduction序列操作中,提供者(supplier) 和 累加器(accumulator) 已经足够了,但为了能够实现并行操作,我们需要实现一个合并器。合并器(combiner)是定义两个结果如何合并的函数。
在并行环境下,流被分为多个部分,每个部分被并行累加。当所有部分都完成时,结果需要使用合并器函数进行合并。下面请看我们的实现代码:

(result1, result2) -> {
                    result1[0] = result1[0].add(result2[0]);
                    return result1;
                }

4、结果数据类型转换

增加一个函数,其映射结果容器至我们需要的类型。这里我们仅仅需要数组的第一个元素:

total -> total[0].doubleValue()

5、完整示例

public class CollectorsUtil {

    public static <T> Collector<T, ?, Double> summingDouble(ToDoubleFunction<? super T> mapper) {
        return Collector.of(
                //1、结果容器
                ()-> new BigDecimal[1],
                //2、累加器
                (result, item) -> {
                    if(item != null && mapper.applyAsDouble(item) !=0){
                        if(result[0] != null){
                            result[0] = result[0].add(new BigDecimal(String.valueOf(mapper.applyAsDouble(item))));
                        }else{
                            result[0] = new BigDecimal(String.valueOf(mapper.applyAsDouble(item)));
                        }
                    }
                },
                //3、合并器
                (result1, result2) -> {
                    result1[0] = result1[0].add(result2[0]);
                    return result1;
                },
                //4、结果转换
                total -> total[0].doubleValue()
        );
    }
}

@Test
    public void testDoubleSum3() {
        List<Double> list = Arrays.asList(6.6, 1.3);
        double result  = list.stream().collect(CollectorsUtil.summingDouble(Double::new));
        System.out.println("通过自定义收集器summingDouble计算的值:" + result);
    }

总结

Java中凡是涉及到浮点数计算精度问题,统一推荐转化为BigDecimal类型去进行计算,这样才能尽可能的避免精度丢失的问题。

本文主要是以Java8中的Stream流式计算采用Collectors.summingDouble()进行double求和计算时出现精度丢失为案例,阐述如果通过自定义收集器Collector解决精度丢失的问题。

Adding up BigDecimals using Streams
Java 8 自定义流Collector实现

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐