Java集合核心原理与面试指南:从ArrayList到HashMap深度解析
2026/9/9 11:51:38 网站建设 项目流程

1. 从"会用"到"懂原理":Java集合复习到底在复习什么

说实话,Java集合这块东西,很多写了三五年代码的人也不敢拍胸脯说完全搞懂了。平时CRUD写得飞起,ArrayList一把梭,HashMap用得滚瓜烂熟,可真到了面试或者线上排查问题的时候,才发觉自己只是"会用",不是"懂原理"。

集合这个知识点比较特殊——它不像JVM调优那样偏门,也不像并发编程那样陡峭,它就是日常开发里每天都要碰的基础设施。但也正因为它太常用了,反而容易被忽略。很多人复习集合,就是背一遍ArrayList和LinkedList的区别、HashMap的put流程,然后面试的时候背给面试官听。这种复习方式不能说错,但它解决不了实际问题。

我理解的"复习集合"应该是三个层次:

  • 第一层是API层面:知道有哪些集合类,各自怎么用,增删改查的API是什么。
  • 第二层是数据结构层面:知道每个集合底层是什么数据结构,增删改查的时间复杂度是多少,为什么这个场景要用ArrayList而不是LinkedList。
  • 第三层是设计思想层面:知道为什么Java集合框架要设计成接口、抽象类、实现类三层结构,迭代器模式解决了什么问题,fail-fast机制到底在保护什么。

这三个层次,刚好对应了日常开发、线上排错、面试深挖三个场景。这篇文章我打算把这三个层次串起来讲一遍,重点放在那些"文档里不会直接写、但实际工作中一定会遇到"的细节上。

先说一下这篇复习笔记适合谁。如果你是刚学完Java基础、准备找工作的应届生,这篇文章能帮你把集合的知识点串成体系,而不是东一榔头西一棒槌。如果你是有几年经验的开发,准备跳槽或者梳理基础知识,这篇文章里有很多"面试官真正想问的东西"。哪怕你只是想在项目里把集合用得更好,读一读也不亏。

Java集合框架的整体结构,一句话可以概括:两大体系,三个接口,若干实现类。两大体系就是Collection体系和Map体系,三个接口是Collection、List、Set(Map是独立体系),实现类就是ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap这一堆。复习的第一步,就是把这张"地图"刻在脑子里,然后往每个节点上填细节。

2. 先搞懂这棵"接口继承树",后面所有细节才有地方挂靠

2.1 Collection体系的一条主线:Collection → List/Set → 具体实现类

很多初学者有一个很常见的困惑:为什么Java集合要设计这么多接口?直接用ArrayList不就行了吗?

这个问题其实回答了一个核心设计问题:面向接口编程。Collection是所有单列集合的顶层接口,它定义了集合最基本的操作规范——add、remove、size、contains、isEmpty、iterator这些。List在Collection的基础上增加了"有序、可重复、可通过索引操作"的语义,所以多了get(int)、add(int, E)、remove(int)这类方法。Set则强调"不可重复",所以没有索引相关的方法。

有了这层设计,你的代码就可以这样写:

