☰
Java集合框架详解:从ArrayList到HashMap的选型与源码分析
2026/9/30 4:02:55 网站建设 项目流程

学过 Java 的人应该都听过这句话:Java 集合框架(Java Collections Framework,JCF)是 Java 语言的基石之一。但在 javase 学习阶段,很多人对“集合”这个概念其实是模糊的——它和数组有什么区别?为什么有了数组还要有集合?ArrayList 和 HashMap 到底怎么选?这些问题如果没想清楚,后面读源码、刷面试题、做项目都会觉得隔了一层。

我当年学 javase 到集合这一块时,最大的感受是:集合不是“一种东西”,而是一整套解决“数据怎么存、怎么取、怎么管理”的方案。这篇文章就围绕“集合到底是什么”这个核心问题,把我自己的理解、实践过的代码、踩过的坑一次性讲透。

这篇内容适合三类人:正在学 javase 的初学者,准备 Java 面试的求职者,以及想系统梳理集合知识体系的开发者。我会从集合的本质讲起,逐步拆解 Collection 和 Map 两大体系,再用大量代码和场景告诉你“什么时候该用哪个”,最后把面试高频问题一并梳理。

1. 集合出现的真正原因:数组不够用

1.1 数组的三个“硬伤”

很多教材讲集合时,开头都会说“数组长度固定,无法动态扩展,所以需要集合”。这话对,但不全对。我用自己的话说清楚数组到底哪里不好用。

第一个硬伤,长度不可变。定义一个String[] arr = new String[10],这辈子它最多装 10 个元素。程序跑起来之后,你根本不知道用户会输入多少条数据,10 个不够,100 个也不够,怎么办?自己写一个“扩容”方法?可以,但每次扩容都要新建数组、拷贝元素、回收旧数组,这个动作做多了,代码里全是这种胶水逻辑,真正的业务逻辑反而被淹没了。

第二个硬伤,增删元素太痛苦。数组在内存里是连续空间,想在中间插入一个元素,得把后面的元素全部往后挪;想删掉一个元素,得把后面的元素全部往前挪。数组越长,挪的成本越高。而且这个“挪”的细节必须由程序员自己控制,稍不注意数组下标就越界。

第三个硬伤,数组没有“按内容查找”的能力。你想知道数组里有没有一个叫 “张三” 的字符串,只能写循环遍历,一个个 equals 比较。数据量小还好,数据量一大,这个 O(n) 的线性扫描就是性能瓶颈。

这三个问题不是 Java 独有的,所有编程语言都遇到过。C 语言里有链表、有结构体,程序员得自己造轮子;Java 的设计者干脆把常用数据结构都封装好了,这就是集合框架的由来。

1.2 集合到底解决什么问题

理解了数组的痛点,集合的价值就清楚了。我总结成一句话:集合是“更高级的数组”,它在数组之上提供了动态扩容、灵活增删、高效查找、自动管理等一系列能力。

具体来说,集合解决四类问题:

  • 容量问题:集合不需要预定义大小,能自动扩容。ArrayList 内部是数组,但它在 add 的时候发现容量不够,会自动触发 grow 方法,把数组扩大 1.5 倍(源码里是oldCapacity + (oldCapacity >> 1))。
  • 数据组织问题:你需要“先进先出”的队列,有 ArrayDeque、LinkedList;需要“后进先出”的栈,有 Stack、ArrayDeque;需要“键值对映射”,有 HashMap、TreeMap。集合框架把常见数据结构都做好了,你只管用。
  • 查找效率问题:HashSet、HashMap 基于哈希表,查找时间复杂度是 O(1),比数组的 O(n) 线性扫描快几个数量级。TreeSet、TreeMap 基于红黑树,查找是 O(log n),还自带排序能力。
  • 类型安全问题:Java 5 引入泛型之后,集合可以限定元素类型。List<String>就保证这个列表里只能放 String,编译期就帮你挡住 ClassCastException。

所以你在 javase 里学集合,本质上学的不是“几个类怎么调用”,而是数据结构在 Java 里的落地形态。理解了这一点,后面的东西就都不是死记硬背。

1.3 集合框架的整体格局

Java 集合框架从顶层看就两大体系:Collection和Map。Collection 之下又分 List、Set、Queue 三大子接口。这个继承关系我建议你一定要画一遍,画完就会发现它其实非常有逻辑。

Collection (接口) ├── List (接口): 有序、可重复 │ ├── ArrayList │ ├── LinkedList │ └── Vector (已较少使用) ├── Set (接口): 无序、不可重复 │ ├── HashSet │ │ └── LinkedHashSet │ └── TreeSet └── Queue (接口): 队列 ├── ArrayDeque └── LinkedList Map (接口): 键值对 ├── HashMap │ └── LinkedHashMap ├── TreeMap └── Hashtable (已较少使用)

