做Java做了快十年,集合框架几乎是从我第一次写代码到现在每天都在碰的东西。不管是写业务逻辑、做数据过滤,还是准备面试,翻来覆去就是这些集合类来回选。前两天我特意让DeepSeek把Java集合框架里所有集合的异同点从头到尾梳理了一遍,又结合我自己这些年踩过的坑和实际项目的选型经验,整理出了这篇内容。如果你是刚学Java、想在集合这块打下扎实基础,或者正在背面试题准备跳槽,这篇都值得从头到尾认真看一遍。我不光会告诉你每个集合是什么,还会讲清楚它们底层怎么设计的、什么时候该用谁、用错了会有什么后果。
1. 集合框架整体认知:为什么每个Java程序员都绕不开它
1.1 数组的局限性与集合框架的诞生
想彻底理解集合框架,得先搞清楚它到底解决了什么问题。很多新手第一反应是:不是有数组吗?存个东西用数组不就行了?但你在真实项目里写代码就会发现,数组做业务存储简直处处是坑。
数组最大的问题就是定长。你创建int[10],那它就永远是10,存满了就得自己写扩容逻辑——新建一个更大的数组,把旧数据拷贝过去,再丢掉旧数组。这个操作又啰嗦又容易出bug。另外,数组只能按顺序存储,你要查一个元素得自己写循环遍历,要删除一个中间元素还得自己挪位置,现实中业务数据的查找、插入、删除频率远比你想象得高。
集合框架把这些脏活累活全部封装好了。你只管往里面放数据、取数据,扩容、缩减、索引维护这些底层逻辑全由集合类内部处理。更重要的是,集合框架把数据结构做成了体系化的接口和实现类,List、Set、Queue、Map四大体系各管一摊,每种都有自己的适用场景和性能特点。你只要选对了集合类,很多代码性能问题在源头就避免了。
1.2 两大体系:Collection与Map
Java集合框架的整体结构其实就两大分支:一个是Collection接口体系,存的是单个元素;另一个是Map体系,存的是键值对。你打开JDK源码,所有集合类最终都归到这俩分支下面。
Collection下面又分出三个子接口:List(有序可重复)、Set(无序不可重复)、Queue(队列,主要用于先进先出或者按优先级出队)。图我就不画了,你在脑子里面想象一棵树就行。Map体系则是独立的一棵,HashMap、TreeMap、LinkedHashMap、ConcurrentHashMap这些全是它的后代。
这里有个很容易让人困惑的点:Set和Map看似完全不相干,底层却是千丝万缕。HashSet内部其实就是一个HashMap,只是它只用key不关心value;TreeSet内部也是一个TreeMap。理解了这条线索,你就能把两大体系串起来记忆。我一会儿讲具体集合的时候会再展开。
1.3 泛型与迭代器:集合框架的地基
在你深入看每个集合之前,有两个基础概念必须先整明白,否则后面代码会写得很痛苦。
第一个是泛型。Java从1.5开始支持泛型,你可以把集合声明成List<String>、Map<String, Integer>这种形式,让编译器帮你检查放进来的数据类型。不用泛型的话,List里能放Object类型,运行时做强制类型转换极其容易踩ClassCastException的坑。扁平化管理时代,写集合代码不加泛型基本等于给自己埋雷。
第二个是迭代器(Iterator)。集合类统一通过迭代器暴露遍历能力,不管是ArrayList还是TreeSet,都能用同一个Iterator接口遍历。这里有个面试常问的机制叫fail-fast,简单说就是集合内部维护了一个modCount字段,用来记录结构性修改次数。迭代器遍历时会记录初始modCount,每次next()都检查一遍,发现被其他线程或者代码修改了就立刻抛ConcurrentModificationException。这个机制不是为了绝对安全,而是尽早暴露并发修改问题。
2. List体系详解:有序可重复的三个主力
2.1 ArrayList:动态数组的扩容哲学
ArrayList是日常开发里用得最多的集合类,没有之一。它底层就是一个Object数组,所有操作本质上都在操作这个数组。
你初始化一个new ArrayList(),它内部是空数组,元素首次add的时候才给你初始化为容量10的数组。这个设计很聪明,避免了无数只创建不使用的空列表浪费内存。扩容时机和倍数也有讲究:每次容量不够就扩容,新容量是旧容量的1.5倍,也就是int newCapacity = oldCapacity + (oldCapacity >> 1)。为什么是1.5倍而不是2倍?因为1.5倍兼顾了空间利用率和扩容频率的平衡——2倍扩容空间浪费太大,1.5倍整体更经济。
由于底层是连续数组,ArrayList的get(int index)是纯内存寻址,时间复杂度O(1),特别适合随机访问场景。但它的弱点也在这里:在列表中间插入或者删除元素,得把后面的所有元素整体往前挪或者往后挪,时间复杂度O(n)。业务上如果你频繁在头部插入,用ArrayList会非常难受,性能迅速劣化。
2.2 LinkedList:双向链表的双面性
LinkedList底层是双向链表结构,每个节点是一个Node对象,包含前驱指针prev、后继指针next和元素本身item。因为不需要连续内存,所以它没有扩容的概念,存多少就分配多少节点。
从性能角度看,LinkedList在头尾两端操作是O(1),addFirst()、addLast()、removeFirst()都非常快。但如果你按照索引取中间元素,比如get(size/2),它不能直接跳到那个位置,只能从链表头或者尾一步步遍历过去,实际开销是O(n/2)。这个特性决定了它不适合随机访问。
还有一个很多人没意识到的点:LinkedList不仅实现了List接口,还实现了Deque接口,所以它本身就可以当双端队列用。进队出队、栈操作它都能干。但我的建议是——日常开发中LinkedList能不用就不用。为什么?我先卖个关子,后面讲ArrayDeque的时候再给你算这笔账。
2.3 Vector与Stack:遗留类的历史包袱
Vector和Stack是JDK 1.0时代就存在的集合类,属于"爷爷辈"的组件。Vector的设计思路和ArrayList几乎一样,底层也是Object数组,主要区别在于它的方法都用synchronized修饰了,也就是线程安全版本。
但这里有个典型的性能陷阱:Vector的方法级别的同步粒度太粗,整个方法加锁。在多线程环境下,即使只是读取操作也会被锁阻塞。实际项目中,并发场景大家早就不用Vector了,而是用Collections工具类包装出来的Collections.synchronizedList(new ArrayList<>()),或者直接上CopyOnWriteArrayList。
Stack就更复古了,它直接继承Vector,提供了push、pop、peek等方法。Stack的问题在于它的语义设计得并不纯粹,继承了Vector的全部能力,你既可以当栈用,也可以当列表用,这在工程上其实是坏事。Java官方自己也承认Stack是个遗留设计,推荐使用ArrayDeque作为栈的替代品。
2.4 三个List到底怎么选:一个表格解决
我直接给你一张我自己整理的对照表,平时选型对着看就够了:
| 维度 | ArrayList | LinkedList | Vector |
|---|---|---|---|
| 底层结构 | Object数组 | 双向链表 | Object数组 |
| 随机访问 | O(1),极快 | O(n/2),慢 | O(1),极快 |
| 尾部插入 | O(1)摊销 | O(1) | O(1)摊销 |
| 中间插入/删除 | O(n),需要移动元素 | O(n)查找+O(1)改指针 | O(n) |
| 头部插入/删除 | O(n),很差 | O(1),优秀 | O(n) |
| 线程安全 | 否 | 否 | synchronized方法 |
| 内存占用 | 连续数组,额外空间少 | 每个节点多两个指针,约8字节额外开销 | 连续数组 |
选型建议非常明确:99%的线性表场景直接用ArrayList。包括很多人以为的"我经常在头部插入是不是该用LinkedList"——实际业务里这种场景通常应该改成ArrayDeque,而不是LinkedList。LinkedList只有在确实需要同时利用List和Deque双接口特性时才算有优势,这种场景在真实项目中少之又少。
3. Set体系详解:不重复背后的三种策略
3.1 HashSet:HashMap的套壳设计
Set的核心语义就一句话:不包含重复元素。三个常见实现各用了一种策略来保证这一点。
HashSet是使用频率最高的,它的底层实现就是HashMap,实例化时创建了一个HashMap<E, Object>,每次add(e)本质上执行的是map.put(e, PRESENT),其中PRESENT是一个静态的Object占位对象。换句话说,HashSet把元素当作key存进HashMap,value永远是一个固定的假对象。利用HashMap天然的key不重复特性,add重复元素时put会返回旧value,HashSet据此判断插入失败。
这也带出一个极其重要的推论:HashSet去重依赖元素的hashCode()和equals()。如果元素是一个自定义类的对象,你必须在类里同时重写这两个方法,而且重写规则是equals相等的对象hashCode必须相等。很多人只重写equals忘了重写hashCode,结果set里面两个内容相同的对象都存进去了,bug查半天查不出来。
因为底层是哈希表,HashSet的add、remove、contains操作平均时间复杂度都是O(1),非常快。但它有一个代价:无序。它不保证元素的迭代顺序,存进去的顺序和取出来很可能是两码事。如果你打印一个HashSet的内容,顺序颠三倒四很正常。
3.2 LinkedHashSet:有序与去重的平衡
LinkedHashSet继承自HashSet,在哈希表的基础上加了一条双向链表来维护元素的插入顺序。它本质是LinkedHashMap实现的,每个节点除了哈希桶的next指针,还有before和after两个指针串成一条链。
这个设计精妙在哪里?它既保留了HashSet键值对操作的O(1)复杂度,又让迭代顺序变得可预测:按插入顺序遍历。这个特性在需要保持插入顺序同时又需要去重的场景非常有用,比如记录用户操作路径去重后还要按照操作顺序展示。
不过LinkedHashSet的代价是内存开销更大,每个元素都多了前后指针,数据量大时额外内存不可忽略。另外一个容易记混的点:LinkedHashSet是插入顺序,不是访问顺序。你要是想实现"访问后移动到末尾"这种LRU效果,得用LinkedHashMap并设置accessOrder为true,LinkedHashSet没有这个选项。
3.3 TreeSet:红黑树支撑的排序集合
TreeSet底层是一个TreeMap,内部基于红黑树数据结构存储元素。它的核心特点是有序:元素存储进去就会自动排好序,迭代时按排序顺序输出。
排序规则有两种来源。第一种是元素本身实现了Comparable接口,比如String、Integer这些JDK自带类都有自然排序。第二种是创建TreeSet的时候传一个Comparator对象进去,自定义排序逻辑。这两种同时存在时,Comparator优先级更高。
因为基于红黑树,TreeSet的增删改查时间复杂度都是O(log n),比HashSet的O(1)慢,但它换来的是排序能力和范围查询能力。比如subSet(from, to)、headSet(to)、tailSet(from)这些范围截断操作,其他Set根本做不到。判断元素是否存在也同样走的是红黑树查找路径,不需要遍历。
需要注意两个坑:第一,TreeSet不允许存null(除非Comparator特殊处理),因为null没法比较大小;第二,元素排序属性修改后不会自动触发重新排序,你得先remove再重新add,否则位置就是错的。
3.4 三种Set的异同对比与去重实战注意事项
我把三个Set的关键异同整理成表格,方便你记忆:
| 维度 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 底层结构 | HashMap | LinkedHashMap | TreeMap(红黑树) |
| 迭代顺序 | 无序 | 插入顺序 | 排序顺序(自然或Comparator) |
| 增删查复杂度 | O(1)平均 | O(1)平均 | O(log n) |
| 是否允许null | 允许一个null | 允许一个null | 不允许null |
| 适用范围 | 常规去重、判重 | 需要保持插入顺序的去重 | 需要排序、范围查询 |
实操中我吃过亏的一个点就是这个:自定义对象放进Set之前,一定确保hashCode和equals两个方法都重写了,且行为一致。你用IDE自动生成的版本就行,但要注意如果用到了Lombok的@EqualsAndHashCode,它在类继承层次复杂时会生成包含父类字段的实现,这块逻辑不一致也可能导致去重失效。更隐蔽的是,对象放进Set之后再修改了参与hashCode计算的字段,会导致对象在哈希桶中的位置错乱,你以为contains能查到它,实际查不到。这类bug极难排查,建议设计上把参与hashCode的字段都设为不可变。
4. Map体系详解:键值对存储的完整图谱
4.1 HashMap:数组+链表+红黑树的进化史
HashMap是整个Java集合框架里最核心、面试问得最多的类,没有之一。JDK 1.8之后它的底层结构是数组加链表加红黑树的三位一体。
核心设计是这样的:HashMap内部维护一个Node数组,也就是哈希桶数组。put一个键值对时,先通过(h = key.hashCode()) ^ (h >>> 16)这个扰动函数把key的哈希值做一次高低位异或,尽量让高位信息也参与散列。然后通过tab[(n - 1) & hash]计算出这个键值对落在哪个桶。这里的n是数组长度,使用n-1的按位与运算相当于取模,但比取模快得多,前提是n必须是2的幂。
如果多个key的哈希值落到了同一个桶,就形成链表挂在数组节点后面。链表长度超过8且数组长度大于等于64时,链表会转成红黑树,把查询复杂度从O(n)降为O(log n)。为什么阈值设成8?这是依据泊松分布计算的,负载因子0.75情况下链表长度达到8的概率约是千万分之六,树化其实是为了抵御极端哈希冲突的场景。
扩容是HashMap里最值得讲明白的点。当map元素数量超过容量 * 0.75时触发resize,数组容量翻倍。所以为什么加载因子默认选0.75?这是时间成本和空间成本的折中,太低会频繁扩容浪费空间,太高则冲突增多降低效率。JDK 1.8对rehash做了个经典优化:扩容后每个元素的新位置要么留在原索引,要么移动到原索引加上旧容量的位置。判断依据就是(e.hash & oldCap),等于0留在原地,等于1移到高区。头插法改尾插法也解决了1.7版本并发扩容可能产生循环链表的问题。
顺便说一句,HashMap允许一个null key和任意多个null value,null key会放到数组第0个桶。这个细节和Hashtable、ConcurrentHashMap都不一样,后面要对比着记。
4.2 LinkedHashMap与TreeMap:两种有序性
HashMap有一个明显痛点:遍历顺序不可控。LinkedHashMap就是针对这个痛点设计的,它继承自HashMap,内部加了双向链表维护节点顺序。
LinkedHashMap支持两种排序模式:插入顺序和访问顺序。默认是插入顺序,就是你put的顺序。如果把构造参数accessOrder设为true,每当get或put访问一个节点,这个节点就会被移动到链表末尾,从而实现"最近最少使用"的淘汰语义。这个特性让它成了实现LRU缓存的绝佳底座。我做过一个项目需要限制内存缓存上限,直接继承LinkedHashMap、重写removeEldestEntry方法判断size超过阈值就返回true,几十行代码就搞定了一个正经的LRU缓存,简单可靠。
LinkedHashMap因为要维护双向链表,put和get的常数项开销比HashMap略高,内存占用也更大。这属于可接受的代价,换来了稳定的迭代顺序。
TreeMap走的是另一种有序路线:基于红黑树对key进行排序。它和TreeSet的关系就像HashMap和HashSet的关系一样,TreeSet底层就是TreeMap。TreeMap的key必须要么实现Comparable,要么在构造时传入Comparator。它支持各种范围操作:subMap、headMap、tailMap、firstKey、lastKey等都是红黑树上的高效操作,时间复杂度O(log n)。核心场景是需要按键的自然顺序或自定义顺序遍历,以及处理区间查询的业务。比如按时间戳排序存储一批数据,然后查"最近一小时到最近十分钟"的数据,TreeMap干这个非常顺手。
4.3 Hashtable与ConcurrentHashMap:线程安全的两种思路
先说Hashtable。这个类名字里的t是小写的,很多人写代码敲成HashTable直接编译报错。Hashtable是JDK 1.0时代的老类,它的线程安全手段极其原始:所有公开方法都用synchronized加锁,等于同一时间只允许一个线程访问整个表结构。这种全表锁在并发量稍高的情况下性能非常差,而且它连get操作也要抢锁,读取都不能并发执行。
Hashtable还有一个严格限制:不允许null key和null value,会直接抛NullPointerException。为什么?因为它设计时认为null有歧义——查不到key的null和值为null的null无法区分。
ConcurrentHashMap是真正的并发之王。JDK 1.8之后它的实现逻辑是:数组节点用volatile修饰,保证可见性;空桶插入时用CAS操作,无锁完成;出现哈希冲突后,在链表头节点或者红黑树根节点上使用synchronized加锁。这种细粒度锁的效果是,多线程可以并发操作不同桶,只有操作同一个桶时才需要竞争。它的读操作完全无锁,性能极其出色。
ConcurrentHashMap同样不允许null key和null value,官方设计的理由是为了避免并发场景下二义性:如果用null表示"查无此key",在多线程环境下get返回null时,你无法区分是key不存在还是key对应的value本身就是null。这个设计取舍你在面试中如果能讲出来,会加分不少。
4.4 Map家族横向对比
| 维度 | HashMap | LinkedHashMap | TreeMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|---|---|
| 底层结构 | 数组+链表+红黑树 | HashMap+双向链表 | 红黑树 | 数组+链表 | 数组+链表+红黑树 |
| 迭代顺序 | 无序 | 插入顺序或访问顺序 | key排序 | 无序 | 无序 |
| 线程安全 | 否 | 否 | 否 | 是(全表锁) | 是(CAS+synchronized) |
| null key/value | 允许 | 允许 | key不允许null | 都不允许 | 都不允许 |
| 常用场景 | 通用键值存储 | LRU缓存、有序迭代 | 排序、范围查询 | 基本已被淘汰 | 高并发共享存储 |
这里我要特别强调一个很多老开发都会踩的坑:多线程环境直接使用HashMap,不出事是运气,出事是必然。哪怕你只是并发读写,在扩容期间就可能出现数据错乱、丢数据甚至CPU跑满的问题。并发场景统一上ConcurrentHashMap,不是可选项,是必选项。
5. Queue体系详解:被忽视的双端与优先级队列
5.1 ArrayDeque:循环数组实现的双端队列
说完List和Map,队列体系经常被很多人忽视,但我觉得这部分在真实场景里价值极高。Deque这个接口提供了双端队列语义,两端都能进能出,既能当普通FIFO队列用,也能当栈用。
ArrayDeque是Deque最推荐的实现。它的底层是循环数组,通过头尾两个指针来标记数据区间。之所以叫循环数组,是因为当tail指针到达数组末尾时,如果头部还有空位,tail会绕回数组头部继续使用,不需要扩容。只有当整个数组真的满了才翻倍扩容。
相比LinkedList,ArrayDeque在同样支持双端操作的前提下,底层是连续数组,CPU缓存友好度更高,节点不需要额外存前后指针,内存更省。实测下来,同样规模的入队出队操作,ArrayDeque明显比LinkedList快。前面我留的问题现在可以回答了:如果只是当栈或者队列用,Stack类和LinkedList都不如ArrayDeque。Java官方文档也直接建议优先使用ArrayDeque来替代Stack。
ArrayDeque唯一需要注意的是:不允许插入null元素。这其实和ConcurrentHashMap的限制逻辑类似,null在队列语义里太含糊了,栈和队列都不太需要null这种"哨兵值"。
5.2 PriorityQueue:二叉堆的优先级世界
PriorityQueue是一个基于二叉堆实现的优先级队列。它的核心逻辑:元素不是按入队顺序出队,而是按优先级出队,优先级最高的元素最先出队。
默认情况下,PriorityQueue是一个小顶堆,堆顶元素始终是队列里最小的那个。如果你想要大顶堆(最大值先出队),可以通过Comparator实现,比如new PriorityQueue<>(Comparator.reverseOrder())。
每次offer入队时,新元素会从堆的末尾加入,然后不断上滤(siftUp),和父节点比较,如果比父节点小就交换位置,直到满足堆的性质。每次poll出队时,堆顶元素被拿走,最后一个元素放到堆顶位置,然后不断下滤(siftDown),和两个子节点中较小的那个交换,恢复堆序。这两个操作都是O(log n),而peek只看堆顶,是O(1)。
PriorityQueue在业务里最典型的应用是任务调度:有一批带优先级的任务,每次都处理优先级最高的那个。它还有两个限制:一是不能存null,二是元素必须可比较,否则你创建队列的时候必须显式传入Comparator。
5.3 队列体系各实现怎么选
| 维度 | ArrayDeque | LinkedList | PriorityQueue |
|---|---|---|---|
| 底层结构 | 循环数组 | 双向链表 | 二叉堆 |
| 出队顺序 | FIFO | FIFO | 按优先级 |
| 入队/出队复杂度 | O(1)摊销 | O(1) | O(log n) |
| 是否可当栈用 | 可以 | 可以 | 不可以 |
| 是否允许null | 不允许 | 允许 | 不允许 |
| 适用场景 | 队列、栈通用容器 | 需要列表与队列双接口 | 任务调度、TopK |
这里补充一个实际项目里的经验:需要找N个元素里最大的K个,或者最小的K个这类TopK问题,用一个容量为K的小顶堆(或大顶堆)解决,遍历一遍数据,堆满后每来一个新元素和堆顶比一下,该替换就替换。整体复杂度O(n log K),比全量排序高效得多,我接过的很多数据统计需求都是这么干的。
6. 集合工具类与整体选型速查
6.1 Collections工具类:包装、排序与同步
Java为集合框架配了两个工具类,一个叫Collections(注意是复数),另一个叫Arrays。它们是被低估的"瑞士军刀",很多场景用好它们能省下大量手写逻辑。
Collections里有几个高频操作:sort(List)对List排序,底层实际是调用了Arrays.sort,非常高效;binarySearch(List, key)对有序List做二分查找;reverse(List)反转列表;shuffle(List)随机打乱;frequency(Collection, obj)统计元素出现次数;min/max(Collection)直接取极值。
更实用的是它的包装方法:unmodifiableList/Set/Map(...)返回只读视图,任何尝试修改的调用都会抛UnsupportedOperationException。这个在做接口返回数据保护时特别有用,防止外层代码意外修改内部数据。synchronizedList/Set/Map(...)则返回同步包装版,给需要快速兼容并发场景的遗留代码用。还有一个冷门好用的:emptyList()、singletonList(e),避免频繁new空集合产生不必要的对象开销。
6.2 Arrays工具类:数组与集合的桥梁
Arrays的用途集中在数组操作和数组与集合的转换上。Arrays.asList(T... a)是把数组包装成List的经典入口,但它有三个你必须知道的坑。
第一,asList返回的List是Arrays内部类ArrayList,不是java.util.ArrayList。你不能对它调用add、remove等结构性修改方法,否则直接抛UnsupportedOperationException。第二,asList得到的列表和原数组共享底层数据,通过list修改元素会同步到数组上。第三,如果你传的是int[]这类基本类型数组,asList得到的List元素类型是整个int数组而不是Integer,泛型会把int[]当成一个整体对象放进去,输出长度永远是1。
如果你想要一个真正独立、可增删的ArrayList,正确姿势是new ArrayList<>(Arrays.asList(arr)),相当于做一次拷贝复制。
另外,Arrays.copyOf、Arrays.sort、Arrays.binarySearch这些方法在性能敏感代码里的出场率极高,建议熟悉。
6.3 面对业务场景的最终选型清单
如果你看完整篇还不太确定怎么选,我给你一份最直白的选型清单,照着套就行:
- 需要线性存储、频繁随机访问、按下标取数据:选ArrayList
- 需要栈或者队列,不做随机访问:选ArrayDeque
- 需要按优先级处理任务:选PriorityQueue
- 需要去重,不在乎顺序:选HashSet
- 需要去重,同时保持插入顺序:选LinkedHashSet
- 需要去重,并且元素要有排序:选TreeSet
- 需要键值对映射,无特殊要求:选HashMap,并发场景换ConcurrentHashMap
- 需要键值对且迭代顺序必须是插入顺序或做LRU:选LinkedHashMap
- 需要按键排序、范围查询:选TreeMap
- 需要给接口返回只读数据,防止外部修改:用Collections.unmodifiableXXX包装
这个清单覆盖了日常开发里九成以上的场景。别小看选型这步,一个HashMap和一个TreeMap,在数据量五万以上的时候性能差距是数量级的。常量级和O(log n)的差异在小数据量下感觉不到,数据量一大立刻原形毕露。
6.4 面试高频考察点清单
结合这些年当面试官和参加面试的经验,我把Java集合这块问得最多的点也一并列出来,你可以对照自查:
- HashMap的put流程、扩容时机、为什么容量是2的幂(答案:为了n-1按位与运算代替取模)
- 加载因子为什么默认0.75、树化阈值为什么是8(答案:泊松分布,空间时间折中)
- HashMap在JDK 1.7和1.8的区别(头插法改尾插法、引入红黑树、扩容不用重新计算index)
- HashSet怎么实现去重的(答案:复用HashMap,元素当key存)
- 重写equals为什么必须同时重写hashCode(答案:哈希集合的存储规则要求相等对象哈希值必须一致)
- ArrayList和LinkedList的区别及各自适用场景(这个最多人问)
- fail-fast机制和ConcurrentModificationException的触发条件
- ConcurrentHashMap为什么读操作无锁还能保证线程安全(答案:volatile保证可见性,CAS加锁粒度的设计)
- 如何实现一个线程安全的HashMap(老生常谈:Collections.synchronizedMap或ConcurrentHashMap)
这些考点你如果能结合底层原理讲清楚,而不是背概念,基本就稳了。面试官想听的永远是"你真正理解了数据结构在你手里的行为",而不是"你记住了这个类的功能描述"。
我个人在实际项目里最深刻的体会是:集合选型这种看似基础的选择,往往决定了代码在线上真实数据下的表现上限。曾经有一个导出服务,最初用LinkedList做中间存储,数据量到几十万条时耗时翻了几倍,换成ArrayList之后耗时直接降了一个数量级,一行业务逻辑都没改。还有一次在多线程环境里图省事用了HashMap存统计数据,线上偶发数据对不上,排查了两天才定位到是并发扩容写丢了数据。从那以后我给自己立了个规矩:涉及共享可变数据一律ConcurrentHashMap,选择集合类之前先想清楚数据规模、读写比例和是否需要有序这三个问题。这些基架层面的注意事项,越早形成习惯,后面省的事就越多。