public void process(List<String> data) { // 只管用List接口的方法,不关心具体实现 }

这个方法传ArrayList也行,传LinkedList也行,传CopyOnWriteArrayList也行。调用方想换实现,根本不用改这个方法内部的代码。这就是接口的意义——把"能做什么"和"怎么做"解耦

继承树上的具体实现类,每个都有明确的定位:

  • ArrayList:基于动态数组,查询快、增删慢(尾部增删除外),日常开发最常用。
  • LinkedList:基于双向链表,头尾操作快、中间查询慢,同时实现了List和Deque双接口。
  • Vector:ArrayList的"古代版本",方法加了synchronized,性能差,已经基本被淘汰。
  • Stack:继承自Vector的栈实现,同样因为继承设计和性能问题,官方推荐用ArrayDeque代替。
  • HashSet:基于HashMap实现,存取快,但无序。
  • LinkedHashSet:继承HashSet,底层是LinkedHashMap,维护插入顺序。
  • TreeSet:基于TreeMap(红黑树),元素有序,但操作复杂度是O(log n)。

2.2 Map体系:不是Collection的"小弟",而是并列的独立体系

Map和Collection最大的区别在于,Map存的是"键值对",一次存两个对象;Collection存的是"单个对象"。这个本质差异决定了Map不可能继承Collection——你没法用Collection的add(E)语义去描述put(K, V)这种操作。

Map体系的几个核心实现:

  • HashMap:基于数组+链表+红黑树,允许null键和null值,无序,最常用。
  • LinkedHashMap:继承HashMap,额外维护了双向链表,可以保持插入顺序或访问顺序(accessOrder参数),是实现LRU缓存的基础。
  • TreeMap:基于红黑树,按键的自然顺序或自定义Comparator排序,不允许null键。
  • Hashtable:HashMap的"古代版本",线程安全但性能差,已经过时。
  • ConcurrentHashMap:线程安全的高性能Map,分段锁/CAS+ synchronized实现,并发场景首选。

这里插一句:面试的时候经常有人把Hashtable和HashMap的区别背得滚瓜烂熟,但你要是问他"为什么有了Hashtable还要设计ConcurrentHashMap",就答不上来了。根本原因在于Hashtable的线程安全是对所有方法加synchronized,相当于给整个表加了一把大锁,并发高的时候性能急剧下降。ConcurrentHashMap用的是锁分段/细粒度锁的思路,读操作几乎不加锁,写操作只锁对应的桶,并发能力完全不同。

2.3 迭代器与fail-fast机制:遍历集合时的"隐形规则"

复习集合一定会碰到迭代器。Iterator接口的设计意图很纯粹:把遍历逻辑从集合实现中抽离出来,不管你底层是数组还是链表还是树,你只要拿到Iterator,就能用统一的方式遍历。这背后是典型的迭代器设计模式。

但真正工作中容易踩坑的是fail-fast机制。简单说,在用迭代器遍历集合的过程中,如果集合的结构被修改了(比如调用了add、remove),迭代器会在下一次调用next()时抛出ConcurrentModificationException。

这个机制的原理不复杂:迭代器内部维护了一个expectedModCount字段,初始化时等于集合的modCount。集合每次结构性修改,modCount都会+1。迭代器在每次next()时都会检查expectedModCount和modCount是否一致,不一致就抛异常。

但注意:fail-fast是"尽量检测"而不是"一定检测"。它是通过modCount的变化来感知并发修改的,某些修改操作(比如修改已有元素的值)不会改变modCount,也就不会被检测到。所以不要依赖fail-fast来保证安全性,它只是在帮你"尽早发现问题"。

遍历中如果确实需要删除元素,正确姿势是用迭代器自己的remove方法:

Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if ("delete".equals(item)) { it.remove(); // 正确:迭代器自己维护了modCount } }

或者用JDK 8以后更优雅的写法:

list.removeIf(item -> "delete".equals(item));

3. 核心实现逐个拆解:ArrayList、LinkedList、HashMap的底层逻辑

3.1 ArrayList:动态数组的扩容机制与"性能陷阱"

ArrayList可能是Java里最常用的集合类,但大部分人只是"无脑add",从没想过它内部是怎么扩容的。

ArrayList的底层就是一个Object数组。当你new ArrayList()的时候,它创建的是一个空数组;第一次add元素时,数组会扩容到默认容量10;之后每次容量不够,就按1.5倍扩容(新容量 = 旧容量 + (旧容量 >> 1))。

来看一下扩容的核心逻辑:

private Object[] grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 如果1.5倍还不够,就用minCapacity if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; } return elementData = Arrays.copyOf(elementData, newCapacity); }

每次扩容都要创建一个新数组,然后把旧数组的所有元素拷贝过去。这个拷贝操作是O(n)的,如果数据量大、add次数多,反复扩容的开销非常可观。所以如果你预先能估到数据量,一定要用指定初始容量的构造方法