Collection 存的是“单个元素”,Map 存的是“键值对”,这是两个体系最本质的区别。很多人一开始搞混List和Map,其实就是没想明白这句话:List 是一排数据,Map 是一张表。生活中到处都是 Map 的例子——手机通讯录(姓名→电话号码)、食堂菜单(菜名→价格)、身份证号→个人信息,全是键值对。

顺带说一句,Java 里的Map并不继承Collection。虽然 Map 也属于集合框架,但它和 Collection 是平级的两个顶层接口。这个细节在面试里偶尔会被问到,属于“基础中的细节”。

2. List 体系:有序数据的最佳选择

2.1 ArrayList:日常开发的主力军

ArrayList 是实际项目里用得最多的集合类,没有之一。它的底层就是一个 Object 数组,所有操作本质上都是对这个数组的操作。

我贴一段最核心的扩容逻辑让大家感受一下:

// ArrayList 源码中 add 方法的关键路径 public boolean add(E e) { modCount++; add(e, elementData, size); return true; } private void add(E e, Object[] elementData, int s) { if (s == elementData.length) { elementData = grow(); } elementData[s] = e; size = s + 1; } private Object[] grow() { return grow(size + 1); } private Object[] grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容 1.5 倍 // ... 最终用 Arrays.copyOf 拷贝 return elementData = Arrays.copyOf(elementData, newCapacity); }

注意看,Java 8 之后的 ArrayList 扩容策略是扩容为原来的 1.5 倍(oldCapacity + (oldCapacity >> 1))。为什么是 1.5 倍而不是 2 倍?这是空间和时间的折中:扩得倍数小,内存浪费少,但扩容次数多;倍数大,扩容次数少,但浪费多。JDK 团队的工程师最终选了 1.5,这个数值是经过大量实践验证的。

实际开发中,如果你能预估数据规模,建议直接调用new ArrayList<>(expectedSize)指定初始容量。比如你知道大概要装 1000 条数据,就别让它从 10 开始一次次扩容,省去中间多次数组拷贝的开销。这是很多新人不会注意的性能细节。

2.2 LinkedList:双向链表的真实面目

LinkedList 底层是双向链表,每个节点(Node)持有三个字段:item(数据)、next(后继节点)、prev(前驱节点)。我放一个简化版的内部结构:

private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }

正因为是链表结构,LinkedList 在头部插入和头部删除上非常快——只需要改几个指针引用,不需要像 ArrayList 那样搬移元素。它的 addFirst、removeFirst、addLast、removeLast 方法都是 O(1)。

但链表的缺点也明显:随机访问慢。list.get(100)需要从头节点开始往后数 100 次,时间复杂度 O(n)。ArrayList 的get(100)是直接通过下标定位数组元素,O(1)。

所以网上很多文章争论“ArrayList 和 LinkedList 谁快”,其实没有绝对答案:

操作ArrayListLinkedList
get(中间位置)O(1),极快O(n),需要遍历
add(尾部)均摊 O(1),可能触发扩容O(1)
add(头部)O(n),所有元素后移O(1),改指针即可
add(中间位置)O(n),需要搬移后半段O(n),但只需改指针,不需要物理搬移
内存占用连续数组,省内存每个节点多两个引用,占内存更多

我个人的实践建议是:95% 的场景用 ArrayList。LinkedList 真正有优势的场景只有“频繁在头部插入或删除”和“实现了 Deque 双端队列接口需要当队列/栈用”。其余情况,ArrayList 的缓存友好性(连续内存)和随机访问性能都更优。很多人做 LeetCode 题喜欢用 LinkedList 当栈,其实 ArrayDeque 在多数场景下都比它更合适,这个后面会讲。

2.3 Vector 和 Stack:历史遗留,知道就好

Vector 是 JDK 1.0 就有的老类,方法加了 synchronized 同步锁,所以线程安全,但代价是性能低。Stack 继承自 Vector,是“后进先出”栈的经典实现。

不过在实际开发里,这两个类基本可以打入冷宫了。原因有二:第一,需要线程安全时,有Collections.synchronizedList(new ArrayList<>())和CopyOnWriteArrayList这些更好的选择;第二,Stack 对同步的依赖太重,而 Java 官方文档自己也推荐用ArrayDeque来实现栈的功能。

// 用 ArrayDeque 替代 Stack Deque<String> stack = new ArrayDeque<>(); stack.push("a"); // 入栈 stack.push("b"); String top = stack.pop(); // 出栈,得到 "b"

