写 Java 的人,基本绕不开集合。平时开发里,List、Set、Map 天天都在用,很多人也觉得自己已经挺熟了。可真到了面试,或者项目里碰到性能、并发、源码细节这些问题时,才发现自己对集合的理解其实并不扎实。

比如 ArrayList 和 LinkedList 到底该怎么选,HashMap 为什么查询快,HashSet 去重靠的是什么,subList() 为什么一不小心就埋坑。这些问题单看都不难,但一旦连起来问,很多人就容易乱。

这篇文章不准备照着教材把集合类挨个讲一遍,而是站在开发和面试两个角度,把 Java 集合里最常用、最容易问、也最容易踩坑的内容捋顺。你看完之后,至少能把选型思路、核心原理和高频问题弄明白。

1. Java 集合别死记,先抓住这两条主线

Java 集合框架看起来一大堆类,实际上先抓住两条主线就够了:

  • Collection:单列集合,一个一个存元素
  • Map:双列集合,以 key-value 方式存数据

而 Collection 下面,又可以继续拆成三类:

  • List:有序,可重复
  • Set:不可重复
  • Queue:队列结构

用一张简单的结构图来看会更直观:

Collection
├─ List
│  ├─ ArrayList
│  ├─ LinkedList
│  └─ Vector
├─ Set
│  ├─ HashSet
│  ├─ LinkedHashSet
│  └─ TreeSet
└─ Queue
   ├─ LinkedList
   └─ ArrayDeque

Map
├─ HashMap
├─ LinkedHashMap
├─ Hashtable
├─ TreeMap
└─ ConcurrentHashMap

这张图不用全背,但你至少要意识到一件事:工程里最常打交道的集合,并没有那么多。

真正的主角,其实长期都是下面这几个:

  • ArrayList
  • HashSet
  • HashMap
  • TreeMap
  • ConcurrentHashMap

把这几个吃透,集合这章基本就站住了。

2. List、Set、Map 到底怎么选,别再凭感觉了

很多初学者写代码时选集合,基本靠手感。想到列表就 List,想到键值对就 Map,剩下的能跑就行。短期看没问题,长期看会吃亏。

2.1 需要顺序、允许重复,用 List

只要你的数据需要“按顺序放着”,并且允许重复,优先考虑 List。

典型场景:

  • 数据库查出来的一批用户
  • 页面上的商品列表
  • 按时间顺序记录的操作日志

而在 List 里,最常用的两个实现类是:

  • ArrayList
  • LinkedList

但这里有个很重要的结论:大多数业务场景里,默认优先 ArrayList。

2.2 需要去重,用 Set

如果你的核心需求不是“存一批数据”,而是“确保数据不重复”,那就该想到 Set。

比如:

  • 一批手机号去重
  • 用户标签去重
  • 判断某个元素是否已经存在

常见实现类:

  • HashSet:最常用,去重快
  • LinkedHashSet:去重的同时保留插入顺序
  • TreeSet:自动排序去重

2.3 需要映射关系,用 Map

只要数据天然是“一个 key 对应一个 value”,就优先用 Map。

典型场景:

  • 用户 ID 对应用户对象
  • 配置项名称对应配置值
  • 单词对应出现次数

常见实现类:

  • HashMap
  • LinkedHashMap
  • TreeMap
  • ConcurrentHashMap

如果你现在只能记一条经验,那就记这一句:

默认列表选 ArrayList,默认映射选 HashMap,默认去重选 HashSet。

3. 为什么大家都在用 ArrayList,而不是 LinkedList

这是集合里最典型的“看上去会,实际上容易答偏”的问题。

很多人第一反应是:链表插入删除快,所以 LinkedList 应该更适合业务开发。这个说法只说对了一半。

3.1 ArrayList 的底层是动态数组

ArrayList 底层是动态数组,所以它的特点非常鲜明:

  • 支持随机访问,get(index) 很快
  • 尾部追加元素效率高
  • 中间插入、删除需要移动元素
  • 容量不够时会扩容

