集合-Set深入解析

概述

在 Java 集合框架的版图中,Set 接口是对数学中"集合"概念的精确工程化抽象------一个不允许包含重复元素 的容器。它不仅承载了离散数学的无序性、互异性,更通过精巧的接口设计与实现类体系,为开发者提供了从散列查找、插入顺序维护、有序导航到高并发读写的完整解决方案。本文将带你深入 HashSetLinkedHashSetTreeSetCopyOnWriteArraySetConcurrentSkipListSet 的底层源码,以详尽的流程图与时序图 揭示它们与对应 Map 实现之间的委托关系 ,剖析去重机制、排序原理、并发原语以及红黑树与跳表的博弈。同时,我们将特别引入对并发安全性的深度分析,对比不同实现在多线程环境下的行为与选型策略。阅读本文后,你将不再停留在"Set 去重"的表层认知,而是能够从数学契约、数据结构选型、性能权衡和 JDK 演进的全维度掌握 Set 分支的精髓。文末专设内容翔实的面试专题,助你从容应对技术深谈。


模块 1:Set 接口设计------无重复元素的数学集合抽象

Set 接口位于 java.util 包,其核心契约可归结为三点:

  1. 无重复元素 :集合中任意两个元素 e1e2 均满足 !e1.equals(e2)
  2. 最多包含一个 null 元素TreeSet 因涉及比较而禁止 null)。
  3. 判断相等依赖 equalshashCode :对于基于散列的 Set(如 HashSet),两个元素相等的充分必要条件是 (e1.hashCode() == e2.hashCode()) && e1.equals(e2);对于基于比较的 Set(如 TreeSet),相等则由比较器或自然顺序决定。

Set 接口本身并未在 Collection 之上增加新方法,但其契约对实现者提出了严格的去重约束。为了减轻实现负担,JDK 提供了抽象骨架实现类 AbstractSet,它实现了 equalshashCoderemoveAll 等方法。此外,JDK 1.2 引入的 SortedSet 接口及后续的 NavigableSet,进一步为有序集合定义了范围视图、导航查找等丰富操作。

classDiagram class Collection~E~ { <> } class Set~E~ { <> } class AbstractSet~E~ { <> } class SortedSet~E~ { <> +comparator() Comparator +first() E +last() E +headSet(E toElement) SortedSet~E~ +tailSet(E fromElement) SortedSet~E~ +subSet(E from, E to) SortedSet~E~ } class NavigableSet~E~ { <> +lower(E e) E +floor(E e) E +ceiling(E e) E +higher(E e) E +pollFirst() E +pollLast() E +descendingSet() NavigableSet~E~ } class HashSet~E~ { } class LinkedHashSet~E~ { } class TreeSet~E~ { } class CopyOnWriteArraySet~E~ { } class ConcurrentSkipListSet~E~ { } Collection <|-- Set Set <|-- SortedSet SortedSet <|-- NavigableSet Set <|.. AbstractSet AbstractSet <|-- HashSet HashSet <|-- LinkedHashSet AbstractSet <|-- TreeSet TreeSet ..|> NavigableSet AbstractSet <|-- CopyOnWriteArraySet AbstractSet <|-- ConcurrentSkipListSet ConcurrentSkipListSet ..|> NavigableSet

图表说明 :该 classDiagram 展示了 Set 家族的继承与实现层级。Set 接口自 Collection 派生;AbstractSet 作为抽象骨架,降低了自定义 Set 的门槛。SortedSet 扩展了排序相关方法,而 NavigableSet 在 Java 6 中进一步增强,提供了 lowerfloorceiling 等导航操作。TreeSetConcurrentSkipListSet 均实现了 NavigableSet,分别基于红黑树和跳表提供有序集合能力。HashSet 和其子类 LinkedHashSet 则专注于散列表的高效存取。


模块 2:HashSet 深度剖析------基于 HashMap 的散列集合

HashSet 是最常用的 Set 实现,它提供了常数时间的增删查性能,其内部实现完全委托给 HashMap,是所有委托模式实现 Set 的典型代表。

Demo 代码(JDK 8 可运行)

java 复制代码
import java.util.HashSet;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;

public class HashSetDemo {
    static class Person {
        private String name;
        private int age;

        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Person person = (Person) o;
            return age == person.age && Objects.equals(name, person.name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(name, age);
        }

        @Override
        public String toString() {
            return "Person{name='" + name + "', age=" + age + "}";
        }
    }

    public static void main(String[] args) {
        Set<Person> set = new HashSet<>();

        Person p1 = new Person("Alice", 30);
        Person p2 = new Person("Bob", 25);
        Person p3 = new Person("Alice", 30); // 与 p1 逻辑相等

        // 添加元素
        set.add(p1);
        set.add(p2);
        boolean added = set.add(p3); // 重复元素,添加失败
        System.out.println("添加重复元素 p3 是否成功: " + added);
        System.out.println("当前集合大小: " + set.size()); // 2

        // 遍历 (顺序无保证)
        System.out.println("遍历元素:");
        for (Person p : set) {
            System.out.println(p);
        }

        // 包含判断
        System.out.println("是否包含 p1: " + set.contains(p1));      // true
        System.out.println("是否包含 new Person(\"Alice\",30): " + 
                           set.contains(new Person("Alice", 30))); // true (重写hashCode/equals)

        // 删除元素
        set.remove(p2);
        System.out.println("删除后大小: " + set.size());

        // 使用迭代器
        Iterator<Person> it = set.iterator();
        while (it.hasNext()) {
            System.out.println("迭代: " + it.next());
        }
    }
}

底层原理深入剖析

存储结构:HashMap 委托与哑元对象

HashSet 内部组合了一个 HashMap<E, Object> 实例:

java 复制代码
private transient HashMap<E,Object> map;
// 所有键映射到同一个哑元值
private static final Object PRESENT = new Object();

操作流程详解(结合源码时序)

1. 插入操作:add(E e)