这里顺带提醒一个新手容易犯的错:Deque接口既支持“先进先出”的队列操作(offer/poll),也支持“后进先出”的栈操作(push/pop),用 push/pop 就是栈,用 offer/poll 就是队列,别混用即可。

3. Set 体系:去重与集合运算的利器

3.1 HashSet:底层是 HashMap,这一点要刻在脑子里

第一次看到 HashSet 源码时我有点震惊——它内部直接持有一个 HashMap:

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); } public boolean add(E e) { return map.put(e, PRESENT) == null; } }

也就是说,HashSet 存元素时,其实是把元素作为 HashMap 的 key 存进去,value 统一用一个占位对象 PRESENT。因为 HashMap 的 key 不能重复,所以 HashSet 天然实现了“去重”功能。

这个设计给我的启发是:Java 集合框架里很多类不是从零造的,而是组合复用已有的类。你在学习时如果发现某个类内部持有了另一个类的实例,别觉得奇怪,这正是 Java 设计者追求代码复用的体现。

HashSet 的去重依赖两个方法:hashCode()和equals()。当你往 HashSet 里 add 一个对象时,流程是这样的:

  1. 调用对象的hashCode()计算出哈希值,定位到哈希桶的位置。
  2. 如果该桶位为空,直接放入,add 成功。
  3. 如果该桶位已有元素,再用equals()逐个比较,看是否真的相等。如果相等,add 失败,不放入;如果不等,发生哈希冲突,用链表或红黑树挂上去。

所以,如果你自定义了一个类,想用它做 HashSet 去重,就必须同时重写 hashCode 和 equals。只重写 equals 不重写 hashCode,会导致两个“逻辑上相等”的对象被散列到不同的桶,去重失效;只重写 hashCode 不重写 equals,则可能出现“哈希值相同但内容不同”的对象被误判为重复。这就是所谓的“约定”:equals 相等的两个对象,hashCode 必须相同。

3.2 TreeSet:排序自动完成

TreeSet 的底层是 TreeMap,也就是红黑树。它的最大特点是:元素自动按自然顺序(或你指定的 Comparator 顺序)排序。

TreeSet<Integer> set = new TreeSet<>(); set.add(5); set.add(3); set.add(8); set.add(1); System.out.println(set); // 输出 [1, 3, 5, 8],已经排好序

注意几个关键约束:

  • 元素必须实现Comparable接口,或者在创建 TreeSet 时传入Comparator,否则 add 时会抛ClassCastException。
  • TreeSet 判断元素是否重复,用的是compareTo或compare方法,不是 equals。也就是说,只要compareTo返回 0,就视为重复,add 失败。
  • 所有基于 Tree 的集合(TreeSet、TreeMap)的增删查都是 O(log n),比 HashSet 的 O(1) 慢,但支持有序性操作,比如first()、last()、subSet(from, to)这些范围查询。

实际业务里,TreeSet 最典型的用途是:维护一个始终有序的集合,比如排行榜、按时间排序的事件队列。如果只是“去重”而不要求排序,用 HashSet;如果“既要排序又要去重”,用 TreeSet;如果“保持插入顺序且去重”,用 LinkedHashSet。

3.3 LinkedHashSet:保持插入顺序的 HashSet

LinkedHashSet 是 HashSet 的子类,额外维护了一个双向链表来记录元素的插入顺序。它的特点是:去重能力和 HashSet 一样(底层还是 HashMap),但迭代顺序是插入顺序。

我举个实际场景。比如你有一个用户 ID 列表,里面有重复,现在要“去掉重复且保持原顺序”。用 HashSet 去重后顺序会乱,用 LinkedHashSet 就能完美保留:

List<String> ids = Arrays.asList("u3", "u1", "u2", "u1", "u3", "u4"); LinkedHashSet<String> unique = new LinkedHashSet<>(ids); System.out.println(unique); // [u3, u1, u2, u4],去重且保持原顺序

这个细节在面试里偶尔被考到:HashSet、LinkedHashSet、TreeSet 三者的区别。标准答案就是:HashSet 无序、LinkedHashSet 保持插入顺序、TreeSet 自动排序,它们的底层分别是 HashMap、HashMap+链表、TreeMap。

4. Map 体系:键值对的艺术

4.1 HashMap:加载因子、哈希冲突与红黑树

如果说 ArrayList 是集合框架里最常用的类,那 HashMap 就是最重要、最值得深入的一个。它的底层结构是“数组 + 链表 + 红黑树”(JDK 8 之后)。

先看几个关键参数,这些是面试官最爱问的:

  • 默认容量:16。也就是散列桶数组初始大小。
  • 加载因子(load factor):0.75。当元素个数超过容量 × 0.75时,触发扩容。
  • 树化阈值:8。当某个桶位的链表长度超过 8 且总容量达到 64 时,链表转为红黑树。
  • 反树化阈值:6。当红黑树的节点数降到 6 以下时,变回链表。

