☰
Java 8 DoubleSummaryStatistics 实现员工工资统计与最高工资人数
2026/10/3 4:04:43 网站建设 项目流程

后端开发里,统计员工工资这种需求太常见了——人数、工资总和、平均工资、最高、最低,挨个算。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 + '}'; } }

测试数据我故意制造了一点戏剧性:让最高工资出现两次,方便后面验证"最高工资人数"的逻辑。

List<Employee> 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()返回0
  • getSum()返回0.0
  • getAverage()返回0.0
  • getMin()返回Double.POSITIVE_INFINITY
  • getMax()返回Double.NEGATIVE_INFINITY

你没看错,空集合下最低工资是正无穷、最高工资是负无穷。这个设计其实是有意为之的:min和max在内部初始化成两个"极端默认值",方便第一次接收数据时直接替换。但如果你不管三七二十一直接展示这两个值,页面上就会出现"最高工资:-Infinity"这种让人摸不着头脑的东西。

所以稳妥的做法是,拿到stats之后先判断getCount() == 0,再决定要不要展示min和max:

if (stats.getCount() == 0) { System.out.println("暂无员工数据"); } else { System.out.println("最低工资:" + stats.getMin()); System.out.println("最高工资:" + stats.getMax()); }

另外如果数据里混入了NaN,getMax()会返回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接入Stream:

SalaryStats stats = employees.stream().collect( Collector.of(SalaryStats::new, SalaryStats::accept, SalaryStats::combine) );

为什么要写combine?因为 parallelStream并行流会把数据分成多段分别统计,最后把各段的中间结果合并,combine就是干这个的。合并时需要注意:如果两边的max相等,maxCount要相加;如果一边的max更大,就取那边的maxCount。这个细节很容易写错,我最初写合并逻辑时就漏了"一方更大"的情况。

这个方案的优势是单趟遍历、常数级内存,适合大列表或多次调用的场景;劣势是代码量明显增加,而且SalaryStats这个类需要自己维护,如果没有对应测试覆盖,出bug的概率不低。

3.3 思路三:groupingBy统计频率,一表查到底

第三种方案换了个角度:干脆把工资按值分组数一下频次,然后取频次表里最大工资对应的数量。

Map<Double, Long> salaryFreq = employees.stream() .collect(Collectors.groupingBy(Employee::getSalary, Collectors.counting())); Optional<Map.Entry<Double, 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调用。比如定义SalaryReport:

public 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(List<Employee> 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。比起方案二的自定义Collector,teeing更语义化,可读性也好。需要注意freq.getOrDefault(max, 0L)这一手:万一数据为空,stats.getMax()是负无穷大,freq表里自然查不到,兜底成0比抛异常体面得多。

4.3 分部门统计:groupingBy+DoubleSummaryStatistics组合

把这个思路往外推一步,就是"按部门分别统计"。用groupingBy配合Collectors.summarizingDouble即可:

Map<String, 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而不是double),mapToDouble会直接抛NPE。

我在写统计逻辑前,一般会在入口统一过滤一遍:

List<Employee> 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()就能省下大半天的功夫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询