sequenceDiagram participant Client participant HashSet participant HashMap participant 哈希桶数组 Client->>HashSet: add(e) activate HashSet HashSet->>HashMap: put(e, PRESENT) activate HashMap HashMap->>HashMap: hash(key.hashCode()) HashMap->>哈希桶数组: 计算索引 i = (n-1) & hash alt 桶 i 为空 HashMap->>HashMap: newNode(...) HashMap->>哈希桶数组: 放入节点 HashMap-->>HashSet: return null else 桶 i 非空(链表或红黑树) loop 遍历节点 HashMap->>HashMap: 比较 hash 和 (k == key || key.equals(k)) alt 找到相同 key HashMap->>HashMap: 替换 value 为 PRESENT HashMap-->>HashSet: return 旧值 (非null) end end alt 未找到相同 key HashMap->>HashMap: 插入新节点 HashMap->>哈希桶数组: 可能树化或扩容 HashMap-->>HashSet: return null end end deactivate HashMap HashSet-->>Client: 返回 put 结果 == null ? true : false deactivate HashSet

图文说明

上述时序图精确刻画了 HashSet.add 的调用链。核心在于 HashMap.put 方法的返回值:若键已存在,HashMap 会返回与该键关联的旧值(即之前的 PRESENT 对象,非 null),此时 add 返回 false;若键首次插入,put 返回 nulladd 返回 true。哈希计算与冲突解决完全由 HashMap 负责,在 Java 8 中,当链表长度超过 8 且桶数组容量 ≥64 时,链表会转化为红黑树以优化最坏情况下的查找性能。源码分析:HashSet.add 就一行 return map.put(e, PRESENT)==null;,毫无额外逻辑。

2. 删除操作:remove(Object o)

sequenceDiagram participant Client participant HashSet participant HashMap Client->>HashSet: remove(o) activate HashSet HashSet->>HashMap: remove(o) activate HashMap HashMap->>HashMap: hash(key.hashCode()) HashMap->>HashMap: 定位桶并遍历 alt 找到匹配的键 HashMap->>HashMap: 移除节点并调整链表/红黑树 HashMap-->>HashSet: return 旧值 (PRESENT) else 未找到 HashMap-->>HashSet: return null end deactivate HashMap HashSet-->>Client: 返回 remove 结果 == PRESENT ? true : false deactivate HashSet

图文说明

删除操作同样直接委托给 HashMap.removeHashSet 仅判断 map.remove(o) 的返回值是否等于 PRESENT(即是否成功删除了一个存在的键)。源码中 public boolean remove(Object o) { return map.remove(o)==PRESENT; },逻辑极其简洁。

3. 查询操作:contains(Object o)

sequenceDiagram participant Client participant HashSet participant HashMap Client->>HashSet: contains(o) activate HashSet HashSet->>HashMap: containsKey(o) activate HashMap HashMap->>HashMap: 计算哈希并查找桶,遍历比较 equals alt 存在匹配键 HashMap-->>HashSet: return true else 不存在 HashMap-->>HashSet: return false end deactivate HashMap HashSet-->>Client: 返回结果 deactivate HashSet

图文说明
contains 委托给 HashMap.containsKey,该方法通过哈希查找,时间复杂度平均 O(1)。源码实现为 public boolean contains(Object o) { return map.containsKey(o); }

并发安全性分析

HashSet 不是线程安全的 。在多线程环境下并发修改(如一个线程迭代,另一个线程添加元素)会触发 fail-fast 机制 ,抛出 ConcurrentModificationException。其根源在于 HashMap 内部的 modCount 字段记录了结构性修改次数,迭代器在访问时检查该值是否发生变化。

若需要线程安全,可采用以下三种策略:

方案 原理 并发粒度
Collections.synchronizedSet(new HashSet<>()) 所有方法使用 synchronized 互斥锁包装 粗粒度,全局锁
ConcurrentHashMap.newKeySet() 基于 ConcurrentHashMap 的 KeySet 视图 分段锁 (JDK 7) / CAS + synchronized (JDK 8)
显式读写锁 ReentrantReadWriteLock 允许并发读,写互斥 自定义控制

最佳实践 :高并发写场景推荐 ConcurrentHashMap.newKeySet(),它提供了与 HashSet 相似的 O(1) 性能,且并发度远超同步包装器。

性能分析

  • 时间复杂度 :平均情况下,addremovecontains 均为 O(1);最坏情况下(哈希冲突严重退化为链表或红黑树)退化为 O(log n) 或 O(n)。
  • 空间消耗 :每个元素在底层 HashMap 中占据一个 Node<K,V> 对象,包含 hashkeyvaluenext 字段,内存开销较大。
  • 负载因子与容量 :默认初始容量 16,负载因子 0.75。当元素数量超过 capacity * loadFactor 时触发扩容(rehashing),带来一定的性能抖动。

注意事项

  • 必须正确重写 hashCodeequals :若自定义对象未重写,则继承自 Object 的方法基于内存地址判断,导致内容相同的对象被视为不同,去重失效。
  • 遍历顺序不确定:顺序取决于哈希桶的分布,且随扩容变化,不可依赖。
  • 线程不安全 :并发修改可能抛出 ConcurrentModificationException 或造成数据不一致,需使用同步包装或 ConcurrentHashMap.newKeySet() 替代。

模块 3:LinkedHashSet 深度剖析------维护插入顺序的散列集合

LinkedHashSetHashSet 的基础上增加了可预测的迭代顺序 ,它通过扩展 HashSet 并利用 LinkedHashMap 实现。

Demo 代码(JDK 8 可运行)

java 复制代码
import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetDemo {
    public static void main(String[] args) {
        // 默认插入顺序
        Set<String> set = new LinkedHashSet<>();
        set.add("banana");
        set.add("apple");
        set.add("orange");
        set.add("apple"); // 重复,不影响顺序
        System.out.println("插入顺序遍历: " + set); // [banana, apple, orange]

        // 利用 Collections 构建访问顺序 Set(LRU 缓存)
        Set<String> lruSet = Collections.newSetFromMap(
            new java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(java.util.Map.Entry<String, Boolean> eldest) {
                    return size() > 3; // 最多保留3个元素
                }
            });
        lruSet.add("A"); lruSet.add("B"); lruSet.add("C");
        lruSet.contains("A"); // 访问 A,将其移至链表尾部
        lruSet.add("D");      // 触发移除最老元素 B
        System.out.println("LRU Set: " + lruSet); // 可能输出 [A, C, D] 或类似
    }
}

底层原理深入剖析

LinkedHashSet 的插入、删除、查询流程与 HashSet 完全一致,差异在于底层使用了 LinkedHashMap,它在 HashMap.Node 基础上增加了双向链表指针。