为什么加载因子是 0.75?这是空间和时间的平衡。太小(比如 0.5)会导致扩容太频繁,浪费内存;太大(比如 1)会让哈希冲突严重,链表变长,查找变慢。0.75 是 JDK 团队在大量 benchmark 后选出的一个折中值。这个知识点,面试官很喜欢让你“分析为什么”,你要能说出“空间时间折中”这个核心逻辑。

HashMap 的 put 流程我拆解一下:

  1. 计算 key 的 hash 值:(h = key.hashCode()) ^ (h >>> 16),让高位也参与低位运算,降低哈希碰撞概率。
  2. 用(n - 1) & hash定位桶下标(n 是数组长度,这里用位运算替代取模,效率更高,前提是 n 是 2 的幂)。
  3. 如果桶为空,直接放新节点。
  4. 如果桶不为空,遍历链表/红黑树:找到相同的 key 就覆盖 value;找不到就尾插新节点。
  5. 如果链表长度达到树化阈值 8 且容量达到 64,转为红黑树。

这里有个很关键的点:HashMap 允许 key 为 null。null 的 hash 值固定为 0,所以 null 永远放在桶下标 0 的位置。Hashtable 不允许 null 键,这也是它们的一个区别。

当并发场景里多个线程同时 put 时,HashMap 并不安全。JDK 7 里并发 put 可能导致链表成环,get 的时候死循环;JDK 8 虽然改成尾插法修了成环问题,但并发下仍可能丢数据。所以多线程环境必须用ConcurrentHashMap,别指望 HashMap 有什么线程安全措施。

4.2 TreeMap:键自动排序的映射表

TreeMap 底层是红黑树,key 按照自然顺序或自定义 Comparator 排序。它和 TreeSet 的关系,就像 HashMap 和 HashSet 的关系:TreeSet 内部其实就是一个 TreeMap。

TreeMap 的核心价值在于“有序的键值对”。比如:

TreeMap<Integer, String> map = new TreeMap<>(); map.put(3, "three"); map.put(1, "one"); map.put(2, "two"); System.out.println(map); // {1=one, 2=two, 3=three},按键升序 System.out.println(map.firstKey()); // 1 System.out.println(map.lastKey()); // 3 System.out.println(map.ceilingKey(2)); // 2,返回 >= 2 的最小键 System.out.println(map.floorKey(2)); // 2,返回 <= 2 的最大键

这种“范围查询”能力,在实现区间统计、日程安排、IP 段匹配等场景非常有用。但代价是写入性能比 HashMap 慢,毕竟是树结构,每次插入都要维护红黑树的平衡。

4.3 LinkedHashMap:能维持插入顺序的 Map

LinkedHashMap 继承自 HashMap,额外维护双向链表记录插入顺序(或访问顺序)。它最经典的应用是实现 LRU 缓存(Least Recently Used,最近最少使用)。

实现 LRU 的关键是开启 accessOrder 模式,并重写 removeEldestEntry 方法:

class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); // 第三个参数 true 表示按访问顺序 this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }

当元素个数超过容量时,removeEldestEntry返回 true,LinkedHashMap 就会自动移除最久未访问的节点。这是集合框架中“面向扩展设计”的一个典型例子,也是面试中“自己实现 LRU”这个高频题的标准解法之一。

4.4 Hashtable 和 ConcurrentHashMap:线程安全的正确打开方式

Hashtable 是 JDK 1.0 的老类,所有方法都用 synchronized 锁整个表。并发高的时候,所有线程抢同一把锁,性能极差,基本可以淘汰。

ConcurrentHashMap 是 Java 并发包(JUC)里的明星类,它用了锁分段/锁粒度更细的策略。JDK 8 之后的 ConcurrentHashMap 放弃了分段锁,改用CAS + synchronized 只锁单个桶的方式,并发度大幅提升。

用一句话总结选型:单线程用 HashMap,多线程用 ConcurrentHashMap,Hashtable 不要用。这是面试必考结论,也是实际开发的铁律。

5. Queue 与 Deque:排队与双端操作

5.1 Queue:先进先出

Queue 接口定义了队列的基本操作:

  • add(e)/offer(e):入队。add 失败抛异常,offer 返回 false。
  • remove()/poll():出队。remove 失败抛异常,poll 返回 null。
  • element()/peek():查看队首元素。element 失败抛异常,peek 返回 null。

