news 2026/10/3 4:04:42

Java用DoubleSummaryStatistics统计员工工资六个指标实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java用DoubleSummaryStatistics统计员工工资六个指标实战

做Java开发的朋友一定遇到过这种场景:数据库里几万条员工记录摆在那,领导一句话就要求统计员工数量、平均工资、最高工资、最低工资、工资总和,顺手还要一个最高工资人数。SQL里一条SELECT加GROUP BY就能办到,可问题常出在代码层——数据已经从接口或者缓存里以List<Employee>的形式取出来了,这时候再折回去查库就太蠢了,动手写for循环又显得很原始。我第一次接触到DoubleSummaryStatistics的时候,是在一个薪资报表模块里,发现用Collectors.summarizingDouble一行代码就能拿到大部分指标,连“最高工资人数”这种偏门统计也能从统计结果里推导出来。这篇内容就围绕我用DoubleSummaryStatistics统计员工工资数据的完整过程来写,适合正在使用Java 8及以上版本、需要在内存中做聚合统计的开发者,也算是一份能直接抄作业的实战备忘。

1. 项目需求拆解与方案选型

1.1 六个统计指标的真实含义

标题里的六个指标看似简单,但拆开来看其实分成了三个层次。

第一层是最基础的计数指标,也就是员工数量。这个指标直接对应getCount(),没有任何计算成本,纯粹看集合里有多少条记录。

第二层是聚合计算指标,包括平均工资、工资总和、最高工资、最低工资这四个。这四个本质上都依赖同一个遍历过程:每读取一个员工的工资,就更新累加和、更新最大值、更新最小值,最后除以总数得到平均值。手动实现的话,需要维护至少四个局部变量,还得注意初始值怎么设置,比如最大值要初始化为Double.MIN_VALUE,最小值要初始化为Double.MAX_VALUE,稍不留神就会搞错方向。

第三层是最容易被忽略的“最高工资人数”。这个指标不是简单的聚合值,而是一个条件计数:先要知道最高工资是多少,再去数有多少员工的工资等于这个值。所以它天然依赖最高工资这个统计结果,属于二次计算的范畴。明确了这层关系,后面代码实现的时候思路就清晰很多,不需要为了这一个指标去设计一套复杂的统计逻辑。

1.2 为什么优先选DoubleSummaryStatistics

如果只需要统计一两次,写for循环也不是不行,但问题在于这类统计需求会不断变化。今天要算平均工资,明天可能就要算工资中位数,后天又想把离职员工和在职员工分开统计。每次都靠修改循环体来适配新需求,代码会越来越臃肿,而且很容易把统计逻辑和业务逻辑混在一起。

DoubleSummaryStatistics解决的核心痛点就是“把统计状态封装起来”。它内部维护了count、sum、min、max四个核心字段,通过accept(double value)方法一条条消费数据,遍历结束后直接通过getCount()、getSum()、getMin()、getMax()、getAverage()取出结果。这种设计有几点优势:

  • 一次遍历完成所有指标计算,时间复杂度是O(n),性能开销极低。
  • 与Stream API天然集成,mapToDouble(...).summaryStatistics()或者collect(Collectors.summarizingDouble(...))两种方式任选。
  • 支持combine合并,可以处理分片计算结果再汇总的场景。
  • 代码量比手写循环少一个数量级,可读性也好很多。

1.3 备选方案横向对比

用一张表把几种常见方案放在一起看会更直观:

方案优点缺点适用场景
手写for循环逻辑完全可控代码量大、易出错、不易扩展简单一次性统计
SQL聚合查询数据库压力小需要回表查询、无法处理内存数据数据在数据库且无缓存时
DoubleSummaryStatistics简洁高效、与Stream结合紧密不直接支持条件计数内存中聚合计算
Int/LongSummaryStatistics同样简洁精度不如double工资用整数表示时
自定义Collector完全定制开发成本高有复杂聚合需求时