List<String> list = new ArrayList<>(10000);

这个习惯能省掉很多次数组拷贝,在大数据量场景下性能差距是数量级的。

还有一个坑:ArrayList的subList方法返回的是原集合的视图,不是新集合。对这个视图做add、remove操作,会直接修改原集合,同时原集合的modCount也会变化。如果你在操作subList之后再去遍历原集合,很容易触发ConcurrentModificationException。很多人不知道这个细节,排查了半天才发现问题出在subList上。

3.2 LinkedList:双向链表的结构与"看似美好的操作"

LinkedList底层是双向链表,每个节点维护了prev和next两个指针。因为有了这些指针,它在头尾插入删除的场景下确实是O(1)的,这是它比ArrayList强的地方。

但很多人对LinkedList有个误解:以为链表操作"什么都快"。实际上,链表的随机访问是O(n)的——你要找第5个元素,必须从head开始一个一个往下走。ArrayList的get(int)是O(1)的,直接按下标定位。

还有一个隐藏的性能问题:LinkedList在中间插入元素时,虽然插入本身只是修改指针,但找到插入位置是需要遍历的。所以"LinkedList插入快"是有条件的,只有"在已知节点的前后插入"才是O(1),如果你要在第100个位置插入,光定位就要O(n)。

实际开发中的选择建议是:

场景推荐原因
频繁随机访问、遍历ArrayList连续内存,CPU缓存友好,get是O(1)
频繁在头部/尾部增删ArrayDeque / LinkedList头尾操作O(1)
频繁在中间增删都不是最优建议评估数据结构是否合理
数据量未知但巨大优先预估容量用ArrayList链表节点对象本身也有内存开销

顺便说一句,LinkedList空间利用率也比ArrayList低。ArrayList是连续数组,每个元素就是一个引用;LinkedList每个节点除了元素引用,还要额外存储两个指针,内存开销大。数据量大的时候,LinkedList占用的内存可能是ArrayList的好几倍。

3.3 HashMap:数组+链表+红黑树的完整故事

HashMap是整个Java集合框架里最值得深挖的一个类,没有之一。它涉及了哈希算法、数组索引、链表冲突、树化退化、扩容迁移、负载因子设计等多个知识点,每一块都能展开讲很久。

先说整体结构:HashMap底层是一个Node数组,每个数组元素(桶)要么是null,要么是一个链表的头节点,要么是一棵红黑树的根节点。put一个键值对的时候,流程是这样的:

  1. 对key计算hash值,为了让高位也参与运算,做了扰动处理:(h = key.hashCode()) ^ (h >>> 16)
  2. 用hash值和数组长度减一做位与运算,得到桶的下标:(n - 1) & hash。之所以用位与而不是取模,是因为数组长度是2的幂次时,(n-1) & hash等价于hash % n,但位与运算更快。
  3. 如果桶里没有元素,直接放进去;如果有,说明发生了哈希冲突,遍历链表找有没有相同key,有就覆盖,没有就尾插新节点。
  4. 链表长度超过阈值8并且数组长度超过64,就把链表转成红黑树。

这个过程中有两个值得仔细想的点:

第一个点:为什么加载因子是0.75?

JVM默认的加载因子是0.75,这是时间成本和空间成本的一个折中。加载因子越大(比如1),链表越容易变长,哈希冲突增多,查询效率降低;加载因子越小(比如0.5),冲突减少,但很多空间被浪费,数组扩得早、扩得频繁,浪费内存。0.75是官方在大量测试后给出的平衡点,实际工程里基本不用改。

第二个点:扩容为什么是2倍?

HashMap的扩容是数组长度翻倍。这里有个很精妙的设计:扩容后重新计算下标时,因为容量是2的幂次,每个元素的新位置只有两种可能——原下标,或者原下标+旧容量。判断依据就看hash值在新增的那一位上是0还是1。