为什么同一件事搞两个方法?这是为了区分“有容量限制的队列”和“无容量限制的队列”。比如ArrayBlockingQueue有容量上限,offer 时如果队列已满不会抛异常而是返回 false,这样更温和,方便做条件判断。实际开发中,非阻塞场景推荐用 offer/poll/peek 这一组,因为它不会因为异常打断流程。

5.2 Deque:两头都能进出

Deque 是 double-ended queue 的缩写,双端队列,支持在头部和尾部同时入队/出队。ArrayDeque 是它的主要实现。

我特别想强调的一点是:当栈用,优先用 ArrayDeque,而不是 Stack。原因有两个:一是 Stack 继承 Vector,方法带同步锁,性能差;二是 ArrayDeque 的 API 更丰富,push/pop 用起来和栈完全一样。Java 官方文档明确建议优先使用 Deque。

ArrayDeque 的底层是循环数组,扩容时按 2 的幂增长。它的迭代器是 fail-fast 的——如果在迭代过程中修改了队列结构,会抛出ConcurrentModificationException。这个机制在后面讲遍历时要重点注意。

LinkedList 也实现了 Deque 接口,所以它既能当链表用,也能当队列和栈用。但综合性能来说,单纯用队列/栈功能时 ArrayDeque 通常优于 LinkedList,因为数组的局部性更友好,内存访问更连续。

6. 集合的遍历:for 循环之外的选择

6.1 三种遍历方式的区别

Java 中遍历集合主要有三种方式:普通 for 循环、增强 for(foreach)、Iterator 迭代器。很多人只会在 foreach 里打遍历,却不知道三者之间的微妙差异。

普通 for 循环:适合有索引的集合,比如 List。通过get(i)逐个访问,灵活度最高,但 Set 这种无序集合无法用这种方式。

增强 for 循环:本质是语法糖,编译后底层就是 Iterator。写法简洁,适合读操作。但它有一个隐性问题:不能在循环体内部直接调用集合的 remove 方法,否则抛ConcurrentModificationException。

// 这种写法会抛 ConcurrentModificationException List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (String s : list) { if (s.equals("b")) { list.remove(s); // 编译通过,运行报错 } }

Iterator 迭代器:允许在遍历过程中安全删除当前元素,只要调用的是iterator.remove()而不是集合自己的 remove。

Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("b")) { it.remove(); // 安全删除 } }

还有一种很实用的写法是用removeIf方法,Java 8 之后引入,一行搞定:

list.removeIf(s -> s.equals("b"));

它内部就是迭代器加删除,且没有任何并发问题。做“边遍历边删”操作时,我优先推荐 removeIf。

6.2 fail-fast 机制到底是什么

提到ConcurrentModificationException,就必须说 fail-fast(快速失败)机制。它的核心思想是:在迭代过程中,如果结构被修改,就立即抛异常,而不是等到错误扩散后再处理。

原理是:集合内部维护了一个modCount字段,每次结构性修改(增、删、扩容)都会让 modCount 加 1。迭代器内部也保存了一个expectedModCount,每次next()时比较两者是否一致,不一致就抛异常。

// Iterator 源码中的核心检查 final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }

这个设计是为了在“单线程中自己误改集合”和“多线程中其他线程修改集合”两种情况下快速暴露问题,避免出现不可预期的错误结果。它不是用来保证并发安全的,只是“尽早发现问题”。

6.3 Stream 遍历:函数式风格的新选择

Java 8 之后,集合可以转成 Stream 进行更优雅的遍历和处理。下面这几种写法在开发中很常见:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); // 过滤 + 收集 List<String> filtered = names.stream() .filter(s -> s.length() > 3) .collect(Collectors.toList()); // 映射转换 List<Integer> lengths = names.stream() .map(String::length) .collect(Collectors.toList()); // 分组 Map<Integer, List<String>> groupByLength = names.stream() .collect(Collectors.groupingBy(String::length));

Stream 的优点是代码简洁、语义清晰、适合链式操作;缺点是调试不便,性能在某些场景下不如传统 for 循环(比如小数据集)。实际开发中,我个人的习惯是:简单遍历用 for 或 foreach,复杂数据处理链用 Stream。

7. 泛型与集合:一对拆不开的搭档

7.1 为什么集合一定要配泛型

Java 5 之前,集合是不带泛型的,存进去的都是 Object,取出时必须强转。这带来两个麻烦:一是代码啰嗦;二是类型不安全,稍一疏忽就ClassCastException。

泛型出现之后,集合能声明“这个列表是装 String 的”“这个 Map 的键是 Integer,值是 User 对象”,编译阶段就能发现类型错误:

// 编译期报错,根本运行不到 List<String> list = new ArrayList<>(); list.add(123); // error: incompatible types