示例:

List<String> list = new ArrayList<>();
list.add("Java");
list.add("Spring");
list.add("MySQL");

System.out.println(list.get(1)); // Spring

3.2 LinkedList 的底层是双向链表

LinkedList 的优势在于:

  • 头尾插入删除方便
  • 不需要像数组那样整体搬迁元素

但它的问题也很明显:

  • 随机访问慢
  • 查找某个位置时需要遍历
  • CPU 缓存友好性通常不如数组

所以真实项目里,LinkedList 并没有很多人想象中那么常用。

更准确的理解应该是:

  • 普通业务列表:优先 ArrayList
  • 频繁头尾操作:再考虑 LinkedList 或 ArrayDeque

这也是为什么很多人写了几年 Java,项目里看到的 ArrayList 数量远远多于 LinkedList。

4. Set 为什么能去重,核心就在 equals 和 hashCode

集合里有一个点,面试一定会问,项目里也一定会碰到,那就是:Set 为什么能去重?

以 HashSet 为例,它底层其实是基于 HashMap 实现的。换句话说,HashSet 的去重能力,本质上来自 HashMap 的 key 不可重复。

当我们往 HashSet 里放对象时,通常会经历两个关键判断:

  1. 先比较 hashCode()
  2. 如果哈希值相同,再比较 equals()

所以一定要记住这个结论:

重写了 equals(),就必须同时重写 hashCode()。

来看一个例子:

class User {
    private String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof User)) {
            return false;
        }
        User user = (User) o;
        return Objects.equals(name, user.name);
    }

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

如果你只重写 equals() 不重写 hashCode(),那么逻辑相等的两个对象,可能会因为哈希值不同而落到不同位置,最终导致 HashSet 去重失败。

这个坑,真不是只存在于面试题里。业务代码里自定义对象放入 Set、作为 Map 的 key 时,经常就会踩到。

5. HashMap 为什么这么重要,甚至可以说是集合的核心

如果说 Java 集合里有一个类是“你绕不过去的最终 boss”,那基本就是 HashMap。

5.1 HashMap 的底层结构,到底是什么

JDK 8 里的 HashMap,底层结构可以概括成一句话:

数组 + 链表 + 红黑树

它的工作过程大致是这样的:

  1. 先通过 hash 计算桶位置
  2. 如果桶里没有元素,直接放进去
  3. 如果有冲突,就先挂到链表上
  4. 如果链表太长,再转成红黑树

为什么要这么设计?

因为理想情况下,哈希定位能让查找接近 O(1);但一旦冲突多了,链表会让性能变差,于是 JDK 8 用红黑树来兜底。

5.2 HashMap 为什么查找快

说白了,HashMap 快,不是因为它“遍历得快”,而是因为它大多数时候根本不用全量遍历。

它先用 hash 把查找范围压缩到某个桶,再在这个桶里继续判断。只要哈希分布比较均匀,效率就会很高。

这也是为什么 HashMap 在业务开发中几乎随处可见。

5.3 关于 HashMap,你至少还要知道这几件事

1)它允许 null

HashMap:

  • 允许 null key
  • 允许 null value

但 ConcurrentHashMap 不允许,这是面试里的高频对比点。

2)已知数据量时,尽量初始化容量

如果你大概知道要放多少元素,创建时最好顺手指定容量,避免频繁扩容。

Map<String, Integer> map = new HashMap<>(16);

在工程规范里,这也是一个很实用的优化习惯。

3)遍历时优先 entrySet

遍历 Map 时,优先用 entrySet(),不要总是先拿 keySet() 再去 get()。

for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println(entry.getKey() + " -> " + entry.getValue());
}

6. TreeMap 和 TreeSet 不是冷门,它们只是有明确适用场景

很多人学集合时会把 TreeMap、TreeSet 当成“知道有这个东西就行”,其实没那么简单。

它们底层基于红黑树,最大的特点就是:

  • 自动排序
  • 支持有序遍历
  • 适合范围查询