// 扩容迁移时的判断逻辑 if ((e.hash & oldCap) == 0) { // hash值在新增bit位上是0,位置不变 } else { // hash值在新增bit位上是1,位置 = 原位置 + oldCap }

这样做的好处是:不用重新计算每个元素的hash值(hash值本身没变),只需要做一次位与运算就能确定新位置,同时原来在同一链表上的元素会均匀分散到两个位置上,大大缩短了链表长度。

还有一个在JDK 8中非常重要的变化:链表插入从头插法改成了尾插法。JDK 7的头插法在并发扩容时可能导致链表形成环,从而在get时出现死循环。JDK 8改成尾插法后,这个问题从机制上得到了缓解。但是要说清楚的是,HashMap依然不是线程安全的,并发写场景千万要用ConcurrentHashMap,不要因为"尾插法解决了死循环"就觉得可以裸用HashMap了。

3.4 HashSet、LinkedHashMap和TreeMap:被低估的"细节控"集合

HashSet这个类本身几乎没有什么逻辑,它内部就是一个HashMap:

public class HashSet<E> extends AbstractSet<E> implements Set<E> { private transient HashMap<E,Object> map; // 所有元素都放在key上,value统一用一个dummy对象 private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; } }

所以HashSet的复习重点其实在HashMap上。只要HashMap搞懂了,HashSet就是"用key去重"的应用场景,没什么额外概念。

LinkedHashMap值得单独说一说,因为它太常被用来做LRU缓存了。它继承了HashMap,但内部额外维护了一条双向链表,用来记录元素顺序。有两个构造参数值得记住:

LinkedHashMap<K, V> map = new LinkedHashMap<>(initialCapacity, loadFactor, accessOrder);

第三个参数accessOrder默认是false,表示按插入顺序维护;设为true则表示按访问顺序维护,每次get或者put访问元素后,这个元素会被移动到链表末尾。基于这个特性,实现一个简单的LRU缓存只需要两步:

class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int maxCapacity; public LRUCache(int maxCapacity) { super(maxCapacity, 0.75f, true); this.maxCapacity = maxCapacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > maxCapacity; } }

removeEldestEntry这个钩子方法在每次put后会被调用,返回true就把最久没访问的Entry移除。这个用法在面试里问得很多,实际项目里做本地缓存也很好用。

TreeMap就是一个基于红黑树的有序Map,它的核心价值是"按键排序"。你可以在构造时传一个Comparator:

Map<String, Integer> map = new TreeMap<>((a, b) -> b.compareTo(a)); // 倒序

TreeMap的操作复杂度是O(log n),比起HashMap的O(1)肯定慢,但它自带排序能力,在某些场景下比"存完再排序"要高效得多。不过要注意:TreeMap不允许key为null,因为要比较大小,null没法比。

4. 日常开发里的"正确姿势":初始化、遍历、不可变集合与常见坑

4.1 优先使用Arrays.asList和List.of,但要注意"结构性限制"

创建一个List,最传统的方式是:

List<String> list = new ArrayList<>(); list.add("a"); list.add("b"); list.add("c");

其实有更简洁的写法:

// 方式一:Arrays.asList,返回的是固定大小的List List<String> list = Arrays.asList("a", "b", "c"); // 方式二:JDK 9+的List.of,返回的是不可变List List<String> list = List.of("a", "b", "c");

这里有两个很容易踩的坑。

Arrays.asList返回的List是Arrays内部类ArrayList(注意不是java.util.ArrayList),它是一个大小固定的List,支持set修改元素,但不支持add和remove。如果你往里add,会抛UnsupportedOperationException。很多人习惯性地把asList的返回值当作普通ArrayList用,一add就报错,还以为是JDK的bug,其实人家就是这么设计的。

List.of更进一步,它返回的是一个真正不可变的List,连set都不允许。它的优点是:因为不可变,所以可以用更紧凑的内存布局,也更加安全(不用担心被意外修改)。如果你的数据本来就是固定的、初始化后不会变,优先用List.of。

4.2 遍历方式的选择:for-each、迭代器与Stream的适用边界