1. 插入流程与双向链表维护

sequenceDiagram participant Client participant LinkedHashSet participant LinkedHashMap participant 双向链表 Client->>LinkedHashSet: add(e) activate LinkedHashSet LinkedHashSet->>LinkedHashMap: put(e, PRESENT) activate LinkedHashMap LinkedHashMap->>LinkedHashMap: 哈希查找 (同 HashMap) alt 键不存在 LinkedHashMap->>LinkedHashMap: 创建 LinkedHashMap.Entry 节点 LinkedHashMap->>双向链表: 将新节点链接至尾部 (tail) LinkedHashMap-->>LinkedHashSet: return null else 键已存在 (替换旧值) alt accessOrder == true LinkedHashMap->>双向链表: 将节点移至链表尾部 (记录访问) end LinkedHashMap-->>LinkedHashSet: return 旧值 end deactivate LinkedHashMap LinkedHashSet-->>Client: 返回插入结果 deactivate LinkedHashSet

图文说明
LinkedHashSet 继承了 HashSet 的所有方法,但构造时通过特定构造器将底层 map 初始化为 LinkedHashMapLinkedHashMapEntry 类在 HashMap.Node 基础上增加了 beforeafter 两个指针,从而将散列桶中的所有节点串联成一个双向链表。上图展示了 add 操作时,新节点默认被追加到链表尾部(维护插入顺序);若构造函数指定 accessOrder=true,则访问(包括 put 更新、get)会将节点移至尾部,以实现 LRU 顺序。源码片段:

java 复制代码
// LinkedHashSet 构造器
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
// LinkedHashMap.Entry
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
}
// LinkedHashMap 的 afterNodeInsertion 钩子,put 后回调以维护链表

2. 删除与查询

  • 删除LinkedHashSet.remove 同样委托 LinkedHashMap.removeLinkedHashMap 在删除节点时,除从桶中移除节点外,还会调用 afterNodeRemoval 钩子将其从双向链表中摘除,保证链表完整性。
  • 查询contains 执行与 HashSet 相同的哈希查找,双向链表不参与查询路径,仅用于迭代。

并发安全性分析

HashSet 一致,LinkedHashSet 非线程安全 。其 fail-fast 行为同样源自底层 LinkedHashMapmodCount 检测。若需线程安全的顺序集合,可结合 Collections.synchronizedSetLinkedHashSet,或使用 ConcurrentSkipListSet(若需排序)或 ConcurrentHashMap.newKeySet()(不关心顺序但需并发安全)。

性能分析

  • 时间复杂度 :增删查 O(1)(平均),与 HashSet 相同。
  • 空间开销:每个节点额外存储两个引用(约 16 字节),但迭代遍历时无需遍历空桶,效率更高。

注意事项

  • 若需访问顺序特性,应使用 Collections.newSetFromMap(new LinkedHashMap<>(16, 0.75f, true))

模块 4:TreeSet 深度剖析------基于红黑树的有序集合

TreeSet 不仅保证元素唯一,还提供了有序性 。底层通过委托给 TreeMap(红黑树实现)来完成所有操作。

Demo 代码(JDK 8 可运行)

java 复制代码
import java.util.Comparator;
import java.util.TreeSet;

public class TreeSetDemo {
    public static void main(String[] args) {
        // 自然顺序
        TreeSet<Integer> numbers = new TreeSet<>();
        numbers.add(5);
        numbers.add(2);
        numbers.add(8);
        System.out.println("有序集合: " + numbers); // [2, 5, 8]
        
        // 导航操作
        System.out.println("lower(5): " + numbers.lower(5));     // 2 (严格小于)
        System.out.println("floor(5): " + numbers.floor(5));     // 5 (小于等于)
        System.out.println("ceiling(6): " + numbers.ceiling(6)); // 8 (大于等于)
        System.out.println("higher(6): " + numbers.higher(6));   // 8 (严格大于)

        // 自定义比较器 (按长度倒序)
        TreeSet<String> strSet = new TreeSet<>(Comparator.comparingInt(String::length).reversed());
        strSet.add("apple");
        strSet.add("dog");
        strSet.add("banana");
        System.out.println("自定义排序: " + strSet); // [banana, apple, dog]
    }
}

底层原理深入剖析:红黑树交互

1. 插入操作:add(E e) 与红黑树自平衡

sequenceDiagram participant Client participant TreeSet participant TreeMap participant 红黑树节点 Client->>TreeSet: add(e) activate TreeSet TreeSet->>TreeMap: put(e, PRESENT) activate TreeMap TreeMap->>TreeMap: 比较器 compare(e, 根节点) 或 e.compareTo(根节点) loop 沿树下降查找位置 TreeMap->>TreeMap: cmp < 0 去左子树,cmp > 0 去右子树 alt cmp == 0 TreeMap->>TreeMap: 替换值为 PRESENT,返回旧值 TreeMap-->>TreeSet: return oldValue (非null) end end TreeMap->>红黑树节点: 创建新节点并插入为叶子(默认红色) TreeMap->>TreeMap: fixAfterInsertion(新节点) loop 若父节点为红色且叔叔为红色 TreeMap->>TreeMap: 变色 (父、叔黑,祖父红) 并向上递归 end loop 若父红叔黑且新节点与父节点不在同侧 TreeMap->>TreeMap: 旋转 (左旋或右旋) end TreeMap->>TreeMap: 根节点设为黑色 TreeMap-->>TreeSet: return null deactivate TreeMap TreeSet-->>Client: 返回 true deactivate TreeSet

图文说明

该时序图展示了 TreeSet.add 触发的红黑树插入及自平衡过程。代码路径:

  1. TreeSet.addTreeMap.put(e, PRESENT)
  2. TreeMap.put 从根节点开始,通过比较器确定方向;若找到相同键( compare == 0),直接替换值并返回旧值,TreeSet 返回 false
  3. 若未找到,新建节点作为叶子(默认红色),然后调用 fixAfterInsertion 进行自平衡:检查父节点颜色,若父红则可能通过变色(叔红)或旋转(叔黑)恢复红黑树性质。
  4. 最终返回 null 表示插入成功。

源码片段:

java 复制代码
// TreeMap.put 部分逻辑
do {
    parent = t;
    cmp = cpr.compare(key, t.key);
    if (cmp < 0) t = t.left;
    else if (cmp > 0) t = t.right;
    else return t.setValue(value);
} while (t != null);
// 插入后调用 fixAfterInsertion(e)

2. 删除操作:remove(Object o) 与复杂调整

flowchart TD A["remove 调用"] --> B["找到待删除节点 p"] B --> C{"节点是否有两个子节点?"} C -->|"是"| D["用后继节点 s 替换 p 的内容,转而删除 s"] D --> E["待删节点变为 s (至多一个子节点)"] C -->|"否"| E E --> F{"待删节点是否为黑色?"} F -->|"是"| G["调用 fixAfterDeletion 调整树结构: 兄弟节点借色 旋转等"] F -->|"否"| H["直接删除红色节点"] G --> H H --> I["断开节点引用,返回旧值"]

图文说明

删除流程较插入更为复杂:

  • 第一步:在红黑树中通过比较器定位待删除节点 p
  • 第二步:若 p 有两个子节点,则找到它的后继节点(右子树的最小节点)s,将 s 的关键字段复制到 p,之后实际删除的是 s(因为 s 至多只有一个右子节点)。
  • 第三步:判断被删除节点的颜色。若为黑色,则需要进行复杂的平衡调整(fixAfterDeletion),包括向兄弟节点借一个黑色节点、旋转、变色等操作,以维持"任意路径黑色节点数量相等"的红黑树性质。
  • 第四步:物理删除节点(解除引用),返回旧值。

3. 查询操作:contains(Object o) 直接调用 TreeMap.containsKey(o),利用比较器在红黑树上二分查找,时间复杂度 O(log n)。

并发安全性分析

TreeSet 非线程安全 。底层 TreeMap 没有任何同步机制,并发写入可能导致树结构损坏(如节点丢失、循环引用)或丢数据。即使是并发读,若存在并发写,迭代器也可能因 modCount 变化而抛出 ConcurrentModificationException

并发替代方案:

需求 推荐方案 特点
需要排序 + 线程安全 ConcurrentSkipListSet 基于跳表,高并发,O(log n)
不需要排序,但需线程安全 ConcurrentHashMap.newKeySet() O(1),无序
只需同步,数据量小,并发竞争不高 Collections.synchronizedSortedSet(new TreeSet<>()) 全局锁,简单但并发度低

性能分析

  • 时间复杂度:增删查 O(log n)。
  • 空间开销 :每个树节点包含左右子、父引用及颜色,约 40 字节/元素,高于 HashMap.Node

注意事项

  • 元素必须可比较,且 null 会抛出 NullPointerException
  • 比较器应与 equals 一致,否则可能违反 Set 契约。
  • 多线程环境下请选用 ConcurrentSkipListSet

模块 5:并发场景下的 Set 全景对比与选型策略

在深入分析 CopyOnWriteArraySetConcurrentSkipListSet 之前,我们先建立一个多线程环境下 Set 选型的宏观视角。以下表格汇总了各实现在并发读写下的行为与适用边界:

实现类 线程安全 读并发度 写并发度 有序性 适用数据量 典型场景
HashSet / LinkedHashSet - - 无序/插入 任意 单线程程序
Collections.synchronizedSet 互斥 (一次一个读) 互斥 同原集合 任意 低并发改造老旧代码
CopyOnWriteArraySet 完全无锁,快照读 全局锁 + 复制数组 插入顺序 极小 (< 100) 配置信息、监听器列表、黑名单
ConcurrentSkipListSet 完全无锁 (搜索) 局部 CAS + 少量锁 排序 中大 高并发排序场景,如排行榜、时序数据
ConcurrentHashMap.newKeySet() 高并发读 (无锁) 高并发写 (分段/CAS) 无序 中大 高并发写场景,如实时去重、会话ID管理

接下来,我们逐个深入剖析两个并发 Set 的实现原理。


模块 6:CopyOnWriteArraySet 深度剖析------读多写少的并发集合

CopyOnWriteArraySet 位于 java.util.concurrent 包,是 写时复制(Copy-On-Write) 思想在 Set 接口上的体现。它适用于读操作频率远远高于写操作、且数据规模较小的场景。

Demo 代码(JDK 8 可运行)

java 复制代码
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArraySet;

public class CopyOnWriteArraySetDemo {
    public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>();
        set.add("A");
        set.add("B");
        set.add("C");

        // 写线程:每秒添加一个元素
        Thread writer = new Thread(() -> {
            for (char ch = 'D'; ch <= 'Z'; ch++) {
                set.add(String.valueOf(ch));
                System.out.println("[写] 添加: " + ch);
                try { Thread.sleep(500); } catch (InterruptedException e) { break; }
            }
        });
        writer.start();

        // 读线程:每 300ms 遍历一次 (快照,不受写影响)
        Thread reader = new Thread(() -> {
            while (writer.isAlive()) {
                System.out.print("[读] 当前快照: ");
                Iterator<String> it = set.iterator();
                while (it.hasNext()) {
                    System.out.print(it.next() + " ");
                }
                System.out.println();
                try { Thread.sleep(300); } catch (InterruptedException e) { break; }
            }
        });
        reader.start();

        Thread.sleep(3000);
        writer.interrupt();
        reader.interrupt();
    }
}

底层原理深入剖析:写时复制与去重

CopyOnWriteArraySet 直接委托给 CopyOnWriteArrayList,没有任何额外的数据结构。

插入操作:加锁、复制、检查重复

sequenceDiagram participant Thread as 写线程 participant COWAS as CopyOnWriteArraySet participant COWAL as CopyOnWriteArrayList participant Lock as ReentrantLock participant Array as 底层数组 Thread->>COWAS: add(e) activate COWAS COWAS->>COWAL: addIfAbsent(e) activate COWAL COWAL->>Lock: lock() COWAL->>Array: Object[] elements = getArray() COWAL->>COWAL: 通过 indexOf 线性查找 e 是否存在 alt e 不存在 COWAL->>COWAL: Object[] newElements = Arrays.copyOf(elements, len+1) COWAL->>COWAL: newElements[len] = e COWAL->>Array: setArray(newElements) COWAL->>Lock: unlock() COWAL-->>COWAS: return true else e 已存在 COWAL->>Lock: unlock() COWAL-->>COWAS: return false end deactivate COWAL COWAS-->>Thread: 返回结果 deactivate COWAS

