按月分组推荐使用yearmonth作为键,它线程安全、可排序、无时区歧义;通过yearmonth.from()提取年月,配合groupingby和reducing或teeing实现单次流多指标聚合,并支持补全缺失月份与自然排序。

在报表统计中,按月分组是最常见需求之一,比如统计每月订单金额、用户活跃数、销售数量等。Java 8 的 Collectors.groupingBy 配合 LocalDateTime 或 LocalDate 的时间截取能力,能简洁高效地实现“按月分组”。关键不在于分组本身,而在于如何**正确提取“年-月”维度**并保证分组键可读、可排序、无时区歧义。
提取“年-月”作为分组键(推荐用 YearMonth)
直接用 LocalDateTime::getYear 和 getMonthValue 拼字符串(如 "2024-03")虽可行,但易出错且不可比较。更稳妥的方式是使用 YearMonth —— 它是不可变、线程安全、天然支持自然排序的日期单位:
- 从原始时间字段(如
orderTime: LocalDateTime)提取:YearMonth.from(order.getOrderTime()) - 若数据是
String(如 "2024-03-15 10:22:30"),先解析再提取:YearMonth.from(LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) -
YearMonth默认 toString() 为 "2024-03",可直接用于展示;调用atDay(1)可转回LocalDate,方便后续数据库查询或格式化。
基础分组:按月聚合数值总和
假设有一个订单列表 List<order></order>,每个订单含 amount: BigDecimal 和 orderTime: LocalDateTime:
Map<yearmonth bigdecimal> monthlyAmount = orders.stream()
.collect(Collectors.groupingBy(
order -> YearMonth.from(order.getOrderTime()),
Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)
));</yearmonth>
说明:
- 第一参数是分类函数(key),返回
YearMonth; - 第二参数是下游收集器,这里用
reducing累加金额,避免空值问题; - 结果 Map 的 key 是
YearMonth(如 2024-03),value 是当月总金额。
进阶分组:同时统计多个指标(金额、订单数、去重用户数)
单次流处理中完成多维统计,避免多次遍历:
Map<yearmonth summary> summaryByMonth = orders.stream()
.collect(Collectors.groupingBy(
order -> YearMonth.from(order.getOrderTime()),
Collectors.collectingAndThen(
Collectors.teeing(
Collectors.summingBigDecimal(Order::getAmount),
Collectors.counting(),
Collectors.mapping(Order::getUserId, Collectors.toSet()),
(sum, count, users) -> new Summary(sum, count, users.size())
),
map -> map // tee的结果已是Summary,此处可省略
)
));</yearmonth>
更清晰写法(推荐):
- 定义一个轻量
Summary类(含 amountSum、orderCount、uniqueUserCount); - 用
Collectors.collectingAndThen+Collectors.teeing并行计算三项指标; - 注意:Java 12+ 才原生支持
teeing,低版本可用Collectors.groupingBy嵌套 + 手动 merge,或引入StreamEx库。
补全缺失月份(如1~12月连续展示)
原始数据可能缺某些月份(如2月没订单),但报表常需“补零”显示完整时间轴:
- 先获取数据中的最小/最大
YearMonth; - 生成该区间内所有月份:
Stream.iterate(start, ym -> ym.plusMonths(1)).limit(ChronoUnit.MONTHS.between(start, end) + 1) - 用
Map.merge或computeIfAbsent合并原始分组结果与全量月份键; - 最终按
YearMonth自然排序输出(TreeMap(YearMonth::compareTo)或sorted(Map.Entry.comparingByKey()))。
不复杂但容易忽略:时区要统一(建议入库即存 UTC 时间)、空时间字段需过滤、前端展示前把 YearMonth 格式化为“2024年03月”更友好。