遍历集合是每天都会做的事,但很多人只是"能用就行",从未想过不同遍历方式各有优劣。

for-each循环本质上是语法糖,编译后会变成Iterator遍历。它适用于大多数场景,代码简洁可读。但如果需要在遍历过程中删除元素,直接用for-each就会抛ConcurrentModificationException,这时候要用迭代器或removeIf。

普通for循环配合get(i)只适用于List,因为它依赖随机访问能力。在ArrayList上没问题,但在LinkedList上用get(i)遍历就是O(n²)的灾难——每次get都要从头遍历链表。

Stream遍历是JDK 8之后的新宠。它的优势在于可以链式组合过滤、映射、收集等操作,配合Lambda表达式代码非常简洁:

List<String> filtered = list.stream() .filter(s -> s.startsWith("a")) .map(String::toUpperCase) .collect(Collectors.toList());

但要注意:Stream不是万能的。如果只是简单遍历,不涉及复杂的链式操作,普通for-each的性能和可读性都更好。Stream适合的是数据处理流水线,不是无脑替代for循环。

4.3 Collections工具类:那些"锦上添花"的静态方法

java.util.Collections是个宝藏工具类,提供了大量操作集合的静态方法,但很多人用到的只有sort和shuffle。

比较实用的几个:

// 创建不可变集合 List<String> emptyList = Collections.emptyList(); Map<String, String> singletonMap = Collections.singletonMap("key", "value"); // 创建线程安全的集合包装 List<String> syncList = Collections.synchronizedList(new ArrayList<>()); Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>()); // 查找和替换 int index = Collections.binarySearch(list, key); Collections.reverse(list); Collections.fill(list, "default");

关于synchronizedList,有个细节要特别提醒:它只是对每个方法加了synchronized,但复合操作不是原子的。比如"先判断contains再add"这种操作,两个方法中间的间隙依然是线程不安全的,你仍然需要自己加锁。所以如果是并发场景,优先考虑JUC包下的CopyOnWriteArrayList、ConcurrentHashMap这些专门为并发设计的集合,而不是Collections.synchronizedList。

4.4 集合的数组互转:toArray与Arrays.asList的"类型陷阱"

集合转数组是一个高频操作,但很多人踩过类型相关的坑。

集合转数组有两种姿势:

// 方式一:不传参数,返回Object[] Object[] objects = list.toArray(); // 方式二:传入指定类型的数组 String[] array = list.toArray(new String[0]);

推荐使用第二种方式,传入一个长度为0的数组。有人疑惑"为什么不传new String[list.size()]?"其实这两种写法都可以,但传0的写法更简洁,而且JDK会根据集合实际大小重新分配数组。传入更大数组也不是不行,只是多出来的空间会被置为null,没什么意义。当然,如果你很在意那一次数组分配的性能,可以传入size大小,不过在绝大多数场景下这个优化可以忽略。

数组转集合用Arrays.asList,这在4.1节已经说过要小心它返回的是固定大小List。还有一个细节:Arrays.asList(T... a)的泛型是T,如果是基本类型数组,比如int[],你得到的List里只有一个元素——这个int[]本身。所以基本类型数组要先装箱:

Integer[] arr = {1, 2, 3}; List<Integer> list = Arrays.asList(arr); // 正确

直接Arrays.asList(new int[]{1,2,3})得到的是List<int[]>,不是你要的结果。

5. 集合相关的内存与并发问题:OOM、ConcurrentModificationException与线程安全选型

5.1 "Java: OutOfMemoryError: Insufficient memory"里,集合常常是元凶

看到这个报错,很多人第一反应是JVM堆内存不够,马上调-Xmx。但根据我的经验,线上OOM有相当大比例是代码里集合使用不当导致的,单纯加内存只是治标不治本。

最常见的几种集合导致OOM的场景:

场景一:无限往集合里塞数据。比如从数据库或者消息队列拉数据,循环里不加限制地add到List里,数据量又没控制好。这种要么是分页没做好,要么是漏了limit,结果就是堆内存被打爆。

