
后端开发里统计员工工资这种需求太常见了——人数、工资总和、平均工资、最高、最低挨个算。Java 8的DoubleSummaryStatistics就是专门为这类汇总统计准备的工具类配合Stream一行就能把五个核心指标同时算出来。但实际接需求时往往不止这五个数比如领导经常会问一句拿最高工资的有几个人这个DoubleSummaryStatistics偏偏不直接给。这篇文章就拿员工薪资统计这个场景把类的用法、边界情况和最高工资人数的几种实现一起讲透给你一套能直接抄的代码。1. DoubleSummaryStatistics能给你什么五个指标一次遍历全拿到1.1 底层原理为什么summaryStatistics能一遍过DoubleSummaryStatistics是java.util包下的小工具类从Java 8开始随Stream API一起出现。它的核心设计思路很朴素维护count、sum、min、max、average这几个内部状态每接收一个double值就同步更新一次。你把它当成一个带状态的累加器就对了。通常我们不会直接new这个类而是通过Stream的链式调用让它自动工作DoubleSummaryStatistics stats employees.stream() .mapToDouble(Employee::getSalary) .summaryStatistics();这里有个关键点用的是mapToDouble而不是map。原因在于summaryStatistics()只在DoubleStream、IntStream、LongStream这几个原始类型流上提供普通对象流是不行的。mapToDouble会把工资字段拆成double流然后流内部把每个元素逐个喂给DoubleSummaryStatistics。为什么要强调一遍过因为传统写法如果你想拿到五个指标要么在一个循环里同时维护五个临时变量要么把数据存下来来回扫好几遍。而summaryStatistics在遍历过程中同时更新所有状态时间和空间上都是单趟、O(1)额外内存不需要把工资列表复制一份出来。这在数据量大、字段多的场景下优势非常明显。1.2 对比手写循环少写五十行还更稳没有这个类之前我写过很多次类似的循环long count 0; double sum 0; double min Double.POSITIVE_INFINITY; double max Double.NEGATIVE_INFINITY; for (Employee e : employees) { double salary e.getSalary(); count; sum salary; if (salary min) { min salary; } if (salary max) { max salary; } } double avg count 0 ? 0.0 : sum / count;这段代码看着还行但一旦需求变成按部门分别统计只统计工资大于某个阈值的员工循环体就开始膨胀if嵌套越来越多很容易在某个分支里忘了更新某个变量。DoubleSummaryStatistics的优势在于统计逻辑被封装好了我只关心取出指标不关心怎么算的。而且它天然和Stream的filter、map、groupingBy等操作组合统计条件可以灵活叠加代码结构反而是更清晰的。2. 员工薪资统计核心代码五个标准指标一次算完2.1 准备员工对象和测试数据先定义一个简单的员工类字段就按最常见的来姓名、部门、工资。public class Employee { private String name; private String department; private double salary; public Employee(String name, String department, double salary) { this.name name; this.department department; this.salary salary; } public String getName() { return name; } public String getDepartment() { return department; } public double getSalary() { return salary; } Override public String toString() { return Employee{ name name \ , department department \ , salary salary }; } }测试数据我故意制造了一点戏剧性让最高工资出现两次方便后面验证最高工资人数的逻辑。ListEmployee employees Arrays.asList( new Employee(张三, 技术部, 15000), new Employee(李四, 技术部, 18000), new Employee(王五, 市场部, 12000), new Employee(赵六, 市场部, 18000), new Employee(钱七, 人事部, 9800), new Employee(孙八, 人事部, 10500) );2.2 用summaryStatistics()一次性算出五大指标核心代码非常简单DoubleSummaryStatistics stats employees.stream() .mapToDouble(Employee::getSalary) .summaryStatistics(); System.out.println(员工人数 stats.getCount()); System.out.println(工资总和 stats.getSum()); System.out.println(平均工资 stats.getAverage()); System.out.println(最高工资 stats.getMax()); System.out.println(最低工资 stats.getMin());输出结果员工人数6 工资总和83300.0 平均工资13883.333333333334 最高工资18000.0 最低工资9800.0五个getter对应关系一目了然getCount()拿人数getSum()拿总和getAverage()拿平均getMax()和getMin()拿最高和最低。这里注意getAverage()返回的是double是sum除以count直接除出来的没有做四舍五入所以打印出来一长串小数。如果想要一个带格式的展示可以配合String.format或者BigDecimal做舍入System.out.printf(平均工资%.2f%n, stats.getAverage());2.3 五个getter的边界行为空流、NaN、无穷大这部分属于踩过坑才能记住的知识点务必留意。当员工集合为空时DoubleSummaryStatistics默认状态是getCount()返回0getSum()返回0.0getAverage()返回0.0getMin()返回Double.POSITIVE_INFINITYgetMax()返回Double.NEGATIVE_INFINITY你没看错空集合下最低工资是正无穷、最高工资是负无穷。这个设计其实是有意为之的min和max在内部初始化成两个极端默认值方便第一次接收数据时直接替换。但如果你不管三七二十一直接展示这两个值页面上就会出现最高工资-Infinity这种让人摸不着头脑的东西。所以稳妥的做法是拿到stats之后先判断getCount() 0再决定要不要展示min和maxif (stats.getCount() 0) { System.out.println(暂无员工数据); } else { System.out.println(最低工资 stats.getMin()); System.out.println(最高工资 stats.getMax()); }另外如果数据里混入了NaNgetMax()会返回NaN因为内部比较时NaN不等于任何值也不小于任何值很容易把统计结果污染掉。我一般会在入参阶段就把非法值过滤掉filter(e - e.getSalary() 0)至少能把明显的脏数据挡在门外。3. 最高工资人数官方没给现成的三种思路自己补拿到五个标准指标之后领导要的第六个指标就来了拿最高工资的有几个人。说句实话DoubleSummaryStatistics没有提供这样的getter因为它内部只保存了max的值没有保存这个max出现了多少次。不过思路并不复杂本质就是统计最高工资在数据里的频次下面三种方案我都试过各有利弊。3.1 思路一先取max再二次过滤最简单第一种思路最直白先用summaryStatistics拿到最高工资然后重新遍历员工列表统计工资等于这个最高值的人数。DoubleSummaryStatistics stats employees.stream() .mapToDouble(Employee::getSalary) .summaryStatistics(); long maxSalaryCount employees.stream() .filter(e - Double.compare(e.getSalary(), stats.getMax()) 0) .count(); System.out.println(最高工资人数 maxSalaryCount);这里我用的是Double.compare(e.getSalary(), stats.getMax()) 0而不是直接写e.getSalary() stats.getMax()。原因很简单stats.getMax()拿到的最大值本身就是从这批数据里来的直接等值比较大多数情况下没问题但double的比较用总有踩NaN或-0.0这种特殊值的风险用Double.compare更稳妥这也是Java官方推荐的对象间double比较方式。这个方案的优点是肉眼可见的简单五行代码搞定缺点是遍历了两遍数据。在员工量级不大的场景下完全够用我实测过几万条数据二次过滤的耗时几乎可以忽略。3.2 思路二自定义Collector一趟遍历全搞定追求性能的话可以自己写一个容器类把最高工资人数这个状态一起维护进去。思路是在接收每个工资值时同步维护四个东西当前最大值、当前最大值出现次数、最小值、总和和计数。public class SalaryStats { private long count; private double sum; private double min Double.POSITIVE_INFINITY; private double max Double.NEGATIVE_INFINITY; private long maxCount 0; public void accept(Employee e) { double salary e.getSalary(); count; sum salary; if (salary min) { min salary; } if (salary max) { max salary; maxCount 1; } else if (salary max) { maxCount; } } public SalaryStats combine(SalaryStats other) { count other.count; sum other.sum; if (other.min min) { min other.min; } if (other.max max) { max other.max; maxCount other.maxCount; } else if (other.max max) { maxCount other.maxCount; } return this; } public double getAverage() { return count 0 ? 0.0 : sum / count; } // 省略getter }关键逻辑在accept里遇到新的更大值重置max并把maxCount设为1遇到等于当前max的值maxCount加一小于max的值只更新min和sum。这个模式保证maxCount永远跟随当前最大值的频次。使用方式是通过Collector.of接入StreamSalaryStats stats employees.stream().collect( Collector.of(SalaryStats::new, SalaryStats::accept, SalaryStats::combine) );为什么要写combine因为 parallelStream并行流会把数据分成多段分别统计最后把各段的中间结果合并combine就是干这个的。合并时需要注意如果两边的max相等maxCount要相加如果一边的max更大就取那边的maxCount。这个细节很容易写错我最初写合并逻辑时就漏了一方更大的情况。这个方案的优势是单趟遍历、常数级内存适合大列表或多次调用的场景劣势是代码量明显增加而且SalaryStats这个类需要自己维护如果没有对应测试覆盖出bug的概率不低。3.3 思路三groupingBy统计频率一表查到底第三种方案换了个角度干脆把工资按值分组数一下频次然后取频次表里最大工资对应的数量。MapDouble, Long salaryFreq employees.stream() .collect(Collectors.groupingBy(Employee::getSalary, Collectors.counting())); OptionalMap.EntryDouble, Long maxEntry salaryFreq.entrySet().stream() .max(Map.Entry.comparingByKey()); if (maxEntry.isPresent()) { double maxSalary maxEntry.get().getKey(); long maxSalaryCount maxEntry.get().getValue(); System.out.println(最高工资 maxSalary); System.out.println(最高工资人数 maxSalaryCount); }这里用groupingBy(Employee::getSalary, Collectors.counting())把每个工资值映射到出现次数然后通过entrySet().stream().max()找到key最大的那个Entry它的value就是我们要的人数。这个方案顺手把按工资分布也做出来了如果后续想看工资分布直方图这个Map直接就能用。唯一需要注意的是Double作为Map的key等值判断是按double的位模式走的正常场景没问题如果数据里同时存在0.0和-0.0这种极端值它们会被当成不同的key实际业务里基本遇不到但知道有这回事就好。3.4 三种方案怎么选成本、可读性、风险对比方案遍历次数代码量可读性主要风险先max再过滤2次最少最好大列表下多一次遍历自定义Collector1次最多一般combine逻辑容易写错groupingBy频次2次中等较好Double作为Map key的边界值我的建议是常规业务代码优先选方案一简洁直观别人接手一看就懂如果你在一个内层循环里反复调这个统计或者数据量到了百万级再考虑方案二方案三适合你恰好需要工资分布数据的场景顺手复用。没有银弹按需取舍。4. 封装成可复用的统计模块4.1 用独立类和Builder组织统计结果实际项目里不会只在一个地方用统计所以我会把结果封装成一个独立对象避免到处散落着DoubleSummaryStatistics的getter调用。比如定义SalaryReportpublic class SalaryReport { private final long count; private final double sum; private final double min; private final double max; private final double avg; private final long maxSalaryCount; // 全参构造器和getter省略 Override public String toString() { return String.format( 人数%d, 总和%.2f, 平均%.2f, 最低%.2f, 最高%.2f, 最高工资人数%d, count, sum, avg, min, max, maxSalaryCount); } }然后在统计入口处统一组装public SalaryReport buildReport(ListEmployee employees) { DoubleSummaryStatistics stats employees.stream() .mapToDouble(Employee::getSalary) .summaryStatistics(); double max stats.getMax(); long maxCount employees.stream() .filter(e - Double.compare(e.getSalary(), max) 0) .count(); return new SalaryReport(stats.getCount(), stats.getSum(), stats.getMin(), max, stats.getAverage(), maxCount); }这样调用方拿到的就是一个语义清晰的报表对象而不是一堆散装指标。UI层直接report.toString()就能打印出一行可读的汇总。4.2 Java 12的teeing一个collect返回完整报表Java 12引入了Collectors.teeing可以把两个收集器的结果合并成一个。这个特性非常适合我们的场景一个收集器算标准统计另一个收集器算最高工资频次最后合并成报表。SalaryReport report employees.stream().collect( Collectors.teeing( Collectors.summarizingDouble(Employee::getSalary), Collectors.groupingBy(Employee::getSalary, Collectors.counting()), (stats, freq) - { double max stats.getMax(); long maxCount freq.getOrDefault(max, 0L); return new SalaryReport(stats.getCount(), stats.getSum(), stats.getMin(), max, stats.getAverage(), maxCount); } ) );这段代码把统计和查频次并行执行再在合并函数里拼出SalaryReport。比起方案二的自定义Collectorteeing更语义化可读性也好。需要注意freq.getOrDefault(max, 0L)这一手万一数据为空stats.getMax()是负无穷大freq表里自然查不到兜底成0比抛异常体面得多。4.3 分部门统计groupingByDoubleSummaryStatistics组合把这个思路往外推一步就是按部门分别统计。用groupingBy配合Collectors.summarizingDouble即可MapString, DoubleSummaryStatistics deptStats employees.stream() .collect(Collectors.groupingBy( Employee::getDepartment, Collectors.summarizingDouble(Employee::getSalary) )); deptStats.forEach((dept, stats) - System.out.printf(%s人数%d平均%.2f最高%.2f最低%.2f总和%.2f%n, dept, stats.getCount(), stats.getAverage(), stats.getMax(), stats.getMin(), stats.getSum()) );这个写法把分组和统计两件事组合在了一起Stream API对这类需求的表达能力是真的强。如果还想在部门维度上加最高工资人数那就要在groupingBy的value收集器上再动脑筋通常做法是先groupingBy到员工列表再对每个列表单独统计灵活度更高。5. 实操避坑这些问题我基本都踩过5.1 金额精度double算钱要谨慎DoubleSummaryStatistics底层是double而double在表示小数时本身就有精度问题。工资这种业务数据如果你只用来做报表展示double的误差在大多数情况下看不出来但只要涉及财务结算、对账或者金额要参与后续计算我强烈建议换成以分为单位的long用LongSummaryStatistics去统计或者干脆用BigDecimal手动聚合。BigDecimal total employees.stream() .map(e - BigDecimal.valueOf(e.getSalary())) .reduce(BigDecimal.ZERO, BigDecimal::add); System.out.println(工资总和 total.setScale(2, RoundingMode.HALF_UP));这里BigDecimal.valueOf(double)会使用Double.toString的结果来构造能规避一部分二进制浮点误差是double转BigDecimal时的推荐做法。5.2 空集合与空值别让NPE毁掉统计两个最常见的线上事故源头。第一是员工列表为空前面说了min和max会变成正负无穷展示前一定先判断getCount() 0。第二是列表里有null元素或者某个员工的工资字段为null如果你用的是Double而不是doublemapToDouble会直接抛NPE。我在写统计逻辑前一般会在入口统一过滤一遍ListEmployee validEmployees employees.stream() .filter(Objects::nonNull) .filter(e - e.getSalary() 0) .collect(Collectors.toList());别小看这两行filter它能帮你挡掉大量边界脏数据让后面的统计逻辑保持简单。5.3 parallel流和自定义Collector的combine用parallelStream()配自定义Collector时combine的正确性至关重要。我吃过一次亏合并逻辑里只处理了两边max相等的情况没处理一边max更大的情况导致并行处理时最高工资人数统计错了好几倍。后来我加了一条铁律自定义Collector合并两个容器时先比较min和max再依据最大值来自哪一侧决定maxCount怎么合并顺序不能乱。如果不想自己维护这套逻辑建议在并行场景下直接用teeing或者回退到串行流。统计场景的数据量通常没大到非并行不可串行流的稳定性能已经够用。5.4 大数据量性能实测我在本地用十万条员工数据做过一次简单对比单次summaryStatistics()串行流耗时在几十毫秒内parallelStream()并不总是更快反而有线程调度开销小数据量下更慢。方案一的max后二次过滤在十万条数据下也基本感觉不到延迟所以性能不该成为你选复杂方案的核心理由代码可维护性才是。6. 后续还能怎么扩展6.1 用record/DTO封装统计结果Java 16可以改用record来承载统计结果代码会非常清爽public record SalaryReport( long count, double sum, double min, double max, double avg, long maxSalaryCount) { }record自动生成构造器、getter和toString用来做这种不可变的数据载体再合适不过。如果你的项目还在Java 11那老老实实写个普通类或者用Lombok的Value也行。6.2 结合BigDecimal做金额格式化报表最终要给人看double的原始输出太丑。我通常会在SalaryReport里加一个带格式的辅助方法public String toFormattedString() { DecimalFormat df new DecimalFormat(#,##0.00); return 人数 count , 总和 df.format(sum) , 平均 df.format(avg) , 最低 df.format(min) , 最高 df.format(max) , 最高工资人数 maxSalaryCount; }这样展示层只管调用不用关心金额格式化逻辑散落在哪里。我自己在实际项目里用得最多的组合是DoubleSummaryStatistics算五个标准指标再用一次filter把最高工资人数补上最后塞进一个DTO统一返回。简单、直观、团队成员接手也毫无压力。如果你想把工资统计做得更稳记住两个原则入参先清洗、空数据先判断。这套代码你完全可以照抄进自己的工具类下次再有类似的统计需求直接一行summaryStatistics()就能省下大半天的功夫。