1. 集合框架的架构全景:三大流派与底层数据结构
很多人学 Java 集合,第一反应是背“接口有哪些实现类”,背完就忘。我建议换个角度:把集合框架当成三类“数据结构仓库”来看——存单列的、存键值对的、以及辅助排序和队列操作的。JDK 给我们的不是一堆孤立的类,而是一套从接口到抽象类再到具体实现的层级体系:Collection 派生出 List、Set、Queue,Map 是另一条独立的线。
先看 List 这条线。ArrayList 底层是 Object 数组,胜在随机访问,按索引取元素的时间复杂度是 O(1),但中间插入或删除需要搬移后续元素,平均 O(n)。LinkedList 底层是双向链表,两头操作是 O(1),但想按下标取第 n 个元素就得从头往后走,O(n) 起步。很多人问“到底哪个快”,实际场景里 LinkedList 的“链表优势”往往体现不出来,因为 CPU 缓存的局部性原理让数组的连续内存访问远比链表指针跳跃更友好,这也是为什么我在实际项目中几乎只在需要反复在头部插入、且数据量可控时才考虑 LinkedList。
Set 这条线的本质是“不重复”,但实现方式完全不同。HashSet 底层就是 HashMap,只用到 key 部分,查找、插入平均 O(1);LinkedHashSet 在 HashMap 基础上加了双向链表维持插入顺序;TreeSet 底层是红黑树,元素天然有序,但每次操作涉及树的旋转和染色,O(log n)。要注意的是 HashSet 的去重依赖 hashCode() 和 equals() 的配合,这是老生常谈但永远有人踩坑:只重写 equals 不重写 hashCode,对象丢进 Set 后会出现“看着相等,却去不了重”的诡异现象。
Map 是另一套体系,HashMap、LinkedHashMap、TreeMap、Hashtable、ConcurrentHashMap 这个家族各有各的脾气。HashMap 最常用,JDK 8 开始当链表长度超过 8 且数组容量达到 64 时,链表会树化成红黑树,这是为了防哈希碰撞极端场景下的退化问题;LinkedHashMap 在哈希表基础上维持双向链表,能实现 LRU 缓存;TreeMap 因为基于红黑树,天然按 key 排序,适合范围查找、取最大值最小值这种操作;Hashtable 是上古遗留的线程安全类,整个方法体用 synchronized 锁住,并发度极低,新代码不建议碰。ConcurrentHashMap 是并发场景下的主力,JDK 8 之后的实现已经抛弃了 JDK 7 的分段锁,改用 synchronized + CAS,锁粒度从“段”细化到“桶”。后面我会专门拆它的源码。
在设计新系统时,我一般先问三个问题:数据量级多大?核心操作是读还是写?要不要有序、要不要去重?这三个问题的答案基本上就能定位到具体实现类了。这比拿着 API 文档一条条对效率高得多,这就是“架构选型黄金法则”的基础思路。
2. 选型黄金法则:从时间复杂度和操作特征倒推实现类
什么样的需求配什么样的集合,严格来说没有“银弹”,但确实存在一套可以反复验证的决策路径。我梳理了大约七条常用的判断规则,基本覆盖日常开发里 90% 的场景。
2.1 按索引取元素为主?优先 ArrayList,别再纠结 LinkedList
如果你的核心操作是 get(i),比如按序号翻页、轮询名单、批量遍历,那么 ArrayList 就是最优解。数组支持 O(1) 随机访问,内存连续,遍历时 JVM 可以预取后续缓存行,实测性能优势非常明显。LinkedList 的 get(i) 是 O(n),n 大了之后惨不忍睹,就算 n 只有几千,在循环里反复调 get(i) 也会让整体耗时飙升。我在某项目中做过对比:同样是 10 万条数据,ArrayList 遍历大约 5 毫秒,LinkedList 用 for 循环按索引取完是几百毫秒,差别不是一点半点。唯一需要注意的就是 ArrayList 扩容时的损耗,但扩容是倍增策略,均摊下来每次 add 仍然是 O(1)。
2.2 频繁在头部插入或删除?要么 LinkedList,要么 LinkedList 也不用
LinkedList 的唯一优势场景是“在头部或中部频繁插入删除且操作的指针位置已知”。但现实里如果你真的需要头部操作,Deque 接口的 ArrayDeque 往往比 LinkedList 更合适,前者是循环数组实现,头部插入删除也是 O(1),而且内存更紧凑。所以我的个人结论是:大多数用到 LinkedList 的地方,其实是可以用 ArrayDeque 替代的,LinkedList 的正确定位更偏向“实现 Deque 的链表形态”。
2.3 需要“不重复且无序”?HashSet
HashSet 底层是 HashMap,添加元素时值放在 key 的位置,value 统一用固定对象填充。它适合幂等判断、数据去重、判断“某个值是否出现过”这类场景。哈希表理想状态下 O(1) 的查询性能,让它成为“查重”的第一选择。但要注意,如果元素是自定义对象,务必同时重写 hashCode 和 equals,否则去重逻辑完全失真。
2.4 需要“不重复且有序”?按“插入序”还是“自然序”分别选择
如果你只关心元素不重复,还想按插入顺序遍历,LinkedHashSet 是对的。它内部在 HashMap 之外连了一条双向链表,遍历时按插入顺序输出,代价是每个元素多两个指针的内存开销。
如果你需要元素自带排序,遍历时从小到大输出,TreeSet 是正确的选择。它基于红黑树,支持按范围取子集,比如取出“大于某个值”的所有元素,或者找集合里的最大最小值,这在日程系统、排行榜、区间查询里很常用。代价是每次插入 O(log n),比哈希表慢一些。使用 TreeSet 还要求元素实现 Comparable,或者在构造时传入 Comparator,否则运行时抛 ClassCastException。
2.5 键值对映射的选型:HashMap 打底,LinkedHashMap 做 LRU,TreeMap 做范围查询
键值对场景,90% 以上可以用 HashMap。但有一个细节很多人忽略:HashMap 的遍历顺序不是插入顺序,而是根据 hash 落桶的顺序,也就是说是“伪随机”的。如果你需要保持插入顺序,用 LinkedHashMap;如果你需要 key 有序,用 TreeMap,它的 floorKey、ceilingKey、subMap 这类方法是做区间统计的利器。
这里我给一个额外提示:LinkedHashMap 重写了 removeEldestEntry 方法之后再结合 accessOrder=true 参数,就可以轻松实现一个 LRU 缓存。很多缓存框架的核心就是这一句话的事。我在某项目中就用手写了一个几十行的 LRU Map,避免了引入额外依赖,效果非常稳定。源码层面,LinkedHashMap 的 afterNodeAccess 方法会在每次 get 命中时把节点移到链表尾部,这样链表头部自然就是近期最少访问的节点。
| 选型维度 | 首选实现 | 复杂度 | 适用场景 |
|---|---|---|---|
| 随机访问 get(i) | ArrayList | O(1) | 列表展示、按索引轮询 |
| 头部频繁增删 | ArrayDeque | O(1) | 队列、栈、滑动窗口 |
| 去重且无顺序要求 | HashSet | O(1) 均值 | 幂等、判重、白名单 |
| 去重且保插入序 | LinkedHashSet | O(1) 均值 | 按注册顺序去重 |
| 去重且自然排序 | TreeSet | O(log n) | 排行榜、区间查询 |
| 键值映射且无序 | HashMap | O(1) 均值 | 字典、索引、缓存 |
| 键值映射保插入序 | LinkedHashMap | O(1) 均值 | 字典且需要按插入顺序输出 |
| 键值映射且 key 有序 | TreeMap | O(log n) | 订单按时间排序、区间统计 |
2.6 初始容量预估:直接填,别让集合反复扩容
这是最简单也是收益最明显的一条。ArrayList 默认容量 10,HashMap 默认容量 16,如果在不确定量级时持续 add,底层数组会反复扩容拷贝,白白浪费性能。比如你明确知道会有 2 万条数据,那 new ArrayList<>(20000) 或者 new HashMap<>(20000) 就把扩容次数降到零。HashMap 的容量必须是 2 的幂,构造时传入 20000,实际会被调整到 32768 左右。另外,HashMap 还有一个负载因子默认 0.75 的概念,即当元素数量超过“容量 × 0.75”时会触发扩容。如果预估元素数量为 size,建议初始容量设为 size / 0.75 + 1,防止刚填完就扩容。
2.7 内存敏感的场景:考虑数组或专门的结构
集合框架虽好,但每个元素都有对象头、引用、指针等额外开销。比如 Integer 数组中每个元素占用不止 4 字节;HashSet 里每个元素要额外存 HashMap Node,包括 hash、key、next 指针等信息。如果是百万级以上的数据承载,该用 int[] 或 long[] 就用基础数组,或者考虑第三方工具专门做内存压缩。这个建议来自我一线的真实体会:集合让你写得爽,但 GC 压力会让线上服务很受罪。
3. 线程安全三座大山:同步包装器、迭代时修改与复合操作
线程安全是集合框架里翻车率最高的部分。很多人的认知停留在“用 Collections.synchronizedXxx 不就安全了”的水平,但真实场景里,这个结论经常是错的。下面我把三个最常见的问题拆开讲。
3.1 同步包装器:锁的是整个对象,但不解决复合操作
Collections.synchronizedList、synchronizedMap 这类包装器做的事情很简单:把原来的 ArrayList 或 HashMap 包起来,每个方法都加 synchronized 锁住 this。原理一句话,但坑也就在这里。
先说结论性代码。下面的代码即使在 synchronizedList 上运行,仍然是不安全的:
List<String> list = Collections.synchronizedList(new ArrayList<>()); // 两个线程同时执行这段代码 synchronized (list) { if (!list.contains(element)) { list.add(element); } }单独调 contains 是原子的,单独调 add 也是原子的,但“先判断再添加”这个组合不是。两个线程同时进来,可能都发现不存在,然后都执行 add,结果就出现了重复元素。如果不在外部手动 synchronized(list) 把整段逻辑包起来,这种复合操作一定会出问题。所以说:同步包装器只保证单个方法内部原子,不保证多个方法的组合原子。
另一个问题是性能。synchronizedList 的所有操作锁的是同一个对象,读操作也在抢同一把锁,高并发读场景下完全没有并发度可言。比如一个列表被几十个线程频繁读取,synchronizedList 会让它们全部串行化,吞吐量断崖式下降。JDK 8 里 ArrayList 的读操作本来就不需要加锁,CopyOnWriteArrayList 设计成读多写少、读时不加锁才符合真实需求。
3.2 迭代时修改:ConcurrentModificationException 的真正触发机制
几乎每个 Java 开发者都见过这个异常。它本质上不是多线程专属问题,单线程也可能触发。来看代码:
List<String> list = new ArrayList<>(); list.add("a"); list.add("b"); list.add("c"); for (String s : list) { if ("b".equals(s)) { list.remove(s); } }这段代码运行时会抛 ConcurrentModificationException。原因在 ArrayList 内部有一个 modCount 字段,每次结构性修改(add、remove、clear 等)都会让 modCount++。迭代器创建时会记录 expectedModCount,每次 next() 都校验 modCount 是否等于 expectedModCount,不等就抛出异常。这就是“快速失败”(fail-fast)机制。
但为什么有些人在 for 循环里删元素不报错?因为他用的是迭代器自己的 remove:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) { it.remove(); // 这个 remove 会同步修改 expectedModCount } }迭代器内部的 remove 会把 expectedModCount 同步成最新的 modCount,所以不会触发异常。除此之外,使用 CopyOnWriteArrayList 或 Stream 的 filter + collect 也是不错的替代方案。关键要记住:不是不能删,而是你不能用集合自己的删除方法去破坏迭代器的预期计数。
多线程场景下比这个更隐蔽的是“弱一致迭代器”——ConcurrentHashMap 的迭代器在遍历过程中如果别的线程改了 map,不会抛异常,但遍历结果可能不一致。这是弱一致性的特性,和 fail-fast 是两种截然不同的策略。
3.3 复合操作:从“先检查后操作”到“先读后写”
复合操作的坑不止在 List 上。经典的 check-then-act 在 Map 场景一样致命。比如一个全局缓存 Map,线程 A 和线程 B 同时判断 key 是否存在,不存在就放入。如果用的是 HashMap,不仅在并发下会丢数据,极端情况下(JDK 7 及以前)还会因为头插法造成链表环形引用,调用 get 时直接死循环,CPU 飙到 100%,这个事故我见过不止一次。JDK 8 重写了 resize 的逻辑,链表不会再成环了,但并发 put 之间依然会互相覆盖。
那么怎么解决“不存在才放入”这类 check-then-act 操作?用 ConcurrentHashMap 也不是万能药。ConcurrentHashMap 的单个方法(put、get、remove)是线程安全的,但 containsKey 后再 put 依然是两个操作,整体不原子。正确做法是用 putIfAbsent 原子方法:
ConcurrentHashMap<String, Value> map = new ConcurrentHashMap<>(); Value result = map.putIfAbsent(key, value); if (result == null) { // 说明之前不存在,本次放入成功 }类似地,如果要对“旧值”做一些判断再更新,用 compute、merge 这类原子方法,它们能在内部锁粒度上完成整个计算逻辑。这是从“靠外部加锁”到“利用并发容器自带原语”的关键思维转变,也是代码高并发环境下少出幺蛾子的必备意识。
3.4 三条线程安全自查清单
我在 review 代码时习惯过一遍这三个问题,基本上能拦掉大多数隐患:
- 问题一:有没有“单独方法原子、组合不原子”的代码?有的话,用 ConcurrentHashMap 的原子方法,或者在外部加同一把锁。
- 问题二:有没有在遍历过程中删除或修改集合?改用 Iterator.remove、Collectors 过滤或 CopyOnWriteArrayList。
- 问题三:新代码里还在用 Hashtable、Vector、synchronizedMap 吗?如果是,考虑替换成 ConcurrentHashMap、CopyOnWriteArrayList,或者接受锁粒度的代价并说明理由。
4. 源码级拆解:并发容器的锁粒度、CAS 与写时复制
前面聊了很多概念,这节进入源码层面。理解并发容器“为什么快”,本质上就是理解三件事:锁粒度够不够细、能不能不上锁、以及牺牲一致性能换到什么。
4.1 ConcurrentHashMap 的 JDK 7 与 JDK 8 差异:锁粒度演进
JDK 7 的 ConcurrentHashMap 用分段锁,把整个 Map 分成默认 16 个 Segment,每个 Segment 是一把独立的锁。写操作只需锁住目标 key 所在的 Segment,不同 Segment 的写操作可以并发执行。但在数据量很大、key 分布集中时,单个 Segment 内部仍然可能发生大量锁竞争。
JDK 8 抛弃了 Segment 这个类,锁粒度直接细化到哈希桶数组的每个桶。put 的时候,先算 hash 定位到桶。如果桶为空,用 CAS 操作直接写入,不需要加锁;如果桶不为空,synchronized 锁住这个桶的头节点。链表的剩余部分操作都在这把桶锁的保护下完成。这样一来,并发写的争用范围被压小到一个桶,理论上多线程同时写不同桶时完全互不干扰。
JDK 8 的 put 流程大致如下(源码层面的关键判断):
final V putVal(K key, V value, boolean onlyIfAbsent) { // 计算 hash,进入循环 // 如果 table 还没初始化,先初始化 // 如果桶位为空,CAS 直接放入,不锁 // 如果桶位是 ForwardingNode,说明在扩容,协助迁移 // 否则 synchronized 锁住桶头节点,遍历链表或红黑树插入 }这其中最值得注意的设计是“扩容时旧桶写入要同步等待”。扩容操作会把数组从旧大小搬到两倍大小,期间读线程可以直接读到旧数组的数据,写线程如果刚好落在已经迁移过的桶上,就去辅助扩容。这个协作机制叫“多线程协助扩容”,思路是让所有线程都参与搬运任务,而不是扩容线程独自干活。虽然代码复杂,但效果就是扩容期间的整体性能不会断崖式下跌。
JDK 8 里另一个重要点是 CAS + volatile。table 数组本身用 volatile 修饰,保证扩容后新数组对读线程可见;桶位是否为空的判断用 CAS,避免加锁。这套组合的意义是:大量读操作和分散写操作可以完全无锁并发,只有在桶内有冲突时才升级到 synchronized。
4.2 CopyOnWriteArrayList:写时复制到底在牺牲什么
CopyOnWriteArrayList 是一个极端偏科的容器:读操作完全不加锁,写操作会复制底层数组。每次 add 或 set 时,它会创建一个新数组,长度是当前长度加一,拷贝原数组内容,在新数组里写入目标元素,然后让内部引用指向新数组。这个思路的代价一目了然:每次写都是 O(n) 的数组拷贝,写频率一高,GC 压力和复制消耗都很大。
但它换来了一些很有意思的特性:读线程永远不会被写线程阻塞,迭代过程中如果其他线程改了数据,迭代器仍然基于修改前的快照工作,不会抛 ConcurrentModificationException。所以这个容器特别适合“写少读非常多”的场景,比如监听器列表、配置白名单、全局只读数据的更新场景。
源码里的 add 方法核心逻辑非常直白:
public boolean add(E e) { synchronized (lock) { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len + 1); newElements[len] = e; setArray(newElements); return true; } }注意它仍然用了 synchronized 锁,但锁的作用范围只是“保证并发写时不会产生多个副本互相覆盖”,读操作完全不经过这把锁。get 方法本质就是一次 volatile 读:
public E get(int index) { return get(getArray(), index); }从这里可以看到,真正让 CopyOnWriteArrayList 适合读多写少的原因不是“完全无锁”,而是“读写互不阻塞+弱一致”,它把读性能做到了极致,用写性能的牺牲去换。
4.3 阻塞队列家族:不只是线程安全,还有“等待与唤醒”
如果要选一个被低估的集合类,我会投 BlockingQueue 一票。ArrayBlockingQueue 底层是环形数组,有界;LinkedBlockingQueue 底层是链表,可以设置容量也可以无界;SynchronousQueue 不存元素,一个 put 必须等一个 take 配对;PriorityBlockingQueue 是无界的按优先级出队。
以 ArrayBlockingQueue 为例,它的 take 方法在队列空时会调用 notEmpty.await() 释放锁,等待生产者唤醒;put 方法在队列满时调用 notFull.await()。这个机制让线程安全从“加锁保护数据”升级到“条件等待协调流程”,是生产者消费者模型里的基础组件。
我后来用 ThreadPoolExecutor 时发现,它的工作队列参数传的就是 BlockingQueue 的实现类。有界队列防止任务堆积过多,无界队列容易导致内存飙升,选择哪种直接影响系统的背压能力。这些都不是 API 层面的小事,是架构层面的取舍。
4.4 为什么有些容器选择弱一致而非强一致
强一致性意味着“任何时刻读到的都是最新已提交的写”,实现方式要么加全局锁,要么让读写串行化,性能都有代价。弱一致性的含义是:读操作看到的数据可能是某一瞬间的快照,不一定反映后续最新写入,但最终会收敛。CopyOnWriteArrayList 和 ConcurrentHashMap 的迭代器选择的就是弱一致性。
这个选择对不对?要看场景。缓存、统计、白名单这类数据,读到旧值问题不大,甚至可以容忍短暂不一致,但系统吞吐量会更高;而银行扣款、库存扣减这类必须强一致的场景,集合容器的弱一致性压根不适合,要引入分布式锁或是数据库层的事务保证。理解了这一点,你就能解释为什么 ConcurrentHashMap 的 size() 只是近似值(JDK 8 是统计各个桶的计数总和,不加锁也不求精确),而不会在关键业务里错误依赖它。
5. 避坑顺带清单:真实项目里的翻车现场与检查清单
技术点讲了那么多,最后分享几个我在真实项目里遇到或排查过的集合问题,每个都有代表性,也都有直接的整改思路。这些场景里的“项目”,按照通用表述,就是我自己参与过的某几个模拟项目和线上系统,逻辑可靠。
5.1 翻车现场一:HashMap 并发 put 导致服务器 CPU 100%
背景是我参与过的某消息处理系统,一个无锁的 HashMap 被多个线程往里写,运行一段时间后 CPU 打到 100%,线程 dump 显示 HashMap.get 卡在了一个循环里。原因我们在前面聊过:JDK 7 的 HashMap 在扩容时并发 rehash,链表成环。解决办法是换 ConcurrentHashMap,同时初始容量按数据量精确配置。当时没有深入分析根本原因就到处加 synchronized,后来发现锁整个 HashMap 虽然也能解决,但吞吐量下降太严重,不是最优解法。
这个案例提醒我一件事:排查并发问题,第一步一定要看“用的是哪个集合类”,而不是直接往方法上加 synchronized。看集合类的并发策略比看锁范围更高效。
5.2 翻车现场二:synchronizedMap 下的 check-then-act
另一个项目是做订单去重,两个线程同时收到相同订单号,用 synchronizedMap 包了一个订单缓存。测试环境并发量低,问题没暴露;一旦发版到压力环境,重复订单率瞬间上升。排查之后定位到代码里“先 containsKey 再 put”的逻辑,两个操作单独都是安全的,组合起来不是。修复方式是将整个复合逻辑包在 synchronized(map) 中,或者更彻底地换成 ConcurrentHashMap.putIfAbsent。从那以后,我写代码默认规避检查后写入这种模式,能用一个原子方法就不写两条语句。
5.3 翻车现场三:迭代删除元素,线上偶发异常
某个定时任务里,用增强 for 循环遍历集合并删除过期元素,线上日志偶尔出现 ConcurrentModificationException。很多人以为这是多线程问题,其实单线程也会触发。因为增强 for 循环本质是迭代器,循环体内直接调用 list.remove 会破坏迭代器计数。修复方案很简单:改用 iterator.remove() 或收集要删除的元素到最后一次性 removeAll。
这个问题的隐蔽性在于不是必然触发。删除的是倒数第二个元素,或者删除后循环恰好结束,都可能不报错。这种“时好时坏”的 bug 最耗时间,因为你很难马上复现。
5.4 集合选型自查清单
我把日常代码评审时用的集合自查清单放在这里供你参考:
- 这个集合会被多线程同时读写吗?是的话,优先 ConcurrentHashMap 或 CopyOnWriteArrayList。
- 存在“先读后写”或者“先查后增”的复合逻辑吗?存在的话,用容器自身的原子方法。
- 有在遍历过程中修改集合的操作吗?有的话,改成迭代器删除或收集后统一删除。
- 高频读取、低频更新的数据适合 CopyOnWriteArrayList 吗?如果写频率超过每秒几次,慎用,写复制成本不低。
- 集合初始化前能估算容量吗?能的话,直接指定初始容量,减少扩容。
- 业务要求强一致吗?要求的话,不要依赖 ConcurrentHashMap 的弱一致迭代器。
- 需要按顺序输出吗?需要的话,用 LinkedHashMap、LinkedHashSet 或 TreeMap,不要用 HashMap 后再自己排序。
6. 我的实操体会:源码怎么读、集合怎么练
最后说一个所有写 Java 的人迟早会面对的问题:源码怎么读才有用?很多人打开 HashMap 源码,看到红黑树插入就陷入细节出不来,读了一个下午,合上书全忘了。我的方法是分层读。
第一遍只读主干流程。比如 HashMap,先看 put 方法从头到尾怎么走:算 hash、找桶、插入、判断扩容。第二遍再读分支逻辑,也就是“什么情况下会走树化”“什么情况下走替换旧值”。第三遍才算深入,去看 resize 方法里旧数组的数据是怎么分到新数组里去的。每一遍停留的时间不超过一个小时,分段消化,这比一次想把所有细节吞下去要有效得多。
如果你问我这整篇文章最核心的一句话是什么,那就是:集合框架的选型本质上是数据结构与并发策略的组合决策。数据结构决定单线程下的效率,并发策略决定多线程下的正确性与吞吐量,两者缺一不可。可以把 ArrayList、HashMap 这些基础实现当成“默认配置”,把它们背熟;把 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue 当成“增强配置”,真正高并发场景直接选用;而 synchronizedMap、Hashtable 这种“妥协配置”,只在维护老代码时才需要关心。
在实际开发里,每次要引入一个新的集合类时,先停一下,想一想它的底层数据结构是什么、它在并发下用了什么策略、它牺牲了什么换来了什么。这三个问题想明白,就能少踩很多坑。我的经验是,调试一个集合相关的线上问题,往往比写业务逻辑多花好几倍的时间。所以,选型时多花十分钟,运行时就少熬三个小时。
写到这里,顺带分享一个小技巧:可以用 JOL 工具查看集合对象在内存中的占用情况,或者用 jmap 对比不同实现类在相同数据量下的对象数量差异。这些能很直观地告诉你,Set 和 List 的底层差别不仅仅是“去重”两个字而已。建议你自己跑一次实验:拿 10 万个对象,分别扔进 ArrayList、HashSet、LinkedHashMap,看内存和耗时数据,感受会远比看文章来得深刻。