图文说明

核心在于 addIfAbsent 的原子性实现:

  1. 获取锁(ReentrantLock),保证同一时刻只有一个写线程执行。
  2. 在锁保护下,获取当前数组快照 elements
  3. 调用 indexOf 线性扫描判断元素是否已存在(O(n))。
  4. 若不存在,则通过 Arrays.copyOf 复制出一个长度 +1 的新数组,尾部放入新元素。
  5. 最后通过 setArray 将内部 volatile 引用指向新数组,读线程自此可见新版本。

源码关键段:

java 复制代码
// CopyOnWriteArrayList.addIfAbsent
public boolean addIfAbsent(E e) {
    Object[] snapshot = getArray();
    return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
           addIfAbsent(e, snapshot);
}

删除与查询流程

  • 删除:与添加逻辑类似,加锁后复制一个长度减少的新数组,跳过待删除元素,然后替换引用。
  • 查询contains 直接调用 indexOf(e) >= 0,在不加锁 的情况下遍历当前数组,因此可与其他写操作并发执行,但具有弱一致性(可能看到旧数据)。
sequenceDiagram participant Reader participant COWAS participant Array as 当前数组快照 Reader->>COWAS: contains(e) activate COWAS COWAS->>COWAL: indexOf(e) activate COWAL COWAL->>Array: Object[] snapshot = getArray() loop 遍历 snapshot COWAL->>COWAL: 比较对象 (equals) end COWAL-->>COWAS: 返回索引 (>=0 存在) deactivate COWAL COWAS-->>Reader: 返回结果 deactivate COWAS

迭代器快照特性

CopyOnWriteArraySet 的迭代器持有创建时的数组快照,因此在遍历过程中:

  • 不会抛出 ConcurrentModificationException
  • 对写操作完全透明:迭代期间即使有元素被添加或删除,迭代器依然遍历旧快照。

并发安全性深度分析

  • 写操作的互斥 :依赖 ReentrantLock 确保同一时刻只有一个写线程修改数组,避免了并发写导致的数据损坏。
  • 读写分离 :读操作完全无锁,通过 volatile 保证内存可见性,写线程完成数组替换后,所有后续读操作自动看到新数组。
  • 弱一致性问题:这意味着一个读操作可能无法立即看到刚刚完成的写操作(若它已经拿到了旧数组的引用),但这在配置信息、监听器等场景下是可接受的,甚至带来了极高的读取吞吐量。

性能分析

  • 读操作contains 需线性扫描 O(n),但无锁,并发吞吐量极高。
  • 写操作:加锁且复制整个数组 O(n),内存临时翻倍,写频繁将导致严重的 GC 压力。
  • 适用场景:数据规模小(通常 < 100),写频率极低(如每小时几次),读频繁(如每次请求都要遍历检查)。

注意事项

  • 严禁大数据量:假设集合有 10 万元素,每次添加都需复制 10 万长度的新数组,性能和内存均无法接受。
  • 写性能瓶颈:高并发写时,所有写线程在锁上排队,且复制数组开销巨大。
  • 迭代器不支持 remove() :调用会抛出 UnsupportedOperationException

模块 7:ConcurrentSkipListSet 深度剖析------高并发有序集合

ConcurrentSkipListSet 是高并发场景下的有序 Set 首选,它基于**跳表(SkipList)**数据结构,实现了无锁或细粒度锁的并发控制,在提供 O(log n) 操作复杂度的同时,支持多线程高吞吐。

Demo 代码(JDK 8 可运行)

java 复制代码
import java.util.concurrent.ConcurrentSkipListSet;

public class ConcurrentSkipListSetDemo {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>();
        set.add(10);
        set.add(5);
        set.add(15);
        System.out.println("有序集合: " + set); // [5, 10, 15]

        // 导航操作
        System.out.println("lower(10): " + set.lower(10));   // 5
        System.out.println("floor(10): " + set.floor(10));   // 10
        System.out.println("ceiling(13): " + set.ceiling(13)); // 15
        System.out.println("higher(13): " + set.higher(13)); // 15

        // 并发测试:100 个线程各插入 1000 个元素
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                set.add(Thread.currentThread().getId() * 10000 + i);
            }
        };
        Thread[] threads = new Thread[100];
        for (int i = 0; i < 100; i++) {
            threads[i] = new Thread(task);
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println("并发插入后集合大小: " + set.size());
    }
}

底层原理深入剖析:跳表与无锁并发

ConcurrentSkipListSet 完全委托给 ConcurrentSkipListMap,后者是并发跳表的实现。

跳表数据结构简介

跳表是一种多层链表索引结构

  • Level 0(底层):包含所有元素的有序单向链表。
  • Level 1 ~ N:每一层都是下一层的稀疏索引,节点数约为下层的一半(通过随机概率决定是否提升)。

查找时从最高层开始,向右移动直到超过目标值,然后下降一层,重复此过程。这种"高层跳、低层走"的策略将查找复杂度降为 O(log n)。由于没有复杂的旋转操作,并发控制比红黑树更易实现。

插入操作:无锁 CAS 构建多层索引

sequenceDiagram participant Thread participant SkipListSet participant SkipListMap participant IndexLevel as 跳表层 Thread->>SkipListSet: add(e) SkipListSet->>SkipListMap: put(e, Boolean.TRUE) SkipListMap->>SkipListMap: doPut(...) loop 查找插入位置 (findPredecessor) SkipListMap->>IndexLevel: 从最高层索引向右/向下移动 Note over SkipListMap: 使用 CAS 比较和设置 next 指针 end SkipListMap->>SkipListMap: 在底层链表插入节点 (CAS 设置 next) SkipListMap->>SkipListMap: 随机生成 level (概率 50% 提升一层) loop i in 1..level SkipListMap->>IndexLevel: 创建垂直索引节点,通过 CAS 插入各层 end SkipListMap-->>SkipListSet: 返回 null (插入成功) 或旧值 SkipListSet-->>Thread: 返回结果

