1. Java 8中的BigDecimal分组聚合基础
在金融和电商系统中,我们经常需要处理精确的金额计算。BigDecimal作为Java中处理高精度数值计算的利器,配合Java 8的Stream API,可以轻松实现复杂的数据聚合操作。先来看个电商场景的例子:假设我们需要计算不同商品类别的销售总额。
List<Product> products = Arrays.asList( new Product("手机", "电子产品", new BigDecimal("3999.99")), new Product("笔记本", "电子产品", new BigDecimal("5999.99")), new Product("衬衫", "服装", new BigDecimal("199.99")), new Product("裤子", "服装", new BigDecimal("299.99")) ); // 按商品类别分组求和 Map<String, BigDecimal> categorySum = products.stream() .collect(Collectors.groupingBy( Product::getCategory, Collectors.reducing(BigDecimal.ZERO, Product::getPrice, BigDecimal::add) )); categorySum.forEach((k,v) -> System.out.println(k + ": " + v));这段代码会输出:
服装: 499.98 电子产品: 9999.98这里用到了Stream API的两个核心方法:
groupingBy():按指定字段分组reducing():对分组后的元素进行归约操作
2. 自定义Collector实现高级统计
Java 8原生只提供了对基本类型(int/long/double)的统计Collector,对于BigDecimal我们需要自己实现。下面这个工具类可以解决这个问题:
public class BigDecimalCollectors { // 求和 public static <T> Collector<T, ?, BigDecimal> summingBigDecimal(Function<? super T, BigDecimal> mapper) { return Collector.of( () -> new BigDecimal[1], (a, t) -> a[0] = (a[0] == null ? BigDecimal.ZERO : a[0]).add(mapper.apply(t)), (a, b) -> { a[0] = a[0].add(b[0]); return a; }, a -> a[0] == null ? BigDecimal.ZERO : a[0] ); } // 求平均值(四舍五入,保留2位小数) public static <T> Collector<T, ?, BigDecimal> averagingBigDecimal(Function<? super T, BigDecimal> mapper) { return Collector.of( () -> new BigDecimal[2], // [0]总和 [1]计数 (a, t) -> { a[0] = (a[0] == null ? BigDecimal.ZERO : a[0]).add(mapper.apply(t)); a[1] = (a[1] == null ? BigDecimal.ZERO : a[1]).add(BigDecimal.ONE); }, (a, b) -> { a[0] = a[0].add(b[0]); a[1] = a[1].add(b[1]); return a; }, a -> a[0].divide(a[1], 2, RoundingMode.HALF_UP) ); } }使用示例:
// 按类别统计销售总额 Map<String, BigDecimal> salesByCategory = products.stream() .collect(Collectors.groupingBy( Product::getCategory, BigDecimalCollectors.summingBigDecimal(Product::getPrice) )); // 按类别统计平均价格 Map<String, BigDecimal> avgPriceByCategory = products.stream() .collect(Collectors.groupingBy( Product::getCategory, BigDecimalCollectors.averagingBigDecimal(Product::getPrice) ));3. 处理空值和异常情况
实际项目中,我们经常会遇到数据不完整的情况。比如某些商品的price字段可能为null。这时候直接计算会抛出NullPointerException。下面介绍几种处理方式:
方法1:过滤空值
Map<String, BigDecimal> result = products.stream() .filter(p -> p.getPrice() != null) .collect(Collectors.groupingBy(...));方法2:使用默认值
// 在自定义Collector中处理null public static <T> Collector<T, ?, BigDecimal> summingBigDecimal(Function<? super T, BigDecimal> mapper) { return Collector.of( () -> new BigDecimal[1], (a, t) -> { BigDecimal value = mapper.apply(t); a[0] = (a[0] == null ? BigDecimal.ZERO : a[0]) .add(value == null ? BigDecimal.ZERO : value); }, (a, b) -> { a[0] = a[0].add(b[0]); return a; }, a -> a[0] == null ? BigDecimal.ZERO : a[0] ); }方法3:使用Optional
Map<String, BigDecimal> result = products.stream() .collect(Collectors.groupingBy( Product::getCategory, Collectors.reducing( BigDecimal.ZERO, p -> Optional.ofNullable(p.getPrice()).orElse(BigDecimal.ZERO), BigDecimal::add ) ));4. 多级分组与复合统计
在更复杂的业务场景中,我们可能需要多级分组统计。比如在电商系统中,我们可能需要按"商品类别+用户等级"两级分组统计:
// 假设User类有getLevel方法返回用户等级 List<Order> orders = ...; // 订单数据 // 两级分组统计 Map<String, Map<String, BigDecimal>> result = orders.stream() .collect(Collectors.groupingBy( o -> o.getProduct().getCategory(), Collectors.groupingBy( o -> o.getUser().getLevel(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) ) )); // 输出结果 result.forEach((category, levelMap) -> { System.out.println("【" + category + "】"); levelMap.forEach((level, sum) -> System.out.println(" " + level + "用户: " + sum)); });对于需要同时计算多个统计指标的场景(如同时计算总和、平均值、最大值、最小值),我们可以使用Collectors.collectingAndThen:
class Stats { BigDecimal sum; BigDecimal avg; BigDecimal max; BigDecimal min; // 构造方法、getter省略 } Map<String, Stats> statsByCategory = products.stream() .collect(Collectors.groupingBy( Product::getCategory, Collectors.collectingAndThen( Collectors.toList(), list -> { Stats stats = new Stats(); stats.sum = list.stream() .map(Product::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); stats.avg = stats.sum.divide( new BigDecimal(list.size()), 2, RoundingMode.HALF_UP); stats.max = list.stream() .map(Product::getPrice) .reduce(BigDecimal::max) .orElse(BigDecimal.ZERO); stats.min = list.stream() .map(Product::getPrice) .reduce(BigDecimal::min) .orElse(BigDecimal.ZERO); return stats; } ) ));5. 性能优化与最佳实践
在处理大数据量时,Stream API的性能问题需要注意。以下是几个优化建议:
- 并行流谨慎使用:对于简单的数值计算,并行流可能带来额外开销
// 只有当数据量很大时才考虑并行 BigDecimal sum = bigList.parallelStream() .map(Item::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add);避免重复计算:如果需要多个统计指标,尽量在一次遍历中完成
使用基本类型:对于确定不会溢出且精度要求不高的场景,可以考虑先用基本类型计算再转换
// 先用double计算再转BigDecimal(慎用) double sum = products.stream() .mapToDouble(p -> p.getPrice().doubleValue()) .sum(); BigDecimal total = BigDecimal.valueOf(sum);- 预分配容器:对于已知大小的集合,预分配可以提升性能
List<BigDecimal> bigList = new ArrayList<>(10000); // 预分配大小- 注意BigDecimal的不可变性:每次运算都会创建新对象,大量计算时可能产生较多临时对象
6. 实际应用案例:销售报表生成
让我们通过一个完整的电商销售报表案例,综合运用前面介绍的技术:
public class SalesReportGenerator { public void generateReport(List<Order> orders) { // 1. 按月份统计销售额 Map<String, BigDecimal> monthlySales = orders.stream() .collect(Collectors.groupingBy( o -> DateTimeFormatter.ofPattern("yyyy-MM").format(o.getOrderDate()), BigDecimalCollectors.summingBigDecimal(Order::getAmount) )); // 2. 按商品类别统计 Map<String, BigDecimal> salesByCategory = orders.stream() .collect(Collectors.groupingBy( o -> o.getProduct().getCategory(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) )); // 3. 按用户等级+商品类别两级分组 Map<String, Map<String, BigDecimal>> salesByLevelAndCategory = orders.stream() .collect(Collectors.groupingBy( o -> o.getUser().getLevel(), Collectors.groupingBy( o -> o.getProduct().getCategory(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) ) )); // 4. 生成报表... } // 支持自定义时间维度的统计 public Map<String, BigDecimal> salesByTimeDimension( List<Order> orders, Function<Order, String> dimensionMapper) { return orders.stream() .collect(Collectors.groupingBy( dimensionMapper, BigDecimalCollectors.summingBigDecimal(Order::getAmount) )); } }使用示例:
List<Order> orders = orderService.getOrders(lastYear); SalesReportGenerator generator = new SalesReportGenerator(); // 按季度统计 Map<String, BigDecimal> quarterlySales = generator.salesByTimeDimension( orders, o -> "Q" + ((o.getOrderDate().getMonthValue() - 1) / 3 + 1) ); // 按周统计(ISO周) Map<String, BigDecimal> weeklySales = generator.salesByTimeDimension( orders, o -> o.getOrderDate().get(WeekFields.ISO.weekOfYear()) + "周" );7. 常见问题与解决方案
在实际使用中,我遇到过不少坑,这里分享几个典型问题的解决方法:
问题1:精度丢失
BigDecimal a = new BigDecimal("0.1"); BigDecimal b = new BigDecimal("0.2"); BigDecimal sum = a.add(b); // 0.3(正确) // 错误做法: BigDecimal c = new BigDecimal(0.1); // 精度已丢失 BigDecimal d = new BigDecimal(0.2); BigDecimal wrongSum = c.add(d); // 0.300000000000000016...解决方案:始终使用String构造BigDecimal,或者使用valueOf方法:
BigDecimal c = BigDecimal.valueOf(0.1); // 内部会转成字符串处理问题2:除不尽异常
BigDecimal a = new BigDecimal("10"); BigDecimal b = new BigDecimal("3"); BigDecimal result = a.divide(b); // 抛出ArithmeticException解决方案:指定舍入模式
BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP); // 3.33问题3:分组键为null
// 如果getCategory()可能返回null,会抛出NPE Map<String, BigDecimal> map = products.stream() .collect(Collectors.groupingBy( Product::getCategory, Collectors.summingBigDecimal(Product::getPrice) ));解决方案:使用null-safe的分组键
Map<String, BigDecimal> map = products.stream() .collect(Collectors.groupingBy( p -> Optional.ofNullable(p.getCategory()).orElse("其他"), Collectors.summingBigDecimal(Product::getPrice) ));问题4:内存溢出:处理超大集合时,可以考虑分批处理
// 分批处理大集合 int batchSize = 10000; for (int i = 0; i < bigList.size(); i += batchSize) { List<Order> batch = bigList.subList(i, Math.min(i + batchSize, bigList.size())); // 处理每个批次... }8. 扩展应用:财务系统中的金额统计
在财务系统中,金额统计的要求更为严格。下面是一个财务金额统计的工具类实现:
public class FinancialStatsUtils { // 安全求和(处理null值) public static BigDecimal safeSum(Collection<BigDecimal> amounts) { return amounts.stream() .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); } // 加权平均计算 public static BigDecimal weightedAverage( List<BigDecimal> values, List<BigDecimal> weights) { if (values.size() != weights.size()) { throw new IllegalArgumentException("值和权重的数量必须相同"); } BigDecimal sumProduct = BigDecimal.ZERO; BigDecimal sumWeights = BigDecimal.ZERO; for (int i = 0; i < values.size(); i++) { BigDecimal value = values.get(i); BigDecimal weight = weights.get(i); if (value != null && weight != null) { sumProduct = sumProduct.add(value.multiply(weight)); sumWeights = sumWeights.add(weight); } } return sumWeights.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : sumProduct.divide(sumWeights, 6, RoundingMode.HALF_UP); } // 计算增长率(百分比) public static BigDecimal growthRate( BigDecimal current, BigDecimal previous, int scale) { if (previous.compareTo(BigDecimal.ZERO) == 0) { return current.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : null; // 或者抛异常,根据业务需求 } return current.subtract(previous) .divide(previous, scale + 2, RoundingMode.HALF_UP) .multiply(new BigDecimal("100")) .setScale(scale, RoundingMode.HALF_UP); } }使用示例:
// 计算月度增长率 BigDecimal janSales = new BigDecimal("1000000"); BigDecimal febSales = new BigDecimal("1200000"); BigDecimal growthRate = FinancialStatsUtils.growthRate(febSales, janSales, 2); System.out.println("增长率: " + growthRate + "%"); // 增长率: 20.00%9. 与数据库聚合的结合使用
在实际项目中,我们经常需要将Java层的聚合与数据库层的聚合结合使用。以下是一个结合JPA的示例:
public interface OrderRepository extends JpaRepository<Order, Long> { // 数据库层聚合:按类别统计销售数量 @Query("SELECT o.product.category as category, COUNT(o) as count " + "FROM Order o GROUP BY o.product.category") List<Object[]> countByCategory(); // Java层聚合:获取明细后在内存中计算 default Map<String, BigDecimal> sumAmountByCategory() { return findAll().stream() .collect(Collectors.groupingBy( o -> o.getProduct().getCategory(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) )); } // 混合方案:数据库过滤,Java聚合 default Map<String, BigDecimal> sumAmountByCategory(Date start, Date end) { return findByOrderDateBetween(start, end).stream() .collect(Collectors.groupingBy( o -> o.getProduct().getCategory(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) )); } List<Order> findByOrderDateBetween(Date start, Date end); }选择策略:
- 大数据量简单统计 → 优先数据库聚合
- 复杂业务逻辑 → Java层聚合
- 中等数据量混合统计 → 数据库过滤+Java聚合
10. 测试与验证
为了保证统计结果的正确性,完善的测试必不可少。以下是一些测试用例的示例:
class BigDecimalStatsTest { @Test void testSummingBigDecimal() { List<Product> products = Arrays.asList( new Product("A", new BigDecimal("100.50")), new Product("B", new BigDecimal("200.75")), new Product("C", new BigDecimal("300.25")) ); BigDecimal sum = products.stream() .collect(BigDecimalCollectors.summingBigDecimal(Product::getPrice)); assertEquals(new BigDecimal("601.50"), sum); } @Test void testSummingWithNullValues() { List<Product> products = Arrays.asList( new Product("A", new BigDecimal("100.50")), new Product("B", null), new Product("C", new BigDecimal("300.25")) ); BigDecimal sum = products.stream() .collect(BigDecimalCollectors.summingBigDecimal(Product::getPrice)); assertEquals(new BigDecimal("400.75"), sum); } @Test void testAveragingBigDecimal() { List<Product> products = Arrays.asList( new Product("A", new BigDecimal("100")), new Product("B", new BigDecimal("200")), new Product("C", new BigDecimal("300")) ); BigDecimal avg = products.stream() .collect(BigDecimalCollectors.averagingBigDecimal(Product::getPrice)); assertEquals(new BigDecimal("200.00"), avg); } @Test void testMultiLevelGrouping() { List<Order> orders = Arrays.asList( new Order(new User("VIP"), new Product("A", "电子"), new BigDecimal("100")), new Order(new User("VIP"), new Product("B", "服装"), new BigDecimal("200")), new Order(new User("普通"), new Product("A", "电子"), new BigDecimal("50")) ); Map<String, Map<String, BigDecimal>> result = orders.stream() .collect(Collectors.groupingBy( o -> o.getUser().getLevel(), Collectors.groupingBy( o -> o.getProduct().getCategory(), BigDecimalCollectors.summingBigDecimal(Order::getAmount) ) )); assertEquals(new BigDecimal("100"), result.get("VIP").get("电子")); assertEquals(new BigDecimal("200"), result.get("VIP").get("服装")); assertEquals(new BigDecimal("50"), result.get("普通").get("电子")); } }