从表格能看出来,DoubleSummaryStatistics在大多数内存统计场景下是最均衡的选型。它不追求花哨的定制能力,但把最常见的统计需求覆盖得很完整,这也是我最终选它的原因。

2. DoubleSummaryStatistics核心细节与统计实现

2.1 类结构与方法全景

先把这个类的主要方法捋一遍。

方法作用注意事项
getCount()返回已统计的元素个数空统计时为0
getSum()返回所有值的总和double累加可能丢失精度
getMin()返回最小值空统计时返回Double.POSITIVE_INFINITY
getMax()返回最大值空统计时返回Double.NEGATIVE_INFINITY
getAverage()返回平均值空统计时返回0.0
accept(double)添加一个值线程不安全
combine(DoubleSummaryStatistics)合并另一个统计实例常用于分片汇总
toString()返回可读字符串适合日志输出

参与实现前要特别注意一点:这个类对所有值的处理都会经过accept方法,而accept方法对NaN值有特殊处理逻辑,会把NaN忽略掉。这意味着如果你的工资数据里混入了NaN,统计结果不会报错,但可能和你预期的不太一样。后面在常见问题里我会再展开讲。

2.2 员工数据模型与准备

接下来实操。假设我们的员工类长这样:

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("张三", "技术部", 18000), new Employee("李四", "技术部", 22000), new Employee("王五", "产品部", 22000), new Employee("赵六", "产品部", 15000), new Employee("钱七", "市场部", 22000), new Employee("孙八", "市场部", 12000) );

这批数据里最高工资是22000元,有三位同事(李四、王五、钱七)都拿到了这个数,所以“最高工资人数”的预期结果应该是3。后续代码会围绕这个基准验证结果。

2.3 标准统计指标代码实现

先看最推荐的写法,用Stream API配合Collectors.summarizingDouble:

DoubleSummaryStatistics stats = employees.stream() .collect(Collectors.summarizingDouble(Employee::getSalary)); 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()); System.out.println(stats);

输出结果大概是:

员工数量: 6 工资总和: 111000 平均工资: 18500.0 最高工资: 22000.0 最低工资: 12000.0 DoubleSummaryStatistics{count=6, sum=111000.000000, min=12000.000000, average=18500.000000, max=22000.000000}

还差一个“最高工资人数”,放到下一节专门讲。

除了collect这种写法,还可以用mapToDouble加summaryStatistics:

DoubleSummaryStatistics stats2 = employees.stream() .mapToDouble(Employee::getSalary) .summaryStatistics();

这两种方式的差别很小。前者内部也是调用mapToDouble后生成DoubleStream,最后收集为DoubleSummaryStatistics。实际使用时我更喜欢collect(Collectors.summarizingDouble(...)),因为它在groupingBy分组后作为下游收集器时写起来更自然,比如:

Map<String, DoubleSummaryStatistics> deptStats = employees.stream() .collect(Collectors.groupingBy(Employee::getDepartment, Collectors.summarizingDouble(Employee::getSalary)));

这样就能按部门得到各自的统计结果,非常顺手。

2.4 “最高工资人数”的三种实现姿势

先说最简单粗暴的思路:先通过stats.getMax()拿到最高工资,再回到员工列表里filter计数。

double maxSalary = stats.getMax(); long maxCount = employees.stream() .filter(e -> e.getSalary() == maxSalary) .count(); System.out.println("最高工资人数: " + maxCount);

这段代码看着简单,但要注意它遍历了两次原列表,第一次用于统计,第二次用于过滤。数据量小的时候无所谓,几万条数据也完全没问题;但如果数据量达到百万级别,两次遍历的开销就需要考虑了,而且第二次遍历还依赖第一次的结果,无法并行化。

第二种方式是把所有指标放到一个自定义的Collector里,一次遍历把所有结果全算出来。我一般会定义一个内部类来承载统计状态:

public class EmployeeStats { private long count; private double sum; private double min = Double.POSITIVE_INFINITY; private double max = Double.NEGATIVE_INFINITY; private long maxCount; public void accept(Employee e) { double salary = e.getSalary(); count++; sum += salary; min = Math.min(min, salary); if (salary > max) { max = salary; maxCount = 1; } else if (salary == max) { maxCount++; } } public EmployeeStats combine(EmployeeStats other) { if (other.count == 0) { return this; } count += other.count; sum += other.sum; min = Math.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 : sum / count; } public double getMaxSalary() { return max; } public long getMaxSalaryCount() { return maxCount; } @Override public String toString() { return String.format("员工数量=%d, 工资总和=%.2f, 平均工资=%.2f, 最高工资=%.2f, 最高工资人数=%d, 最低工资=%.2f", count, sum, getAverage(), max, maxCount, min); } }

然后定义对应的Collector:

public static Collector<Employee, ?, EmployeeStats> employeeStatsCollector() { return Collector.of( EmployeeStats::new, EmployeeStats::accept, EmployeeStats::combine ); }

调用方式也很简单:

EmployeeStats employeeStats = employees.stream() .collect(employeeStatsCollector()); System.out.println(employeeStats);

这里有一个细节:accept方法里用salary > max判断是否更新最高工资,一旦出现更大的值,maxCount要重置为1,而不是累加。很多人第一次写这个逻辑时忘记重置,导致最高工资人数虚高,这是最容易踩的坑。combine合并分区结果时同理,也要判断两个分区的max谁更大,相等时才能累加maxCount。

第三种方式是偷懒但有效的办法:直接用DoubleSummaryStatistics自带的能力,先拿到max,再用一个泛型更强的filter配合peek或者collect做计数。实际效果和第一种类似,但可以把代码压缩成一行:

long maxCount = employees.stream() .map(Employee::getSalary) .filter(s -> s == stats.getMax()) .count();

个人建议:如果项目中“最高工资人数”只是一个临时统计需求,直接采用第一种方式,简单清晰;如果这类统计是长期且高频的,就封装成第二种自定义Collector,一次遍历解决所有问题。

3. 实操记录:完整代码与分组统计

3.1 一次搞定的完整示例

把前面的内容组装成一个可直接运行的程序:

import java.util.Arrays; import java.util.List; import java.util.DoubleSummaryStatistics; import java.util.stream.Collectors; public class SalaryStatisticsDemo { public static void main(String[] args) { List<Employee> employees = Arrays.asList( new Employee("张三", "技术部", 18000), new Employee("李四", "技术部", 22000), new Employee("王五", "产品部", 22000), new Employee("赵六", "产品部", 15000), new Employee("钱七", "市场部", 22000), new Employee("孙八", "市场部", 12000) ); DoubleSummaryStatistics stats = employees.stream() .collect(Collectors.summarizingDouble(Employee::getSalary)); double maxSalary = stats.getMax(); long maxCount = employees.stream() .filter(e -> e.getSalary() == maxSalary) .count(); System.out.printf("员工数量: %d%n", stats.getCount()); System.out.printf("工资总和: %.2f%n", stats.getSum()); System.out.printf("平均工资: %.2f%n", stats.getAverage()); System.out.printf("最高工资: %.2f%n", stats.getMax()); System.out.printf("最低工资: %.2f%n", stats.getMin()); System.out.printf("最高工资人数: %d%n", maxCount); } }

运行结果:

员工数量: 6 工资总和: 111000.00 平均工资: 18500.00 最高工资: 22000.00 最低工资: 12000.00 最高工资人数: 3

这个程序已经覆盖了标题里的全部六个指标。值得一提的是String.format的输出方式,如果用stats.getSum()直接拼接字符串,当数值较大时可能出现1.11E5之类的科学计数法,展示很不友好。加个%.2f格式化一下,日志和报表都舒服不少。

3.2 combine方法:按部门统计再汇总

