每年面试季,Java集合这块都是必考的重头戏。不管是校招还是社招,几乎每一轮技术面都会从集合切入——HashMap的底层原理、ArrayList和LinkedList的区别、ConcurrentHashMap的线程安全实现,这些题目就像面试的“基本功考试”,答不好后面聊得再嗨也白搭。我见过不少候选人项目经验挺扎实,结果栽在集合的追问上,一问底层就卡壳,很可惜。
所以这篇就把Java集合面试里最高频、最容易踩坑的考点一次性梳理清楚。我会按面试官习惯的考察路径来组织:先搭整体框架,再逐个击破List、Map、Set,重点讲HashMap和ConcurrentHashMap的底层实现,最后补充fail-fast机制和几个日常编码中特别容易出问题的细节。每一块都尽量从“面试官为什么会问这个”的角度去讲,把原理、源码、坑位都点透,保证你面完这一篇心里有底。
1. 集合面试的整体框架:先画地图再背细节
Java集合这块,面试官考察的其实就两件事:第一,你知不知道有什么;第二,你知不知道为什么。前者考察你对集合框架的熟悉程度,后者考察你对底层实现的真实理解。很多人挂在第二关,就是因为只记住了“是什么”,不知道“为什么”。
先看整体结构。Java集合框架主要分为两个体系:Collection和Map。Collection下面又有List、Set、Queue三个子接口,Map是独立的一套键值对体系。面试官特别喜欢让你从顶层接口开始说,层层往下讲,看你脑子里有没有一张完整的地图。
我把面试里最常见的考察点整理成一个速查表,方便你对照复习:
| 集合类 | 底层结构 | 线程安全 | 排序性 | 高频考点 |
|---|---|---|---|---|
| ArrayList | 动态数组 | 否 | 按插入序 | 扩容机制、与LinkedList对比 |
| LinkedList | 双向链表 | 否 | 按插入序 | 底层结构、插入删除效率 |
| Vector | 动态数组 | 是(synchronized) | 按插入序 | 与ArrayList区别、已经过时 |
| HashSet | HashMap | 否 | 无序 | 为什么用HashMap实现 |
| LinkedHashSet | LinkedHashMap | 否 | 按插入序 | 怎么做到有序的 |
| TreeSet | TreeMap(红黑树) | 否 | 自然序/定制序 | 排序原理 |
| HashMap | 数组+链表+红黑树 | 否 | 无序 | 高频中的高频,必问底层 |
| LinkedHashMap | HashMap+双向链表 | 否 | 按插入序/访问序 | 实现LRU |
| TreeMap | 红黑树 | 否 | 按key排序 | 与HashMap对比 |
| ConcurrentHashMap | 数组+链表+红黑树 | 是(CAS+synchronized) | 无序 | 线程安全实现、JDK1.7与1.8差异 |
这个表你要是能不看资料自己默写出来,说明基础框架已经上了。下面每个核心类我都会展开讲面试官最爱的追问点。
2. List篇:ArrayList与LinkedList对决
2.1 底层实现与时间复杂度对比
“ArrayList和LinkedList有什么区别?”这道题几乎每场面试必出,没跑。如果你只知道“一个底层是数组,一个是链表”,那只能算及格,面试官很快会深入问下去。
ArrayList底层是Object数组,所以它支持随机访问,通过下标取元素是O(1)的复杂度。而LinkedList底层是双向链表,每个节点都持有前驱和后继的引用,要访问第n个元素得从头部或尾部开始遍历,时间复杂度是O(n)。
插入和删除呢?这里有很多人答反了。很多人条件反射地说“LinkedList插入删除快,ArrayList慢”,这话只说对了一半。LinkedList的插入确实只需要修改指针,O(1)搞定位,但前提是你已经定位到了那个节点。实际场景下,如果按索引插入,LinkedList还是要先遍历找到那个位置,复杂度还是O(n)。而ArrayList在尾部插入是O(1),只有在中间插入时才需要搬移后续元素,是O(n)。
所以更准确的说法是:LinkedList适合频繁在头部或中间做插入删除、且不依赖随机访问的场景;ArrayList适合读多写少、或者只在尾部追加的场景。而且绝大多数业务代码里,ArrayList的表现反而优于LinkedList,原因后面我会讲。
还有一个细节:LinkedList还实现了Deque接口,可以当双端队列用,支持addFirst、addLast这些操作。面试官可能会顺嘴问一句“LinkedList能实现栈吗”,答案是能,官方文档推荐用ArrayDeque来实现栈,因为它不需要维护节点对象,内存更紧凑,性能更好。
2.2 ArrayList扩容机制细节
ArrayList扩容是八股文里的经典题。基础版本大家都知道:默认初始容量是10,每次扩容为原来的1.5倍。但我劝你尽量把源码细节也答出来,因为面试官想听到的不是一个数字,而是你对机制的理解。
用无参构造创建ArrayList时,其实底层是一个空数组,只有在第一次add元素时才真正初始化为容量10的数组。这是一个懒加载的设计,避免创建了对象却不使用就白白占用内存。
扩容发生时,会计算出新容量:oldCapacity + (oldCapacity >> 1),也就是老的1.5倍。然后调用Arrays.copyOf把老数组的元素整体拷贝到新数组里。这里有一个细节值得注意:如果oldCapacity是奇数,右移一位会向下取整,比如11扩容后是16而不是16.5,因为容量必须是整数。
为什么是1.5倍而不是两倍?这也可能是加分项的回答角度。1.5倍的扩容策略在空间和时间上做了权衡:扩容倍数太低,会导致频繁扩容、频繁拷贝,浪费性能;倍数太高,比如直接三倍,内存浪费明显。1.5倍是比较经典的折中方案,既保证一定的扩容频率控制,又不会让太多空间闲置。
2.3 Arrays.asList与subList的坑
面试官问集合的坑,有一半都会落在Arrays.asList和subList上,这两个都是实际开发中特别容易埋雷的地方。
先说Arrays.asList,它的返回值是一个Arrays类的内部类ArrayList,不是java.util.ArrayList。这个内部类直接复用了传入的数组,不进行拷贝,所以它有两个硬伤:第一,不能调用add和remove方法,会抛UnsupportedOperationException,因为并没有实现这两个方法;第二,修改返回的List,原数组也会跟着变,反之亦然。
再说subList,这个坑更深。subList返回的是原List的一个视图,不是复制出来的新List。对subList做的修改会直接反映到原List上。更危险的是,如果在subList操作期间原List发生了结构性修改(比如add或remove),再操作subList会抛出ConcurrentModificationException。
我工作中踩过一次很深的坑:用subList做分页,然后对子列表做批量删除,结果把原列表的数据也删了。排查了挺久才意识到是视图共享的问题。所以面试的时候你如果能主动讲出这个经验,比干巴巴背八股强多了,至少说明你真的写过代码、真的踩过坑。
3. HashMap:面试必被深挖的核心
3.1 底层结构与hash定位原理
HashMap是整个Java集合面试里的重中之重,没有之一。面试官对HashMap的考察几乎是穷尽式的,从数据结构到put流程,从扩容机制到并发问题,可以连环追问十几分钟不重样。这一块值得你多花时间。
先说底层结构。JDK 1.8之后,HashMap的底层是数组+链表+红黑树。数组也叫桶数组,每个桶的位置要么是null,要么是单个节点,要么是链表,要么是红黑树。为什么要引入红黑树?因为当多个元素哈希冲突严重,导致某个桶里链表很长时,查询复杂度会退化成O(n)。红黑树能保证最坏情况下的查找复杂度是O(log n),所以链表长度超过阈值8并且数组容量达到64时,链表会转换为红黑树。
接下来是hash定位。很多人以为HashMap就是直接用hashCode做下标,其实不是。key的hashCode先经过一次扰动:h = key.hashCode() ^ (h >>> 16),也就是把高16位和低16位做异或。这样做的目的是让高16位的特征也能参与到低位的计算中,减少冲突概率。
然后计算数组下标:tab[i = (n - 1) & hash]。这里用位运算而不是取模,前提是数组长度必须是2的幂。所以HashMap每次扩容都是翻倍,就是为了保持长度是2的幂,让位运算能替代取模运算,效率更高。这也解释了为什么HashMap对容量有“必须是2的幂”这个隐含要求——即使你传入一个不是2的幂的初始容量,它也会向上取整到最近的2的幂。
3.2 put流程完整走一遍
面试官让你“说说HashMap的put过程”,这几乎是必考题。我建议你把源码层面的完整流程背下来,但不要死背,而是理解每个分支为什么存在。
第一步,对key计算hash值,经过扰动处理。第二步,判断桶数组table是否为null或者长度是否为0,是则先resize初始化。第三步,用hash和(n-1)做与运算得到数组下标,如果该位置为null,直接newNode放进去。第四步,如果该位置不为null,说明发生冲突,先判断头节点是否和key相同(即hash相等且equals相等),是则覆盖value。第五步,如果头节点是TreeNode类型,说明该桶已经是红黑树,走红黑树的插入逻辑。第六步,否则说明是链表,遍历链表,如果找到相同的key就覆盖value并返回旧值,如果遍历到尾部还没找到,就在尾部插入新节点。第七步,插入完成后,检查链表长度是否超过8,超过则调用treeifyBin尝试转红黑树(但treeifyBin内部还会判断数组长度是否到64,不到就先扩容)。第八步,最后检查size是否超过threshold,超过则resize。
有个细节很多人容易漏:第五、六步里,如果找到相同key并覆盖value,这个操作是返回旧value的,而且不会触发后面的扩容判断,因为size并没有改变。这个点在面试官的追问里经常出现。
还有一个容易记混的地方:JDK 1.7是头插法,新节点插在链表头部;JDK 1.8改成尾插法,新节点接在链表尾部。为什么要改?主要是因为头插法在并发扩容时会形成环形链表,导致get时死循环。虽然HashMap本来就不保证线程安全,但1.7这个bug实在太出名了,面试官会特意拿来考察你对并发问题的理解深度。
3.3 扩容、树化、退化机制
HashMap的扩容机制也是重点。默认的负载因子是0.75,threshold = 容量 × 负载因子。当元素个数超过threshold时,触发扩容,新容量是旧容量的两倍,同时threshold也翻倍。扩容的过程是创建一个新数组,然后把旧数组上的所有元素重新散列到新数组上。
为什么负载因子是0.75而不是0.5或者1?这是空间和时间的一个平衡。负载因子太大,比如1,虽然空间利用率高,但冲突概率也随之上升,查询效率会明显下降;负载因子太小,比如0.5,虽然冲突少、查询快,但空间浪费严重,动不动就扩容。0.75是官方在大量测试后给出的一个较优值。在不能确定元素数量时,这是一个默认推荐的设置,我们能做的就是了解它的设计思路。
关于树化的两个条件要记牢:链表长度大于等于8,且数组长度大于等于64。两个条件必须同时满足,否则不会树化。如果只是链表长度到了8但数组长度还没到64,HashMap会优先扩容,通过扩容来打散链表。
为什么要阈值设成8?官方注释里给了一个泊松分布的推算,在负载因子0.75、随机hashCode的条件下,链表长度达到8的概率大约是千万分之六,非常低。设成8是为了保证树化只发生在极端冲突的情况下,平时大部分时间链表都很短,没必要树化。反过来,当红黑树的节点数少到6个时,会退化成链表,这样在频繁增删的场景下可以避免树和链表之间来回切换的性能开销。
3.4 为什么HashMap线程不安全
面试官问“HashMap线程安全吗”,标准答案是不安全。但你要能说出具体不安全在哪几个方面,这才显出功底。
第一,数据覆盖问题。两个线程同时put,恰好都定位到同一个桶位置,而且该位置是null,两个线程都会newNode然后赋值给该位置,后赋值的会把先赋值的覆盖掉,导致丢失更新。这个在JDK 1.7和1.8里都存在。
第二,size计数不准确。HashMap的size字段是普通的int,没有原子性保护。多个线程同时put或remove时,size的增减可能相互覆盖,导致最终的size和实际元素数不一致。
第三,JDK 1.7中的扩容死循环。这个前面提到过,头插法在并发扩容时可能导致链表成环,get操作会陷入无限循环。JDK 1.8改成尾插法后这个问题不复存在,但数据覆盖和size不准的问题依然在。
所以面试官如果问“那我想用线程安全的Map怎么办”,你要能给出三个选项并说明场景:ConcurrentHashMap(高并发首选)、Hashtable(全方法加锁,并发效率低,已过时)、Collections.synchronizedMap(包装类,原理也是整体加锁)。多数场景下直接选ConcurrentHashMap就行。
4. ConcurrentHashMap与并发集合的演进
4.1 JDK 1.7的分段锁设计
讲ConcurrentHashMap,最好从版本演进讲起,因为面试官很想听到“为什么1.8要抛弃1.7的设计”这个层面的思考。
JDK 1.7的ConcurrentHashMap采用的是Segment分段锁的架构。底层是一个Segment数组,每个Segment继承自ReentrantLock,可以看作一个小的Hashtable。默认是16个Segment,所以理论上并发度是16,也就是说同时可以有16个线程各自操作不同的Segment而互不竞争。
这种设计的优点是并发度比Hashtable整体加锁高得多,缺点是分段不均匀会导致某些Segment变成热点,而且一旦需要扩容,一个Segment只能内部自己扩容,不能整个Map一起扩。另外定位一个元素需要两次hash:第一次定位到Segment,第二次定位到Segment内部的桶位置。
4.2 JDK 1.8的CAS+synchronized改进
JDK 1.8对ConcurrentHashMap做了大刀阔斧的重构,彻底抛弃了Segment,直接用Node数组+链表+红黑树,和HashMap的数据结构几乎一致,只是在并发控制和关键节点上做了额外处理。
put流程上,1.8采用CAS + synchronized的组合。具体来说:计算hash后定位到桶位置,如果该位置为null,就用CAS直接放入,不需要加锁;如果该位置不为null,就用synchronized锁住这个桶的头节点,然后进入链表或红黑树的插入逻辑。这样锁的粒度从1.7的Segment级别细化到了单个桶级别,并发度进一步提升。
为什么1.8选择synchronized而不是ReentrantLock?这是面试官常问的一个点。诚实的回答是:synchronized在JDK 1.6之后做了大量优化(偏向锁、轻量级锁、锁升级),性能已经不输ReentrantLock,而且synchronized不需要手动释放锁,代码上更简洁,出错概率更低。在桶级别的细粒度场景下,synchronized的方案既简单又高效,官方自然就这么选了。
还有一个独特的点是扩容协助机制。1.8的扩容不是单线程完成的,而是多线程可协助的。扩容时,会把任务分片,参与操作的线程在put或get时如果发现正在扩容,可以帮忙迁移一部分节点,而不是干等着。这个机制也是面试加分项,知道它就能说明你对源码的阅读不是停留在表面。
4.3 并发场景还该知道哪些集合
除了ConcurrentHashMap,并发的集合类还有几个,面试官可能会顺带考察,至少要知道它们存在的意义。
CopyOnWriteArrayList,写时复制的List,适用于读多写极少、数据量不大的场景。它读的时候不加锁,写的时候加锁,然后把整个底层数组复制一份,在新数组上修改,最后把引用指向新数组。缺点是每次写都有数组拷贝的开销,频繁写会很浪费。
CopyOnWriteArraySet,底层就是一个CopyOnWriteArrayList,用于去重并且写少的并发场景。
ConcurrentLinkedQueue,无界非阻塞队列,基于CAS实现,适合生产者消费者模式中吞吐量要求高的场景。
BlockingQueue是一族阻塞队列,实现类有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue等,在线程池里被大量使用。如果你要说线程池,那LinkedBlockingQueue和ArrayBlockingQueue的区别就是必考题。
5. Set与排序:容易被小看的高频考点
5.1 HashSet的实现套路
很多人背过HashSet的结论:底层是HashMap。但面试官如果追问一句“它怎么用HashMap实现的”,一时还是有不少人答不上来。
HashSet内部确实持有HashMap实例,它添加元素时,是把元素作为HashMap的key,然后所有value统一用一个固定值——一个静态的Object常量PRESENT。所以HashSet的底层就等于HashMap的key那一层,去重依靠的就是HashMap的key不能重复这个特性。
这个考点本身就很有代表性:Java里很多类的实现是“组合复用”,不是每个功能都从零写。HashSet和HashMap的关系就是一个典型的组合模式案例,理解了这一点,你对LinkedHashSet的问题也顺带懂了——LinkedHashSet是HashSet的子类,底层用的是LinkedHashMap,这样既能去重又能维护插入顺序。
另一个高频追问是equals和hashCode的关系。HashSet判断两个元素是否重复,会先用hashCode定位桶,再通过equals判断是否相等。所以如果你往HashSet里放了自定义对象,却不重写equals和hashCode,即使两个对象的业务字段完全一样,也会被认为是两个不同的元素。这个坑在开发中很常见,面试里也经常作为场景题出现。
5.2 TreeMap/TreeSet的排序逻辑
TreeMap和TreeSet是另一对需要了解的组合。TreeMap底层是红黑树,key会按照自然顺序或者你传入的Comparator进行排序。TreeSet底层是TreeMap,和HashSet用HashMap是同一套路。
这里要理解两个概念:自然排序和定制排序。自然排序是让key实现Comparable接口,重写compareTo方法;定制排序是在创建TreeMap时传入一个Comparator实现。两者不能同时存在,如果同时用了,定制排序优先。
红黑树的时间复杂度是O(log n),所以TreeMap的get、put、remove都是O(log n),比HashMap的O(1)慢,但它的优势在于key是有序的,可以方便地获取最小key、最大key、按区间遍历等。面试中经常让你对比HashMap和TreeMap,核心维度就三个:顺序性、时间复杂度、是否允许null。
需要注意的是TreeMap不能有null的key,因为红黑树需要基于key进行比较排序,null无法比较。而HashMap允许一个null key,因为它在存储null时统一放到下标0的桶里。
5.3 Comparable与Comparator答题要点
Comparable和Comparator的区别是基础中的基础,但面试官会变着花样问。最简单的问法是“说说区别”,你至少要答出三四个层次,才能算过关。
第一,包路径不同:Comparable是java.lang包下的,Comparator是java.util包下的。第二,位置不同:Comparable是让类自己实现,是“自身具备比较能力”;Comparator是单独写一个比较器,是“外部定义比较规则”。第三,方法不同:Comparable只有一个compareTo方法;Comparator有compare方法,而且从JDK 8开始还有一堆默认方法,比如thenComparing、reversed,可以链式组合多个比较规则。
从设计角度说,如果一个类的自然排序是稳定的、明确的,比如Integer的自然排序就是按数值大小,那就用Comparable;如果同一个类有不同的排序需求,比如员工按年龄排、按工资排、按工号排,那Comparable只能有一种实现,剩下就得靠多个Comparator来实现。
在面试里如果被问到排序,你可以顺手补一句:Collections.sort内部调用的其实是List.sort,而List.sort在JDK 8之后默认走的是Arrays.sort,对对象数组用的是TimSort算法,对基本类型数组用的是双轴快速排序。能说清楚这个调用链的人不多,属于高阶加分项。
5.4 快速排序与冒泡排序的Java实现
集合排序里经常捎带考察排序算法,尤其是冒泡排序和快速排序,这俩属于手写题里的常客。我这里把基础版本放出来,你自己跑一遍,顺便思考一下每个步骤的时间复杂度。
冒泡排序的Java实现,核心思想是相邻元素两两比较,大的往后挪:
public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { // 加个标志位,如果某轮没有交换说明已经有序,提前结束 boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) { break; } } }快速排序的Java实现,核心是分治+基准值:
public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivot = arr[left]; int i = left; int j = right; while (i < j) { while (i < j && arr[j] >= pivot) { j--; } while (i < j && arr[i] <= pivot) { i++; } if (i < j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } arr[left] = arr[i]; arr[i] = pivot; quickSort(arr, left, i - 1); quickSort(arr, i + 1, right); }手写算法的时候有几个细节容易出问题:递归的终止条件别忘了写;快排的双指针扫描,右边先动还是左边先动,取决于基准值选取的位置,你要是基准值取left,那就必须先让right指针向左找小于基准值的元素,否则正确性会出问题。面试手写时我建议先选一个简单的不容易出错的算法,比如递归快排加数组copy,虽然性能差一点,但不容易写错。
6. fail-fast机制与日常编码中的集合坑
6.1 modCount与ConcurrentModificationException
fail-fast机制,直译是“快速失败”,是Java集合框架里的一个安全设计。它的原理是:集合内部维护了一个modCount字段,记录结构修改次数(add、remove、clear等会导致结构性修改的操作都会让modCount加1)。迭代器创建时会保存当前的modCount作为expectedModCount,每次迭代都会检查这两个值是否一致,不一致就抛出ConcurrentModificationException。
这个机制的目的是尽早暴露并发修改问题,而不是等到出现不确定的结果才报错。在单线程下,如果你在遍历一个ArrayList的同时调用add或者remove,也会触发这个异常。很多初学者遇到这个异常一脸懵,其实记住一句话就够了:不要在foreach循环里直接改集合结构。
要特别留神的是,这个检查是在迭代器的next方法里做的,所以有时候你改了之后报错的位置可能隔了几次循环才出现,看起来像是随机崩溃,其实是必然的。
6.2 遍历时安全删除的正确姿势
那遍历时想删元素怎么办?这是面试里经常出的场景题。有几种安全的方式,我按推荐度排个序。
第一,使用迭代器自己的remove方法:
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if ("需要删除的".equals(item)) { iterator.remove(); } }这才是foreach循环在语法层面隐藏的那个迭代器,区别在于调用的是迭代器自己的remove,它会同步更新modCount和expectedModCount,所以不会触发fail-fast异常。
第二,使用JDK 8的removeIf方法:
list.removeIf(item -> "需要删除的".equals(item));这是最推荐的方式,一行代码搞定,内部就是用迭代器实现的,安全又简洁。
第三,倒序遍历索引删除:
for (int i = list.size() - 1; i >= 0; i--) { if ("需要删除的".equals(list.get(i))) { list.remove(i); } }倒序删除不会影响前面元素的下标,所以不会漏删,也不用担心索引越界。这个方式虽然能用,但不够优雅,应付笔试时可以写。
我在面试里见过不少候选人,表达上会说“用迭代器删除”,但真让他手写时,写的却是forEach里调list的remove方法,这就是理论和实践的差距。建议自己把上面三种方式都跑一遍,感受一下异常到底在哪行触发。
6.3 集合判空、初始化容量等编码建议
集合这块的编码规范,也是面试中可能出现的“软问题”,面试官不直接问八股,而是让你评价一段代码或者让你改进。
第一,判断集合是否为空,不要用list.size() > 0,因为可能为null导致空指针。正确姿势是有判断工具类就用,比如org.apache.commons.collections.CollectionUtils.isEmpty或者JDK 9之后的List.of,否则就手动判断null。很多人写代码习惯只判断size,结果线上环境被空指针搞了一次才长记性。
第二,使用HashMap时建议指定初始容量。如果已知数据量大约是10000,直接new HashMap<>(10000)只是字面上对,实际上HashMap会把容量向上取整到2的幂,也就是16384而不是10000。如果你不想触发扩容,initialCapacity应该按照“数据量 / 负载因子 + 1”来估算。这是很多大厂面试中问得比较刁钻的细节。
第三,Arrays.asList的返回值不能增删,这个问题前面说过,开发中也是高频踩坑点。建议创建可变集合时直接new ArrayList<>(Arrays.asList(...))包一层。
第四,集合里存的如果是自定义对象,一定要确认equals和hashCode是否重写,否则去重、包含判断、Map的key都会出问题。
7. 面试答法:怎么把八股说成自己的经验
7.1 面试官追问的常见套路
背八股最大的问题是:你背的是标准答案,面试官问的是真实场景,两者一旦错位,就露馅了。我总结几个常见追问,你可以提前演练。
第一,“你刚才说HashMap在链表长度超过8时会转红黑树,为什么不一开始就用红黑树?”这个问题考的是你对红黑树特性的理解。红黑树虽然查询快,但每个节点需要用额外的指针维护父子关系,内存占用比链表节点大,而且插入和删除时需要旋转来维持平衡,吞吐量反而不如链表。所以只在冲突严重时才转,避免常态化的额外开销,这就是“空间换时间但只在必要时换”的思想。
第二,“集合里的元素是自定义对象,你重写equals但没重写hashCode,会发生什么?”这个问题的坑在于违反了equals和hashCode的约定:equals相等要求hashCode必须相等。如果你只重写equals,两个equals相等的对象可能hashCode不同,在HashSet或HashMap里会被分到不同桶,导致equals判断完全失效。
第三,“HashMap的key如果是null,它放在哪里?”答案是数组下标0的位置。因为null无法计算hashCode,HashMap内部做了特殊处理,把null的hash直接当成0。而ConcurrentHashMap不允许null的key和value,目的是避免多线程下二义性——线程读到null时无法区分是key不存在还是value本来就为null。
第四,“如果让你设计一个LRU缓存,你会怎么做?”这道题LinkedHashMap的默认实现就是很好的答案基础。LinkedHashMap维护了一个双向链表记录访问顺序,构造时传入accessOrder=true,每次get会把节点移动到尾部,这样头部就是最久未使用的,容量满了就删除头节点。
7.2 个人经验:准备这一块的方法论
集合这块我面试过不少人,也反复研究过自己的学习方法,有几点心得想分享。
第一,八股一定要结合源码背。你直接背“链表长度超过8转红黑树”很容易,但面试官一问到机制里的细节就暴露了。真正的做法是把HashMap那几个核心方法的源码读一遍,逐行理解每一个分支,把过程真正吃透,这样无论面试官怎么换着花样问,你都能用源码逻辑兜底。
第二,把集合串成一条线来记忆。不要孤立地背每个类,而是从ArrayList到Vector、从HashMap到Hashtable到ConcurrentHashMap,把“为什么会出现后面这个类”作为主线,你就自然理解了整条演进逻辑。面试官要的也是这种抽象能力。
第三,动手写一遍核心场景的错误代码。比如故意在foreach里删除元素触发ConcurrentModificationException,看看异常是什么时候抛出的;比如用subList做修改看看原List有没有跟着变。踩过这些坑之后,你讲出来的经验才是活的。
第四,提前把自己项目里的集合使用场景准备一个。面试官问到集合时大概率会加一句“你项目中哪里用到了”,这时候如果你能说一个真实案例,比如用ConcurrentHashMap做本地缓存、用ArrayBlockingQueue做异步削峰,你的回答就比单纯背八股高出一个档次。
集合这部分面好了,面试官对你的评价通常不会低,因为它是Java功底最直接的体现之一。我建议你把上面每一个考点都自己验证一遍,该写代码的写代码,该看源码的看源码。资料背得再熟,不如自己动手跑一遍记得深。祝面试顺利。