从集合框架的角度看,泛型的本质是“类型参数化”。List<E>是泛型接口,E 在创建实例时被具体类型替代。理解这个“占位符→实际类型”的过程,是理解泛型集合的关键。

7.2 泛型通配符:? extends 和 ? super

面试里经常考? extends T和? super T的区别。简单记一个口诀:extends 限定上界,只读不写;super 限定下界,只写不读。

// 可以存任何 Fruit 的子类(包括 Fruit 自己),但取出来只能当 Fruit 用 List<? extends Fruit> fruits = new ArrayList<Apple>(); // fruits.add(new Apple()); // 编译错误,无法确认具体类型 // 可以存 Fruit 及其父类,但取出来只能当 Object 用 List<? super Fruit> fruits2 = new ArrayList<Object>(); fruits2.add(new Apple()); // 编译通过,Apple 是 Fruit 的子类

为什么extends不能 add?因为编译器只知道这个列表装的是“某个 Fruit 的子类”,但不确定具体是 Apple 还是 Banana,为了安全,只能禁止写入。而super不能安全读取,因为列表可能是List<Object>,取出来具体是什么类型编译器无法确定。

这个知识点看着绕,但你只要抓住“编译器必须保证类型安全”这条主线,逻辑就通了。

7.3 泛型擦除:运行时的真相

泛型信息只在编译期有效,运行时会擦除。List<String>和List<Integer>在运行时 Class 对象是一样的,都是ArrayList.class。这就是“类型擦除”(type erasure)。

List<String> list1 = new ArrayList<>(); List<Integer> list2 = new ArrayList<>(); System.out.println(list1.getClass() == list2.getClass()); // true

所以,你不能用list instanceof List<String>这样的写法来判断泛型类型。也正因如此,Java 的泛型不支持基本类型(int、double 等),只能包装类型(Integer、Double)。想装 int 就用List<Integer>,但注意自动装箱和拆箱会有少量性能开销。

8. Collections 工具类:集合操作的瑞士军刀

8.1 排序、查找、反转、洗牌

java.util.Collections是一个纯静态工具类,它提供了一堆操作集合的方法,真正常用的我列一下:

List<Integer> list = new ArrayList<>(Arrays.asList(5, 3, 8, 1)); Collections.sort(list); // [1, 3, 5, 8],升序排序 Collections.reverse(list); // [8, 5, 3, 1],反转 Collections.shuffle(list); // 随机洗牌 Collections.max(list); // 最大值 Collections.min(list); // 最小值 Collections.binarySearch(list, 3); // 二分查找,返回下标,前提是列表已排序 Collections.fill(list, 0); // 用 0 填充所有元素

特别注意binarySearch,它要求集合必须是已排序的,否则结果不可预期。这是工具方法使用上的一个隐藏坑。而sort方法要求 List 中的元素实现了 Comparable,或者你传入一个 Comparator。

8.2 不可变集合与线程安全包装

Collections 还能把一个可变集合包装成不可变集合或线程安全集合:

List<String> immutableList = Collections.unmodifiableList(list); // 只读 Set<String> synchronizedSet = Collections.synchronizedSet(set); // 线程安全

unmodifiableList包装后的集合,任何修改操作都会抛UnsupportedOperationException。这在防御性编程里很常用:对外暴露内部数据时,不要直接返回原始集合,返回它的只读视图,防止调用方意外修改。

不过要注意,Java 9 之后有了更简洁的创建不可变集合方式:

List<String> immutable = List.of("a", "b", "c"); Set<String> immutableSet = Set.of("a", "b"); Map<String, Integer> immutableMap = Map.of("a", 1, "b", 2);

List.of创建的集合不仅不可变,还拒绝 null 元素,在编写一些工具方法时非常推荐。

8.3 空集合与单例集合

还有一个经常被忽略的小技巧:返回空集合时,别用null,用Collections.emptyList()或Collections.emptyMap()。这样可以避免调用方反复做 null 判断,也符合“返回空集合而不是 null”的编程习惯。

List<User> users = queryFromDb(); // 若没有数据 return users != null ? users : Collections.emptyList();

同理,想返回“只包含一个元素的集合”,可以用Collections.singletonList(obj)。这几个 API 看着不起眼,但在公共方法设计、参数校验等场景特别实用。

9. 集合与数组的互转

9.1 数组转 List:Arrays.asList 的坑

数组转集合最常用的是Arrays.asList(),但这个方法有两个大坑:

第一个坑:返回的 List 是固定大小的,不能 add 和 remove,否则抛UnsupportedOperationException。因为它内部是一个“把数组当 List”的视图,底层还是原数组。

String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr); list.add("d"); // 抛 UnsupportedOperationException