图文说明
ConcurrentSkipListSet 的操作均转发给 ConcurrentSkipListMap。跳表插入分为三步:

  1. 查找前驱findPredecessor 从最高层索引头节点开始,向右找到小于插入键的最大节点,然后下降一层继续,直到底层链表。整个过程通过 compareAndSwap 无锁更新 next 指针,若出现竞争则重新尝试。
  2. 底层插入:在底层链表中,通过 CAS 将新节点链接到前驱之后。若 CAS 失败(被其他线程抢先),则重新查找前驱并重试。
  3. 建立索引:通过随机函数决定新节点的层高(例如抛硬币,连续正面则加一层,概率 50%)。然后创建垂直索引节点,并从第 1 层到第 level 层依次通过 CAS 插入到各层链表中。索引层的插入也可能因 CAS 失败而重试。

源码片段(简化):

java 复制代码
// ConcurrentSkipListMap.doPut 部分逻辑
for (;;) {
    Node<K,V> b = findPredecessor(key, cmp); // 查找前驱
    for (;;) {
        Node<K,V> n = b.next;
        // ... 检查是否需协助删除等
        if (b.casNext(n, z)) {  // CAS 插入新节点
            // 随机计算层级
            int rnd = ThreadLocalRandom.nextSecondarySeed();
            if ((rnd & 0x80000001) == 0) {
                int level = 1;
                while (((rnd >>>= 1) & 1) != 0) ++level;
                // 建立索引
                Skiplist.buildIndex(z, level);
            }
            return null;
        }
        // 若 CAS 失败,重试
    }
}

删除操作:逻辑删除 + 物理移除

删除分为两个阶段:

  1. 逻辑删除 :将目标节点的 value 字段通过 CAS 置为 null,并设置删除标记。
  2. 物理删除 :后续的遍历或插入操作会"协助"将被标记的节点从链表中摘除(helpDelete),实现懒删除。

这种设计避免了加锁,同时保证了并发正确性。

查询操作:contains(Object o)

通过 findPredecessor 类似的查找过程,在跳表索引层加速定位到底层链表,然后扫描比较,复杂度 O(log n)。

并发安全性深度分析

ConcurrentSkipListSet 的并发控制精妙之处在于:

  • 细粒度锁定 :仅在极少数需要修改多层索引结构时使用少量 synchronized 块(JDK 8 对 synchronized 做了大量优化),多数操作依赖 CAS 循环。
  • 无锁读 :所有读操作(containsiterator)均不用锁,通过 volatile 变量保证内存可见性。
  • 并发协助:遍历过程中若遇到被标记删除的节点,会主动帮助将其移除,加速删除操作的完成。

TreeSet + 同步包装器的对比测试结果(多核并发写场景):

  • ConcurrentSkipListSet 吞吐量是 synchronizedSortedSet(new TreeSet())5~10 倍
  • 原因在于同步包装器在所有方法上使用了全局互斥锁,导致读写全部串行化。

性能分析

  • 时间复杂度 :增删查平均 O(log n),最坏 O(n)(概率极低,与随机数质量有关)。
  • 空间开销 :节点需存储多层 next 指针(层数随机),平均额外空间约 1.33 倍,比红黑树略高,但在并发收益面前可接受。
  • 并发度:近乎无锁,适用于高并发读写场景。

注意事项

  • 元素必须可比较 :与 TreeSet 相同,必须实现 Comparable 或提供 Comparator,否则抛出 ClassCastException
  • null 处理 :一般不允许 null 元素(与 TreeSet 一致)。
  • 弱一致性迭代 :迭代器是弱一致的,不会抛出 ConcurrentModificationException,但可能不反映迭代开始后的更新。

模块 8:面试专题------Set 高频考题深度解析

本章节是 Set 面试内容的总集成,涵盖从基础概念到并发进阶的 15 道高频题目。每道题均包含标准回答、深度追问及加分回答,旨在帮你构建完整的应答体系。


面试官:我们先从最常用的开始:HashSet 如何保证元素唯一性?

标准回答
HashSet 内部基于 HashMap 实现,元素作为 Map 的键存储,值是一个共享的 Object 常量 PRESENT。当调用 add(E e) 时,实际执行 map.put(e, PRESENT) == nullHashMapput 方法通过 hashCode 定位桶,然后遍历桶内节点,若通过 equals 找到相同键则替换旧值并返回旧值(非 null),add 返回 false;若未找到则插入新节点并返回 nulladd 返回 true。因此,元素的唯一性由 HashMap 键的唯一性保证。

追问 1 :HashMap 的 put 方法返回值机制具体是怎样的?
回答put 方法返回该键之前关联的旧值,若首次插入则返回 nullHashSet 中所有键的值都是 PRESENT(非 null),所以返回 null 必定代表插入新键。
追问 2 :如果在多线程环境下同时往 HashSet 添加同一个元素,会发生什么?
回答 :HashSet 非线程安全,可能造成数据不一致甚至结构损坏。两个线程可能同时判断元素不存在,然后各自执行 put,但由于 HashMapput 并非原子操作,可能导致一个线程覆盖另一个线程的修改,或者引发死循环/链表环(JDK 7 扩容时常见)。在高并发下应使用 ConcurrentHashMap.newKeySet()
加分回答 :Java 8 中当链表长度超过 8 且桶数组容量 ≥64 时,桶内结构会转为红黑树,此时键的比较会涉及 compareTo 辅助排序,但相等性仍然依赖 equals


面试官:HashSet 与 TreeSet 的去重依据有何不同?

标准回答
HashSet 依据 hashCodeequals 判断元素是否重复;而 TreeSet 仅依据比较器(Comparator)或自然顺序(Comparable)的 compare/compareTo 方法返回值是否为 0 来判断重复,与 equals 无关。

追问 1 :若 compare 返回 0 但 equals 返回 false,TreeSet 会如何处理?
回答TreeSet 会认为这两个元素相同,拒绝添加第二个。这违反了 Set 接口基于 equals 定义的契约(虽然文档允许这种不一致),因此在设计自定义比较器时应尽量保证一致性。
追问 2 :如果要在 TreeSet 中存储自定义对象,但不想实现 Comparable,怎么办?
回答 :通过 TreeSet(Comparator<? super E> comparator) 构造器传入一个外部比较器。比较器须满足比较逻辑的传递性、自反性和对称性。
加分回答 :比较器还应考虑与序列化相关的特性,因为 TreeSet 本身是可序列化的,内部比较器如果不是 Serializable 会在序列化时抛出异常。


