在Java开发里,经常会碰到这样的需求:给定一个集合,例如存放用户ID的List,或者保存单词的Set转换来的List,需要弄清楚里面每个不同元素各自出现了多少次。这个问题本质上就是频率统计,看似简单,但实现方式直接影响代码可读性和运行效率。

一、使用HashMap手动循环统计
最基础也最容易理解的做法,就是借助HashMap来记录每个元素的出现次数。我们遍历集合,对于每个元素,如果Map里已经有对应的键,就把值加一;如果没有,就初始化为1。这种方式不依赖任何高级特性,在几乎所有Java版本中都能运行。
下面是一段典型的实现代码,假设我们要统计一个字符串列表中每个单词的出现频率:
import java.util.*;
public class FrequencyDemo {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "apple", "orange", "banana", "apple");
Map<String, Integer> freq = new HashMap<>();
for (String word : words) {
// 如果之前没出现过,getOrDefault返回0,再+1
freq.put(word, freq.getOrDefault(word, 0) + 1);
}
for (Map.Entry<String, Integer> entry : freq.entrySet()) {
System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
}
这种写法的优点非常明显:逻辑直观,新手也能一眼看懂;且没有使用任何可能引起额外开销的流操作,在超大规模数据下性能可控。不过缺点在于代码量偏多,如果统计逻辑分散在多个业务方法中,容易产生重复代码。
另外需要注意,如果集合元素可能为null,上面的代码会抛出NullPointerException,因为HashMap允许null作为键,但getOrDefault的调用本身没问题,只是语义上要确认是否把null当作一个独立元素统计。若需要支持null,可以保留默认行为,或在循环里单独判断。
二、利用Java 8 Stream API统计
从Java 8开始,函数式编程风格让集合统计变得极为简洁。通过Stream的collect方法结合Collectors.groupingBy和Collectors.counting,可以用一行核心代码完成分组计数。这种方式更符合现代Java书写习惯,也便于并行化处理。
下面的示例完成了与前一节完全相同的功能,但代码长度大幅缩短:
import java.util.*;
import java.util.stream.*;
public class StreamFreqDemo {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "apple", "orange", "banana", "apple");
Map<String, Long> freq = words.stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()));
freq.forEach((k, v) -> System.out.println(k + " : " + v));
}
}
这里groupingBy的第一个参数是分类函数,直接返回元素本身;第二个参数counting()是一个下游收集器,用来统计每组数量。返回值是Map<String, Long>,计数类型为Long而非Integer,这是API设计使然,在多数业务里影响不大。
如果数据量非常大且机器是多核环境,还可以把stream()换成parallelStream(),让统计自动并行。但要注意,并行流在元素较少时可能因线程调度带来额外开销,反而比顺序流慢,因此实际使用前最好做简单基准测试。
三、使用Collections.frequency辅助统计
JDK的Collections工具类提供了一个frequency方法,可以统计某个特定元素在集合中出现的次数。但它只能针对单一元素查询,如果要统计所有不同元素的频率,仍需配合Set去重后遍历,本质还是循环。
示例代码如下:
import java.util.*;
public class CollectionsFreqDemo {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "apple", "orange", "banana", "apple");
Set<String> distinct = new HashSet<>(words);
for (String word : distinct) {
int count = Collections.frequency(words, word);
System.out.println(word + " : " + count);
}
}
}
这种做法可读性也不错,但效率偏低:每次调用frequency都会完整遍历原集合,外层又遍历一次去重后的集合,时间复杂度达到O(n²)。仅建议在临时调试或数据量极小的时候使用,不建议放在核心业务逻辑中。
与之相比,前面两种方案都是一次遍历或分组完成,时间复杂度为O(n),在性能敏感场景优势明显。因此除非是非常简单的脚本,否则不推荐用Collections.frequency做全量统计。
四、线程安全场景下的统计思路
如果统计动作发生在多线程环境,例如多个消费者线程向同一个共享集合追加数据并实时计数,直接使用HashMap会带来并发修改异常或数据错乱。此时可以使用ConcurrentHashMap,并利用它的原子方法。
Java 8的ConcurrentHashMap提供了computeIfAbsent或merge方法,能安全地完成计数累加:
import java.util.concurrent.*;
public class ConcurrentFreqDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> freq = new ConcurrentHashMap<>();
List<String> words = Arrays.asList("apple", "banana", "apple");
words.parallelStream().forEach(word -> {
freq.merge(word, 1, Integer::sum);
});
freq.forEach((k, v) -> System.out.println(k + " : " + v));
}
}
merge方法的含义是:如果键不存在就放入值1;如果存在,则用旧值和1调用Integer::sum得到新值。整个过程是线程安全的,不需要额外加锁。对于高并发计数,比如统计接口访问的IP频率,这种模式非常实用。
如果是在普通单线程业务里,就没必要引入ConcurrentHashMap,因为它的内部分段锁或CAS机制会带来轻微开销。选方案时还是要根据实际运行环境来判断,避免过度设计。
五、总结与选型建议
统计集合中不同元素出现频率,在Java里至少有三种主流路线:手动HashMap循环适合所有版本且逻辑透明;Stream加groupingBy适合Java 8以上且追求简洁的代码;ConcurrentHashMap适合并发写入场景。而Collections.frequency仅适合小数据或临时检查。
日常业务中,如果项目已经用上Java 8或更高版本,优先推荐Stream写法,既减少样板代码也方便后续改成并行流。当遇到明确的并发需求时,再切换到ConcurrentHashMap的方案,这样才能在可读性和性能之间取得平衡。