刚看完一段线上事故的复盘,起因是开发同学用HashMap做缓存,却忽略了并发写入的问题,结果线上服务时不时卡顿,最后排查下来是扩容时产生了循环依赖。这类问题其实和“Java 集合框架”本身无关,而是因为你只记住了 API,没理解它底层的运行逻辑。Java 集合框架我用了十多年,真正觉得自己“搞懂”它,不是从文档开始的,而是从一次 OOM 和一次死循环排查开始的。
这篇文章不打算把Collection、Map的所有方法念一遍,而是想把集合框架从接口设计、底层原理到实战选型完整串起来。你会看到ArrayList为什么扩容 1.5 倍、HashMap为什么树化、负载因子为什么是 0.75,也会看到面对具体业务时到底该选哪个集合。无论你是刚学 Java 的新手,还是准备面试的候选人,又或是写了几年代码想回头补原理的开发者,这篇文章应该都能帮你少走弯路。
1. 集合框架到底解决了什么问题
1.1 数组的三大限制
很多人刚开始学 Java 时都听过一句话:集合就是“可变长度的数组”。这句话没错,但它把集合的价值说小了。数组有三个很头疼的限制:
第一,长度固定。声明new int[10]之后,想放第 11 个元素就得重新创建一个数组,再把旧数据复制过去,这个逻辑写起来非常繁琐。第二,类型太死。数组在声明时就要确定元素类型,虽然有泛型数组的替代方案,但在日常业务中,我们更常需要一个“能放任意对象”的结构。第三,方法太少。数组只能通过下标访问,没有add、remove、contains这些语义化操作,所有增删改查都得自己写循环。
集合框架的出现,本质上就是把“数据该怎么存、怎么取、怎么管理”这套通用逻辑抽象出来。你不需要关心底层是数组还是链表,只需要面向接口调用add、get、remove,剩下的交给实现类。这个设计让业务代码干净很多,也让维护成本大幅下降。
举个我经常举的例子:购物车的商品列表。如果用数组实现,每次加一件商品都要做一次扩容复制,还要自己管理下标;用ArrayList只需要add一个对象,扩容逻辑框架内部处理。这就是集合框架最直观的价值:把通用数据结构从业务里剥离出来。
1.2 Collection 和 Map 的设计边界
搞清楚集合框架的整体脉络,看两张“族谱”就够了。
第一张是Collection,代表“单个元素的容器”。它下面又分三个子接口:
List:有序、可重复、可以通过下标访问。典型实现是ArrayList、LinkedList。Set:不可重复,核心语义是“去重”。典型实现是HashSet、LinkedHashSet、TreeSet。Queue:队列,通常按 FIFO(先进先出)规则操作,典型实现是ArrayDeque、LinkedList,还有并发包里的各种BlockingQueue。
第二张是Map,代表“键值对的容器”。它不属于Collection,但同样是集合框架的一员。Map的核心语义是通过key找到value,典型实现有HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。
这里有个很实用的规律:Java 里集合实现类的命名基本都有规律。“Hash”前缀表示基于哈希表,“Linked”表示在哈希表之上维护了一个双向链表,“Tree”表示基于红黑树。你看到一个类名,基本就能猜出它的底层和特性。HashSet内部实际上就是封装了一个HashMap,只是把 value 固定成了一个空对象;LinkedHashSet同理,内部是LinkedHashMap。理解这条规律,比逐个背源码高效得多。
Collection和Map的选择其实非常直观:如果业务上只需要处理一批元素,用Collection;如果还有“通过键快速定位值”的需求,用Map。很多人在设计接口时喜欢直接写HashMap<String, Object>,但更好的做法是声明成Map<String, Object>,这样后续替换实现类时不需要改任何业务代码。
2. 核心数据结构原理拆解
2.1 ArrayList:动态数组的扩容机制与随机访问优势
ArrayList是日常最常用的集合,它的底层就是一个Object[]数组。当你new ArrayList()时,默认构造的是一个空数组;第一次add时才开始分配默认容量,默认大小是 10。面试里经常问“ArrayList 扩容到多少倍”,答案是 1.5 倍。
为什么要 1.5 倍而不是 2 倍?这里面有工程权衡。扩容意味着要先申请一块更大的新数组,再把旧数组的元素用System.arraycopy复制过去,这个操作的时间复杂度是 O(n)。如果扩容倍数太大,比如 10 倍,虽然扩容次数变少,但一次性占用的内存可能远超过实际需求,尤其在数据量波动大的场景下容易浪费空间。如果扩容倍数太小,比如 1.1 倍,扩容次数就会非常频繁,复制成本累计起来很高。1.5 倍是空间和时间的折中:每次扩容之后,数组还能留出大约 50% 的余量,既能减少复制次数,又不会浪费太多内存。
还有一个细节值得注意:ArrayList的add方法在扩容前会计算最小需要容量,grow方法里有一个oldCapacity + (oldCapacity >> 1)的逻辑,这就是 1.5 倍。如果你是批量插入很多数据,建议在构造时直接指定预估容量,比如new ArrayList<>(expectedSize),这样可以把扩容次数降为零。
小技巧:如果你知道数据量很大,但又拿不准准确值,可以用Math.max(10, (int) (expectedSize / 0.75f) + 1)之类的公式来估算初始容量,这和我们后面要讲的HashMap思路一致。提前指定容量对性能有提升,尤其在大数据量场景下,能避免多次数组复制。
ArrayList的随机访问性能非常好,get(index)的时间复杂度是 O(1),因为它可以直接通过下标计算内存地址。但它的插入和删除就没那么友好了,如果在中间位置插入,需要把后续所有元素后移一位,时间复杂度 O(n);在头部插入更是会把所有元素整体移动。所以当业务频繁在头部或中间插入删除数据时,ArrayList不一定是最佳选择。
2.2 LinkedList:双向链表的结构与真实使用场景
LinkedList的底层是一个双向链表,每个节点Node中持有三个字段:当前元素item、前驱引用prev、后继引用next。链表的优势在于插入和删除只需要修改前后节点的引用关系,理论时间复杂度 O(1),前提是已经定位到了对应节点。
但LinkedList有个很容易被忽视的短板:它的随机访问性能很差。想看第 n 个元素,只能从头或尾部开始遍历,源码里有一个node(int index)方法,会先判断 index 靠近头部还是尾部,然后选择从first还是last开始找,这种折半查找虽然比纯从头遍历好一些,但整体时间复杂度仍是 O(n)。所以如果你主要做get(index)操作,LinkedList并不适合。
那LinkedList到底该用在什么场景?我的经验是:频繁在链表头部或尾部插入删除、同时不需要随机访问时,它才有优势。比如实现一个简单的撤销/重做栈,或者一个需要不断从队头取任务、从队尾加任务的调度队列。另外LinkedList同时实现了Deque接口,可以当双端队列用,提供了addFirst、pollLast等方法,比单独用Stack更灵活。
不过说实话,在绝大多数后端业务里,LinkedList的使用频率远低于ArrayList。很多人以为“链表插入快所以一定好用”,但实际开发中链表引用的拆装、断链、垃圾回收开销并不低,数据量不大时反而没有数组来得直接。我见过不少项目里用LinkedList存几千条数据做遍历,性能完全不如ArrayList。选择数据结构,不能只看某单个操作的时间复杂度,还要看整体访问模式。
2.3 HashMap:哈希、扰动函数、树化与负载因子的完整链路
HashMap是 Java 集合框架里信息密度最高、面试最爱深挖的一个类。它底层是“数组 + 链表 + 红黑树”,JDK 8 之后在极端哈希冲突时会从链表升级成红黑树。
先看哈希过程。put(key, value)时,先计算key.hashCode(),然后通过一个扰动函数(h = key.hashCode()) ^ (h >>> 16)把高 16 位和低 16 位混合,再和数组长度减一做按位与,得到桶下标。为什么要扰动?因为桶下标计算用的是hash & (n - 1),当数组长度是 2 的幂次方时,这个操作等同于取模,但只有低位参与计算。如果hashCode的低位相同、高位不同,很容易产生碰撞。把高位“扰动”到低位,可以降低碰撞概率。
数组长度为什么必须是 2 的幂?因为n - 1的二进制低位全是 1,这样hash & (n - 1)的结果能均匀分布。每次扩容时,长度翻倍,元素在新数组中的位置要么在原索引,要么在原索引加旧容量的位置,这个特性让扩容时的 rehash 变得非常高效。
HashMap的默认初始容量是 16,默认负载因子是 0.75。负载因子代表“数组使用率达到多少时触发扩容”,默认 0.75 是时间和空间的折中。如果负载因子太大了,比如 1,空间利用率高,但哈希冲突概率也会上升,链表变长,查询性能下降;如果负载因子太小,比如 0.5,冲突少了,但数组很多位置空着,浪费内存。0.75 是实测下来综合表现比较好的值,日常基本不需要改。
树化条件很多人背过:链表长度超过 8,且桶数组长度大于等于 64 时,链表会转成红黑树。如果数组长度没到 64,即使链表超过 8,也只会先扩容。为什么是 8?源码注释里提到,理想情况下随机哈希分布中,同一个桶里出现 8 个元素的概率约为千万分之六,属于极端情况。树化是为了防止恶意哈希碰撞导致性能退化,红黑树查找 O(log n),比链表的 O(n) 稳得多。树化之后还会有一个“退化”规则:链表节点少于 6 时从树转回链表,避免红黑树维护成本过高。
实际操作中最容易出现问题的,是HashMap的初始容量设置。很多人直接new HashMap<>(1000),以为可以装 1000 条数据,其实不是这样。HashMap检查是否扩容的阈值是capacity * loadFactor,默认负载因子 0.75 时,容量 1024 的HashMap大约在元素数量到 768 时就会扩容。所以如果你明确知道要装 1000 条数据,初始容量应该设置为1000 / 0.75 + 1,向上取整到 2 的幂,也就是 2048 左右。这也是我项目里常用的一个初始容量估算公式:expectedSize / 0.75 + 1。
还有一个经典问题:HashMap不是线程安全的。JDK 7 中并发扩容可能产生环形链表,导致get死循环;JDK 8 在扩容方式上做了很大调整,新插入的元素在尾部而不是头部插入,大大降低了死循环概率,但并发下仍可能出现数据覆盖、丢失等更隐蔽的问题。所以并发场景中,用ConcurrentHashMap,不要图方便直接对HashMap做外部同步。
2.4 TreeMap、LinkedHashMap 的有序性实现原理
TreeMap的底层是红黑树,它按照 key 的自然顺序(Comparable)或者你在构造时传入的Comparator来排序。所以TreeMap的key必须可比较,否则运行时会抛ClassCastException。它提供了firstKey、lastKey、ceilingKey、floorKey等方法,适合做区间查询、排行榜、最近到期优先等场景。每次插入删除都要重新平衡红黑树,所以它的单次操作是 O(log n),没有HashMap的 O(1) 快。
LinkedHashMap则是在HashMap基础上额外维护了一个双向链表,用来记录元素的插入顺序或访问顺序。构造时有一个accessOrder参数,如果设置为true,每次get或put一个已存在的 key,都会把这个节点移到链表尾部,这样链表头部就是“最久没被访问”的元素。利用这个特性,可以实现一个简单的 LRU 缓存,重写removeEldestEntry方法,当元素数量超过阈值时自动淘汰最旧的节点。这个设计在面试中也经常被问到,理解之后很容易举一反三。
3. 实战选型:从业务场景倒推集合类型
3.1 常见业务场景的选型对照
选集合类型,最怕的是“拿一把锤子看什么都是钉子”。我之前见过有人所有地方都用ArrayList,也有人一律HashMap,结果遇到去重、排序、队列场景时写出一堆别扭的代码。下面这个对照表是我在实战中比较常用的选型参考,可以先对着场景看:
| 业务场景 | 推荐集合 | 理由 |
|---|---|---|
| 按索引访问列表,读多写少 | ArrayList | 随机访问 O(1),内存连续,遍历快 |
| 频繁在头部/尾部插入删除 | LinkedList或ArrayDeque | 链表操作无需移动元素,队列语义也合适 |
| 需要为每个用户/请求维护键值映射 | HashMap | 基于哈希,平均 O(1) 查找 |
| 需要去重,不关心顺序 | HashSet | 底层就是HashMap,天然去重 |
| 去重但要求保持插入顺序 | LinkedHashSet | 哈希去重,链表保序 |
| 需要按 key 排序、范围查询 | TreeMap | 红黑树保证 key 有序 |
| 需要按访问顺序淘汰缓存 | LinkedHashMap | accessOrder为 true 时可做 LRU |
| 生产者消费者队列 | ArrayBlockingQueue/LinkedBlockingQueue | 支持阻塞等待,线程安全 |
| 临时保存最近 N 个数据 | ArrayDeque | 循环数组实现,头尾操作都很高效 |
这个表格不是绝对的,但它给了一个很清晰的判断路径:先看数据形态是单元素还是键值,再看是否允许重复,再看是否要求有序,最后看是否需要线程安全,每一步都能排除一批候选。
3.2 选型背后的三个性能指标
判断一个集合是否合适,不能只看某一个操作的时间复杂度。我在做技术评审时通常会看三个指标:随机访问成本、插入删除成本、内存占用。
数组型结构(ArrayList、ArrayDeque)随机访问极快,但中间插入删除要移动元素,内存上也有一定的容量冗余。链表型结构(LinkedList)插入删除灵活,但每个节点都要额外存前后指针,内存开销增大,随机访问很慢。哈希型结构(HashMap、HashSet)平均性能最好,但需要处理冲突,扩容时成本较高,且会占用一定额外空间。树型结构(TreeMap、TreeSet)性能稳定,但常数因子大,适合排序要求明确的场景。
还有一个容易忽略的指标:缓存友好性。数组的内存是连续分配的,遍历时 CPU 缓存命中率高;链表节点散落在内存中,每次跳转都可能缓存未命中。所以即使链表和数组的某些操作时间复杂度相同,实际执行时间数组型结构往往更稳。这也是为什么很多做底层的开源框架内部更偏爱数组。
3.3 并发场景下的线程安全集合
并发场景里,普通集合类都不能直接用,但也不建议在外部加锁。Collections.synchronizedList这类包装类虽然简单,但它是把整个对象锁住,并发度很低。更好的做法是直接使用并发包里的专用容器。
读多写少的列表场景,用CopyOnWriteArrayList。它的核心思想是“写时复制”:每次add或remove都会复制一份底层数组,在新数组上修改,然后volatile替换引用。这样做的好处是读操作完全不加锁,特别适合配置下发、监听器列表这种读频率远高于写的场景。代价是每次写操作都全量复制数组,列表很大时开销惊人,所以不适合频繁写入。
键值对场景用ConcurrentHashMap。它的设计是 JDK 8 之后分段锁升级为 CAS + synchronized,只对链表/树的首节点加锁,并发度比 JDK 7 的 Segment 更高。日常使用中,直接用ConcurrentHashMap基本就够了,不要自己去包一层synchronizedMap。
队列场景用BlockingQueue系列,比如ArrayBlockingQueue有界、LinkedBlockingQueue可无界,它们天然支持生产者消费者模型。如果只是单生产者单消费者,用无锁队列更好,但这是另一个话题了。
3.4 代码层面的一些规范建议
选型不是只在“创建那一刻”起作用,代码习惯同样重要。我总结了几条实战规范:
第一,变量类型尽量声明成接口。List<String> list = new ArrayList<>(),Map<String, Object> map = new HashMap<>(),不要写ArrayList<String>这样的具体类型。这样将来因为性能或功能需求换成LinkedList或LinkedHashMap时,业务代码不用动。接口类型还能避免你用一些实现类特有的方法,降低偶合。
第二,预估容量就提前给。new ArrayList<>(expectedSize)、new HashMap<>(expectedSize)这类写法能显著减少扩容次数。扩容不是免费的,它会复制旧数据、重新哈希(对HashMap而言),大数据量时耗时明显。
第三,优先考虑不可变集合。如果集合创建后不会再变,用List.of、Map.of、Set.of这类不可变 API,或者用Collections.unmodifiableList包装。不可变集合天然线程安全,也不怕被其他地方意外修改,对排查线上问题很有帮助。不过要注意,List.of不接受null元素,这也是一个隐藏特性。
4. 常见问题与面试踩坑实录
4.1 fail-fast:为什么遍历时不能随便增删
很多面试题喜欢问“ConcurrentModificationException是怎么产生的”。这背后是集合的fail-fast机制。ArrayList、HashMap等内部维护了一个modCount字段,每次结构性修改(比如add、remove、clear)都会把它加一。创建迭代器时会把当时的modCount存到expectedModCount,每次next()前检查两者是否一致,不一致就抛ConcurrentModificationException。这个设计的本意是快速发现并发修改的问题,而不是保持对未知状态的容忍。
一句话总结:不要在forEach里直接调用list.remove,也不要边遍历边put。如果你需要在遍历时删除元素,用Iterator的remove方法,它会把expectedModCount同步更新;或者先把要删除的元素收集起来,遍历结束再批量removeAll。
4.2 hashCode 与 equals 不配对的坑
HashSet与HashMap依赖hashCode先定位桶,再用equals判断是否相等。如果两个对象equals相等但hashCode不同,它们会落在不同的桶里,导致判断“是否存在”时出错。典型错误是只重写equals而不重写hashCode,或者hashCode实现里用了会变化的字段。
更隐蔽的坑是:把对象放进集合后,又修改了对象中参与hashCode的字段。这样对象在集合里的“哈希位置”已经失效,你用原来的对象去contains或get时,大概率找不到。碰到这种情况,要么把参与hashCode的字段设置成不可变的,要么不要用这类对象做Map的 key。
4.3 可变对象做 key 与内存泄漏
接上面的问题,我们可以引出一个典型的内存泄漏场景:HashMap的 key 是可变对象,并且在放入后就改变了它,这会让原有 key 的 hash 值变化,但桶的下标已经确定。之后这个“脏数据”既无法通过新值查询,旧的引用也可能一直占着内存。尤其是缓存场景,如果 key 是某实体对象,实体更新后应该先从Map中移除再重新放入,而不是直接改字段。
另外,ArrayList里的元素如果自己持有大量引用,并且在不再需要时没有清理引用,也会有内存飙升的风险。集合本身不会自动帮你回收元素,它只是持有引用。如果你用集合做缓存,最好设计过期机制:LinkedHashMap的 LRU 淘汰、ConcurrentHashMap配合remove、或者专门的缓存框架,都比“放着不管”更稳妥。
4.4 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
遍历时抛ConcurrentModificationException | 迭代期间结构修改 | 使用Iterator.remove()或先收集后删除 |
Arrays.asList返回的列表add报错 | Arrays.asList返回的是定长列表 | 用new ArrayList<>(Arrays.asList(...))包装 |
subList修改后原列表异常 | subList返回的是原列表的视图 | 为了安全,使用subList后谨慎修改,或者复制为独立列表 |
重写equals后HashSet去重仍失效 | 没有同时重写hashCode | 保证hashCode与equals语义一致 |
HashMap里get返回 null,但 key 明明存在 | key 对象hashCode发生变化 | 不要用可变对象做 key,或先移除再重新放入 |
| 集合里元素删不掉 | remove(Object)和remove(int)混淆 | 明确是要按对象删还是按下标删 |
数据量大的ArrayList频繁卡顿 | 频繁扩容复制 | 初始化时指定容量,或改用LinkedList看访问模式 |
这张表基本覆盖了日常最常见的集合问题。这些坑很多都不是“不会写代码”造成的,而是没有把集合的底层约定当回事。equals/hashCode的约定、fail-fast的约定、hashCode不可变性的约定,比接口方法本身重要得多。
5. 一些实操心得和最后的建议
写代码时,我很少去纠结某个集合方法的名字,反而更关注三件事:集合的初始状态、集合的生命周期、集合是否并发可见。
集合的初始状态对应的是“是否给了足够的初始容量”。一个有 10 万条数据要插入的ArrayList,如果你不指定容量,它会从 10 开始一路扩容到 10 万,期间经历十几次数组复制和内存分配,虽然最终结果没问题,但浪费的时间和内存没必要。我在项目里通常会在构造函数里做一次容量预估,写一个简单的估算工具方法,后面所有集合创建都走同一个入口。
集合的生命周期对应的是“元素会不会无限累积”。如果你用集合做缓存,一定要有淘汰策略;如果你用集合接收消息,一定要有最大数量限制。否则即使集合本身性能再好,内存也会被拖垮。LinkedHashMap的 LRU 淘汰、ArrayBlockingQueue的有界特性,都是天然的生命周期管理工具。
集合是否并发可见对应的是“线程之间是否能安全共享”。我见过很多代码,在并发工具类里用HashMap存共享状态,然后自己在synchronized块里加锁。能用并发容器解决的,不要重复造轮子;能用不可变集合解决的,不要暴露可修改的引用。这是减少线上故障最有效的手段之一。
最后分享一个我踩过的坑:早期做列表页缓存时,我用HashMap<String, List<Object>>存用户筛选结果,结果每次用户筛选条件变化都往同一个 key 里追加数据,没有清理旧 key,最终把内存耗光了。后来改成ConcurrentHashMap<String, List<Object>>,并在每次写入前remove旧 key,再加一个定时清理任务,问题才彻底解决。集合本身没有错,错的是我把集合当成了无限容量的袋子。搞懂集合框架,不只是为了面试时能背出扩容倍数和树化阈值,更是为了在真实场景里,让每一块内存和数据都处在它该在的位置上。