面试官:LinkedHashSet 如何维护插入顺序?

标准回答
LinkedHashSet 继承 HashSet,但通过构造器钩子将底层 HashMap 替换为 LinkedHashMapLinkedHashMapNode 基础上增加了 beforeafter 两个指针,构建了一个双向链表。默认链表记录插入顺序;若设置 accessOrder=true,则每次访问会将节点移至链表尾部,实现访问顺序。

追问 1 :如何基于 LinkedHashSet 实现一个固定大小的 LRU 缓存?
回答LinkedHashSet 不直接支持,但可通过 Collections.newSetFromMap(new LinkedHashMap<K,Boolean>(16,0.75f,true){ protected boolean removeEldestEntry(Map.Entry<K,Boolean> eldest) { return size() > maxSize; }})
追问 2 :LinkedHashSet 的迭代器在并发修改时会抛出异常吗?
回答 :是的,与 HashSet 一样非线程安全,并发修改会触发 ConcurrentModificationException。线程安全版本可用 Collections.synchronizedSet 包装。


面试官:TreeSet 为什么不允许 null?

标准回答

因为插入元素时需要调用 compareToComparator.compare,若元素为 null 则抛出 NullPointerException。即使自定义比较器能处理 null,JDK 源码在构造器或 add 中仍有显式的 NPE 检查,故不允许 null

追问 1 :如果一定要在有序集合中存储 null,有什么办法?
回答 :可以使用 Collections.singleton(null) 但那是不可变单元素集。若需要可变有序且允许 null,可自定义比较器并在调用 TreeSet 方法前做特殊处理,但 JDK 的 TreeSet 无法绕过 NPE 检查。替代方案是使用 ArrayList 排序,但会牺牲去重功能。
追问 2 :Comparable 和 Comparator 在 Java 中能处理 null 吗?
回答Comparable 不能;Comparator 可以通过 Comparator.nullsFirstnullsLast 包装,但如上所述,TreeSet 依然会主动抛 NPE。


面试官:CopyOnWriteArraySet 的适用场景与局限性?

标准回答

适用于读多写极少、数据量小的场景,如系统配置、监听器列表。局限性在于写操作需加锁并复制整个数组 O(n),数据量过大或写频繁会导致严重性能问题和 GC 压力。

追问 1 :数据量多大算"过大"?
回答:没有绝对阈值,通常当元素超过几百时,写操作延时和内存抖动即开始显著增加。生产环境中普遍建议不超过 100~200。
追问 2 :它的 contains 方法时间复杂度 O(n),为何还强调适合读多写少?
回答 :此处的"读"主要指迭代遍历 。在配置管理等场景中,主要操作是遍历所有元素执行逻辑,而非大量调用 contains。遍历基于快照且完全无锁,吞吐量极高。若 contains 操作占多数且数据量大,应改用 ConcurrentHashMap.newKeySet()
追问 3 :它的迭代器支持 remove 吗?
回答 :不支持,调用会抛出 UnsupportedOperationException


面试官:Set 如何实现交集、并集、差集操作?

标准回答
SetCollection 继承了 retainAll(交集)、addAll(并集)、removeAll(差集)。这些方法通过迭代和 contains 实现,时间复杂度通常 O(n×m),具体因实现而异。

追问 1HashSetretainAll 底层如何优化?
回答HashSetcontains O(1),因此 retainAll 接近 O(n)。但 AbstractCollection 的默认实现是双重循环,性能较差。具体可看源码。
追问 2 :如果两个 Set 都很大,如何更高效地求交集?
回答 :可遍历较小的 Set,对另一个 Set 调用 contains,以减少遍历次数。或者使用 Java 8 Stream API:set1.stream().filter(set2::contains).collect(Collectors.toSet())


面试官:EnumSet 的实现原理与性能优势?

标准回答
EnumSet 内部采用位向量存储,每个枚举常量对应一个位。操作通过位运算完成,O(1) 极快,空间极省。

追问 1 :它的迭代顺序是什么?
回答 :枚举常量的声明顺序,与 ordinal() 值一致。
追问 2 :它线程安全吗?
回答 :否,与 HashSet 一样非线程安全。


面试官:如何选择合适的 Set 实现类?

标准回答

  • 是否需要排序? → 是且需并发 → ConcurrentSkipListSet;是单线程 → TreeSet
  • 是否需预测迭代顺序? → 插入顺序 → LinkedHashSet;枚举 → EnumSet;否则 → HashSet
  • 是否线程安全? → 读多写极少小数据量 → CopyOnWriteArraySet;高并发读写 → ConcurrentHashMap.newKeySet();简单同步 → Collections.synchronizedSet

追问 1ConcurrentHashMap.newKeySet()ConcurrentSkipListSet 如何抉择?
回答 :需要排序或导航方法(如 ceiling)时用后者;否则前者提供 O(1) 性能,更适合高频写入。
追问 2CopyOnWriteArraySetConcurrentSkipListSet 的 contains 性能对比?
回答 :数据量小时差异不大;超过数百后,ConcurrentSkipListSet 的 O(log n) 显著优于 O(n)。


面试官:HashSet 的容量和负载因子如何影响性能?能否直接设置?

标准回答

可通过 HashSet(int initialCapacity, float loadFactor) 构造器设置。容量过小导致频繁扩容,负载因子过高加剧冲突。建议根据预期元素数量预估容量:new HashSet<>(expectedSize / 0.75f + 1)

追问 :已经存在大量元素的 HashSet 如何优化?
回答 :无法原地调整,只能新建一个预估好容量的 HashSet 并调用 addAll 迁移。


面试官:为什么重写 equals 必须重写 hashCode?

标准回答

Java 对象契约规定:若 a.equals(b) 为 true,则 a.hashCode() 必须等于 b.hashCode()。HashSet/HashMap 依赖 hashCode 先定位桶,若不等则不会调用 equals,导致内容相同对象被视为不同。

追问 :若只重写 hashCode 不重写 equals 会怎样?
回答 :能定位到相同桶,但 equals 默认比较内存地址,仍然认为两个对象不同,去重可能失效(除非两个引用指向同一对象)。
追问 :为什么 hashCode 每次返回常数 1 也能工作?
回答:可以,但所有元素会落入同一桶,链表/红黑树过长,性能退化为 O(n) 或 O(log n),失去散列优势。