真实业务场景里,工资统计往往不是只算一个全公司大数,而是要先按部门分头统计,最后再拼总账。DoubleSummaryStatistics.combine就是为这种场景准备的。

举例,按部门统计后,再把所有部门汇总为整体:

Map<String, DoubleSummaryStatistics> deptStats = employees.stream() .collect(Collectors.groupingBy( Employee::getDepartment, Collectors.summarizingDouble(Employee::getSalary) )); deptStats.forEach((dept, stat) -> System.out.printf("部门%s: 人数=%d, 平均工资=%.2f, 最高=%.2f%n", dept, stat.getCount(), stat.getAverage(), stat.getMax())); // 汇总所有部门的统计 DoubleSummaryStatistics totalStats = new DoubleSummaryStatistics(); deptStats.values().forEach(totalStats::combine); System.out.printf("全公司合计: 人数=%d, 工资总和=%.2f, 平均工资=%.2f%n", totalStats.getCount(), totalStats.getSum(), totalStats.getAverage());

输出的结果:

部门市场部: 人数=2, 平均工资=17000.00, 最高=22000.00 部门技术部: 人数=2, 平均工资=20000.00, 最高=22000.00 部门产品部: 人数=2, 平均工资=18500.00, 最高=22000.00 全公司合计: 人数=6, 工资总和=111000.00, 平均工资=18500.00

combine的语义是“把另一个统计实例并入当前实例”,合并后当前实例的count、sum、min、max会同步更新。它内部对min和max的处理就是Math.min(Math.min)和Math.max(Math.max),所以即使某个部门的统计是空对象,也不会污染总体结果。空对象的max是负无穷大,和任何真实最大值比较都会被忽略掉,这个设计其实很巧妙。

需要注意的一点是,combine方法需要返回一个DoubleSummaryStatistics对象,虽然它修改的是调用者本身,但链式调用时也别把返回值丢掉,否则某些场景下可能拿到的是旧的引用。

3.3 封装一个实际可用的统计工具类

线上项目里,我不会在业务代码中反复写filter计数的逻辑,而是会封装一个工具类,把前面提到的“基础统计 + 最高工资人数”打包成一个入口。基于前面的EmployeeStats,再加一个静态工厂方法就够了:

public class EmployeeStatUtils { public static EmployeeStats collect(List<Employee> employees) { return employees.stream().collect(employeeStatsCollector()); } public static EmployeeStats collectByDepartment(List<Employee> employees, String department) { return employees.stream() .filter(e -> department.equals(e.getDepartment())) .collect(employeeStatsCollector()); } }

这样业务层调用就是一行代码:

EmployeeStats stats = EmployeeStatUtils.collect(employees); System.out.println(stats);

输出的对象里已经带上了最高工资人数,后续无论是要展示还是接报表接口,都很方便。这里也推荐大家在封装工具时,把格式化输出的toString方法一并做好,因为生产环境里你大概率会直接把这个对象打到日志里,一个清晰可读的toString能省很多排查时间。

4. 常见问题与排查技巧

4.1 工资到底能不能用double算

这是很多Java开发者都会纠结的问题。0.1 + 0.2 != 0.3这个经典问题决定了直接拿double做金额计算是有风险的。但工资统计这个场景要分情况看待:

  • 如果工资以“元”为单位且只涉及展示、求和、平均、比较大小,double精度在正常工资范围内(几千到几万)完全够用,肉眼根本看不出误差。
  • 如果工资需要参与后续的税费计算、利息计算、分摊计算,那就必须改用BigDecimal,或者更实际的做法,把工资单位改成“分”,用long来存储和计算。

我的建议是,建模阶段尽量用long单位“分”,展示阶段再除以100换成“元”。但如果你接手的项目已经用double存了工资,统计时也不必过度紧张,DoubleSummaryStatistics本身就是为了处理double场景设计的,日常统计需求它完全hold得住。真正要小心的反而是另一种情况,就是工资字段可能为null。

