Java Set 集合全家桶:HashSet、LinkedHashSet、TreeSet 详解与实战

一、Set 核心本质:基于 Map 的 "键封装"

所有 Set 实现类的底层都是复用 Map 实现 (元素存在 Map 的 key 位置,value 用静态常量 PRESENT 占位),这是理解 Set 特性的核心:

java

运行

复制代码
// HashSet 源码核心片段(JDK 1.8)
public class HashSet<E> extends AbstractSet<E> {
    private transient HashMap<E, Object> map;
    // 占位用的静态常量,所有元素共享同一个value
    private static final Object PRESENT = new Object();
    
    public HashSet() {
        map = new HashMap<>();
    }
    
    public boolean add(E e) {
        return map.put(e, PRESENT) == null; // 本质是Map的put,key重复则返回旧值(添加失败)
    }
}

结论:Set 的所有特性(去重、有序性、性能)都继承自底层 Map,理解了 HashMap/TreeMap,就理解了对应的 Set 实现。

二、HashSet 深度解析(补充底层核心)

1. 去重原理的本质(面试高频)

HashSet 的去重依赖 HashMap 的 key 唯一性,核心两步校验:

  1. 哈希值校验 :调用元素的 hashCode() 计算哈希值,确定存储桶位置;
  2. equals 校验 :若桶中已有元素,调用 equals() 比较,返回 true 则视为重复,拒绝添加。

关键易错点本质

  • 仅重写 hashCode():不同元素可能哈希值相同(哈希冲突),equals() 会判断为不同,导致重复;
  • 仅重写 equals():不同元素哈希值不同,不会进入 equals() 比较,直接视为不同元素;
  • 必须同时重写两者:保证 "哈希值相同的元素一定 equals,equals 的元素一定哈希值相同"。
2. 性能优化核心:初始容量计算

HashSet 默认负载因子 0.75,扩容触发条件 size > 容量 × 负载因子,提前计算初始容量可避免频繁扩容:

java

运行

复制代码
// 公式:初始容量 = 预估元素数 / 负载因子 + 1(向上取整)
int expectedSize = 10000;
// 正确:10000/0.75≈13333.33,+1避免边界值触发扩容
HashSet<String> set = new HashSet<>((int) (expectedSize / 0.75) + 1);
3. 并发安全替代方案(实战首选)

HashSet 非线程安全,原文的 Collections.synchronizedSet() 是 "包装式同步"(效率低),现代开发优先使用 ConcurrentHashMap 封装的 Set

java

运行

复制代码
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class ConcurrentSetDemo {
    public static void main(String[] args) {
        // 方式1:JDK 8+ 推荐(ConcurrentHashMap的newKeySet)
        Set<String> concurrentSet = ConcurrentHashMap.newKeySet();
        
        // 方式2:手动封装(兼容旧版本)
        ConcurrentMap<String, Object> map = new ConcurrentHashMap<>();
        Set<String> set = map.keySet();
        
        // 多线程安全添加
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                concurrentSet.add(Thread.currentThread().getName() + "-" + i);
            }
        };
        new Thread(task).start();
        new Thread(task).start();
    }
}

三、LinkedHashSet 深度解析(补充有序性本质)

1. 底层结构:HashMap + 双向链表

LinkedHashSet 继承 HashSet,底层是 LinkedHashMap(HashMap + 双向链表):

  • HashMap:保证元素唯一、O (1) 性能;
  • 双向链表 :维护元素顺序(插入顺序 / 访问顺序),链表节点额外存储 before/after 指针。
2. 两种顺序模式的核心场景

表格

顺序模式 触发条件 核心场景 典型案例
插入顺序 默认(accessOrder=false) 记录操作日志、保存浏览历史(需固定顺序) 用户操作轨迹、接口调用记录
访问顺序 accessOrder=true LRU 缓存(最近访问的元素排在末尾,淘汰最久未访问) 本地缓存、热点数据存储

LRU 缓存简易实现

java

运行

复制代码
import java.util.LinkedHashSet;

// 基于LinkedHashSet实现LRU缓存(固定容量,满了删除最久未访问)
class LRUCache<E> extends LinkedHashSet<E> {
    private final int capacity;