第二个坑:asList 返回的列表中,元素与数组共享内存。改 list 里的元素,数组也会变;改数组,list 也变。这往往不是开发者想要的效果。

正确的转换方式是用流:

String[] arr = {"a", "b", "c"}; List<String> list = Arrays.stream(arr).collect(Collectors.toList());

这样得到的 ArrayList 是完全独立的、可自由增删的。

9.2 List 转数组

用list.toArray()会得到Object[];如果指定类型,用list.toArray(new String[0])或list.toArray(new String[list.size()])。

List<String> list = Arrays.asList("a", "b", "c"); String[] arr = list.toArray(new String[0]);

关于传new String[0]还是new String[list.size()],这曾经是个有争议的性能细节。JDK 8 之后,官方推荐传 0 长度的数组,因为源码里对空数组做了优化,性能反而更好。这是一个很小的点,但面试官偶尔会看你知不知道。

9.3 实际开发中的集合转换组合

在真实项目里,集合之间经常需要互相转换。最常见的组合就三组:

// List 转 Set(去重) List<String> list = Arrays.asList("a", "b", "a", "c"); Set<String> set = new HashSet<>(list); // [a, b, c] // Map 的键集合、值集合 Map<String, Integer> map = Map.of("a", 1, "b", 2); Set<String> keySet = map.keySet(); Collection<Integer> values = map.values(); // List 转 Map List<User> users = ...; Map<Integer, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, u -> u, (oldV, newV) -> newV));

第三个转换要特别注意:如果 List 里有重复的 key,Collectors.toMap默认会抛IllegalStateException,所以必须提供第三个参数(oldV, newV) -> newV来指定遇到重复 key 时保留哪个值。这是 stream 转 map 最常见的运行时报错点之一。

10. 集合的选型与性能对比

10.1 一张表看懂什么时候用哪个

我把 Java 集合框架中常用的类按“用途”做成一张速查表,这是我日常写代码时真正会去参考的东西:

需求推荐实现理由
有序列表,按下标访问ArrayList随机访问 O(1),内存连续
频繁在头部插入/删除ArrayDeque 或 LinkedList头部操作 O(1)
需要去重,无顺序要求HashSet基于 HashMap,O(1) 去重
需要去重,且保持插入顺序LinkedHashSet去重 + 双链表记录顺序
需要去重,且自动排序TreeSet红黑树,O(log n) 且有序
键值对,无顺序要求HashMapO(1) 读写,日常首选
键值对,按键排序TreeMap红黑树,支持范围查询
键值对,保持插入顺序LinkedHashMapHashMap + 链表
线程安全的键值对ConcurrentHashMapCAS + 锁桶,并发性能好
栈/队列ArrayDeque官方推荐,性能优于 Stack/LinkedList
只读、不可变数据List.of / Set.of / Map.of简洁高效,拒绝修改

这个表我建议直接收藏,或者自己画一遍加深印象。真正的“选型能力”不在背接口,而在根据场景匹配数据结构。

10.2 并发场景下的集合选择

多线程环境下,最常问的问题就是“HashMap 不安全,那用哪个?”除了前面说的 ConcurrentHashMap 外,针对 List、Set 也有对应方案:

  • CopyOnWriteArrayList:读多写少的场景,写时复制整个数组,读不加锁。适合缓存列表、白名单这类场景。
  • CopyOnWriteArraySet:基于 CopyOnWriteArrayList 实现,适合小规模的线程安全去重集合。
  • ConcurrentSkipListSet:跳表实现,线程安全且有序。相当于并发的 TreeSet。
  • BlockingQueue系列:ArrayBlockingQueue、LinkedBlockingQueue 等,用于生产者-消费者模型。

这些类都在java.util.concurrent包下,属于 JUC 的内容。javase 阶段你可能还没学到,但知道它们的存在,心里有个“并发有专门武器”的概念,对后续学习很有帮助。

10.3 集合初始容量设置技巧

很多人在创建 HashMap 时会想:“容量设多少合适?”如果已知数据量 N,计算公式是:

// 避免扩容,初始容量 = N / loadFactor + 1 int initialCapacity = (int) (N / 0.75f) + 1; Map<String, Object> map = new HashMap<>(initialCapacity);

同理,ArrayList 如果明确知道大概规模,直接new ArrayList<>(N),避免频繁扩容导致的数组拷贝开销。这个技巧在大数据量批次处理时尤其明显,比如读取十万行 Excel 数据,明明可以一次开够容量,非要让它扩容十几次,白白浪费 CPU。