4.2 空集合和null字段的坑

如果把一个空集合交给DoubleSummaryStatistics,会发生什么?

DoubleSummaryStatistics emptyStats = new DoubleSummaryStatistics(); System.out.println(emptyStats.getCount()); // 0 System.out.println(emptyStats.getSum()); // 0.0 System.out.println(emptyStats.getAverage()); // 0.0 System.out.println(emptyStats.getMin()); // Infinity System.out.println(emptyStats.getMax()); // -Infinity

是的,空统计对象的最小值是正无穷大,最大值是负无穷大。如果你直接把getMax()拿去和别人比较,很容易得到荒谬的结果。所以使用getMin()和getMax()之前,最好先看一眼getCount()是不是大于0,或者在下游逻辑里对无穷值做防御性判断。

null字段的问题是另一类陷阱。如果员工对象的工资是null,直接mapToDouble(Employee::getSalary)会抛出NullPointerException。此时需要在统计前先filter掉空的工资:

DoubleSummaryStatistics safeStats = employees.stream() .filter(e -> e.getSalary() != null) .collect(Collectors.summarizingDouble(Employee::getSalary));

如果你的员工类用的是基本类型double,那就不存在这个问题,但数据库查出来的BigDecimal转double时要记得判空。

4.3 并行流统计与共享实例的线程安全问题

DoubleSummaryStatistics的accept方法并没有做同步处理,所以多个线程同时往同一个实例里塞数据,结果一定是错的。我自己刚接触并行流时也天真地以为它线程安全,结果统计出的工资总和一会儿一变,排查了半天才发现问题。

正确用法是:并行流配合collect时,每个线程处理自己的分片,各自持有独立的DoubleSummaryStatistics实例,最后通过combine汇总。这种使用方式是安全的,因为串行收集过程中没有共享可变状态。

DoubleSummaryStatistics parallelStats = employees.parallelStream() .collect(Collectors.summarizingDouble(Employee::getSalary));

但千万别这么干:

DoubleSummaryStatistics sharedStats = new DoubleSummaryStatistics(); employees.parallelStream() .forEach(e -> sharedStats.accept(e.getSalary())); // 错误示范

这会造成严重的线程安全问题,谁抢到写锁谁先改,最终结果完全不可预期。

4.4 输出格式化与科学计数法

DoubleSummaryStatistics的toString()输出在数值特别大时可能会使用科学计数法。比如工资总和达到几千万时,打印出来是3.25E7,虽然数值没错,但人看起来很不直观。解决方式很简单,用String.format格式化:

System.out.printf("工资总和: %,.2f 元%n", stats.getSum()); System.out.printf("平均工资: %,.2f 元%n", stats.getAverage());

这样输出就会变成111,000.00 元这种人类友好的格式,尤其要展示给业务同事看时,这个细节能少很多沟通成本。

4.5 三个SummaryStatistics类怎么选

Java标准库里其实有三个类似的类:IntSummaryStatistics、LongSummaryStatistics、DoubleSummaryStatistics。选哪个取决于你数据本身的类型:

类型适用场景潜在问题
IntSummaryStatistics整数数据无法统计带小数工资
LongSummaryStatistics以分为单位的整数工资与double精度不同
DoubleSummaryStatistics带小数的double工资精度损失、NaN处理

如果工资用long以分为单位存储,LongSummaryStatistics是最稳妥的,求和不会有精度损失。如果工资本身是double,那就老老实实用DoubleSummaryStatistics。这里提醒一句,两个不同类型的统计对象之间没有互相转换的现成方法,需要自己做新老数据适配时,可以直接构造新对象并accept源数据的值。

4.6 关于NaN值的隐形问题

