做Java开发的,可以不知道Spring的加载细节,也可以记不全JVM参数,但集合框架这一块如果说不明白,出门跟人聊技术都底气不足。我接触过的Java基础面试,十个里面八个会从集合切入;日常业务代码里,数据存取、去重排序、状态聚合,样样都绕不开“Java集合”这个核心关键词。
这篇文章我打算站在一个写了好几年Java、也当过面试官的人的角度,把集合框架从头到尾捋一遍。从整体结构、核心实现类怎么选,到HashMap的源码细节、并发场景下的方案取舍,再到面试必问的“八股”陷阱和日常编码里真正会踩的坑,一次讲透。不管你是准备校招的应届生,还是工作两三年想系统补一遍基础的同学,都能从里面拿到可以直接用的结论和方法。
1. 先看清集合框架的全貌再动手
1.1 两大体系、三个分支
Java的集合框架本质就干两件事:存单个对象的,跟存键值对的。前者叫Collection,后者叫Map,这两个接口是整个框架的地基。
Collection下面又分成三个分支:
- List:有序、可重复,按索引访问,像排队领餐一样,谁先来谁在前面。
- Set:不可重复,相当于数学里的集合概念,用来做去重。
- Queue:队列,先进先出,适合做任务排队、消息缓冲。
Map跟Collection没有继承关系,它保存的是K-V键值对,每个key映射到一个value。
这套体系的结构,我习惯用“两条线、一棵树”来记:一条线以Collection为根,往下分List、Set、Queue;另一条线以Map为根,往下分HashMap、TreeMap、LinkedHashMap等。实际类图里还有AbstractCollection、AbstractList这些抽象类,它们是骨架实现,主要帮我们减少重复代码,但日常使用中你基本不会直接碰它们。
新手学集合最容易犯的错就是对着类图死记硬背,背完就忘。我的建议是先站在接口层面理解:List强调“有序可重复”,Set强调“去重”,Map强调“KV映射”。这三个核心语义记住了,后面的实现类都是围绕它们做数据结构的落地。
1.2 数组和集合的本质差别
这个点是很多零基础同学的第一道坎。数组长度固定、元素类型一致;集合用的时候不用关心长度,装多少自动扩。用一个生活化类比:数组像固定座位的电影院,票卖多少就是多少,满了只能站着;集合像有弹性的收纳箱,东西放多了会自动变大,不用你提前算好容量。
因为这个本质差别,集合框架必须解决三个核心问题:
- 动态扩容:底层数组装满了怎么办?答案是创建新数组、把老数据搬过去。
- 内存利用率:扩容太频繁浪费性能,扩容太大浪费内存,需要找一个平衡点。
- 迭代安全:遍历过程中集合被修改了怎么办?所以有了fail-fast机制(后面专门讲)。
理解了这三点,你再看ArrayList、HashMap的源码,思路会清晰很多。它们的所有复杂设计,几乎都是围绕“怎么高效地解决好这三个问题”展开的。
2. 实现类怎么选:别上来就无脑ArrayList和HashMap
2.1 List选型:ArrayList vs LinkedList vs Vector
先下结论:绝大多数场景直接用ArrayList,不要犹豫。我知道很多人背过“LinkedList适合频繁插入删除”,这句话本身没错,但工程上经常被误用。
ArrayList底层是Object[]数组,连续内存。随机访问get(i)是O(1),直接按下标取;中间插入删除要搬运后续元素,是O(n);尾部添加均摊O(1)。因为它内存连续、缓存命中率高,实际循环遍历性能往往比LinkedList好。
LinkedList底层是双向链表,每个节点额外存了前驱后继指针。中间插入删除理论上是O(1),前提是你已经拿到了那个位置的节点引用;但如果你只知道下标,光定位到那个位置就要先从头遍历,又是O(n)。我踩过的坑是:以为LinkedList头插更快,结果数据量大了以后,每次插入都要重新定位到头部,整体跑下来跟ArrayList差距并不明显,还白白多占了不少内存。
Vector是早期线程安全方案,所有方法都加了synchronized。但因为锁粒度太大,性能差,现在基本不用了。它的子类Stack也已经被Deque取代。
用一个表格直观对比:
| 实现类 | 底层结构 | 随机访问 | 插入/删除 | 线程安全 | 内存开销 | 推荐度 |
|---|---|---|---|---|---|---|
| ArrayList | 动态数组 | O(1) | 尾部O(1),中间O(n) | 否 | 低 | 首选 |
| LinkedList | 双向链表 | O(n) | 已知节点O(1),按索引O(n) | 否 | 每节点多两个指针 | 特定场景 |
| Vector | 动态数组 | O(1) | 尾部O(1),中间O(n) | 是(synchronized) | 低 | 基本不用 |
2.2 Set选型:三种去重场景三选一
Set的实现类基本都是Map的“阉割版”,底层直接复用Map的key来存储。选型逻辑也很清晰:
- HashSet:底层是HashMap,哈希表结构,存取O(1),元素无序,去重最快。最常用。
- LinkedHashSet:底层是LinkedHashMap,在哈希表基础上串了条链表,按插入顺序遍历。适合需要“去重但保留添加顺序”的场景,比如维护一批不重复的配置项。
- TreeSet:底层是TreeMap,红黑树结构,元素按自然顺序或自定义Comparator排序,操作O(log n)。适合需要有序去重的场景,比如排行榜前N名。
很多人记不住这三个,我给个口诀:“Hash快、Link稳、Tree排”。不要求顺序用HashSet,要求顺序用LinkedHashSet,要求排序用TreeSet。
2.3 Map选型:四兄弟各有各的命
Map是集合框架里最常用的体系。HashMap、LinkedHashMap、TreeMap、Hashtable,这四者看名字就能猜出大半功能。
HashMap用哈希表,无序,允许一个null key和多个null value,O(1)读写,是绝对的主力。
LinkedHashMap在HashMap基础上加了双向链表维护顺序,默认按插入顺序遍历。日常做LRU缓存就是继承它并重写removeEldestEntry方法,这是很多缓存框架的底层方案。
TreeMap用红黑树,key按自然序或Comparator排序,操作O(log n),支持范围查询,比如找所有大于某个key的映射。
Hashtable是个老古董,方法全部synchronized,不允许null key/value,性能和并发能力都差,已经被ConcurrentHashMap取代。
我给你的选型结论就一句话:90%的场景用HashMap;要保插入顺序用LinkedHashMap;要排序用TreeMap;并发用ConcurrentHashMap。别的都是浮云。
3. 源码级拆解:HashMap和扩容机制
3.1 HashMap底层结构演进
HashMap是整个集合框架的灵魂,面试里被问得最多。很多人背了“数组+链表+红黑树”,但根本不知道为什么。
JDK 1.7时代的HashMap是“数组+链表”,通过hash值定位到数组桶,桶内用链表解决哈希冲突。但链表太长时,查找效率退化成O(n)。所以JDK 1.8引入了红黑树优化:当某个桶的链表长度超过阈值8,并且数组长度至少64时,链表转成红黑树,查找降到O(log n)。
为什么哈希冲突会这么多?因为HashMap的哈希值本质上是把对象的hashCode做了一次“扰动”再取模。理论上只要hashCode设计得好,链表一般不会太长。但恶意构造或极端场景下,冲突可能集中在一个桶里。红黑树就是用来兜底的,防止最坏情况发生。
3.2 hash散列与索引计算
看JDK 1.8的hash方法:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这个操作叫“扰动函数”,把高16位和低16位做异或。为什么要这么做?因为计算桶下标用的是(n - 1) & hash,如果数组容量比较小(n是2的幂),参与运算的只有低几位,高位信息就丢了。异或一下,等于把高位特征混进低位,散列更均匀,减少碰撞。
桶下标计算是(n - 1) & hash,跟hash % n等价,但位运算更快。这里有个隐藏前提:n必须是2的幂,这也解释了为什么HashMap的容量永远是2的幂次方,每次扩容都翻倍。
3.3 扩容机制:初始容量16、负载因子0.75
默认初始容量是16,负载因子是0.75。什么时候扩容?当size > capacity * loadFactor,也就是元素个数超过12时,容量翻倍到32。为什么选0.75?这是一个时间与空间的折中:负载因子太小,空间浪费;负载因子太大,链表变长,性能下降。HashMap源码注释里有一段基于泊松分布的计算,假设负载因子0.75,随机哈希下链表长度达到8的概率约为千万分之几,所以树化阈值选8是有数学依据的。
扩容流程是JDK 1.8的一个重要优化点。老版本扩容时每个元素都要重新hash;新版本因为容量翻倍,元素的索引要么不变,要么“原索引 + 旧容量”。判断依据就是看新增的那个bit位是0还是1,这样就把rehash的成本省下来了,还保证了扩容后元素依然相对均匀。
顺便说一句,实际开发里如果提前知道数据量,创建HashMap时可以指定初始容量。比如要存1000个元素,初始化容量要设成2048(保证大于1000/0.75≈1334的2的幂),能省掉好多次扩容拷贝。
3.4 ArrayList扩容的1.5倍法则
ArrayList的扩容逻辑也值得聊。第一次add时,默认容量是10;满了以后调用grow方法,新容量是oldCapacity + (oldCapacity >> 1),也就是1.5倍。代码是这样的:
int newCapacity = oldCapacity + (oldCapacity >> 1);为什么选1.5倍而不是2倍?扩容要新开数组并且拷贝老数据,是O(n)操作。如果翻倍,扩容次数少但浪费空间;如果只加固定数量,扩容太频繁。1.5倍是工程上的折中方案。每次扩容都是Arrays.copyOf,底层调用System.arraycopy,是native方法,效率很高。
你可能会问:那我要存很多数据时,ArrayList会不会频繁扩容?会。比如从默认容量一路加到100万,中间要经过20多次扩容,每次都要全量拷贝。解决办法还是提前预估大小:
List<String> list = new ArrayList<>(expectedSize);4. 并发场景:线程安全集合怎么选
4.1 线程不安全的根因
HashMap在多线程下是“不安全”的,这个结论背得人很多,但真正理解的人少。主要问题不是数据覆盖,而是并发put、扩容时的数据错乱。
JDK 1.7及更早版本的HashMap扩容时,采用头插法迁移链表,多线程并发扩容会形成环形链表,get的时候一旦命中环,CPU直接飙升到100%。这是教科书级的惨案。JDK 1.8改成了尾插法,解决了环的问题,但并发put仍可能互相覆盖丢失数据。所以,并发场景下坚决不要用HashMap。
4.2 老方案:Vector、Hashtable与Collections.synchronized
老方案思路很简单:把每个方法都加锁。
Vector、Hashtable直接在方法上写public synchronized ...,锁的是整个对象。Collections.synchronizedMap则是包了一层,内部每个方法外面再包一层synchronized(mutex),本质没区别。
这种“全方法锁”的问题是锁粒度太大。一个线程在写入时,所有读写都阻塞,并发度几乎为零。在单线程时代够用,多核时代就成瓶颈了。
4.3 当代方案:ConcurrentHashMap
ConcurrentHashMap是并发场景的正主。它的演进思路很有代表性。
JDK 1.7采用Segment分段锁,把整个Map分成16个段,每个段独立加锁。理论上支持16个线程并发写,比全局锁强,但段之间无法进行跨段操作,而且锁粒度还是偏粗。
JDK 1.8放弃了分段锁,改成一桶一锁:每个桶的头节点作为锁对象,结合CAS空桶插入。写操作先CAS尝试,失败就synchronized锁住头节点。锁粒度从“段”细化到“桶”,并发度大幅提升。这也是为什么现在面试Java并发集合,问得最多的就是它。
ConcurrentHashMap的size()、computeIfAbsent等复合操作在JDK 1.8里也做了大量优化,包括CounterCell计数、扩容时的协助迁移机制。这些细节能展开写一篇长文,但核心思想就一句话:把锁拆小,把CAS用起来。
4.4 CopyOnWriteArrayList的应用与禁区
CopyOnWriteArrayList是另一种思路:读多写少的场景,写的时候复制一份新数组,写完后把引用指过去;读永远读老数组,不加锁。原理就是“写时复制”。
它的优点是读操作完全无锁,迭代器是弱一致的,遍历时并发修改也不会抛异常。缺点是每次写都要复制整个数组,内存开销大;读到的数据可能不是最新版本,因为读的是旧数组引用。所以它只适合读多写少、对实时性要求不高的场景,比如缓存的list快照。
4.5 fail-fast与fail-safe机制
Java集合的迭代器分两类:fail-fast和fail-safe。
ArrayList、HashMap的迭代器属于fail-fast:内部维护一个modCount,每次结构性修改(add、remove、clear)都会modCount++。迭代器在遍历时校验modCount,一旦发现和预期值不一致,立刻抛ConcurrentModificationException。这个机制不是用来保证数据一致性的,而是“快速失败、尽早暴露bug”。
CopyOnWriteArrayList的迭代器属于fail-safe:它遍历的是创建迭代器时的数组快照,之后的修改不影响本次遍历。代价是弱一致性。
这里有个面试经常挖的陷阱:单线程下,遍历时自己用list.remove()删除元素也会抛ConcurrentModificationException,哪怕没有并发。原因是remove改变了modCount,而普通for循环里你没有通过迭代器的remove方法来同步更新expectedModCount。正确做法是用Iterator.remove()。
5. 面试高频问题与日常避坑指南
5.1 必背核心问题速查表
这些年我整理过一份集合高频题清单,每道题都能串出一串考点:
| 常见问题 | 核心得分点 |
|---|---|
| HashMap底层结构 | 数组+链表+红黑树,JDK1.8以后引入树化 |
| 为什么容量是2的幂 | 配合(n-1)&hash位运算,扩容效率高 |
| 负载因子为什么是0.75 | 空间和时间的折中,源码有泊松分布论证 |
| 链表长度到多少转红黑树 | 阈值8,且数组长度>=64 |
| ConcurrentHashMap怎么保证安全 | JDK1.8: CAS+synchronized锁桶头节点 |
| ArrayList和LinkedList区别 | 随机访问O(1) vs O(n),插入删除场景 |
| HashSet怎么去重 | 底层HashMap,key即元素,equals+hashCode双重判断 |
| TreeMap排序原理 | 红黑树,key实现Comparable或传入Comparator |
| hashCode和equals不一致会怎样 | HashSet可能把不同对象当成同一个,或者去重失效 |
| 并发map选什么 | ConcurrentHashMap,不要Hashtable |
5.2 equals与hashCode:HashSet去重的底层逻辑
这是最容易写错的点。HashSet去重靠两步:先用hashCode找到桶,再用equals比较桶内元素是否相等。所以Java有个硬性约定:equals相等的对象,hashCode必须相等;但hashCode相等,equals不一定相等(哈希碰撞)。
如果你重写了equals但没有重写hashCode,两个逻辑上相同的对象会被HashSet当作两个元素,去重失败。反过来,两个equals为false的对象hashCode相等就会落在同一个桶,只是影响性能,不会出大错。
写一个自定义对象存HashSet时,我建议用IDE自动生成equals和hashCode,别自己手敲。理由很简单:手写hashCode很容易写出不均匀的散列,比如全部返回固定值,功能上没错但性能灾难。
5.3 三个日常编码最容易踩的坑
坑一:Arrays.asList返回的不是可变List。
List<Integer> list = Arrays.asList(1, 2, 3); list.add(4); // 抛UnsupportedOperationExceptionArrays.asList底层是固定长度数组的视图,没有实现add/remove。想转真正可变的列表,得包一层:new ArrayList<>(Arrays.asList(...))。
坑二:subList是视图,不是副本。
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); List<Integer> sub = list.subList(0, 2); list.add(6); // 修改父列表 System.out.println(sub.size()); // 再操作sub时抛ConcurrentModificationExceptionsubList通过父列表的modCount做一致性校验,父列表结构变了,子列表立即失效。而且对subList的修改会直接反映到父列表。想要独立副本,请new一个ArrayList传进去。
坑三:foreach遍历时直接删除元素。
for (String item : list) { if ("bad".equals(item)) { list.remove(item); // ConcurrentModificationException } }正确做法是使用迭代器:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if ("bad".equals(item)) { it.remove(); } }Java 8以后还可以用removeIf一行解决:list.removeIf("bad"::equals),更优雅。
5.4 一条学习建议:源码要自己读一遍
面试前背答案和真正理解,差距在追问环节一下就暴露了。比如你背了“HashMap线程不安全”,我问你“JDK1.8的HashMap怎么处理并发put的?”你可能就卡住了。
我的建议是拿一个下午,把HashMap的putVal、resize、treeifyBin这三个方法从头到尾读一遍,每个if分支都想一下“为什么要这么写”。读完以后你会发现,集合框架那些“八股文”不再是孤立的知识点,而是一套自洽的工程决策。这套决策的推理过程,才是面试官真正想看的东西。
我个人在实际面试别人的时候,最认可能力的人都有一个共同特点:不是干巴巴背知识点,而是能讲出“这个设计是为了解决什么问题”“如果换一种方案会有什么代价”。这种能力的养成,除了动手写代码,就是老老实实读源码。
最后分享一个小技巧:在IDE里给HashMap、ArrayList、ConcurrentHashMap分别写一个断点,然后跑一段包含put、扩容、遍历的demo,单步跟进去看。你亲手看到链表变成红黑树的那一刻,比背十遍源码注释都管用。这套集合框架,不值得只停留在面经里,它是你每天写代码都在用的工具箱。