前两天帮一个朋友做模拟面试,连续三个候选人都在同一个地方卡了壳:HashMap的扩容过程。有个兄弟能把“链表转红黑树”讲得头头是道,但一问“扩容时元素的新位置怎么算”,他就愣住了,最后憋出一句“好像是重新哈希”。Java集合这块,在所有面试知识点里属于位置很特殊的一类——它不像算法题那样需要大量练习,也不像框架八股那样跟实际工作脱节,它考的全是你天天在写的代码。几乎所有面试官都会从这里切入,而且问法极其固定:先问ArrayList和LinkedList区别,再追到HashMap底层,最后落向ConcurrentHashMap。这一套连环问下来,一个人是背过答案还是真正写过、读过源码,立刻就能分辨出来。
这篇内容是我这几年面别人、也被别人面,攒下来的一份Java集合考点整理,覆盖了List、Map、并发容器、迭代器陷阱和视图类容器的坑。适合准备校招和社招的Java同学,也适合那种“天天用集合但没翻过源码”的开发者。花一两个晚上把它消化掉,面试时至少能接住八成追问题。
1. 一张考点地图:面试官问“集合”时到底在考什么
1.1 Collection与Map:先分清两条主线
Java集合框架整体上分两派:Collection体系存储单个元素,Map体系存储键值对。Collection顶层是Iterable,下分List、Set、Queue三个接口;Map是独立的一条线,不继承Collection。很多新手一上来就背一堆类的名字,却不知道这些类之间的血缘关系,面试官随便问一句“HashSet和HashMap什么关系”就露馅了。
先把这张关系图默画出来,而不是背出来:
- List:ArrayList、LinkedList、Vector(已过时,偶尔在问线程安全时被拎出来)
- Set:HashSet(底层是HashMap)、LinkedHashSet、TreeSet(底层是TreeMap)
- Queue:PriorityQueue(二叉堆)、ArrayDeque(循环数组)、LinkedList
- Map:HashMap、LinkedHashMap、TreeMap(红黑树)、Hashtable、ConcurrentHashMap
这张图的价值在于,面试官可以顺着任意一个节点往下深挖。比如看到了TreeSet,就会问“它为什么能排序”,答“底层是TreeMap,红黑树”,然后接着问“红黑树的特性”。你光背类名是不够的,得知道每个类底层长什么样,为什么存在。
1.2 高频考点与典型追问路线
我把集合面试题按照难度和频率排成一座金字塔:
- 第一层:API用法,比如ArrayList和LinkedList的区别、HashMap和Hashtable的区别。这部分是送分题,但送分题答不好扣分极其严重。
- 第二层:实现原理,比如ArrayList扩容机制、HashMap的put流程。这部分需要看过源码或者至少看过靠谱的流程图。
- 第三层:并发安全,比如ConcurrentHashMap为什么比Hashtable性能好、CopyOnWriteArrayList适合什么场景。
- 第四层:源码级细节,比如HashMap扩容时为什么节点要么在原位置、要么在原位置加旧容量,ConcurrentHashMap的size()怎么实现。
面试官的追问路线,我见过最多的是这么一套:ArrayList扩容倍数是1.5,为什么不是2倍?HashMap默认容量为什么是16?为什么负载因子是0.75?线程安全的HashMap应该用什么?ConcurrentHashMap JDK8做了什么改动?这套问题链从简单到难,每一环都叫“往深处挖一层”,一旦中途答不上来,他基本就知道你的上限在哪里了。
1.3 概率统计和集合八股的一个有趣交点
热搜词里有个“集合与概率统计的联系”,听着像数学话题,但Java集合八股里还真有一个非常经典的交叉点:HashMap的树化阈值为什么是8。JDK作者在源码注释里用泊松分布算过,在负载因子0.75的情况下,单桶内的链表长度达到8的概率大约是千万分之六,几乎不可能发生。也就是说,红黑树不是给正常数据准备的,而是为了防御恶意哈希碰撞导致的性能攻击。这个细节一讲出来,面试官就知道你不仅看了源码,还看了源码注释,印象分会明显不一样。
2. ArrayList与LinkedList:扩容、删除和时间复杂度的恩怨
2.1 ArrayList扩容:默认10,1.5倍,拷贝数组
ArrayList底层就是一个Object数组,名字叫elementData。无参构造创建的是一个空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,直到第一次add元素时才真正扩容到10。这个细节很多人不知道,以为new ArrayList()就分配了10个空间,实际上它连数组都懒的建。
每次add时,都要检查minCapacity是否大于当前数组长度,如果不够,就调用grow方法扩容。JDK8里扩容的核心代码是:
int newCapacity = oldCapacity + (oldCapacity >> 1);也就是老容量加老容量右移一位,等于1.5倍。然后调用Arrays.copyOf把老数组内容搬进新数组,这一步是O(n)的。如果预估数据量很大,最好在创建时指定初始容量,比如new ArrayList<>(10000),可以减少扩容次数。这里有个简单的计算:默认从10开始,1.5倍涨,涨到超过100万大约需要多少次?10乘以1.5的n次方大于100万,算下来n在28左右。也就是说,如果不指定容量,往ArrayList里塞100万条数据,底层会反复扩容约28次,每次都有一次全量拷贝。数据量小还好,数据量大了这个开销很扎眼。
另外一个容易被追问的点是ArrayList的最大容量。源码里定义了MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8,减8是因为部分JVM实现需要在数组头存一些元信息。当然没人真能用到这个上限,但面试时把这个数字带出来,能显得你确实翻过源码。
2.2 LinkedList:双向链表不是“万能快”
LinkedList底层是双向链表,每个Node持有前驱引用、后继引用和数据。头部插入和尾部插入确实是O(1),但是中间插入并没有想象中那么快,因为你在插入之前要先找到对应index的节点。LinkedList的get(int index)做了个优化:判断index离头部近还是离尾部近,然后决定从头往后走还是从尾往前,复杂度仍然O(n)。
实际工程里有个反直觉的现象:ArrayList的中间插入,不一定比LinkedList慢。因为ArrayList批量插入用的System.arraycopy是底层内存拷贝,速度非常快,而LinkedList需要先O(n)遍历定位,再逐个修改指针。很少见到LinkedList在性能上能真正干掉ArrayList的场景。官方文档其实也暗示过:如果只是想用栈和队列,优先用ArrayDeque,它用循环数组实现,内存连续,缓存友好,比LinkedList更省内存、更快。LinkedList如今存在的意义更多是作为一个“什么都能干”的兜底,而不是“最优选”。
2.3 删除元素时必踩的坑:正向遍历remove会漏删
这个坑我在实际代码里见过不止一次,也在模拟面试里考过不止一次。假设有一个List,内容是a、b、c、d,你想把所有元素都删掉,写这样一段代码:
for (int i = 0; i < list.size(); i++) { list.remove(i); }跑完之后你会发现,list还剩两个元素。原因很简单:删掉索引0的元素a之后,b自动前移到索引0,此时循环进入i=1,删掉的是c,b被跳过了。偶数个元素时删一半,奇数个元素时留下的更多。正确做法是倒序遍历:
for (int i = list.size() - 1; i >= 0; i--) { list.remove(i); }或者用迭代器的remove(),更稳妥的是JDK8之后的removeIf:
list.removeIf(item -> condition);明白了这个坑,才算真正理解了“删除元素会影响索引”这个最基本的动态数组特性,而不是死记“删除要倒着删”这个结论。
2.4 实践选型:别看单个操作复杂度
我把ArrayList和LinkedList的核心差异整理成一张表,面试前建议自己默写一遍:
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | Object数组 | 双向链表 |
| 随机访问 | O(1) | O(n) |
| 头部插入 | O(n),需要搬移 | O(1) |
| 尾部插入 | 分摊O(1),可能扩容 | O(1) |
| 中间插入 | O(n)但内存拷贝快 | O(n)需遍历定位 |
| 内存占用 | 连续内存,紧凑 | Node对象多,占用大 |
| 适合场景 | 绝大多数日常场景 | 有迭代器且频繁增删 |
我个人的工程原则很简单:没有明确需要就用ArrayList。真要用队列和栈,选ArrayDeque。LinkedList更像是面试里的话题担当,而不是实战主力。这个结论你可以放心带进面试里,只要能说出“为什么”,面试官不会反驳你。
3. HashMap:从底层结构到连环追问的完整拆解
3.1 JDK7的死循环与JDK8的红黑树
HashMap是Java面试八股里的“珠穆朗玛峰”,几乎每场必考。先说历史背景:JDK7的HashMap采用数组加链表,链表新增节点用的是头插法。头插法在高并发扩容时会形成环状链表,一旦形成环,get操作就可能死循环,CPU直接飙到100%。这是当年线上环境真实发生过的惨案,也是ConcurrentHashMap出现的原因之一。
JDK8砍掉了头插法,改为尾插法,从机制上避免了扩容死循环。同时引入红黑树:当单桶链表长度超过8,并且数组容量大于等于64时,链表会树化,也就是转成红黑树,把最坏情况的查找复杂度从O(n)降到O(logn)。面试经常追问:为什么是8而不是其他数?这个问题的答案就是我在前面提到的泊松分布,在负载因子0.75下,链长8出现的概率低到约千万分之六,所以正常业务数据几乎不会触发树化;真触发了一般是哈希函数变态或者遭遇了恶意数据。红黑树节点本身比链表节点大一倍左右,所以树化属于一种“用内存换安全”的防御机制,不是常态。链表长度从树退化为链表时,阈值是6,差2是防止频繁震荡。
3.2 put流程:从hash到插入链表
HashMap的put流程是面试官最爱要求你“画图讲”的题目。完整链路是这样的:
- 对key.hashCode()做一次扰动:h = key.hashCode() ^ (h >>> 16)。就是把哈希值的高16位和低16位异或,让高位信息也参与索引计算,降低碰撞概率。
- 计算桶下标:index = (n - 1) & hash,n是数组长度。
- 如果桶下标位置是null,直接newNode放入。
- 如果桶非空,先检查key是否equals,相等就替换value。
- 不相等就是哈希冲突,JDK8追加到链表尾部;如果当前链表长度超过树化阈值8,且数组容量大于等于64,转红黑树。
- 插入完成后,size自增,判断size是否大于threshold。threshold = capacity * loadFactor,默认是16乘以0.75等于12。超过就扩容。
这段流程里藏着一个高频追问点:为什么用&而不用取模%?因为当n是2的幂次方时,hash % n的结果和(n - 1) & hash完全等价,而位运算更快。HashMap保证容量永远是2的幂次方,就是为了这个位运算优化。
3.3 扩容机制:为什么是2的幂次方,什么又是高低位拆分
扩容发生在put后,当size超过threshold时,数组长度翻倍,从16变成32。JDK8的resize过程有一个非常经典的设计:扩容后每个节点要么留在原始索引位置,要么去“原索引+oldCapacity”的位置。为什么只需要这两种情况?因为数组长度翻倍,意味着索引计算的掩码从(n-1)变成了(2n-1),等于多出来一个二进制位。这个新增的高位,取决于hash值在该二进制位上到底是0还是1。是0就留在原地,是1就加上oldCapacity。所以JDK8在迁移时不需要重新计算每个节点的hash,只需要判断:
if ((e.hash & oldCap) == 0) { // 留在原位置 } else { // 原位置 + oldCap }这段代码在面试里出现频率很高,建议手写一遍。理解它之后,你就明白为什么HashMap的容量设计成2的幂次方是深思熟虑的结果,而不只是一个约定俗成的数字。
3.4 高频追问:null键、equals与hashCode
HashMap允许有一个null key,它的hash固定为0,坐在table[0]的位置。这个细节太容易被忽略了,但面试官偶尔会突然问一句“HashMap能不能存null”,你就得答出来。
另一个追问点是equals和hashCode的约定。为什么重写equals时必须重写hashCode?因为HashMap查找时先算hash定位桶,再在桶里用equals精确匹配。如果你重写了equals但没重写hashCode,两个逻辑上相等的对象很可能算出来的hash不同,被放进不同的桶,get的时候自然就找不到了。反过来,hashCode相等不代表equals相等,那只是哈希碰撞。对于自定义对象作为key的场景,建议用不可变类,否则字段变了,hashCode也会变,你再拿它去get原来的value就找不到。这些不是八股结论,是真实线上事故的根源。
4. ConcurrentHashMap的进化史与并发容器的选型思路
4.1 JDK7:Segment分段锁的巧妙设计
线程安全的Map,面试官会先问Hashtable,然后问能不能用Collections.synchronizedMap。这两个都是把整个map加一把全局锁,所有读写串行化,并发量一高就完蛋。ConcurrentHashMap就是要解决这个问题的。
JDK7的ConcurrentHashMap内部维护一个Segment数组,默认16个Segment,每个Segment继承ReentrantLock,相当于把整个map切成了16段,不同Segment之间的读写互不干扰。put的时候先定位到某个Segment,然后锁住这一段;get的时候不加锁,利用HashEntry的volatile字段保证可见性。这样就实现了“并发度16”的读写。size()方法比较有意思,它先乐观地读两次,如果两次之间没有结构性变化,就直接返回;如果有变化,再对全部Segment加锁重新统计。这种“无锁先试,不行再加锁”的思路,和乐观锁是一脉相承的。
4.2 JDK8:CAS加synchronized,锁粒度打到单桶
JDK8的ConcurrentHashMap把Segment整个废弃了,结构回归到和HashMap类似的Node数组。读操作依旧无锁,写操作分两种情况:如果桶位是空的,用Unsafe的compareAndSwapObject(CAS)直接把节点放进去,不需要加锁;如果桶位非空,就锁住桶位的头节点,再插入或更新。
这里锁粒度已经从“一段”细到了“一个桶”。不同桶之间完全无竞争,而且Java内置的synchronized在低竞争下开销比ReentrantLock更小。这个设计的精髓在于:用一个轻量的CAS尽量规避加锁,只有在真正发生冲突时才用synchronized托底。面试时可以顺嘴提一下tabAt、casTabAt这些Unsafe操作,以及扩容时ForwardingNode和helpTransfer——多个线程可以同时帮忙迁移数据,这就是并发扩容。
4.3 容易卡住的问题:get需要加锁吗?size怎么算?
get操作不需要加锁。因为Node里的key和val都被volatile修饰,数组引用也是volatile读,JMM保证了可见性。这是ConcurrentHashMap一个很大的优势:读读并发、读写并发都很温柔。
size()的实现也是重点。它维护一个baseCount,修改时先尝试CAS更新baseCount,冲突了再用CounterCell数组分段累加,最后把baseCount和所有CounterCell加总。这是LongAdder的套路,本质上和“分段锁”一个思路:分散热点,减少竞争。面试官问“size()为什么不是精确的”,你可以说在并发环境下它只能给出一个弱一致的结果,但大多数业务场景这个精度已经够了。
4.4 其他并发容器:别只会一个ConcurrentHashMap
并发集合不是只有Map。CopyOnWriteArrayList适合读多写少的场景,比如配置白名单、监听器列表。它的读不加锁,写的时候复制整个底层数组,再替换引用,代价是写操作极贵,并且迭代器是快照式的,读到的可能是旧数据。BlockingQueue是生产消费者模型的核心,ArrayBlockingQueue有界,LinkedBlockingQueue可选择有界,SynchronousQueue不存数据,直接交接。ConcurrentLinkedQueue则是纯CAS实现的无界队列。
我个人的选型表是这样:
| 场景 | 推荐容器 | 原因 |
|---|---|---|
| 并发读写的Map | ConcurrentHashMap | 桶级锁,读无锁 |
| 读多写少的List | CopyOnWriteArrayList | 读完全无锁 |
| 生产者消费者 | BlockingQueue | 自带阻塞与唤醒 |
| 高并发无界队列 | ConcurrentLinkedQueue | CAS入队出队 |
| 不需要并发的普通场景 | HashMap/ArrayList | 不追并发,性能最好 |
5. fail-fast机制与三种视图容器的隐藏陷阱
5.1 modCount:为什么foreach里remove会抛异常
很多人在实际开发里写过类似代码:
for (String item : list) { if (item.equals("a")) { list.remove(item); } }跑起来直接抛ConcurrentModificationException,而且很多人搞不清为什么单线程也抛。原因是ArrayList和HashMap维护了一个modCount字段,每次结构性修改(add、remove、clear)都会把它加1。迭代器创建时会记录expectedModCount,每次调用next时检查expectedModCount和modCount是否一致,不一致就抛异常。你的remove方法修改的是ArrayList的modCount,而迭代器不知道,所以它认为“集合被并发修改了”。
正确做法是用迭代器自己的remove方法,它会同步更新expectedModCount:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if (item.equals("a")) { it.remove(); } }JDK8之后直接用removeIf最省事。注意,HashMap遍历时也不能直接在循环里map.remove(key),同样会抛异常,要用entrySet().removeIf(entry -> condition)。
5.2 fail-safe为什么存在,以及它的代价
和fail-fast相对的是fail-safe机制,典型代表就是CopyOnWriteArrayList和ConcurrentHashMap的迭代器。这类集合的迭代器遍历的是创建时的一个快照,或者基于无锁数据结构,所以即使在遍历过程中集合被修改,也不会抛异常。
面试官常问:fail-fast这么多限制,为什么不都用fail-safe?答案在于代价。CopyOnWriteArrayList每次写都复制底层数组,如果写频繁,数组会被反复copy,性能和内存都受不了;ConcurrentHashMap的迭代器是弱一致性的,不能保证遍历时能看到最新数据。所以fail-fast是“用异常提醒你代码写错了”,fail-safe是“用一致性换取可用性”,两者没有绝对优劣。
5.3 Arrays.asList、subList和unmodifiableList三个大坑
第一个坑是Arrays.asList。很多人以为它返回的是一个普通List,直接调用add,结果抛出UnsupportedOperationException。它的底层是Arrays内部类,直接把数组包成List,所以长度是固定的,只能set,不能add/remove。想要真正可变的List,得这样:
List<String> list = new ArrayList<>(Arrays.asList("a", "b"));第二个坑是subList。subList返回的不是新List,而是原List的一个视图,共享底层数组。更危险的是,如果subList创建之后,原List发生了结构性修改,再操作subList会抛ConcurrentModificationException。反过来,你修改subList的内容,原List也会跟着变。所以subList适合临时读,不适合长期持有后操作。
第三个坑是Collections.unmodifiableList。它返回的是一个只读包装,直接调用add会抛异常,这个大家都知道。但少有人意识到,unmodifiableList包装的底层list如果变了,包装后的list也会变,因为它不是快照。要想获得真正不可变的快照,在包装前拷贝一份:
List<String> safe = Collections.unmodifiableList(new ArrayList<>(source));JDK9之后还有List.of,本身就是不可变,更省事,但要注意它不接受null元素。
6. 最后给面试者的一套自测清单和几句大实话
6.1 15个问题,答案在心里过一遍
我在模拟面试里经常用下面这套问题收尾,不用写代码,就是口述。你要是能一口气讲明白,集合这块基本就稳了:
| 问题 | 期望的答案要点 | 自我评分 |
|---|---|---|
| ArrayList扩容机制 | 初始10,1.5倍,Arrays.copyOf | |
| 为什么HashMap容量是2的幂 | 索引用位运算,扩容高低位拆分 | |
| HashMap树化阈值为什么是8 | 泊松分布,概率约千万分之六 | |
| JDK8 HashMap并发还会死循环吗 | 尾插法,不会死循环,但可能丢数据 | |
| ConcurrentHashMap JDK8怎么加锁 | CAS + synchronized锁桶头 | |
| ConcurrentHashMap怎么统计size | baseCount + CounterCell | |
| equals和hashCode有什么关系 | 相等则hashCode必等,反之不必然 | |
| HashSet底层是什么 | HashMap,key是元素,value是常量 | |
| TreeMap底层是什么 | 红黑树 | |
| 为什么LinkedList很少用 | 中间插入也需要遍历定位 | |
| foreach里remove为什么报错 | modCount不一致 | |
| Arrays.asList能add吗 | 不能,定长视图 | |
| subList改了原List会怎样 | 视图失效,抛并发修改异常 | |
| CopyOnWriteArrayList适合什么 | 读多写少,写复制 | |
| HashMap允许null key吗 | 允许,null key索引0 |
这些问题我每个都可以在2分钟内讲完,你要是有一个卡壳,就回到对应的章节再看一遍。
6.2 答不上来时,怎么补救而不是硬编
面试最忌讳的是不懂装懂。HashMap的扩容迁移代码你只听说过名词,没看过实现,被问到底时不要硬编,可以说:“我在源码里看过大概思路,是判断e.hash & oldCap的结果来决定留在原位置还是原位置加旧容量,但详细的链表拆分细节我记得还不熟。”这比你支支吾吾说“重新计算哈希”体面得多。
我自己的经验是,面试官其实并不期待你背出每一行源码,他更在意你遇到知识盲区时的反应。坦诚边界,然后把问题引导到你熟悉的邻近区域,比如“虽然这块我还没看仔细,但我对put流程的主干很清楚,我可以画一下”。能画出来,刚才那个没答上的细节,他可能就不追究了。
6.3 把八股变成肌肉记忆的几个习惯
背八股最容易出现的情况是:看了就忘,忘了再看,看了还是忘。我自己的做法是,把集合源码的注释当课文读,尤其是HashMap里的treeifyBin注释和grow方法的注释,那里有整个设计取舍的来龙去脉。然后手绘put流程图和扩容流程图,一遍不行画两遍,直到闭着眼能画出来。最后写几段验证代码,专门去踩那些坑——正向删主ArrayList漏元素、foreach里remove报异常、subList改原List,亲眼看到异常和结果,记忆才会深刻。
还有一个小技巧:面试前别说“我看了HashMap源码”,要说“我读过JDK8的HashMap实现,印象最深的是扩容高低位拆分那段”,这句话本身就是锚点,能引出你所有准备过的细节。面试官通常会顺着你的锚点走,而不是重新掷骰子出题。
八股这个词在圈里多少带点贬义,但集合这块的八股本质上就是源码阅读的沉淀产物。我见过太多人花力气去刷算法题,却很少专门读一遍自己天天在用的HashMap源码。其实这两个晚上读源码的投资回报率,在所有面试准备里是最高的。你不需要把每个类的每个方法都背下来,只需要把本文清单里的15个问题讲透,大部分面试官在集合这一环,就挑不出毛病了。