前面提到过accept方法对NaN有特殊处理。如果某个员工的工资是NaN,DoubleSummaryStatistics会直接忽略它,不参与count、sum、min、max的计算。这可能导致一个诡异的现象:明明集合里有6个人,统计出来的getCount()却只有5。排查这类问题时,要给工资字段加上合法性校验,或者在采集统计前就用filter过滤异常值。

DoubleSummaryStatistics cleanStats = employees.stream() .map(Employee::getSalary) .filter(Double::isFinite) .collect(Collectors.summarizingDouble(Double::doubleValue));

Double::isFinite会过滤掉NaN和无穷值,适合用在外部数据源清洗环节。

我在实际项目中还踩过一个组合拳的坑:先用parallelStream统计,又用同一个共享的DoubleSummaryStatistics做后续操作,结果数据错乱不说,排错还特别麻烦。后来把流程改成“先收集后合并”,所有问题迎刃而解。如果你也准备在生产环境使用这个类,记住两个原则就够了。第一,单线程下随便用;第二,多线程场景只通过collect和combine协作,绝对不要共享同一个实例去accept。

这个统计类真正好用的地方不在于它能省几行代码,而在于它把“聚合状态”变成了一个可传递、可合并、可复用的对象。你在分组统计、分页统计、多级汇总这些场景里,都可以把DoubleSummaryStatistics当作最小统计单元传来传去。最高工资人数那个指标,即便官方类没有直接支持,结合一次filter或者一个自定义Collector也能轻松拿到。后面如果再遇到类似的统计需求,不管是算绩效总分、项目工时、库存金额,都可以把这套思路直接搬过去复用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 4:03:53

Python+SQLite实现运动会管理系统:从数据模型到排名算法全解析

简介&#xff1a;基于Python的运动会管理系统源码&#xff0c;是面向软件工程课程设计的完整项目&#xff0c;适合课设答辩、技术学习或快速搭建赛事管理平台&#xff0c;核心解决选手报名、赛程编排、成绩记录与报表生成的自动化问题。压缩包共326个文件&#xff0c;包含162个…

作者头像 李华
网站建设 2026/10/3 4:03:51

从零搭建能干活的Agent:LLM、LangChain、RAG与Agent Skills全路径

1. 这套Agent Skills课程到底在讲什么先把话说在前头&#xff1a;这不是那种“三天速成大模型专家”的营销课。我花了半个月时间&#xff0c;把市面上关于Agent Skills的零散知识重新梳理成一条从零到能上手的路径&#xff0c;核心目标只有一个——让你学完之后&#xff0c;能自…

作者头像 李华
网站建设 2026/10/3 4:03:06

无穷小偶极子天线近场远场Matlab仿真:从闭式解到工程验证

简介&#xff1a;这份资源围绕无穷小偶极子天线这一经典电磁学模型&#xff0c;提供基于Matlab的完整仿真实现&#xff0c;面向电子信息、通信工程等专业的本科生、研究生及教研人员&#xff0c;用于理解天线近场与远场的辐射特性、场强分布规律及数值计算方法。压缩包共6个文件…

作者头像 李华
网站建设 2026/10/3 4:01:46

基于Spring Boot的旅游管理系统设计与实现全解析

开头做Java后端开发的都知道&#xff0c;Spring Boot在毕设圈子里几乎是统治级的存在。每年计算机科学与技术、软件工程、信息管理这些专业的学生&#xff0c;十个里面有七八个会选Spring Boot做技术底座。而在这些题目里&#xff0c;“旅游管理系统”又是一个永不掉队的选择—…

作者头像 李华
网站建设 2026/10/3 4:01:14

从Agent loop到computer use:用大模型生成AI简史的实操与思考

1. 一个标题引发的思考&#xff1a;从"AI简史"看技术演进脉络第一次看到"Opus 5.5生成-从现在看过去-AI简史"这个标题&#xff0c;我的第一反应是&#xff1a;这是一个用大模型来回顾AI发展历程的创意项目。说白了&#xff0c;就是让一个当前最前沿的模型&…

作者头像 李华