先说一个观点:Java集合类是每一个Java开发者的“日常口粮”,也是面试里出镜率最高的基础题。不管你是刚学完语法准备找工作,还是已经工作两三年想跳槽,集合类这一块都是绕不过去的。很多人背了一堆类名,什么ArrayList、HashMap、HashSet,面试时候能说出来,但一追问“底层结构是什么”“为什么用红黑树”“扩容怎么扩”,就卡壳了。这篇文章就把集合类整个家族捋一遍,从整体框架到具体实现,再到工具类、选型和面试考点,尽量用大白话讲清楚,让你看完不只能应付面试,回到业务里也能选对容器。
先明确一下适用范围:你至少要知道Java基本语法、类和对象是怎么回事,最好已经写过几个小项目。如果你连 `ArrayList list = new ArrayList()` 都还没敲过,建议先把环境配好跑通一个HelloWorld再回头看这篇。下面内容比较多,但每节我都尽量按“是什么、为什么、怎么用、坑在哪”的顺序来写,你可以跳着看,面试前重点看第1、2、3和5章就够了。
1. 集合框架的整体架构与核心设计思路
1.1 为什么需要集合类:数组先天的三宗罪
很多新手一开始接触的是数组,比如 `int[] arr = new int[10]`,但用久了就会发现数组在业务开发里非常难受。先说第一宗罪:长度固定。你声明了10个长度,存到第11个就崩,要么自己写扩容逻辑,要么预估一个很大的长度浪费内存。第二宗罪:操作麻烦。数组只有下标访问,想在中间插入一个元素,需要把后面所有元素都后移一位,逻辑不难但非常容易出错;想删除一个元素同理。第三宗罪:算法能力基本为零。数组本身没有排序、查找、去重这些方法,什么都得你自己写。
集合类就是为了解决这些痛点出现的。它本质上是“可以动态扩展、自带算法支持的对象容器”。你可以把它想象成一个带滑轮的整理箱,东西多了会自动变大,你想按颜色分类、按大小排序,它都给你现成的方法。Java把这一整套容器抽象成了两个族系:一个是 `Collection`,存单个元素的;一个是 `Map`,存键值对的。这两个族系下面又衍生出List、Set、Queue、Map的各种实现类。搞懂这个框架,你才算真正入门了“面向对象编程Java”里容器管理这块核心内容。
1.2 两大核心接口与家族全景
先看一张“家谱”式的结构描述:`Collection` 接口是所有单列集合的根接口,下面派生出 `List`、`Set`、`Queue` 三个子接口;`Map` 是另一棵独立的树,跟 `Collection` 平级。很多人刚学的时候会误以为Map属于Collection,其实不是,Map存的是键值对,不是单元素,所以它单独一棵树。这么设计有一个好处:所有实现类都遵循统一的接口规范,意味着你可以用同样的姿势遍历、添加、删除,只是底层行为不同。
我整理了一张常用实现类的速查表,先混个脸熟:
| 接口 | 实现类 | 底层结构 | 特点一句话 |
|---|---|---|---|
| List | ArrayList | 动态数组 | 查询快、增删慢,日常最常用 |
| List | LinkedList | 双向链表 | 增删快、随机访问慢,也实现了Deque |
| List | Vector | 动态数组 | 线程安全但性能差,老代码遗留 |
| Set | HashSet | 哈希表(HashMap) | 无序、唯一、速度快 |
| Set | LinkedHashSet | 哈希表+链表 | 有插入顺序、唯一 |
| Set | TreeSet | 红黑树 | 有序、唯一,可自定义排序 |
| Queue | PriorityQueue | 堆 | 按优先级出队,默认最小堆 |
| Map | HashMap | 数组+链表+红黑树 | 无序、key唯一,使用率最高 |
| Map | LinkedHashMap | 哈希表+双向链表 | 有插入顺序/访问顺序 |
| Map | TreeMap | 红黑树 | key有序,支持区间查询 |
| Map | Hashtable | 哈希表 | 线程安全,性能差,已过时 |
这里要补充一个很多教程不讲清楚的基础点:集合里存放的是对象的引用,不是对象本身。也就是说,`list.add(obj)` 只是把对象的地址放了进去,后续你修改obj的内容,集合里的数据也跟着变。这点在业务代码里经常会造成诡异的bug,比如你往集合里add了一个对象,然后又改了对象的字段,结果发现集合里的“旧数据”也变了。新手经常踩,先记在心里。
2. Collection家族之路:List、Set、Queue逐个拆解
2.1 List系列:ArrayList、LinkedList、Vector怎么选
List是有序、可重复的集合,这个概念先记住。日常代码里出现频率最高的就是ArrayList,它底层就是一个动态数组 `Object[] elementData`,通过一个 `size` 字段记录实际元素个数。查询全靠下标,所以时间复杂度是O(1),非常快;但中间插入和删除需要移动元素,最坏情况要移动n个,所以是O(n)。
LinkedList底层是双向链表,每个节点 `Node` 持有prev、next、item三个引用。它的优势在增删:只要修改前后节点的指针即可,时间复杂度O(1)(前提是你已经拿到了那个节点)。但如果要在链表中间查找某个元素,必须从头开始遍历,时间复杂度O(n)。很多人以为“LinkedList增删一定比ArrayList快”,这是个误区。如果你是在尾部追加元素,ArrayList因为有自动扩容机制,其实不比LinkedList慢,甚至因为数组连续内存访问更友好,性能反而好。真正的强项是频繁在头部或中部增删的场景。
Vector是个老古董了,它的方法和ArrayList几乎一样,但所有方法都加了 `synchronized`,所以线程安全。问题在于这种粗粒度加锁性能太差,现在并发场景基本都用CopyOnWriteArrayList或者Collections工具类包装,Vector只在面试题里还有存在感。选择建议很简单:业务开发无脑ArrayList,队列场景可以用LinkedList,多线程读多写少的场景考虑CopyOnWriteArrayList。
2.2 ArrayList扩容机制:从默认容量到1.5倍
ArrayList的扩容几乎是Java基础面试必考题,每次面试都会被问到。它默认构造创建的是一个空数组,首次调用add时才初始化容量为10。当元素个数size超过当前容量时,触发扩容,新容量计算公式是 `int newCapacity = oldCapacity + (oldCapacity >> 1)`,也就是扩容为原来的1.5倍。
举个例子:第11个元素要加进来,当前容量10不够了,新容量就是 10 + 5 = 15。然后调用 `Arrays.copyOf` 把原数组元素复制到新数组,旧数组交给垃圾回收。这里有个细节:扩容是要复制所有已存元素的,如果集合很大,扩容一次代价很高。所以如果你知道数据量大概有1000条,最好直接 `new ArrayList<>(1000)` 指定初始容量,能避免多次扩容带来的性能损耗。这块也顺便解释了一个常见问题:为什么ArrayList不是一开始就把容量给大?因为内存是宝贵的,大部分场景数据量都不大,10个初始容量足够覆盖绝大多数情况,扩容属于“按需分配”。
还有个小点,ArrayList的 `ensureCapacity` 方法可以主动扩容,但平时很少用,因为底层自动扩容已经够用了。面试官如果再追问“扩容到15之后,再存多少个元素触发下一次扩容”,那就数一下:第16个元素超过了容量15,扩容到15 + 15>>1 = 22,依此类推。这个等比增长的思路也可以用到你自己设计的缓冲结构里。
2.3 Set系列:HashSet、LinkedHashSet、TreeSet各自的应用
Set的核心理念是“去重”,但不同实现类对“顺序”的理解完全不同。最常用的HashSet,底层就是HashMap,元素被当成HashMap的key,value统一是一个固定的Object常量 `PRESENT`。因为HashMap的key不能重复,所以HashSet天然就去重了。它的特点是无序,遍历顺序不保证和插入顺序一致,但查找、插入都很快,平均O(1)。
LinkedHashSet比HashSet多了维护一个双向链表,所以遍历顺序和插入顺序一致。代价是每个元素多存了几个指针,内存占用略高。如果你要做“去重但保留第一次出现的顺序”,用它就对了。典型场景是处理用户提交的标签列表,既要去掉重复项,又不想破坏原有顺序。
TreeSet底层是红黑树,元素会按照自然顺序(升序)或者你传入的Comparator排序。注意它用的是“比较”而不是“哈希”来判断重复,所以如果两个对象通过compareTo返回0,即使equals为false,也会被认为重复。这是TreeSet一个容易踩的坑。它的插入、删除、查找是O(log n),适合需要有序集合且数据量不大不小的场景。另外,TreeSet还支持 `first()`、`last()`、`subSet(a, b)` 这类区间操作,在做排行榜、区间筛选时很方便。
关于去重的底层逻辑,必须补一句:HashSet去重依赖的是对象的hashCode和equals。如果往HashSet里放自定义对象,一定要同时重写这两个方法,否则即使两个对象业务属性完全一样,hashCode不同也会被当成不同元素。重写的时候遵循规则:equals相等的两个对象hashCode必须相等,否则集合里会出现“看着重复但去不掉”的问题。
2.4 Queue与Deque:从普通队列到优先队列
Queue接口代表先进先出的队列,核心方法是 `offer`(入队)、`poll`(取出并移除队头)、`peek`(只看队头不移除)。注意不要用 `add` 和 `remove`,因为它们在队满或队空时会抛异常,而offer/poll返回特殊值,更安全。
实现类方面,ArrayDeque底层是循环数组,性能比LinkedList好,做栈和队列都可以,推荐使用。LinkedList也实现了Deque接口,可以当作队列用,但如果你只是为了当容器存数据,ArrayDeque更轻量。PriorityQueue则特殊得多,它的底层是一个最小堆,元素出队顺序不是按照入队顺序,而是按照优先级——默认是自然顺序的最小值先出队。你可以传一个Comparator改变优先级规则。
PriorityQueue在算法题里出场率极高,“返回TopK”其实就是一个维护大小为K的小顶堆:堆顶是当前K个里最小的,遇到比堆顶大的就替换。Java `PriorityQueue` 默认就是最小堆,所以实现TopK只需要传入 `(a, b) -> b - a` 变成最大堆即可。这个点在后面竞赛章节还会展开。
3. Map家族全解析
3.1 HashMap底层:数组+链表+红黑树与树化阈值
HashMap是整个集合框架里最核心的内容,没有之一。它底层是一个Node数组,每个Node要么是一个链表节点,要么是红黑树节点。添加元素时先对key做哈希,再通过 `(n - 1) & hash` 的方式定位到数组槽位。如果槽位为空,直接放入;如果槽位已经有了其他元素(哈希碰撞),就追加到链表尾部或者树中。
这里有个高频考点:为什么HashMap的哈希要 `h = key.hashCode() ^ (h >>> 16)`?因为hashCode是32位的,数组长度一般没这么大,计算槽位时只用了低位,高位完全浪费。把高16位异或到低16位,相当于把高位信息也混进低位,让散列更均匀。然后 `(n - 1) & hash` 比取模 `% n` 更快,因为位运算直接操作二进制,所以HashMap计算槽位时要求数组长度必须是2的幂。
再说参数:默认初始容量16,加载因子0.75。加载因子的意思是:当元素个数超过容量乘以0.75,也就是12个时,触发扩容,容量变为2倍。这个0.75是空间和时间权衡的经验值,太大会导致哈希碰撞变多,链变长,查询变慢;太小会提前扩容,浪费内存。至于为什么链长超过8就转红黑树,官方文档给过一句话:在随机哈希码下,链表长度达到8的概率非常低,大约是千万分之六。如果真到了8,说明哈希函数有问题,或者数据分布极端异常,用红黑树把最差情况从O(n)降到O(log n),属于兜底方案。当树节点减少到6时再退回链表,中间留了7这个缓冲,防止频繁转换造成抖动。
HashMap还有一个必须知道的坑:线程不安全。JDK 1.7的并发扩容可能造成环形链表,导致get死循环;JDK 1.8改为尾插法修复了死循环,但并发put仍可能覆盖数据、resize时丢失数据。所以多线程场景千万不要用HashMap。
3.2 LinkedHashMap:插入顺序、访问顺序与LRU缓存
LinkedHashMap是HashMap的子类,它在每个桶的节点之外,额外维护了一条双向链表,把所有键值对串起来。根据构造器参数 `accessOrder` 的不同,这条链表有两种排列顺序:默认是false,按插入顺序排列;设为true,则按访问顺序排列,每次get或put都会把对应节点移到链表尾部。
基于这个特性,LinkedHashMap是手写LRU缓存的利器。你只需要继承它,覆写 `removeEldestEntry` 方法,让它返回“当前大小是否超过最大容量”,就可以实现一个“容量满了就把最久没访问的键值对移除”的缓存。这是什么原理?accessOrder=true时,链表头就是最久未访问的数据,链表尾就是最近访问的数据,put新数据时如果触发移除条件,移除链表的首节点即可。一行代码都不多余。
面试中经常问“如何实现一个LRU缓存”,标准答案就是LinkedHashMap覆写removeEldestEntry。但注意,如果业务里有并发访问,还要在外面加锁或者用 `Collections.synchronizedMap` 包裹,因为LinkedHashMap本身不是线程安全的。
3.3 TreeMap与Hashtable:有序Map与历史遗留
TreeMap底层是红黑树,key按照自然顺序或者自定义Comparator排序。它不像HashMap那样通过哈希定位,所有查找、插入、删除都是O(log n)。它最有价值的能力是区间查询:`subMap(fromKey, true, toKey, true)` 可以直接切出一段排序好的键值集合,在做范围统计、区间排行时非常好用。
Hashtable则完全是另一个时代的产物。它是JDK 1.0就有的,所有方法都加 `synchronized`,所以是线程安全的,但性能很差。它和HashMap还有两个不容忽视的差异:Hashtable不允许null key和null value,HashMap允许;Hashtable扩容是2倍加1,而HashMap是2倍。时至今日,除了面试题里问你“HashMap和Hashtable的区别”,业务代码里基本见不到Hashtable了。它的位置被ConcurrentHashMap取代。
3.4 ConcurrentHashMap:并发场景下的正确选择
如果面试官问你“并发下用哪个Map”,答案必须是ConcurrentHashMap。JDK 1.7时代它用的是分段锁,把数据分成一截一截的Segment,每个Segment管一把锁,不同线程操作不同段时可以并行,锁粒度比Hashtable细得多。JDK 1.8进一步放弃了分段锁,直接用Node数组加CAS加synchronized:插入时如果槽位为空,通过CAS放入,不需要加锁;如果槽位非空,对头节点加synchronized锁。这样锁粒度从“段”缩小到“单个桶”,并发度和性能都大幅提高。
这就带来一个非常实用的结论:并发读多写多的场景,直接上ConcurrentHashMap,不要再用Hashtable,更不要自己给HashMap加全局锁。它的size方法也是基于CounterCell分段计数的,在高并发下统计元素个数也不会因为锁竞争而卡死。
我做了一个简单的对照表:
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是 | 是 |
| 锁粒度 | 无 | 整个对象 | 槽位/桶 |
| 允许null key | 是 | 否 | 否 |
| 性能 | 最高 | 差 | 接近HashMap |
| 适用场景 | 单线程内部使用 | 几乎不用 | 并发场景 |
这里注意:ConcurrentHashMap是不允许null key和null value的,因为并发环境下无法判断value为null到底是不存在还是值为null,会引发歧义。
4. 工具类与常用操作
4.1 Collections:最常用的静态方法速查
Java为集合准备了一个万能工具箱 `java.util.Collections`,里面全是静态方法,不需要new对象。日常用得最多的有:`sort`(排序)、`reverse`(反转)、`shuffle`(打乱)、`min/max`(取最值)、`frequency`(统计出现次数)、`copy`(复制)、`replaceAll`(替换)。还有两个高频方法解决并发问题:`synchronizedList`、`synchronizedMap`,可以对现有集合加锁包装,但注意包装后的集合在做迭代时仍然需要手动加锁,因为迭代过程本身不是原子的。
List<Integer> list = new ArrayList<>(Arrays.asList(3, 1, 4, 1, 5)); Collections.sort(list); // 升序排序 Collections.reverse(list); // 反转 Collections.shuffle(list); // 随机打乱 Collections.sort(list, Collections.reverseOrder()); // 降序排序还有一个很有用的方法 `unmodifiableList`:返回一个只读视图,任何修改操作都会抛 `UnsupportedOperationException`。业务中经常用它把内部集合暴露给外部调用方,防止外部把数据改坏。不过要注意这个“只读”只是不能增删改元素,元素本身如果是可变的,仍然可以通过引用修改其字段。
4.2 Arrays与集合互转:一个高频坑
数组和集合互转是业务里特别常见的需求,坑也特别多。先说 `Arrays.asList`,它返回的是一个内部类ArrayList,虽然实现了List接口,但底层仍然是原来的数组,长度固定。你调用add、remove会直接抛 `UnsupportedOperationException`,很多人第一次碰到都懵了:明明返回的是List,为什么不能加元素?
正确做法是在外面包一层真正的ArrayList:
String[] arr = {"a", "b", "c"}; List<String> list = new ArrayList<>(Arrays.asList(arr));反过来,集合转数组用 `toArray`。如果不传参数,返回的是Object[];想得到具体类型的数组,传一个空数组 `list.toArray(new String[0])`。JDK 8之后,传入空数组比传入预先分配好大小的数组性能更好,因为底层会有优化,不用像传入大数组那样先判断长度再复制。这个点也可以当面试题记住。
还有一个相关坑:`Arrays.asList` 把基本类型数组转成List时,如果是 `int[]`,它会把整个数组当成一个元素,而不是转成Integer列表。因为泛型不支持基本类型。解决办法是用 `Arrays.stream(arr).boxed()` 或者手写循环。
4.3 排序与比较器:Comparable与Comparator怎么选
排序是集合类使用频率最高的操作之一。Java提供了两种比较方式:一种是让对象自身实现 `Comparable` 接口,重写 `compareTo` 方法,这叫“自然排序”,比如Integer、String都自带这个能力;另一种是定义外部 `Comparator` 比较器,这叫“策略排序”,你可以在不同场景临时指定不同的排序规则。
比较直观的选择标准:如果这个类在业务全局只有一个公认的排序规则,用Comparable,写在类内部;如果排序规则经常变,或者你无法修改那个类(比如第三方库的类),用Comparator。现在的写法已经很简练了,lambda一行搞定:
list.sort((a, b) -> a.getAge() - b.getAge()); // 升序 list.sort(Comparator.comparing(User::getAge).reversed()); // 按年龄降序还有一个细节:`Collections.sort` 和 `List.sort` 用的是稳定排序,底层是归并排序和TimSort的混合。稳定意味着相等元素的相对顺序不会被颠倒,这在多级排序中非常重要。比如你先按年龄排,再按姓名排,两轮排序不会把第一轮的结果打乱,前提是每轮都用稳定排序。
5. 面试、竞赛与业务选型经验
5.1 面试官最爱问的5个集合类问题
结合最近几年Java面试题的热门方向,我把集合类的问题浓缩成5道核心题,每道都给出答题主线。
第一题:ArrayList和LinkedList的区别。答题要有层次:先说底层结构,数组和双向链表;再说增删查改的时间复杂度;接着说扩容机制;最后给一两个适用场景。如果能提到“尾部添加两者区别不大,LinkedList空间开销更大”就很出彩。
第二题:HashMap的底层实现。主线:数组加链表加红黑树,哈希过程,加载因子0.75,扩容翻倍,树化阈值8和退化阈值6。别只背公式,最好能解释一下0.75是空间时间权衡、8是泊松分布小概率事件。
第三题:HashSet怎么做到去重的?答:底层是HashMap,元素作为key。关键点在hashCode和equals先比较hashCode,再比较equals。
第四题:HashMap为什么线程不安全?答:多线程并发写会造成数据覆盖、size不准确,JDK 1.7还有循环链表风险;然后给出结论——并发用ConcurrentHashMap。
第五题:Collection和Collections的区别。一个是接口,一个是工具类,这是一个基础题但常被混淆,一句话讲清楚。
这五题如果都能不看资料独立讲出来,集合类的面试部分基本就过关了。如果你在准备“Java开发工程师面试题”和“Java八股文”,把这五题当提纲去梳理就够了。
5.2 不同业务场景下的集合选型
回到业务开发,选错集合类会带来肉眼可见的性能问题。这里总结一套我常用的选型参考:
| 业务需求 | 推荐集合 | 理由 |
|---|---|---|
| 存一批订单,按时间顺序展示,频繁读下走 | ArrayList | 查询快,尾部追加快 |
| 需要频繁在列表顶部插入热门商品 | LinkedList / ArrayDeque | 头插成本低 |
| 用户标签去重,不关心顺序 | HashSet | 速度快 |
| 保留第一次出现顺序的去重 | LinkedHashSet | 有序加去重 |
| 排行榜按分数降序取前10名 | PriorityQueue | TopK效率最高 |
| 需要按key范围查数据(如价格区间) | TreeMap | 支持区间视图 |
| 需要缓存且要求淘汰最久未使用 | LinkedHashMap | 天然支持LRU |
| 并发下维护配置缓存映射 | ConcurrentHashMap | 线程安全高性能 |
| 需要线程安全且读多写少的列表 | CopyOnWriteArrayList | 读不加锁 |
另外提醒一点:集合去重不是万能的。如果业务要求“根据对象的某个字段去重”,你得先确认这个字段在equals里是否参与判断。很多人在数据库里看着不重复的ID,放进HashSet却去不掉,就是因为实体类没有重写equals和hashCode,或者重写得不完整。
5.3 蓝桥杯等算法竞赛中集合类的妙用
再结合竞赛方向说几句。蓝桥杯这类比赛里,Java组用到的核心数据结构大多就落在集合类上。比如“数字题目”经常要统计数字出现的次数,用 `HashMap<Integer, Integer>` 做一个计数器,一行 `map.put(key, map.getOrDefault(key, 0) + 1)` 就能搞定,比开数组灵活得多,尤其是key范围很大或者很稀疏的时候。
需要“去重加排序”的题,用 `TreeSet` 一步到位,它自动维护升序,遍历输出就是结果。需要“每次取最大/最小”的贪心题,用 `PriorityQueue`。这类题很典型,比如合并K个有序链表、寻找第K大、任务调度。Java选手经常感叹自己写得慢,其实很多时间就浪费在徒手写堆和手写快排上,直接用PriorityQueue和 `Arrays.sort` 能节约大量时间。
竞赛里还有一个更高阶的用法:`TreeMap` 的 `subMap` 和 `higherEntry` 可以解决区间查询和最近邻问题,比每次遍历整个集合快一个数量级。如果你已经能把HashMap、TreeSet、PriorityQueue烂熟于心,蓝桥杯省赛的数据结构部分就问题不大了。
在做算法题的时候,我个人还有一个小习惯:选手写代码速度很重要,所以集合类的常用API一定要背到肌肉记忆,比如 `map.getOrDefault`、`set.contains`、`queue.offer/poll`,不要每次现查。真到了赛场上,多一次翻阅文档就少几分钟调试。
最后分享一点摸索出来的经验:学集合类千万不要只靠背,最好的办法是动手写一段代码,把一个List反复add到十几万条,观察耗时;把HashMap的容量初始设定为1、2、4,打印出resize前后的日志,看看内部结构怎么变化。JDK里 `HashMap` 的源码是很值得反复读的,读个两三遍,很多面试题就不再需要死记硬背了,因为你会从设计者角度理解为什么这个参数是0.75、为什么链表要转树。面试时候的自信感,通常就来自这些底层细节。