还有一个小细节:HashMap 的底层容量必须是 2 的幂。你传入一个不是 2 的幂的初始容量,构造方法会通过tableSizeFor方法把它转成“最接近且大于等于它的 2 的幂”。比如传 19,实际容量是 32。这背后的原因是:(n - 1) & hash这个位运算定位桶下标,只有在 n 是 2 的幂时,n-1的二进制才全为 1,哈希结果才更均匀。

11. 面试高频问题快答

这些年看了不少 Java 面试题,集合这块的高频问题其实高度集中。我整理了一些典型题和对应的核心得分点,方便自测。

11.1 “ArrayList 和 LinkedList 的区别”

得分点:底层结构不同(数组 vs 双向链表);随机访问 ArrayList O(1)、LinkedList O(n);头部插入 LinkedList O(1)、ArrayList O(n);内存占用 LinkedList 更高;实际使用中 ArrayList 更常用,LinkedList 只有在频繁头部操作或需要实现队列/双端队列时才有优势。

11.2 “HashMap 的 put 流程”

得分点:hash 计算 → 定位桶 → 判断是否空 → 链表插入 or 红黑树插入 → 判断是否需要扩容,这个流程要能完整背出来并解释每个步骤。扩容时机是元素个数超过cap * loadFactor,扩容后重新分配桶位。JDK 8 之后尾插法避免死循环,链表长度超 8 且容量到 64 时树化。

11.3 “HashMap 和 Hashtable 的区别”

得分点:线程安全(Hashtable 同步,HashMap 非同步);null 键值(HashMap 允许,Hashtable 不允许);迭代器(HashMap 的 fail-fast,Hashtable 的 enumerator 不是 fail-fast);性能(Hashtable 全表锁,已不推荐)。

11.4 “HashSet 怎么去重的”

得分点:内部用 HashMap,元素作为 key;先比较 hashCode,再比较 equals;重写 equals 必须重写 hashCode;哈希冲突时用链表/红黑树解决。能讲清楚“hashCode 定位、equals 确认”这两步就算过关。

11.5 “ConcurrentHashMap 和 HashMap 的区别”

得分点:线程安全;JDK 8 之后 ConcurrentHashMap 放弃分段锁,采用 CAS + synchronized 锁桶;并发度高;不允许 null 键值;size 计算方式和扩容机制不同。能提到“锁粒度更细”是加分的。

11.6 “什么是 fail-fast”

得分点:迭代时 modCount 校验;结构性修改触发异常;非线程安全的实现这一机制,用于尽早暴露问题;CopyOnWriteArrayList 是 fail-safe 的,用快照迭代所以不会抛这个异常。

11.7 “TreeSet 和 HashSet 的区别”

得分点:底层结构不同(TreeMap vs HashMap);是否排序;性能 O(log n) vs O(1);判断重复的规则不同(compareTo vs hashCode+equals);TreeSet 要求元素可比较。

11.8 “为什么加载因子是 0.75”

得分点:空间与时间的权衡;0.75 是 JDK 工程师经验选出的平衡点;扩容太频繁浪费内存,冲突太多降低访问效率。要能答出“折中”这两个字,而不是死记 0.75。

这些题看着多,但背后都指向同一个核心:集合是对数据结构的工程化封装,选型的关键是匹配场景。

12. 结尾:给新手的三个建议和我的总结

写到这,这篇笔记也算完整了。最后说几句掏心窝的话。

第一个建议,不要死记硬背集合类的继承关系图。理解 Collection 和 Map 两大体系的本质区别,再根据“是否允许重复”“是否有序”“是否线程安全”这三个维度去选型,比背十张图都管用。

第二个建议,初学阶段一定要去读源码。ArrayList 的 grow 方法、HashMap 的 resize 方法、LinkedList 的 node 方法,篇幅都不长,读懂它们比做一百道练习题更能建立“代码感觉”。我至今还记得第一次读懂 HashMap 的 hash 方法时那种“原来如此”的爽感。

第三个建议,注意区分“表面懂”和“真懂”。能说出 ArrayList 底层是数组不算本事,能解释清楚“为什么 ArrayList 查询快、增删慢,LinkedList 反过来”才算入门。面试官问集合,其实问的不是 API 背得熟不熟,而是你对数据结构的理解透不透。

关于集合,能展开的还有很多,比如 Comparable 和 Comparator 的排序原理、Iterator 的 fail-fast 机制、HashSet 与 HashMap 的关系。这些我打算在后面的学习笔记里继续写。学习 Java 就是这样,一个点接一个点,串起来就是一张网。今天这篇就算是我这张网上的一小格。

扯远了,回到代码本身。集合不过是数据的容器,真正有价值的是容器背后那些解决实际问题的设计思路。理解了这层,你再看 Java 的集合框架,就不会觉得它只是一堆 API,而是一套充满智慧的解决方案。与各位共勉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询