尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java集合框架核心知识点全解析:从底层原理到并发场景实战

Java集合框架核心知识点全解析:从底层原理到并发场景实战 搞 Java 的无论是学生还是工作几年的开发面试被问集合几乎是躲不掉的。我当年也是从“硬背 ArrayList 和 LinkedList 区别”开始到后来才慢慢理解整个集合框架的设计思路。这篇文章就把 Java 集合这块最核心的知识点系统梳理一遍从整体架构到底层原理从单线程应用到并发场景顺带把我实际踩过的坑也交代清楚。内容覆盖面比较全不管是准备面试还是日常开发查漏补缺都可以按目录挑自己薄弱的章节看。1. 集合框架全貌与核心设计思路1.1 前期准备理解接口、抽象类与实现类的三层关系很多人一上来就背各个集合类的方法结果背完就忘。我的建议是先理解 Java 集合的整体设计。集合框架其实就干两件事存储数据容器能力和操作数据算法能力。所有的接口、抽象类和实现类都是围绕这两个核心展开的。最顶层是两个接口Collection 和 Map。Collection 是单列数据的根接口下面又分成 List、Set、Queue 三个子接口Map 代表键值对的双列数据它不继承 Collection是独立的一棵体系。为什么这么设计因为单列数据和键值对数据的操作模型差异太大。List 要有序、可重复所以接口里定义了 get(index)、add(index, element) 这类按位置操作的方法Set 要不重复所以接口里强调 equals 和 hashCode 的约束Map 要通过 key 找 value所以接口核心是 put(key, value) 和 get(key) 的组合语义。理解了这个设计逻辑你就能明白为什么有些方法只在特定接口里出现。比如 List 有 get(int index)Set 里就没有因为 Set 本来就不承诺顺序你按位置取值没有意义。搞清楚接口层面的设计意图再看具体实现类思路就顺了。1.2 各实现类的定位什么时候选谁Java 集合类的数量确实多但真正核心的、面试高频的、工作中常用的其实可以列得很清楚。我用一个表格把最常用的实现类核心特征归纳出来。接口实现类底层结构顺序性线程安全典型应用场景ListArrayList动态数组有否随机访问频繁、尾部插入删除ListLinkedList双向链表有否频繁在头部/中间插入删除ListVector动态数组有是synchronized基本不用SetHashSetHashMap 的 key无否去重、判断存在性SetLinkedHashSetHashMap 双向链表有插入序否需要去重且保留插入顺序SetTreeSet红黑树有排序否需要自动排序的去重集合MapHashMap数组 链表 红黑树无否最常用的键值对存储MapLinkedHashMapHashMap 双向链表有插入序或访问序否LRU 缓存、保留插入顺序MapTreeMap红黑树有Key 排序否需要按 key 排序的范围查询MapHashtable数组 链表无是全表锁基本不用MapConcurrentHashMap数组 链表/红黑树 CAS synchronized无是高并发下的键值对存储你把这个表记住等于把整个集合框架的选型地图装脑子里了。实际开发时80% 的场景就是 ArrayList、HashMap、HashSet 这三兄弟特殊场景用 LinkedList、LinkedHashMap、TreeSet、ConcurrentHashMap。真正的难点不在于“有哪些类”而在于“为什么这个场景必须用这个类”下面我逐个讲。2. List 深度拆解ArrayList 与 LinkedList 的详细对比2.1 ArrayList 的扩容机制与随机访问优势ArrayList 本质上就是一个会自动扩容的数组。它的默认初始容量是 10每次扩容变成原来的 1.5 倍新容量 旧容量 旧容量右移一位。很多人只知道“1.5 倍”这个结论但没想过这个设计背后的巧劲。扩容操作的源头是 add 方法里的 ensureCapacityInternal。当元素个数超过当前容量时它会调用 grow 方法。1.5 倍这个比例不是拍脑袋定的。如果扩容比例太小比如 1.1 倍那么频繁地新增元素会触发频繁扩容和数组复制性能损耗明显如果比例太大比如 2 倍虽然扩容次数变少但可能浪费大量内存空间。1.5 倍本质上是在空间和时间之间取了一个平衡点。另外扩容需要 Arrays.copyOf 把旧数组的元素全部复制到新数组这是一个 O(n) 的操作。这也是为什么如果预先知道数据量最好在构造时指定初始容量比如 new ArrayList(1000)可以避免多次扩容带来的复制开销。RandomAccess 接口在 ArrayList 中扮演的角色也值得注意。这个接口是一个标记接口不包含任何方法。它的作用就是告诉程序“我这个 List 支持高效的随机访问”。Collections.binarySearch 等工具方法会通过 instanceof RandomAccess 判断使用索引二分还是迭代器二分。这属于设计模式中策略模式的一个应用场景。以后你在刷题或者写框架代码时也可以通过这种标记接口做性能优化代码会显得很老练。2.2 LinkedList 的双向链表结构与操作代价LinkedList 底层是一个双向链表每个节点 Node 持有 prev、next、item 三个字段。它的插入删除操作在已知节点引用的情况下是 O(1) 的但随机访问 get(index) 是 O(n)需要从头或从尾遍历。这里有个隐藏的知识点LinkedList 不仅仅是 List它还实现了 Deque 接口也就是说它天生就是双端队列可以直接当栈用、当队列用。Java 官方其实推荐用 ArrayDeque 来当栈和队列因为 ArrayDeque 底层是循环数组内存连续且操作效率更高。只有在需要频繁在队列中间插入删除元素时LinkedList 才有不可替代的优势。还有一点要在面试和实战中特别留意的LinkedList 的 remove(Object) 和 remove(int) 是重载方法。调用 remove(2) 会被编译器解析为 remove(int index)而不是删除值为 2 的元素。如果你要删除值为 2 的 Integer 对象必须写成 remove(Integer.valueOf(2))。这个坑我见过不止一个人踩而且经常出现在线上代码里。类似的还有 Collections.sort 和 Arrays.sort 这种重载方法使用时要注意参数类型。2.3 Vector 与 Stack几乎被淘汰的老前辈Vector 是 JDK 1.0 就存在的类它的方法都加了 synchronized所以线程安全但代价是单线程环境下每次操作都要获取锁性能明显低于 ArrayList。Vector 扩容是翻倍2 倍跟 ArrayList 的 1.5 倍不同。Stack 继承自 Vector也是同步的官方早已建议用 ArrayDeque 替代它做栈操作。所以实际开发中我基本不会主动用 Vector 和 Stack。如果确实需要线程安全的 List更合理的方案是使用 Collections.synchronizedList(new ArrayList()) 或者并发包里的 CopyOnWriteArrayList。现在的 JDK 里 Vector 和 Stack 没有被移除更多是历史兼容性的考量。面试时如果被问到能解释清楚“为什么废弃、用什么替代”就够了。3. Set 的不重复原理HashSet、LinkedHashSet 与 TreeSet3.1 HashSet 的去重逻辑与 hashCode/equals 约定HashSet 的底层其实就是 HashMap只不过 value 固定为一个常量对象。它的 add 方法调用的是 map.put(e, PRESENT)HashMap 的 key 具有唯一性所以 HashSet 天然去重。去重的本质依赖 hashCode 和 equals 两个方法。存入 HashSet 的元素先通过 hashCode 定位到哈希桶如果桶为空直接插入如果桶不为空再用 equals 逐个比较同一个桶内的元素。这里有个关键约定也是 Java 面试 super高频题equals 相等的两个对象hashCode 必须相等hashCode 相等的两个对象equals 不一定相等。这个约定的原因是 hashCode 决定了元素存到哪个桶equals 决定了桶内是否重复。如果两个对象 equals 为 true 但 hashCode 不同那它们会被存到不同的桶里导致 Set 里出现两个“相等”的元素直接破环 Set 的语义。反过来hashCode 相同但 equals 不同是允许的只是会导致哈希冲突多个元素放到一个桶里。所以在实际开发中重写 equals 时必须一并重写 hashCode否则使用 HashSet 或 HashMap 当作 key 时会出现难以排查的 bug。我记得自己在项目里写过重写 equals 忘了改 hashCode结果在 HashSet 判重时两个明明内容一样的对象被当作不同元素存了进去这种 bug 非常隐蔽靠肉眼很难发现想排查真的费了不少劲。3.2 LinkedHashSet去重但保留插入顺序LinkedHashSet 继承了 HashSet内部使用 LinkedHashMap 实现。它在 HashMap 的基础上多加了一条双向链表用来维护元素的插入顺序。所以 LinkedHashSet 既能去重又能按插入顺序遍历。这个特性在某些场景下非常实用比如需要维护“用户最近访问过的项目 ID 列表”既要保证不重复又要保持按访问先后展示。如果只用 HashSet顺序就丢了如果只用 ArrayList又没法去重。LinkedHashMap 在此基础上还支持“访问顺序”模式构造时传入 accessOrdertrue那么每次 get 或 put 一个已有 key 时这个 entry 会被移动到链表尾部。利用这个机制只需重写 removeEldestEntry 方法就能轻松手写一个 LRU 缓存。这也是面试中“手写 LRU”高频考题的标准解法之一。3.3 TreeSet 的排序特性与比较器设计TreeSet 底层是 TreeMap也就是红黑树。它的元素会自动排序排序规则有两种来源元素实现 Comparable 接口或者 TreeSet 构造时传入一个 Comparator。这里有个容易踩的坑排序规则和 equals 不一致时去重逻辑会变得非常诡异。TreeSet 判断元素重复的依据是 compareTo/compare 是否返回 0而不是 equals 和 hashCode。如果一个类有多个字段但你只按其中一部分字段排序那么这些字段相同的对象会被视为“同一个元素”即使其他字段不同。真实项目里我见过一个场景订单对象按金额排序但订单 ID 不同、金额相同的两个订单被 TreeSet 吞掉了。所以使用 TreeSet 时设计排序规则就要同时承担“去重规则”的职责。如果你希望“金额相同也保留两个订单”就不要用 TreeSet或者把排序规则设计成金额相同再比较 ID。4. 双列集合核心HashMap 与 ConcurrentHashMap4.1 HashMap 底层结构与哈希算法解析HashMap 是 Java 集合框架里最为核心的一个类。它的底层结构在 JDK 8 之后是数组 链表 红黑树。数组是主干每个位置叫桶bucket元素通过 key 的哈希值决定落桶位置。如果多个 key 落到同一个桶就用链表存储冲突元素。当链表长度超过阈值默认 8且数组长度达到 64 时链表会转换为红黑树把单个桶内的查找复杂度从 O(n) 降为 O(logn)。哈希算法本身是理解 HashMap 的关键。HashMap 不是直接拿 key.hashCode() 作为桶位置而是先将 hashCode 的高 16 位与低 16 位做异或运算hash ^ (hash 16)得到一个扰动后的哈希值。这样做的目的是让高位信息也参与到底部取模计算中降低哈希碰撞概率。接着通过 (n - 1) hash 计算桶索引其中 n 是数组长度且始终是 2 的幂。为什么数组长度必须是 2 的幂因为 n - 1 的二进制高位全是 0、低位全是 1这样可以保证哈希值均匀分布且计算效率高。如果 n 不是 2 的幂位运算就会失效必须改用取模运算性能就会下降。默认初始容量 16默认负载因子 0.75。这个 0.75 是时间与空间的折中。负载因子越高空间利用率高但哈希冲突概率大查询变慢负载因子越低空间浪费大但查询更快。0.75 在大多数场景下表现均衡。当元素数量超过容量乘以负载因子时HashMap 会触发扩容容量翻倍并重新计算所有元素的位置rehash。这个过程中原来在某个桶里的元素扩容后要么留在原索引要么移动到“原索引 旧容量”的位置。JDK 8 在扩容时引入了一个巧妙的优化不需要像 JDK 7 那样重新计算每个 key 的索引只需看元素的 hash 值在新增的那一位上的值是 0 还是 10 留在原地、1 移动。这是面试中关于“HashMap 扩容为什么高效”的标准答案建议把它理解透能顺手写出来更佳。下面是一个最基础的 HashMap 使用示例包含常见操作和遍历方式。MapString, Integer map new HashMap(); map.put(apple, 1); map.put(banana, 2); map.put(cherry, 3); // 遍历 entrySet最推荐的方式 for (Map.EntryString, Integer entry : map.entrySet()) { System.out.println(entry.getKey() entry.getValue()); } // 根据 key 获取值不存在时返回默认值 int value map.getOrDefault(durian, 0); System.out.println(value); // 只有 key 不存在时才执行 put常用于初始化 map.putIfAbsent(apple, 100); System.out.println(map.get(apple)); // 输出 1因为 key 已存在这段代码里的 putIfAbsent 我很常用在多线程环境下能避免先判断再插入导致的竞态条件。比如统计关键词出现次数时用 putIfAbsent(k, 0) 再 get 就比 containsKey put 安全得多。4.2 线程不安全的典型表现与 HashTable 的局限HashMap 在单线程下性能卓越但它是线程不安全的。多线程并发 put 时可能出现数据覆盖、丢失更新等问题。JDK 7 时代的 HashMap 在并发扩容时甚至会出现死循环导致 CPU 100%。我在早期的项目里就遇到过类似情况当时服务突然卡死线程 dump 一看全是某个线程卡在 HashMap 的 transfer 方法里好在定位到问题后重启恢复后来把代码全部改成 ConcurrentHashMap 才彻底解决。JDK 8 将扩容时的头插法改成了尾插法避免了这个死循环问题但数据覆盖问题依然存在。所以并发场景下不要用 HashMap。Hashtable 作为线程安全的 Map实现方式非常粗暴整个 put/get 方法都加了 synchronized 锁。这意味着并发访问时只要有一个线程在写入其他线程的读和写全部被阻塞。并发量稍高一点这个锁竞争就会导致性能急剧恶化所以现在基本不用。4.3 ConcurrentHashMap 的锁分段到 CAS synchronized 演进并发场景下首选是 ConcurrentHashMap。JDK 7 时期它采用分段锁机制将整个 Map 分成多个 Segment默认 16 个每个 Segment 是一把独立的锁。不同线程操作不同 Segment 时互不干扰并发度等于 Segment 数量。JDK 8 直接放弃了 Segment改为对每个桶单独加锁借助 CAS synchronized 实现更细粒度的并发控制。具体来讲写入时如果目标桶为空通过 CAS 直接放入不需要加锁如果桶不为空则对桶的头节点加 synchronized 锁然后执行插入或更新操作。这种设计既保证了线程安全又把锁的粒度降到了单桶级别并发度比 JDK 7 大幅提升。同时它的 size() 方法也不会像 Hashtable 那样全局加锁而是通过累加不同桶的计数得到一个近似值。准确说size() 返回的只是一个估计值不保证完全精确。这在很多并发写入场景下其实是可以接受的但在需要精确统计的场景要注意这个限制。ConcurrentHashMap 的迭代器是弱一致性的。也就是说在迭代过程中如果其他线程修改了 Map迭代器不会抛出 ConcurrentModificationException也不保证一定能看到最新修改的数据。这跟 fail-fast 的普通集合迭代器完全相反。理解这一点很重要如果你需要“迭代过程中看到的每一笔修改都不能丢”那恐怕得在迭代期间额外加锁或者自己实现快照机制。常用的做法是遍历 entrySet 之后将数据汇总到一个局部数组里再处理这样可以避免弱一致性带来的统计偏差。4.4 业务开发中如何正确选择 Map 实现类实际业务开发中Map 的选型直接决定代码的可维护性和性能表现。我总结了一套很直接的选型判断流程。如果只有一个线程访问 Map直接用 HashMap如果需要按 key 的自然顺序或自定义顺序遍历用 TreeMap如果需要保持插入顺序或用访问顺序做 LRU用 LinkedHashMap如果是多线程高并发读写直接用 ConcurrentHashMap不要图省事再包一层 Collections.synchronizedMap。另外使用自定义对象作为 Map 的 key 时这个对象必须正确重写 equals 和 hashCode。不要用可变对象作 key因为一旦放入 Map 后对象的 hashCode 发生改变就永远没有办法通过原来的 key 在 Map 里找到对应的 value 了。通俗一点说如果你把一个对象塞进 HashMap 之后再修改它的内容导致 hashCode 变化那么这个 key 就“丢了”取不出来内存里白白挂着一个永远访问不到的数据直到整个 Map 被回收。这个问题在项目里真的很常见属于隐蔽度极高的一类 bug。5. 集合遍历删除与并发修改异常5.1 ConcurrentModificationException 的成因在我印象里面试题库里最常出现的一个实战问题是“在遍历 ArrayList 时删除元素为什么会抛 ConcurrentModificationException怎么正确删除”先解释异常成因。ArrayList 内部有一个 modCount 字段记录结构性修改次数。当创建迭代器时迭代器会保存这个 modCount 的副本 expectedModCount。每次调用 iterator.next() 时都会检查 modCount 是否等于 expectedModCount如果不相等直接抛出 ConcurrentModificationException。所以你在循环里调用 list.remove() 时modCount 增加了但迭代器的 expectedModCount 没变下一次 next() 就炸了。常见的错误写法是这样的ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛出 ConcurrentModificationException } } for (int i 0; i list.size(); i) { if (b.equals(list.get(i))) { list.remove(i); // 不报错但必须自己控制下标偏移 } }5.2 四种正确删除元素的方式第一种方式使用迭代器自身的 remove 方法。这个方法会同步修改 expectedModCount所以不会触发异常。IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (b.equals(s)) { it.remove(); } }第二种方式使用 JDK 8 的 removeIf这也是我最推荐的写法代码简洁且语义清晰。list.removeIf(s - b.equals(s));第三种方式倒序 for 循环删除。因为只删除当前下标后边的元素往前移动你从尾部往头部遍历前面的元素不会受影响。for (int i list.size() - 1; i 0; i--) { if (b.equals(list.get(i))) { list.remove(i); } }第四种方式用普通 for 循环正序删除但删除后执行 i-- 修正下标。这种方式容易忘记写 i--我不太推荐。另外CopyOnWriteArrayList 这种并发容器的迭代器可以支持在遍历时调用 list.remove()因为它的迭代器操作的是快照数据修改操作作用于新的数组副本。但这也意味着它的写操作成本很高每次修改都要复制整个底层数组。适用于读多写少的场景比如白名单、配置项列表频繁遍历但很少变更。6. 并发集合与线程安全工具6.1 CopyOnWriteArrayList 与 CopyOnWriteArraySet这几个类通常被称为“并发容器”。在我经历的不少并发项目里它们的出镜率很高。CopyOnWriteArrayList 的核心思想是“写时复制”。每次 add、set、remove 时它都会加锁并复制一份底层数组在新副本上修改修改完成后再把内部引用指向新数组。读操作不加锁直接读当前数组引用指向的内容。这个设计带来两个特性第一读操作的性能极高完全无锁第二迭代器是弱一致性的创建迭代器时它指向的是当时的数组快照即使后续发生修改迭代器仍然可以继续遍历旧数据不会抛 ConcurrentModificationException。它特别适合读多写少的场景。如果写操作非常频繁每次写都复制整个数组内存和性能开销巨大就算不是全量拷贝几十万条数据也会让你明显感觉到压力。这一点我提醒过团队不少次用之前一定先评估数据量级与写入频率。CopyOnWriteArraySet 就更简单了底层就是一个 CopyOnWriteArrayList通过 addIfAbsent 实现去重。它也适用于读多写少且需要去重的并发场景。6.2 阻塞队列 BlockingQueue如果涉及生产者消费者模型BlockingQueue 是首选。它是一组线程安全的队列接口核心方法有 put、take、offer、poll。put 在队列满时会阻塞take 在队列空时会阻塞实现了天然的线程间协调与流量削峰。常见的实现类包括 ArrayBlockingQueue有界数组队列、LinkedBlockingQueue可指定大小的链表队列、SynchronousQueue不存储元素直接把任务从生产者交给消费者、PriorityBlockingQueue支持优先级排序的无界队列。以有界队列为例当生产者速度远超消费者时队列会积压元素达到容量上限后新的入队操作会阻塞生产者线程相当于给生产者背压防止系统内存被无界堆积的任务撑爆。这个机制在消息中间件客户端、线程池的任务队列中都有应用。理解 BlockingQueue 比单纯背“ArrayBlockingQueue 是有界的LinkedBlockingQueue 可以有界可以无界”更有价值。提到的线程池也很有趣线程池内部维护一个 BlockingQueue 作为任务队列。不同线程池类型对应不同队列策略固定线程池用 LinkedBlockingQueue缓存线程池用 SynchronousQueue带定时任务的线程池用 DelayedWorkQueue。你看集合知识其实已经渗透到了并发编程的每一个角落。6.3 Collections 工具类的同步包装方法Collections 工具类提供一组 synchronized 开头的包装方法比如 synchronizedList、synchronizedSet、synchronizedMap。它们返回的集合会在每个方法上用包装对象内部的一把互斥锁做同步。这种方式实现简单但性能不高因为所有的读写都串行化了。如果对并发性能有要求还是应该针对具体场景选择 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 等并发容器。另外这些包装集合的迭代器是 fail-fast 的如果迭代过程中其他线程修改了集合同样会抛 ConcurrentModificationException。要保证迭代期间安全必须手动在迭代代码块上加锁锁对象用包装集合本身。ListString syncList Collections.synchronizedList(new ArrayList()); synchronized (syncList) { IteratorString it syncList.iterator(); while (it.hasNext()) { System.out.println(it.next()); } }7. Java 8 集合 API 增强与 Stream 实战7.1 默认方法与常用新特性JDK 8 给集合接口增加了许多默认方法这波更新可以说极大提升了日常开发效率。我挑重点分享一下实际使用中的高频方法。Map 接口新增的 computeIfAbsent 在实现“按 key 分组构建缓存”时特别好用。比如要从数据库查出用户列表再按部门 ID 分组传统写法还得先判断 key 是否存在再初始化 ListcomputeIfAbsent 一行就能搞定。MapInteger, ListString byDept new HashMap(); for (User u : users) { byDept.computeIfAbsent(u.getDeptId(), k - new ArrayList()).add(u.getName()); }这句代码的意思很简单如果 deptId 在 Map 里不存在就执行后面的 Lambda 创建一个新 ArrayList 放进去不管是否存在都会把当前用户的名字 add 进去。这个写法在统计、缓存、分组等场景里能省下不少样板代码。List 接口也有两个很有用的默认方法sort 用于排序replaceAll 用于批量替换元素值。下面是一个典型场景给一批商品统一打 8 折。ListDouble prices new ArrayList(Arrays.asList(10.0, 20.5, 30.0)); prices.replaceAll(p - p * 0.8);7.2 Stream 流式操作:筛选、转换、分组与排序Stream 是 Java 8 对整个集合生态最有冲击力的一次升级。它不再强调“怎么存”而是强调“怎么处理”。它的设计思路其实很像流水线一个操作接一个操作数据像流水一样流过各个处理环节。举一个实际例子。有一批订单需要筛选出金额大于 100 的订单按用户 ID 分组每组按金额降序排序最后打印出每个用户下单总金额。MapInteger, Double result orders.stream() .filter(o - o.getAmount() 100) .sorted(Comparator.comparingDouble(Order::getAmount).reversed()) .collect(Collectors.groupingBy(Order::getUserId, Collectors.summingDouble(Order::getAmount))); result.forEach((uid, total) - System.out.println(uid : total));这里 filter 负责筛选sorted 负责排序groupingBy 负责分组summingDouble 负责统计。每一步操作语义明确代码量简化了一大截。如果是用传统的 for if Map 写法至少要多写 10 行。这就是 Stream 最核心的吸引力。Stream 还支持 parallelStream 并行流。执行并行流时数据会被自动切分交给 ForkJoinPool 中的多个线程并行处理。这里对齐说一下坑并行流虽然看起来能加快处理速度但并不是万能的。数据量小时线程切换开销可能超过并行收益存在共享可变状态时又会引入线程安全问题。我实测过一个几十万条数据的处理任务parallelStream 确实能逼近线性加速但对一个只有一两千条数据的 list 用 parallelStream反而比串行慢。所以不要为了“看起来高级”就盲目并行先测数据量级再说。7.3 Optional 避免空指针Optional 本身不是集合但它和集合操作搭配很好用。比如从 Map 里取一个可能不存在的值再对这个值做进一步操作传统写法要先判空再调用写起来很啰嗦。MapString, User userMap ...; Optional.ofNullable(userMap.get(zhangsan)) .map(User::getAddress) .ifPresent(System.out::println);这样的话就不需要层层写 if (xxx ! null)。需要注意Optional 本质上只是包装类如果源头数据就是 null把它赋给 Optional 变量仍然可能 NPE。正确用法是把可能为空的返回值包起来处理而不是用来塞 null 值。8. 集合性能优化与典型问题速查表8.1 初始化容量与性能调优集合性能优化最立竿见影的一条就是初始化容量。前面提过 ArrayList 扩容和 HashMap 扩容都会消耗资源。如果你能预估数据量在创建集合时就指定容量可以显著降低扩容带来的性能损耗。对于 HashMap如果预先知道要存 1000 个元素初始容量该设置多少1000 除以负载因子 0.75 约等于 1333.33向上取整得到 1334HashMap 内部会自动计算成不小于这个数的 2 的幂也就是 2048。直接设置 new HashMap(1334) 是可以的不想算的话也可以直接设置 new HashMap(1000 * 2)保险起见给足余量。真实项目里我从数据库拿到一批数据要组装成 Map 时就喜欢先看 list.size()然后 new HashMap(list.size() * 2)实测扩容次数明显减少。8.2 集合嵌套与拆装陷阱嵌套集合是另一个容易出现隐藏问题的地方。比如构建一个 MapString, List 在 put 时如果 key 不存在就得先 put 一个空 List 再 add。很多同学第一版代码会写成if (!map.containsKey(key)) { map.put(key, new ArrayList()); } map.get(key).add(value);这个代码有两个问题一则并发环境下 containsKey 和 put 之间不是原子操作二则代码冗长。我推荐用前面讲过的 computeIfAbsent一行搞定。还有一个常见套路是构建双向映射既能通过 ID 查名称又能通过名称查 ID。这时不要让两边的 Map 各自维护建议封装到一个工具方法里保证更新时两边同时更新避免数据不一致。8.3 常用集合问题与解决方案速查表最后把我这些年项目里经常遇到的集合相关问题和排查思路整理成一张速查表直接备忘。问题现象可能原因解决方案ArrayList 遍历时删除抛 ConcurrentModificationException迭代器与直接修改冲突removeIf / iterator.remove() / 倒序遍历HashSet 里出现“重复”元素equals 或 hashCode 重写不规范同时重写 equals 和 hashCodeTreeSet 丢掉部分元素比较器把不同对象判为相同比较规则覆盖所有需要区分的字段HashMap 并发下数据丢失多线程同时 put 导致覆盖改用 ConcurrentHashMap旧 Map 中找不到已插入的 key可变对象的 hashCode 变化不用可变对象作 key集合占用内存过高容量设置过大且长期不缩容合理设置初始容量及时 clear 或重建集合LRU 缓存实现困难不知道用 LinkedHashMap 访问序new LinkedHashMap(16, 0.75f, true) 重写 removeEldestEntry大列表反复扩容耗时未预估初始数据量new ArrayList(预估大小)自定义对象放入 List 去重失败List 不去重用 HashSet 中转去重8.4 关于 Java 版本迭代的适应建议现在很多项目已经升级到 JDK 17 甚至更高版本也有一些 8。无论什么版本集合知识的内核是稳定的数据结构决定操作效率不可变性保证安全顶层接口约束行为语义。JDK 9 之后提供的 List.of、Map.of 返回不可变集合配合集合拷贝构造方法可以方便地防御外部修改。我在对外提供接口时凡是返回集合数据只要确定可以被外部持有且不允许修改就优先返回不可变集合。比如返回一个空集合用 Collections.emptyList() 或 List.of()比返回 new ArrayList() 少了一次对象分配也更安全。对于刚入门 Java 的朋友我的建议是不要死记硬背各类 API而是先去理解。理解数据结构背后的时间复杂度和空间复杂度理解接口隔离的设计意图理解并发场景对容器的要求。有了这条主线无论 JDK 怎么迭代你都能快速适应。9. 面试高频追问与回答思路9.1 高频问题真题拆解最后这部分我把 Java 集合相关的面试高频题过一遍。我不仅会列出问题还会讲清楚面试官到底想听到什么层次的内容。第一题ArrayList 和 LinkedList 的区别。初级回答是“数组 vs 链表”能及格高级回答需要补充ArrayList 支持 O(1) 随机访问扩容是 1.5 倍尾部插入在无扩容时 O(1)中间插入需要搬移元素LinkedList 支持 O(1) 的头部和尾部插入删除遍历时支持双向迭代随机访问 O(n)。还可以补充一个关键场景在需要频繁插入删除时LinkedList 不一定是正确答案因为在中间插入时虽然节点操作是 O(1)但定位到插入位置仍然需要 O(n) 的遍历。ArrayList 也并非只能用于查询如果数据量小且插入集中在尾部ArrayList 也可能更快。能讲到这个深度说明你真的理解了。第二题HashMap 的 put 过程。完整的回答链路是计算 key 的 hash扰动函数→ 计算桶索引 → 检查桶是否为空为空直接插入 → 不为空则判断 key 是否存在存在则覆盖 value → 存在但 key 不同则插入链表节点或加入树节点 → 检查链表长度是否超过 8 且数组长度超过 64是则转红黑树 → 检查元素总数是否超过阈值是则扩容。每一步背后的原因都要能展开。第三题HashMap 和 Hashtable 的区别。常规答案HashMap 允许 null key 和 null valueHashtable 不允许HashMap 线程不安全Hashtable 线程安全但性能差HashMap 迭代器是 fail-fastHashtable 的枚举不是。我建议还要补充HashMap 在无并发修改时是首选Hashtable 已经被 ConcurrentHashMap 取代不建议在新代码中使用。第四题什么是 fail-fast 和 fail-safe。fail-fast 指迭代器在迭代过程中检测到集合被修改就立即抛出异常像 ArrayList、HashMap 的迭代器都是这种模式。fail-safe 指迭代器不直接访问集合本身而是访问集合的拷贝像 CopyOnWriteArrayList、ConcurrentHashMap 的迭代器。fail-safe 不会抛异常但无法保证看到最新数据。第五题HashSet 怎么保证元素不重复。通过 HashMap 的 key 唯一性实现底层依赖 hashCode 定位、equals 比较两个方法必须配套重写。9.2 如何从源码角度回答追问面试官往往不满足于表面结论会接着追问“你怎么知道的”或“源码在哪体现的”。这需要具备一定的源码阅读能力。我的建议是不要通读整个类太耗时也没必要重点看四个方面类的继承关系、核心字段、关键方法的实现add、put、get、remove、resize、扩容和哈希相关的私有方法。举个例子HashMap 的 putVal 方法里有一段重要的逻辑if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null); else { // 处理哈希冲突 }看懂这段就知道桶索引是怎么算出来的。再看 resize 方法里关于 2 次幂扩容时元素迁移的判断if ((e.hash oldCap) 0) { // 留在原位置 } else { // 移动到 原位置 oldCap }这行代码是 JDK 8 扩容优化最关键的部分。你在面试时能把这两段代码的逻辑讲出来面试官基本就会认定你真正在源码层面研究过集合底层。9.3 手写代码题的常见考法与应对策略Java 集合相关的面试常常有手写题。我最常被问到的一类是实现一个简单的 LRU 缓存。标准做法就是基于 LinkedHashMapclass LRUCache extends LinkedHashMapInteger, Integer { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryInteger, Integer eldest) { return size() capacity; } }这类题目的考察点不是“你会不会调库”而是你是否理解 LinkedHashMap 的 accessOrder 属性和 removeEldestEntry 钩子方法。如果你能补充说明“super(capacity, 0.75f, true) 中第三个参数 true 表示按访问顺序而非插入顺序”然后解释为什么这样就能实现 LRU这个题基本就拿下了。还有一类手写题是“合并两个有序链表”或“数组去重后保持顺序”这类问题本身不复杂但要注意边界条件。我建议面试前把这些基础操作多看几遍因为写代码时最常见的失误就是 index 越界和空指针。10. 关于集合学习的最后建议唠叨了这么多最后分享一点我对 Java 集合学习路径的真实看法。从一开始其实最怕的就是陷入“背 API”的死循环。Java 集合 API 确实很丰富但 80% 的开发场景只围绕十几个类和几十个方法打转。所以与其背方法列表不如先吃透数据结构基础。数组、链表、哈希表、红黑树这四种结构的时间复杂度特性决定了 Java 集合所有实现类的命运。比如你理解了哈希表的基本原理再看 HashMap 的扩容和冲突解决就完全不需要死记硬背理解了红黑树的性质再看 TreeMap 的自动排序和查找性能也顺理成章。数据结构学得越扎实集合学得就越轻松。其次多练习源码阅读。不必一口气读完整个 HashMap那样很枯燥容易劝退。我的习惯是每次遇到一个集合相关的线上问题就顺着问题去源码里找答案。比如线上出现了 OOM那很可能是集合无限制增长导致的这时候我们就去翻源码看看是哪个集合类的哪个方法在不断添加数据最终定位根因。带着问题读源码效率最高记忆也最深刻。最后一定要动手写代码去验证结论。网上说 ArrayList 扩容是 1.5 倍你可以写一段程序打印出当前容量来验证说 HashMap 链表长度到 8 转红黑树你可以构造 8 个哈希值相同的 key 来观察树化行为。动手验证过的知识点才是真正属于你自己的知识。Java 集合是 Java 开发者的基本功往小了说它影响每一个 CRUD 接口的数据组装方式往大了说它决定了系统的性能与稳定性。把这些知识沉淀下来你自己的代码质量和对 Java 的整体理解都会明显上升一个台阶。希望这篇文章能成为你随时翻阅的一本参考手册。
返回列表