    public LRUCache(int capacity) {
        // 初始容量=容量/0.75+1,负载因子0.75,开启访问顺序
        super((int) (capacity / 0.75) + 1, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(java.util.Map.Entry<E, Object> eldest) {
        // 容量满时删除最久未访问的元素
        return size() > capacity;
    }

    // 简化:重写add方法,触发访问顺序更新
    public boolean add(E e) {
        super.add(e);
        return true;
    }
}

public class LRUCacheDemo {
    public static void main(String[] args) {
        LRUCache<String> cache = new LRUCache<>(3);
        cache.add("A");
        cache.add("B");
        cache.add("C");
        System.out.println(cache); // [A, B, C]
        cache.add("A"); // 访问A,移到末尾
        System.out.println(cache); // [B, C, A]
        cache.add("D"); // 容量满,删除最久未访问的B
        System.out.println(cache); // [C, A, D]
    }
}
3. 性能对比:LinkedHashSet vs HashSet

表格

操作 HashSet LinkedHashSet 差异原因
插入 O(1) O (1)(略慢) 需维护链表指针
查询 O(1) O (1)(几乎无差异) 哈希桶定位不受链表影响
遍历 O (n)(无序,哈希桶遍历) O (n)(更快) 双向链表顺序遍历,无需遍历空桶

四、TreeSet 深度解析(补充排序核心)

1. 去重原理的特殊性(与 HashSet 本质不同)

TreeSet 基于 TreeMap 实现,去重不依赖 hashCode/equals,而是依赖排序规则:

  • 自然排序:元素实现 ComparablecompareTo() 返回 0 则视为重复;
  • 定制排序:传入 Comparatorcompare() 返回 0 则视为重复。

核心坑点 :若自定义对象同时重写了 equals 和排序规则,需保证两者逻辑一致,否则会出现 "equals 相同但排序规则不同" 的矛盾:

java

运行

复制代码
// 反例:排序规则与equals不一致
class User implements Comparable<User> {
    String id;
    String name;

    public User(String id, String name) {
        this.id = id;
        this.name = name;
    }

    // 排序规则:按name排序
    @Override
    public int compareTo(User o) {
        return this.name.compareTo(o.name);
    }

    // equals规则:按id判断
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

public class TreeSetConflictDemo {
    public static void main(String[] args) {
        TreeSet<User> set = new TreeSet<>();
        // 两个id相同(equals=true)但name不同的User
        set.add(new User("1001", "张三"));
        set.add(new User("1001", "李四"));
        // 输出size=2(排序规则返回非0,视为不同元素),但equals=true,违反直觉
        System.out.println(set.size()); 
    }
}

正确做法:排序规则必须与 equals 逻辑一致(通常基于唯一标识如 id 排序)。

2. 排序规则的核心要求

TreeSet 的红黑树依赖稳定的排序规则,必须满足:

  1. 自反性compare(a,a) = 0
  2. 对称性compare(a,b) = -compare(b,a)
  3. 传递性compare(a,b) > 0compare(b,c) > 0compare(a,c) > 0

原文中 "随机返回排序结果" 的反例,本质是违反了传递性,导致红黑树结构混乱,甚至抛出异常。

3. 数值排序的精度坑(补充)

对浮点数排序时,避免直接用减法(可能溢出 / 精度丢失),优先用 Double.compare()/Integer.compare()

java

运行

复制代码
// 错误:double减法可能丢失精度(如0.0000001和0.0000002相减)
TreeSet<Double> badSet = new TreeSet<>((a, b) -> (int) (a - b));

// 正确:使用Double.compare
TreeSet<Double> goodSet = new TreeSet<>(Double::compare);

五、三大 Set 增强对比表(补充核心维度)

表格

特性 HashSet LinkedHashSet TreeSet
底层实现 HashMap(数组 + 链表 + 红黑树) LinkedHashMap(HashMap + 双向链表) TreeMap(红黑树)
有序性 无序(哈希桶分布) 插入顺序 / 访问顺序 自然排序 / 定制排序
去重依据 hashCode() + equals() hashCode() + equals() compareTo()/compare()
时间复杂度 增删查 O (1)(平均) 增删查 O (1)(平均,略慢) 增删查 O (log n)
null 支持 允许一个 null 允许一个 null 不支持 null(排序时抛 NPE)
线程安全 非线程安全 非线程安全 非线程安全
并发替代方案 ConcurrentHashMap.newKeySet() 无原生实现(需手动封装 LinkedHashMap) 无原生实现(需手动封装 TreeMap)
内存占用 低(无额外链表) 中(双向链表额外指针) 高(红黑树节点含颜色 / 父 / 子指针)
核心适用场景 纯去重、高性能查询 需保留顺序的去重(日志 / 缓存) 需排序的去重(排行榜 / 有序筛选)

六、Set 与 List 核心差异(补充性能维度)

表格

维度 List(以 ArrayList 为例) Set(以 HashSet 为例) 核心原因
去重 需手动实现(如遍历判断) 自动去重 底层 Map 的 key 唯一性
查找性能 按元素查找 O (n) 按元素查找 O (1) 哈希表直接定位 vs 数组遍历
内存占用 低(仅存储元素) 高(封装为 Map 的 key,额外存储 PRESENT) Set 是 Map 的 "包装器",有额外开销
顺序控制 插入顺序固定,支持索引调整 仅 LinkedHashSet 支持顺序 List 基于数组索引,Set 依赖底层 Map 结构
常用场景 需重复元素、索引访问、频繁修改 需去重、无需索引、高性能查找 -

七、实战选型决策树(快速选对 Set)

复制代码
flowchart TD
    A[选择Set实现类] --> B{是否需要有序?}
    B -->|否| C{是否追求极致性能?}
    C -->|是| D[HashSet]
    C -->|否| E[HashSet(默认首选)]
    B -->|是| F{有序类型?}
    F -->|插入/访问顺序| G[LinkedHashSet]
    F -->|排序(自然/定制)| H[TreeSet]
    A --> I{是否多线程环境?}
    I -->|是| J[ConcurrentHashMap.newKeySet()(替代HashSet)]
    I -->|否| K[按上述规则选择]

八、开发最佳实践

  1. 默认首选 HashSet:90% 的去重场景用 HashSet,创建时指定初始容量优化性能;
  2. 自定义元素必重写 hashCode/equals:尤其是 TreeSet,需保证排序规则与 equals 一致;
  3. 有序去重选 LinkedHashSet:如记录用户浏览历史、操作日志,避免用 TreeSet(性能低);
  4. 排序去重选 TreeSet:仅当需要动态排序时使用,静态排序可先存 HashSet 再排序(更高效);
  5. 多线程禁用原生 Set :优先用 ConcurrentHashMap.newKeySet(),而非 Collections.synchronizedSet()
  6. 大数据量去重:若元素数量超 10 万,优先用 HashSet(O (1) 性能),避免 TreeSet(O (log n))。

总结

  1. HashSet:基于 HashMap,无序、高性能、自动去重,单线程纯去重场景首选,核心是重写 hashCode/equals 并指定初始容量;
  2. LinkedHashSet:基于 LinkedHashMap,有序(插入 / 访问)+ 去重,性能略低于 HashSet,适用于需保留顺序的去重场景;
  3. TreeSet:基于 TreeMap,有序(排序)+ 去重,性能 O (log n),仅适用于需动态排序的场景,核心是保证排序规则的稳定性;
  4. 核心本质:所有 Set 都是 Map 的 "键封装",理解底层 Map 的特性是掌握 Set 的关键。
相关推荐
杨过姑父2 小时前
java 面试,jvm笔记
java·jvm·面试
mldlds2 小时前
Spring Boot应用关闭分析
java·spring boot·后端
woniu_buhui_fei2 小时前
Java 服务最常见的线上性能故障
java·jvm·算法
96772 小时前
Java 类映射数据库表的核心规则
java·数据库·oracle
code_whiter2 小时前
C++2(类与对象上篇)
开发语言·c++
阳光下的米雪2 小时前
存储过程的使用以及介绍
java·服务器·数据库·pgsql
yoyo_zzm2 小时前
Spring Boot 各种事务操作实战(自动回滚、手动回滚、部分回滚)
java·数据库·spring boot
Teable任意门互动2 小时前
中小企业进销存实战:Teable多维表格从零搭建高效库存管理系统
开发语言·数据库·excel·飞书·开源软件
En^_^Joy2 小时前
JavaScript Web API:DOM操作全解析
开发语言·前端·javascript