场景二:缓存集合没有上限。用HashMap当缓存,key一直变,value一直塞,从不考虑淘汰策略。这种场景应该用带淘汰策略的缓存框架(Caffeine、Guava Cache),或者自己用LinkedHashMap实现LRU(4.3节里有代码)。

场景三:集合嵌套导致内存膨胀。Map<String, List<Map<String, Object>>>这种三层嵌套,数据量一大,内存开销是几何级数增长的。HashMap的Entry、ArrayList的扩容冗余,每层都额外吃内存。

场景四:把大对象全部load到内存。比如一次性把一个巨大的Excel文件读进List,每条记录是一个大对象。这种应该考虑流式处理,边读边处理,不要让所有数据常驻内存。

排查OOM的时候,我比较推荐的做法是:先看错误日志是在什么操作时触发的,如果堆栈里出现了集合相关的代码,就重点查这个集合的size、数据的来源和生命周期。如果jmap -dump能拿到堆转储(堆dump的话),用MAT或者JProfiler分析一下对象引用树,很快就能找到到底是谁在占用最多的内存。

5.2 单个线程里也会遇到的ConcurrentModificationException

很多人以为ConcurrentModificationException只会在多线程环境下出现,这是不对的。单线程里照样会触发,而且触发场景非常隐蔽。

最典型的场景:

List<String> list = new ArrayList<>(List.of("a", "b", "c", "d")); for (String item : list) { if ("b".equals(item)) { list.remove(item); // 这里会抛 ConcurrentModificationException } }

原因是for-each在编译后用的是迭代器,迭代器内部会检查modCount,而直接用List.remove会改变modCount但不会同步更新迭代器的expectedModCount,所以下一次next()时就检测到不一致了。

还有一个很隐蔽的坑:遍历嵌套遍历时,内层删除元素会影响外层的modCount检查

for (String outer : outerList) { for (String inner : innerList) { // 如果在这里修改了outerList,外层迭代器也会抛异常 } }

这类问题的解决方式前面已经提过:用迭代器的remove方法、用removeIf、或者收集要删除的元素到临时集合,最后统一removeAll。

5.3 线程安全集合的选型策略:ConcurrentHashMap、CopyOnWriteArrayList与并发集合

关于线程安全集合,我不建议你背"HashMap线程不安全,Hashtable线程安全,ConcurrentHashMap也线程安全"这种表面结论,而是要理解每种方案的取舍。

Hashtable:所有方法都加synchronized,锁粒度是"整个表",并发性能极差。基本可以当反面教材看待。

Collections.synchronizedMap:同样是全表锁,但因为内部用mutex对象做同步,相当于一把更大的锁。并发性能比Hashtable好一点但也有限。适合低并发、只是偶尔需要线程安全的场景。

ConcurrentHashMap:JDK 8的版本摒弃了分段锁,改用CAS + synchronized锁定单个桶(链表头节点/树根节点)。读操作(get)完全无锁,通过volatile保证可见性。并发性能比前两者高一个量级,是并发场景的首选。

CopyOnWriteArrayList:读多写少场景的利器。它的原理是"写时复制"——每次add或remove,都会把底层数组复制一份,在新数组上修改,然后替换引用。读操作不加锁,直接读volatile数组引用。因为每次写都复制数组,所以写性能较差,只适合读多写极少、集合本身不大(比如配置列表)的场景。

ConcurrentLinkedQueue:基于CAS的无界非阻塞队列,适合生产者-消费者模式。它的优点是永远不阻塞,但缺点也是无界——如果消费者处理不过来,队列会无限增长,最终OOM。

选型的时候先把需求理清楚:是读多写多?读多写少?需要有序?需要去重?几个问题问下来,选型基本就明确了。如果说得更朴素一点:大多数场景用HashMap和ArrayList就够了,它们不需要线程安全;真正需要并发安全的场景,直接上ConcurrentHashMap和CopyOnWriteArrayList,基本不会错。

6. 回到面试本身:高频题目背后的"考点"到底是什么

6.1 先看一张"高频面试题-核心考点"对照表

之前在带团队和帮朋友准备面试的过程中,我总结了一个规律:面试官问集合题,看起来问的是具体用法,实际上考的是三件事——底层数据结构、时间复杂度的推演、面对并发场景的判断力

高频问题表面考点深层考点
ArrayList和LinkedList的区别数据结构能否根据复杂度选择合适集合
HashMap的put流程JDK源码理解是否真读过源码而非背答案
HashMap和Hashtable的区别基础记忆是否理解锁粒度与并发模型
ConcurrentHashMap为什么快并发机制是否理解CAS、synchronized、volatile
HashSet怎么保证去重底层复用是否理解equals和hashCode的约定
TreeMap和HashMap怎么选排序与性能是否理解红黑树的适用边界
为什么重写equals必须重写hashCodeJava基础坑能否解释HashMap中的查找逻辑

这张表不用背,它是用来对照的——如果你在准备面试,不妨问自己一句:这些问题如果换个角度问(比如"HashMap在并发下到底会发生什么""HashSet怎么去重的,底层是谁在干活"),我还能答得出来吗?

能答出来,说明你复习到位了;答不出来,就顺着表格去补对应的底层知识。

6.2 equals和hashCode的约定:HashMap、HashSet正确工作的基石

很多人在复习集合时会忽略equals和hashCode,但这是一个大坑。面试里问"为什么重写equals必须重写hashCode"的频次,可能比你想象的高得多。

这个问题的本质在于HashMap和HashSet的查找机制。往HashMap里put一个键值对时,第一步是用key的hashCode值经过扰动后定位桶;第二步才是在桶里用equals比较有没有相同的key。

如果你只重写了equals而不重写hashCode,就会出现这种情况:两个对象equals返回true,但hashCode不同,导致它们被放到不同的桶里。你在map.get(对象A)时,计算出的桶位置和当初put(对象B)时的桶位置不一样,就永远找不到——明明A.equals(B)是true,但get返回null。

反过来,如果你只重写了hashCode而不重写equals,那么即使两个对象hashCode相同、落到同一个桶里,equals比较不相等,就会被当成两个不同的key。

正确的做法是:

public class User { private Long id; private String name; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(id, user.id) && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(id, name); } }

Objects.hash这个工具方法会按照指定的字段计算一个稳定的hash值,省得自己手写。这个约定不仅影响HashMap,还影响HashSet、HashTable、ConcurrentHashMap等所有基于哈希的集合。如果你自定义的对象要放进这些集合里,equals和hashCode必须成对重写。

6.3 集合框架里那些"朴素但高区分度"的面试题

除了上面那些大考点,有些小问题也能很好地考察一个人的基础扎不扎实。

"ArrayList的默认容量是多少?什么时候扩容?扩容成多少?"—— 这是个很细的题。默认容量10,第一次add时扩容到10,之后每次不足时扩容为原来的1.5倍。答出这三个"多少",说明你真看过源码。

"HashMap允许null键吗?Hashtable呢?"—— HashMap允许null键,但null键只能有一个,放在index=0的桶上。Hashtable不允许null键或null值,因为它的put方法会直接检查key是否为null。

"HashSet的add方法返回值是什么?"—— 返回boolean,如果元素已经存在就返回false。这个特性可以用来做"去重判断":"if (set.add(str)) { 第一次见 } else { 重复了 }"。

"Iterator和ListIterator有什么区别?"—— ListIterator是Iterator的子接口,只适用于List,新增了向前遍历(previous)、获取索引(nextIndex/previousIndex)、修改元素(set)、插入元素(add)等方法。

"Comparable和Comparator的区别?"—— Comparable是类自身实现的排序接口,一个类只能实现一种自然排序;Comparator是外部比较器,可以根据不同场景实现多种排序逻辑。TreeSet、TreeMap、Collections.sort、Arrays.sort这些地方都用得到。

这些都是"背了就会、不背就不会"的题,但它们背后往往有一个更大的问题:"你平时写代码的时候,有没有认真看过JDK的源码?"所以我的建议很朴素:复习集合最好的方式不是背面试题,而是打开JDK源码,把ArrayList、LinkedList、HashMap三个类的核心方法各读一遍。读完你的理解深度会和单纯背答案完全不同。

7. 几个值得动手验证的练习思路

复习不能只看不练。这里推荐几个可以自己动手做的小练习,每一个都能帮你在实际操作中巩固上面提到的知识点。

练习一:用数组实现一个简易ArrayList。要求支持add、get、remove、size方法,并实现自动扩容(比如扩容1.5倍)。做完之后你会对"为什么ArrayList随机访问快、中间插入慢"有切身体会。

练习二:写一个程序,验证LinkedList在中间插入和ArrayList在中间插入的性能差距。插入10万条数据到中间位置,对比耗时。你会发现LinkedList并没有想象中那么快,因为查找位置本身就要遍历。

练习三:用LinkedHashMap实现一个LRU缓存。核心步骤就是7里提到的那样,重写removeEldestEntry方法。做完之后可以写一个小测试:put几个键值对,然后get其中一个,观察LinkedHashMap的迭代顺序变化。

练习四:在单线程环境下写一段会触发ConcurrentModificationException的代码,再改成用Iterator.remove解决。这个练习能帮你真正理解fail-fast机制。

练习五:打开HashMap的源码,跟着put的流程走一遍。找出resize方法,看看里面那行(e.hash & oldCap) == 0是怎么把链表拆成两半的。这一步看懂了,HashMap的扩容原理就通了。

这些练习每个都不复杂,但远比刷十道面试题有用。尤其是练习五,源码啃下来之后,你再面对HashMap相关的问题会非常从容。

8. 踩坑记录与日常开发的心得体会

最后整理几条我这些年实际工作中踩过的坑,都是吃过亏才记住的教训,分享给大家有个参考。

第一,别用List的contains做去重。数据量小的时候没有感知,但数据量一上来,List.contains是O(n)的,双层循环去重就是O(n²),几万条数据就可能卡到秒级。去重用HashSet,O(1)的判断,差别是数量级的。

第二,Map的getOrDefault不等于判空。map.getOrDefault(key, defaultValue)在key不存在时返回默认值,这个没问题。但如果key存在但value本身是null,它同样会返回默认值。如果你需要区分"key不存在"和"value为null"这两种情况,要用containsKey来做判断。

第三,不要边遍历边往集合里添加元素。不管是for-each还是迭代器,遍历过程中新增元素都会导致modCount变化,触发ConcurrentModificationException。需要"边遍历边添加"的场景,可以考虑用一个临时集合收集,遍历完统一addAll。

第四,集合做参数传递时,警惕"意外修改"。当你把List或Map传给一个方法时,方法内部如果改了集合内容,调用方那边也会变。这就是引用的天然特性。如果不希望被修改,传入之前用Collections.unmodifiableList(list)List.copyOf(list)包一层。

第五,jdk版本变了,集合行为也会变。比如JDK 8里HashMap引入红黑树优化,链表超过8转树;JDK 9引入List.of、Map.of等不可变集合工厂方法;JDK 10引入List.copyOf;JDK 16里Stream.toList()可以一行替代collect。所以复习的时候建议用你项目实际使用的JDK版本去看源码,不要拿着JDK 8的结论套JDK 17的行为。

集合这块知识,说实话不难,难的是"真正理解"。我见过太多人背了一堆面试题,问"ArrayList扩容机制"答得头头是道,但让他写一个用迭代器删除元素的代码就卡住了。理解永远比记忆重要。这篇文章我尽量把集合相关的原理、细节、实战经验都揉在一起讲了,希望大家看完之后不是"会背了",而是"真的懂了"。以后遇到集合相关的面试题或者线上问题,能有一种"这题我有把握"的底气,那就说明复习到位了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询