比如这些场景就很合适:

  • 按分数排序
  • 按日期维护数据
  • 需要找“大于某个值的最小 key”

示例:

Map<Integer, String> treeMap = new TreeMap<>();
treeMap.put(3, "C");
treeMap.put(1, "A");
treeMap.put(2, "B");

System.out.println(treeMap);
// {1=A, 2=B, 3=C}

注意,TreeMap 和 TreeSet 要么依赖元素实现 Comparable,要么创建时显式传入 Comparator。

7. 并发环境下,别再默认用 HashMap 了

单线程环境用 HashMap 很正常,但只要你进入并发读写场景,就不能继续想当然了。

这时候更合适的选择通常是 ConcurrentHashMap。

Map<String, Integer> counterMap = new ConcurrentHashMap<>();
counterMap.put("Java", 1);
counterMap.put("Spring", 2);

它值得记住的点有三个:

  • 线程安全
  • 并发性能明显优于老的 Hashtable
  • 不允许 null key 和 null value

很多老八股还在围着 Hashtable 讲线程安全,但真实开发里,优先考虑的基本都是 ConcurrentHashMap。

8. 这些集合坑,项目里真的太常见了

如果说源码考点更多是为面试准备,那下面这些坑就真的是为项目避雷准备的。

8.1 Arrays.asList() 不是普通可变 List

很多人会这样写:

List<String> list = Arrays.asList("A", "B", "C");
list.add("D");

然后程序直接报错。

原因是 Arrays.asList() 返回的是固定长度列表,不能随便增删。

如果你需要的是可变 List,应该这样写:

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.add("D");

8.2 subList() 返回的是视图,不是副本

这个坑非常经典。

List<String> sub = oldList.subList(0, 2);

很多人下意识会觉得 sub 是一个新的列表,但其实它只是原列表的一个视图。原集合结构一变,子集合就可能跟着出问题,甚至抛出 ConcurrentModificationException。

如果你想真正拷贝一份,正确做法是:

List<String> newList = new ArrayList<>(oldList.subList(0, 2));

8.3 foreach 里不要直接删元素

错误写法:

for (String item : list) { if ("A".equals(item)) { list.remove(item); } }

这类代码很容易触发 ConcurrentModificationException。

更稳妥的方式是 Iterator:

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if ("A".equals(item)) {
        iterator.remove();
    }
}

8.4 Collectors.toMap() 很容易因为重复 key 报错

下面这段写法看起来很正常,但一旦 key 重复就会抛异常:

Map<String, Integer> map = list.stream()
    .collect(Collectors.toMap(User::getName, User::getAge));

更稳的写法,是显式指定 merge 函数:

Map<String, Integer> map = list.stream()
    .collect(Collectors.toMap(
        User::getName,
        User::getAge,
        (oldValue, newValue) -> newValue
    ));

另外,规范里还特别提醒过:如果 value 为 null,这里也可能出问题。

8.5 Collections.emptyList() 不能改

下面这种写法一样会出现问题:

List<String> list = Collections.emptyList(); list.add("Java");

因为它返回的是不可变空集合。

如果你的语义只是“这里暂时没有数据”,它很好用;但如果你后面还要继续往里加元素,就不要这么写。

9. 一张表,帮你把集合选型记清楚

场景 推荐集合
普通有序列表 ArrayList
频繁头尾操作 LinkedList / ArrayDeque
去重 HashSet
保留插入顺序的去重 LinkedHashSet
自动排序去重 TreeSet
普通键值存储 HashMap
保持插入顺序的映射 LinkedHashMap
自动按 key 排序 TreeMap
并发键值存储 ConcurrentHashMap

如果你想把这一章真正学扎实,我建议至少把下面这套默认心智模型建立起来:

  • 默认列表:ArrayList
  • 默认去重:HashSet
  • 默认映射:HashMap
  • 并发映射:ConcurrentHashMap
  • 需要排序:TreeMap / TreeSet

更多推荐