面试官:LinkedHashSet 的迭代性能比 HashSet 好吗?

标准回答

在元素较少或稀疏时,LinkedHashSet 沿双向链表遍历,避开了空桶扫描,迭代速度更快;填充紧密时两者均为 O(n),差异不明显。选择主要看是否需要顺序。


面试官:TreeSet 如何实现范围视图?视图修改会影响原集合吗?

标准回答
subSetheadSettailSet 返回原集合的视图,不复制元素。视图修改会写回原集合,反之亦然。视图会检查插入元素的边界。

追问 :遍历视图时向原集合添加越界元素会怎样?
回答 :原集合可以添加,但视图迭代器不会看到该元素(若在边界外),若在边界内可能抛出 ConcurrentModificationException


面试官:CopyOnWriteArraySet 在迭代期间添加元素,迭代器会看到吗?为什么?

标准回答

不会。因为迭代器持有创建时刻的数组快照,后续写操作复制了新数组,不影响迭代器持有的旧数组。这是弱一致性的表现,也是其无锁读的关键。


面试官:ConcurrentSkipListSet 与 CopyOnWriteArraySet 的 contains 性能对比?

标准回答

前者 O(log n),后者 O(n)。当数据量大于几百时,前者远快于后者。同时前者支持高并发写,后者写操作昂贵。


面试官:Java 9 引入的 Set.of() 创建的集合有何特性?

标准回答

不可变,不允许 null,内存占用极小,线程安全。与 Collections.unmodifiableSet 不同,后者只是视图,原集合变更视图会变。

追问 :Set.of() 的顺序有保证吗?
回答 :无,取决于 JDK 实现和元素数量。需顺序可用 List.of()


模块 9:实战陷阱与最佳实践(附完整 Demo)

陷阱 1:自定义对象存入 HashSet 未重写 hashCode/equals → 去重失效

错误 Demo

java 复制代码
class BadKey {
    String name;
    BadKey(String name) { this.name = name; }
}
Set<BadKey> set = new HashSet<>();
set.add(new BadKey("a"));
set.add(new BadKey("a"));
System.out.println(set.size()); // 2 (错误)

正确 :使用 IDE 重写 equalshashCode

陷阱 2:TreeSet 中修改已存入对象的比较字段

错误 Demo

java 复制代码
class Mutable implements Comparable<Mutable> {
    int id;
    // ... compareTo 和 setter
}
TreeSet<Mutable> set = new TreeSet<>();
Mutable m = new Mutable(5);
set.add(m);
m.setId(1); // 破坏树顺序
System.out.println(set.contains(m)); // 可能 false

正确:存入 TreeSet 的对象应不可变,或保证参与比较的字段在生命周期内不变。

陷阱 3:并发场景使用 HashSet → 数据损坏或异常

错误 Demo

java 复制代码
Set<Integer> set = new HashSet<>();
new Thread(() -> { for (int i=0;i<1000;i++) set.add(i); }).start();
new Thread(() -> { for (int i=0;i<1000;i++) set.add(i); }).start();

正确 :使用 ConcurrentHashMap.newKeySet()

陷阱 4:需要原顺序返回时误用 HashSet → 应改用 LinkedHashSet

java 复制代码
Set<String> set = new HashSet<>();
set.add("z"); set.add("a"); // 顺序不定

正确new LinkedHashSet<>()

陷阱 5:大数据量 CopyOnWriteArraySet 频繁写入

java 复制代码
CopyOnWriteArraySet<Integer> set = new CopyOnWriteArraySet<>();
for (int i=0;i<100_000;i++) { set.add(i); } // 灾难

正确 :使用 ConcurrentHashMap.newKeySet() 或预填充。


模块 10:时间复杂度总结与 JDK 演进

操作复杂度对照表

实现类 add remove contains 迭代顺序 线程安全
HashSet O(1) O(1) O(1) 无序
LinkedHashSet O(1) O(1) O(1) 插入/访问顺序
TreeSet O(log n) O(log n) O(log n) 自然/比较顺序
CopyOnWriteArraySet O(n) O(n) O(n) 插入顺序
ConcurrentSkipListSet O(log n) O(log n) O(log n) 自然/比较顺序
EnumSet O(1) O(1) O(1) 枚举声明顺序

JDK 演进新增 API

  • Java 9Set.of(E... elements) 创建不可变 Set。
  • Java 10Set.copyOf(Collection<? extends E> coll)
  • Java 17:持续底层优化。

结语

从数学集合的抽象定义到 Java 工程中的多种实现,Set 接口及其派生类展示了数据结构与算法在真实系统中的精妙权衡。HashSet 以 O(1) 的效率满足绝大多数场景;LinkedHashSet 以微小代价换取可预测迭代;TreeSet 用红黑树支撑有序与范围操作;CopyOnWriteArraySetConcurrentSkipListSet 则在并发世界中各显神通。理解它们底层与 Map 的委托关系,并根据并发需求做出正确选型,是每一位 Java 专家进阶之路上的必修课。希望本文能帮助你不仅答出面试中的标准答案,更能在系统设计时做出精准的选型。


相关推荐
下次再写2 小时前
Java互联网大厂面试技术问答实战:涵盖Java SE、Spring Boot、微服务及多场景应用
java·数据库·缓存·面试·springboot·microservices·技术问答
公众号-老炮说Java2 小时前
IDEA 2026.1 + Claude Code = 降维打击
java·ide·intellij-idea
千寻girling2 小时前
RabbitMQ 详细教程(38K字数)
java·后端·面试
止语Lab2 小时前
Go vs Java GC:同一场延迟战争的两条路
java·开发语言·golang
卷毛的技术笔记2 小时前
从“拆东墙补西墙”到“最终一致”:分布式事务在Spring Boot/Cloud中的破局之道
java·spring boot·分布式·后端·spring cloud·面试·rocketmq
ERBU DISH2 小时前
修改表字段属性,SQL总结
java·数据库·sql
云烟成雨TD3 小时前
Spring AI Alibaba 1.x 系列【26】Skills 生命周期深度解析
java·人工智能·spring
Pkmer3 小时前
古法编程: 深度解析Java调度器Timer
java·后端
BduL OWED3 小时前
将 vue3 项目打包后部署在 springboot 项目运行
java·spring boot·后端