今年春招那段时间,不少读者跑来问我同一个问题:有份"Java集合框架65道面试题"的清单到底该怎么刷?有人对着题号挨个背,有人只挑HashMap的题,有人刷完两遍还在ArrayList和LinkedList上栽跟头。我把这份清单从头到尾过了一遍,结合自己这些年面试别人和被别人面试的经验,把我认为真正值得下功夫的考点、最容易踩的坑、以及答题时"怎么讲才能让面试官眼前一亮"的思路,整理成下面这篇文章。
这份题单适合谁?一种是刚开始准备校招、基础还不牢的同学,可以用它做查漏补缺的索引;另一种是有两三年经验、想跳槽往高级岗走的开发,你需要关注的不是题目本身,而是题目背后那一层源码逻辑和设计取舍。本文不打算一行一行翻译所有答案,而是按"从高频到冷门、从原理到坑"的顺序,把65道题拆成六大板块来讲清楚。
1. 面试官出这65道题,真正想考你的三件事
1.1 集合框架的本质:一套数据结构,还是一份工程化设计?
很多同学把集合框架当成"数据结构题"去准备,链表、哈希表、树搞得门清,但面试官问"为什么JDK要设计List、Set、Map三个顶层接口"、"为什么不直接用数组"这类问题时还是会懵。原因在于,集合框架本质上是一套覆盖了工程常见场景的数据结构工具箱,它解决的不只是"存取快不快",还包括"是否支持重复元素"、"是否要求有序"、"是否能并发修改"、"能否在遍历时删除"这些工程问题。
理论数据结构课讲究时间复杂度模型,面试题则偏向"什么时候用哪个实现类"。比如时间复杂度,数组的随机访问是O(1),链表是O(n),这个谁都会背;但问到"ArrayList插入元素为什么不一定比LinkedList慢",很多人就答不上来了。原因很简单,ArrayList的System.arraycopy是底层批量内存拷贝,在数据量几百到几千这个区间里,它往往比LinkedList逐个new Node要快,复杂度分析在这个量级下是失效的。这种题想考的就是你有没有真正在业务里观察过数据结构的实际行为,而不只是背结论。
我面试时问过一个候选人:"如果我要维护一个有序的用户列表,新增操作很频繁,用什么?"他答了TreeSet,我再追问"那用户有重名怎么办",他卡住了。其实答案是什么不唯一,但你必须意识到,这个问题本来就在考你对"有序、可重复、CRUD频率"这三个约束的权衡能力,这就是集合框架的工程属性。
1.2 65道题的隐藏比例:基础结构、Map、并发、遍历四块
把65道题按考察方向拆开,你会发现命题人其实有很明确的侧重。以我经手的题单和多年的面试观察来看,大约有20道集中在Collection接口下的List和Set,25道左右围绕Map展开(其中HashMap独占大头,还会附带TreeMap和LinkedHashMap的对比),剩下15道左右覆盖并发集合与迭代器,其余的零散分布在排序、比较器、Collections工具类和历史集合类上。
这个比例透露了一个信息:面试官对HashMap的偏爱是压倒性的,因为它能同时考到哈希算法、内存布局、扩容策略、树化退化、并发安全五个层级,一道题就能筛出你处于哪个水平。如果你只有一周时间准备,先打透HashMap,性价比最高;如果时间充裕,再按"Collection → Map → 并发 → 工具类"的顺序去补全整个地图。
2. ArrayList扩容公式、Set去重陷阱、LinkedList的真实用武之地
2.1 ArrayList扩容:数学公式怎么算,面试时怎么答
ArrayList是Java面试里必问的第一道菜,最常见的问题是"说一下ArrayList的扩容机制"。及格水平的回答是:默认容量10,当add元素超过容量时,oldCapacity右移一位加上自身(即1.5倍扩容),以Arrays.copyOf把旧数组内容搬到新数组。优秀水平的回答,还得带上两个细节。
第一个细节是扩容时的容量计算公式:newCapacity = oldCapacity + (oldCapacity >> 1),右移一位就是除以2,所以是1.5倍。为什么选1.5而不是2?因为如果扩成2倍,虽然整体搬运次数会减少,但空间浪费严重;1.5倍在时间和空间之间取了一个折中。你甚至可以提一下,如果构造时能预估大小,用new ArrayList<>(expectedSize),java.util.ArrayList并没有真正帮你做"按预估值精准分配"——扩容公式在addAll时用的是Math.max(实际需要的最小容量, 原容量 * 1.5),如果你传入expectedSize = 10万,它并不会直接给你开10万的数组,而是只要原数组1.5倍够用就按1.5倍来。
第二个细节是大批量添加时的性能陷阱。你往ArrayList里addAll一个10万元素的集合,它会先扩容到15万,再把10万搬过去,导致有5万的"闲置容量"一直占着内存。对内存敏感的业务,add完记得trimToSize。这里我建议你答一个"负面经验":某系统里我见过有人反复对一个大ArrayList调用remove(0),结果耗时从几十毫秒涨到几百毫秒,这就是因为remove(0)每次都要整体前移,复杂度是O(n),循环下来成了O(n²),这是ArrayList最典型的滥用场景。
2.2 HashSet去重的真相:equals和hashCode的约束,可变对象的坑
HashSet去重是另一道高频题。表面答案是"HashSet通过hashCode定位桶,如果桶内已有元素再通过equals比较,都相同就认为是重复元素,不再插入"。但出题人喜欢连着问一句:"如果我把一个对象放进HashSet后,修改了它的hashCode相关字段,会发生什么?"
答案是:这个对象会驻留在错误的桶里,再也无法被get和remove命中,造成内存泄漏式的问题。严谨一点的表述是,HashSet底层就是一个HashMap,放入的元素作为key,统一的PRESENT对象作为value。当元素的hashCode计算字段被改动,HashMap的位置索引已经失效,但这个旧引用还挂在桶链表/红黑树中。官方文档明确说"必须谨慎地避免可变对象作为键"。我答这道题的时候习惯补一句高端操作:如果确实有这种需求,要么把参与hashCode的字段设计成final,要么在修改字段前先从集合里remove出去,改完再加回来。
另外,很多面试题会问"HashSet判断两个不同对象相等,需要重写哪些方法"。答案不是只重写equals,而是equals和hashCode必须同时重写,并且遵循"equals相等,hashCode一定相等"的约定。反过来不成立——hashCode相同,equals可以不等,这在哈希冲突时本来就会发生。答到这里可以举一个实际案例:某业务代码把BigDecimal作为HashSet的元素,2.0和2.00的equal比较是true的,如果BigDecimal用的是new BigDecimal("2.0")和new BigDecimal("2.00"),它们的hashCode可能不一样,去重就会失效。这个坑在我接触的项目里真实出现过。
2.3 LinkedList被高估也被低估:它适合什么场合
LinkedList的面试题,常规套路是"ArrayList和LinkedList的区别"。绝大多数人回答的是"数组 vs 双向链表,随机访问 vs 插入删除"。这个答案只能拿到及格分,因为它在多数真实场景下是错的——基于内存拷贝的ArrayList在做"尾插"和"指定位置插入但不触发扩容"时,实际性能常常优于LinkedList;而LinkedList的add在指定下标时也需要先遍历到那个位置,一样是O(n),没有优势。
那LinkedList到底什么时候赢?当你的业务是高频的队头出队和队尾入队,也就是它从头尾两端操作只需要O(1)指针修改,不需要移动数组元素时,它确实有不可替代的优势。此外,LinkedList实现了Deque接口,本质上是Queue和Stack的双重替代品,java.util.ArrayDeque在大部分场景下比它更快(基于循环数组)。所以真正让LinkedList不可被替代的,不是"列表",而是"双端队列"。你可以用这个角度回答"LinkedList还有什么用处",面试官会对你记忆更深刻。
3. HashMap八连问:从哈希散列到红黑树,一道题吃透整条链路
3.1 哈希函数的设计:为什么要异或hashCode的高低位
HashMap的第一问通常是"HashMap的哈希函数怎么设计"。源码很简短:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这行代码的意思是:把key的hashCode高16位和低16位做异或,再作为寻址的输入。为什么要这么干?因为HashMap计算桶下标用的是(n - 1) & hash,n是数组长度,默认是2的幂次。如果数组长度是16,二进制就是0000...00001111,那么hash值的低4位决定了落在哪个桶,高28位全部浪费。如果两个key的hashCode在高位不同、低位相同,它们就会被映射到同一个桶,形成大量冲突。
通过h ^ (h >>> 16)把高位信息"折叠"到低位,让低位尽可能混合高位的特征,从而在数组长度较小的时候也能均匀散列。这一行代码也解释了为什么HashMap要求数组长度必须是2的幂次——用位运算替代取模,前提就是n是2的幂,这样(n-1)&hash等价于hash%n且更快。这里我建议你主动补一句:Java 17之后HashMap引入了Key自制哈希校验的增强,默认依然走上面的逻辑。
3.2 两个关键阈值:0.75、8和64,组合起来怎么讲
第二问是"加载因子为什么是0.75,链表什么时候转红黑树,为什么是8"。0.75的官方注释说的是"在时间和空间成本之间提供良好的权衡"。讲得再透一点:加载因子越大,空间利用率越高但冲突概率增加;加载因子越小,冲突减少但数组稀疏浪费内存。0.75是泊松分布推导出的经验值,配合默认容量16,也就是说HashMap在元素个数达到12时会触发首次扩容。
链表转红黑树的阈值是8,但有前提,判断条件是"链表长度达到8 && 数组长度达到64"。如果链表长度到8但数组长度不足64,优先扩容而不是树化,因为扩容会让链表被拆散。为什么是8?源码注释给出了一个泊松分布的推演,在随机哈希下,链表长度达到8的概率只有千万分之六,几乎永远不会发生。也就是说,一旦你真的见到链表长度超过8,说明hash函数设计有问题,或者被恶意构造了哈希碰撞,这时候才有必要用红黑树来对抗O(n)退化。
第三问会顺着往下走:"红黑树什么时候退化成链表"。答案是当树的节点数小于等于6时,会从红黑树退化为链表。这里注意6和8之间有一个差值2,这是为了留缓冲,避免元素在7和8之间频繁震荡时反复树化和退化。我在项目里真见过有人写代码让Map不停put/remove,导致链表和树来回切换,性能一落千丈——这个机制就是为了防这个。
3.3 扩容机制:rehash全程和并发下的死循环
HashMap的扩容是第四问,需要讲清楚三个点:什么时候扩容、扩多大、旧元素怎么搬。什么时候扩容,就是元素个数超过capacity * loadFactor = 12。扩多大,固定的2倍,16变32变64。旧元素怎么搬,不是直接复制数组,而是重新计算索引:因为新数组长度翻倍,(n-1)&hash的掩码多了一个bit,所以每个旧桶的元素要么留在原下标,要么挪到"原下标 + 旧容量"的位置。
这里有个进阶考点:JDK 8在扩容时对链表采用了尾插法,防止JDK 7头插法带来的死循环。经典的问题场景是:多线程并发put,导致某个桶的链表形成环,下一次get这个桶会进入无限循环。这是老版本HashMap在国际面试题里的"名场面",但如果你直接说"因为用了头插法所以会死循环",还不够。更标准的回答是:JDK 7的头插法在并发rehash时,两个线程同时操作同一个链表,会产生环状引用;JDK 8改成尾插法后,即使没有加锁,也不会形成环,但数据仍会丢失,所以不意味着线程安全。要答到这一层,面试官才会相信你不是背稿子,而是真读过源码里那段rehash的实现。
3.4 HashMap家族横向对比:Hashtable、TreeMap、LinkedHashMap
第五问到第七问通常会演变成一个对比题,问法像"HashMap和Hashtable的区别"、"什么时候用TreeMap"、"LinkedHashMap怎么保持顺序"。我整理过一张高频的对比结论:
| 特性 | HashMap | Hashtable | TreeMap | LinkedHashMap |
|---|---|---|---|---|
| 有序性 | 无序 | 无序 | 按key自然序或Comparator排序 | 按插入顺序或访问顺序 |
| 允许null键 | 允许 | 不允许 | 不允许(依赖比较器) | 允许 |
| 底层结构 | 数组+链表+红黑树 | 数组+链表 | 红黑树 | 数组+链表+红黑树+双向链表 |
| 线程安全 | 否 | synchronized加锁 | 否 | 否 |
| 初始容量 | 16 | 11 | 无固定 | 16 |
| 常用场景 | 通用键值存储 | 基本被淘汰的兼容类 | 需要范围查找/排序 | LRU缓存、需要保持顺序的场景 |
TreeMap值得单独花一点篇幅,因为它经常被误当成"有顺序的Map",很多人直接回答"TreeMap按key排序",这没错,但更值钱的理解是TreeMap实现了NavigableMap,支持subMap、headMap、tailMap这些范围查询,时间复杂度都是O(log n)。如果你的场景是"要实时给出某个时间区间内的所有订单",TreeMap比先排序再过滤的ArrayList方案要高效得多。LinkedHashMap的考点集中在accessOrder这个参数,构造时传入true会开启访问顺序,配合removeEldestEntry重写,可以轻松手写一个LRU缓存。面试官问"Redis的LRU怎么实现",你答"用LinkedHashMap的accessOrder模式原理类似,也可以用它做考点推导",立刻就能拉高档次。
4. 并发集合四件套:Vector、Hashtable、CopyOnWriteArrayList、ConcurrentHashMap
4.1 从全表锁到分段锁:并发容器演进里藏着设计思路
65道题里至少有四五道涉及线程安全的集合类,而且问法是递进的。第一层是"Vector和Hashtable为什么慢",第二层是"CopyOnWriteArrayList的原理",第三层是"ConcurrentHashMap为什么比Hashtable快",第四层是"BlockingQueue的应用场景"。想把这四层串起来回答,你需要抓住一条主线:锁粒度从大到小的演进。
Vector和Hashtable的年代,实现线程安全的方式最粗暴——在方法签名上加synchronized,等同于给整个对象加锁。任何线程调用方法都必须先获取对象锁,即使只是访问不同桶的元素也要互斥等待,并发度几乎等于0。这种方案叫全表锁,在低并发时代尚可接受,一旦并发量上来,锁竞争会让吞吐量直线下降。ConcurrentHashMap在JDK 7引入了分段锁,将数组逻辑分成16段,操作落在同一段才需要竞争锁,不同段可以并行访问,锁粒度下降了,并发度提高了16倍。JDK 8更进一步,虽然写法是synchronized + CAS,但锁的对象从"段"细化到了"单个桶的头节点",只有两个线程同时操作同一个桶才会互相等待。
这一演进过程几乎就是面试官想听的内容,你能把"锁粒度"这个维度贯穿到所有并发集合问题里,答题水平会明显高于只说"Hashtable是同步的所以慢"的候选人。
4.2 ConcurrentHashMap的写操作:CAS和synchronized怎么配合
如果你能讲到源码层面,面试官多半会追问"ConcurrentHashMap的put流程"。标准的拆解顺序是:先用(n-1)&hash定位到桶;如果桶为空,用CAS尝试把新Node放进桶,这一步不需要加锁;如果CAS失败,则说明桶非空,进入synchronized块,锁住这个桶的头节点,再走链表或红黑树的插入逻辑;插入后如果链表长度达到8且数组长度达到64,尝试转换成红黑树;最后检查整个map的元素数量,超过阈值则扩容,JDK 8的扩容支持多线程协助迁移旧桶,也就是sizeCtl由负数触发,各线程认领区间完成rehash。
这里有个容易漏的细节:为什么CAS只用于"桶为空"的情况,而不用在整个put流程里?因为CAS只能保证单点更新的原子性,链表插入涉及"改next指针"+"维护size"多个步骤,无法靠一次CAS完成,必须用锁把这段临界区保护起来。把这句话说出来,面试官会知道你对"CAS的适用边界"有真实的掌握,而不是背了网上那句"CAS+synchronized"就以为自己懂了。
4.3 CopyOnWriteArrayList的读写分离与BlockingQueue的三种缓冲语义
CopyOnWriteArrayList是面试题里的另一个常客,它解决的是"读多写少"的场景。读的时候不加锁,直接读底层的volatile数组;写的时候复制出一个新数组,在新数组上修改,再用volatile引用替换旧数组。这样读线程永远不必等待写线程,但代价是每次写都要复制整个底层数组,如果数据量大、写频繁,GC压力和内存开销会非常恐怖。
这道题的隐藏考点是"读到的数据可能不是最新的"。因为在替换数组的瞬间,可能有线程已经持有了旧数组的引用,它会继续读旧数据。对这个现象,你的标准话术是:CopyOnWriteArrayList提供的是弱一致性,它只保证最终读到某一时刻的完整快照,不保证实时一致。如果面试官拿这个反问"那它算线程安全吗",你要能回答"线程安全不等于强一致,它ArrayIndexOutOfBoundsException是不会发生了,但可见性延迟是设计选择,不是Bug"。这个回答在真实的资深岗位面试里很加分。
BlockingQueue的题更贴近实际工程。ArrayBlockingQueue基于循环数组,take和put用同一把锁;LinkedBlockingQueue在JDK里默认无界,生产环境必须传入capacity限制,否则生产者暴涨会拖垮内存;SynchronousQueue不存储元素,每个put必须直接匹配一个take,适用于"零缓冲"的直传模型。这三种队列语义正好对应线程池的三种任务缓冲策略:有界队列能限流,无界队列在流量小的时候业务简单,SynchronousQueue适合"不排队、来了就执行"的场景。回答的时候,把队列和线程池的拒绝策略挂上钩,比单纯背诵接口方法要有效得多。
5. 遍历和比较器里的失分区:ConcurrentModificationException、Comparable与Comparator
5.1 fail-fast机制:迭代器为什么不让你在遍历时直接remove
面试题里有一道经久不衰的陷阱题:"如下代码会抛什么异常,为什么,怎么解决":
List<String> list = new ArrayList<>(); for (String s : list) { if (s.equals("a")) { list.remove(s); } }答案是抛出ConcurrentModificationException,原因在于ArrayList的迭代器内部维护了一个expectedModCount字段,每次调用next()时都会检查它是否等于ArrayList的modCount。modCount每次结构性修改(add、remove、clear)都会自增;而上面的代码通过list.remove修改了list,迭代器里的expectedModCount却没有同步更新,于是两次数值不一致,迭代器就判定"发生了并发修改",立刻抛出异常。
注意,这个检查只在next和remove方法里做,如果修改发生在迭代结束后,就不会抛异常,这也是为什么有的代码"偶尔不报错"——它不是没修改,而是还没来得及检查到。这个机制的学名叫fail-fast,事件上它保护的是程序的确定性:宁可快速抛错暴露问题,也不要脏数据一路跑到系统深处。解决方案是调用迭代器自己的remove方法,因为迭代器每次remove之后会同步更新expectedModCount。我来面试的时候,还会提醒一句:forEachRemaining是Java 8之后的迭代器方法,它同样会触发fail-fast检查,千万别在lambda里对集合做结构性修改。
5.2 Comparable与Comparator:排序规则的两种设计选择
"Comparable和Comparator的区别"这个问题的标准回答有三个要点。第一,Comparable定义在类内部,意味着"这个类天生具备一种排序方式",compareTo是它的自然排序;Comparator定义在外部,意味着"排序规则独立于类的实现",可以在不修改类代码的情况下,为同一个类定义多种排序规则。第二,使用习惯上,Collections.sort(list)依赖元素自身实现Comparable;Collections.sort(list, comparator)则是把排序规则以参数形式传入。第三,有一个容易忽略的约束:如果同时存在两者,Comparator显式传入时会覆盖自然排序。
这题要答出高级感,可以加一段实际踩坑经历。比如某系统里用户对象实现了Comparable,rule是"按年龄升序",后来产品需求变了,要在某些界面按"最后登录时间降序"。如果直接改compareTo,依赖自然排序的所有逻辑都会跟着变,很容易误伤。正确做法是保留自然排序,为"最后登录时间"新建一个Comparator。这道题给你上的价值课是:Comparable本质上是领域内最被认可的默认顺序,不适合频繁变动;Comparator才是应对多变业务规则的利器。
5.3 迭代器里的removal和sort排序的稳定性
sorted在非稳定排序的语境里还有一道衍生题:"Collections.sort用的是稳定排序吗?"ArrayList里的sort走的是Arrays.sort,对对象数组采用TimSort,是稳定排序。TimSort的核心是识别出数组里已天然有序的片段(run),然后把这些run合并起来。它的复杂度在最坏情况下是O(n log n),在近乎有序时能做到接近O(n)。回答的时候,把稳定排序的定义(相等元素的相对位置不变)和TimSort能兼顾"随机数据"和"部分有序数据"这两个特性讲出来,这道题就算答圆满了。
另外,Java 8的Stream的sorted方法也是稳定排序,适合做"先按A排序再按B排序"的多级排序场景:只要按B先sort,再按A sort一次,整个结果就会在同一A值内保持B的相对顺序。这种用法在写报表排序的时候很实用,值得在回答"稳定排序有什么应用"时举出来。
6. 题单之外:源码阅读方法和刷题策略的实战建议
6.1 怎么把"看过源码"从简历加分项变成面试保命题
很多面试题最终会落脚到"你有没有读过源码"。这里的分寸很重要。如果你的真实经验只有"看过transferFrom的消息通知",在面试时一定要说清楚自己看到哪一层。比如HashMap,你可以说"我读完了put、get、resize和treeifyBin这几个方法",然后当场复述一下treeifyBin的触发条件"链表长度>=8 && 数组长度>=64,否则走resize"——这就够了。最怕的是在简历上写"精通HashMap源码",结果连resize里链表拆分是按(e.hash & oldCap)判断都说不出来,那样反而会直接送掉offer。
如果你还没有完整读过源码,我给你一条务实的路线:先读ArrayList和LinkedList的add/remove,建立线性结构的直觉;接着读HashMap的hash、put、resize,把哈希和扩容的完整链路打通;然后读ConcurrentHashMap的put方法,顺着那两三个synchronized块理解锁粒度;最后读CopyOnWriteArrayList的add操作,理解写复制机制。这四段读透,题单里60%以上的源码类题你都能从容应对。
6.2 65道题的推荐刷题顺序与时间分配
刷题顺序比刷题数量更重要。我建议把65道题分成三轮。第一轮只做"输出型":对着题单的题目,不看答案,用30秒口头讲一遍思路,卡住的地方直接标记,这一轮的作用是暴露盲区。第二轮做"深化型":对高频题(ArrayList扩容、HashMap的put、fail-fast、Comparable与Comparator),不只是讲思路,还要能写出关键的伪代码或源码片段。第三轮做"综合型":把相关联的题串成链条来答,比如从"HashMap为什么线程不安全"串到"ConcurrentHashMap怎么解决",再从ConcurrentHashMap串到"CopyOnWriteArrayList怎么工程化取舍",把零散知识点变成网状结构。
时间分配上,如果总共有两周,我建议第一周按照"基础结构40%、Map 60%",第二周按"并发集合40%、遍历排序30%、工具类30%"来安排。不必一味追求"全部背熟",而是要确保自己熟悉到"能指导别人怎么写代码"的程度——面试官判断你懂不懂,往往不是看你答对了多少,而是看你在讲到某个机制时,会不会主动把"为什么会这样设计"的下半句说出来。
6.3 一道题从"能答"到"让面试官记你":三步加长法
最后分享一个我自己多年面试下来觉得最有效的答题技巧:三步加长法。第一步,先给出精确到关键数字的答案,比如"默认负载因子0.75,容量16,扩容阈值12"。第二步,给你的答案补一点设计初衷,比如"0.75是空间和时间的权衡,避免过早扩容导致内存浪费,也避免过晚扩容导致冲突密集"。第三步,给一个工程上的延伸,比如"如果我能预估元素量是2万,我会直接用new HashMap<>(20480)来初始化,减少扩容带来的复制开销"。
这个方法在HashMap这种题目上效果非常明显。很多人在第一步就停住了,面试官听完内心平静;能做到第二步的,已经超过了大多数人;能稳做第三步的,面试官就会在评价里写"有工程经验、有设计意识"。这65道题里,凡是涉及到"为什么""默认值是多少""什么场景用哪个"的,都值得照这个三步法过一遍。你不需要等面试官挖你,挖是挖不到亮点的——亮点要你自己主动给出去。