判断数据是否存在还在习惯使用 HashMap?不妨试试 HashSet!

📌 引子:一个真实的代码优化案例

在最近的代码审查中,我遇到了这样一个方法:

private Predicate<String> buildEqualsWare(String value) {
    if (StringUtils.isBlank(value)) {
        return (i) -> Boolean.FALSE;
    }

    if (StringUtils.contains(value, ",")) {
        // ❌ 问题:使用 Map 存储集合数据
        Map<String, String> map = Stream.of(StringUtils.split(value, ","))
            .collect(Collectors.toMap(item -> item, Function.identity(), (k1, k2) -> k2));
        return (i) -> map.containsKey(i);
    } else {
        return (i) -> StringUtils.equals(i, value);
    }
}

这段代码的功能很简单:判断输入字符串是否在一组逗号分隔的值中存在。我能理解他使用Map而非List来减少时间复杂度.但实现方式却值得商榷。


🔍 问题分析:为什么这是"不够好"代码?

1️⃣ 语义混乱

Map<String, String> map = ...collect(Collectors.toMap(item -> item, Function.identity()...
return (i) -> map.containsKey(i);
  • keyvalue 是同一个值 → 为什么要存两份?
  • 只使用了 containsKey()那 value 的意义是什么?
  • 合并函数 (k1, k2) -> k2这个行为合理吗?其他人能理解吗?

2️⃣ 内存浪费

假设我们有 1000 个不重复的字符串:

方案 存储结构 内存占用
HashMap HashMap.Entry<String, String>[1000] 1000 × (key + value) ≈ 80KB
HashSet HashMap.Entry<String, Object>[1000] 1000 × (key + PRESENT) ≈ 40KB

原因:HashSet 内部所有 entry 共享同一个 static final Object PRESENT 对象。

3️⃣ 性能开销

// HashMap 版本
Collectors.toMap(
    item -> item,           // keyMapper
    Function.identity(),    // valueMapper - 额外调用
    (k1, k2) -> k2         // mergeFunction - 每次冲突都要判断
)

// HashSet 版本
set.add(item);  // 直接添加,无额外逻辑

✅ 优化方案:用对数据结构

优化后的代码

private Predicate<String> buildEqualsWare(String value) {
    if (StringUtils.isBlank(value)) {
        return (i) -> Boolean.FALSE;
    }

    if (StringUtils.contains(value, ",")) {
        // ✅ 使用 Set 表达"集合存在性检查"的语义
        Set<String> set = new HashSet<>();
        String[] items = StringUtils.split(value, ",");
        for (String item : items) {
            set.add(item);
        }
        return (i) -> i != null && set.contains(i);
    } else {
        return (i) -> StringUtils.equals(i, value);
    }
}

优化点对比

维度 优化前 (HashMap) 优化后 (HashSet) 改进
语义清晰度 需要思考 value 的含义 一眼看出是集合检查 ⭐⭐⭐⭐⭐
内存占用 Key + Value 双份存储 Key + 共享常量 节省 50%
代码可读性 Stream 链 + 合并函数 简单循环 ⭐⭐⭐⭐
执行效率 Stream 管道开销 直接 add 提升 ~30%
空指针安全 依赖 containsKey 隐式处理 显式检查 i != null ⭐⭐⭐⭐⭐

🎯 核心原则:根据场景选择数据结构

📊 决策矩阵

你的需求 推荐数据结构 理由
只判断元素是否存在 HashSet<T> 语义清晰、内存高效
需要存储键值对 HashMap<K, V> 完整的 KV 映射能力
需要保证顺序 LinkedHashSet<T> / LinkedHashMap<K, V> 保持插入顺序
需要排序 TreeSet<T> / TreeMap<K, V> 自然序或自定义比较器
允许重复元素 ArrayList<T> / List<T> 列表结构

💡 实战建议:如何识别"误用"的 HashMap?

🚩 不够好代码信号

如果你的代码中出现以下模式,考虑换成 HashSet:

// ❌ 信号 1: Key 和 Value 相同
Map<String, String> map = new HashMap<>();
map.put(key, key);

// ❌ 信号 2: 只使用 containsKey
if (map.containsKey(target)) { ... }

// ❌ 信号 3: Value 是固定值
Map<String, Boolean> map = new HashMap<>();
map.put(key, Boolean.TRUE);

Map<String, Object> map = new HashMap<>();
map.put(key, SOME_CONSTANT);

✅ 替换为 HashSet

// ✅ 正确用法
Set<String> set = new HashSet<>();
set.add(key);

if (set.contains(target)) { ... }

🔬 深入原理:HashSet 真的是 HashMap 吗?

是的!JDK 中 HashSet 的实现令人惊讶地简单:

public class HashSet<E> extends AbstractSet<E> implements Set<E> {
    
    // 底层就是一个 HashMap
    private transient HashMap<E, Object> map;
    
    // 所有值都映射到同一个PRESENT 对象
    private static final Object PRESENT = new Object();
    
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
    
    public boolean contains(Object o) {
        return map.containsKey(o);
    }
}

设计哲学

  • 复用成熟的 HashMap 实现,避免重复造轮子
  • 提供清晰的 Set 语义,代码更易读
  • 节省内存,所有 entry 共享一个 PRESENT 对象

📈 性能测试对比

我们来做一个简单的基准测试(JMH):

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class CollectionBenchmark {
    
    private static final String TEST_DATA = "a,b,c,d,e,f,g,h,i,j";
    
    @Benchmark
    public boolean testHashMap() {
        Map<String, String> map = Stream.of(StringUtils.split(TEST_DATA, ","))
            .collect(Collectors.toMap(Function.identity(), Function.identity()));
        return map.containsKey("e");
    }
    
    @Benchmark
    public boolean testHashSet() {
        Set<String> set = new HashSet<>();
        for (String item : StringUtils.split(TEST_DATA, ",")) {
            set.add(item);
        }
        return set.contains("e");
    }
}

预期结果

  • HashMap: ~800 ns/op
  • HashSet: ~550 ns/op
  • 性能提升约 30%

🎓 总结与建议

关键要点

  1. 语义优先:选择最能表达意图的数据结构
  2. 避免浪费:不为无用的 value 分配内存
  3. 关注细节:空指针检查、合并函数等边界条件
  4. 持续优化:小改进积累成大提升

行动清单

  • 审查代码中所有的 Map<K, K> 使用场景
  • 将只用于存在性检查的 Map 替换为 Set
  • 确保 Predicate/Lambda 中的空指针处理
  • 向团队成员普及这个最佳实践

🔗 延伸阅读


最后的话:优秀的代码不仅仅是能运行,更要易读、高效、可维护。从正确使用每一个数据结构开始,让我们写出更好的代码!💪

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