Java集合框架核心原理与实战选型:ArrayList与HashMap的扩容奥秘
Java集合可以说是每个 Java 开发者最早接触、也最常用、却最容易用错的一套基础设施。它就像手边一个可伸缩的“万能数据容器”列表不够长会自动扩容映射关系可以直接查去重、排序、队列都有现成的实现类。正因为业务代码里到处都泡着集合我们反而很少停下来想一想这个“万能容器”到底是怎么设计的为什么同样是 ListArrayList 和 LinkedList 的性能差别那么大HashMap 扩容时内部发生了什么这篇文章我会结合日常开发和线上问题排查的经验把 Java 集合的接口设计、核心实现、扩容原理、性能优化和踩坑案例串起来讲。你可以把它当作一份集合选型和排障的参考不用一次全看懂遇到问题时再回来查对应章节也行。1. 集合框架的整体设计从数组到“万能容器”1.1 数组的痛点与集合的出现刚学 Java 的时候我们几乎都用过数组。数组的设计很直接一段连续的内存按下标访问访问速度极快。但它有一个很让人头疼的限制——长度固定。你在创建数组的那一刻就得定好容量可业务数据量往往要到程序运行起来才知道。我以前写早期的商品批量导入逻辑就常见到定义一个大数组的写法比如new Goods[10000]一旦数量超过一万就抛异常或者干脆按“足够大的上限”申请一堆内存白白浪费。而且数组只能表达“一列数据”没法直接表达“键值对”。你想通过订单号找到对应的订单就得自己写循环遍历或者维护一个特殊结构非常别扭。集合框架的出现就是来解决这两个问题的。它把“动态扩容”“键值映射”“去重”“排序”这些能力封装成标准接口背后再提供不同的实现类。使用者的视角简单了底层却可以在不同场景下选择最高效的数据结构。更重要的是集合是“用多少存多少、不够了再长”这是它被称为“可伸缩”的最直观原因。1.2 接口层和实现层的职责分离Java 集合的顶层设计并不复杂核心就两条线Collection和Map。Collection下面派生出三个大类List有序、可重复像数组的升级版。Set不可重复强调去重。Queue队列强调先进先出或者按优先级出队。Map不继承Collection它单独维护键值映射关系。你通过 key 去查 valuekey 不允许重复value 可以重复。为什么要把接口和实现分开举个真实例子你写一个方法接收ListString参数调用方传ArrayList没问题传LinkedList也没问题。接口让你面向抽象编程底层实现随时可以换业务代码不用改。这是集合框架最有价值的设计理念之一。实际编码中我一般都会用List、Map、Set这些接口类型声明变量而不是直接用ArrayList、HashMap就是为了保持灵活。1.3 可伸缩不仅仅是“能变大”很多人理解“可伸缩”就是“自动扩容”这当然没错。ArrayList在元素超过内部数组长度时会重新申请一块更大的数组并复制数据HashMap在元素数量超过阈值时会把桶数组翻倍。但“万能容器”的伸缩还体现在另一个维度同一个集合接口可以随时切换不同的实现。你写一个List变量数据量小用ArrayList合适如果出现频繁在头部插入元素的场景换LinkedList也许更有利。你写一个Map变量默认用HashMap需要按键排序时换成TreeMap需要按插入顺序遍历时换成LinkedHashMap。调用方感知不到内部变化这就是设计上的伸缩性。还有一个容易被忽略的组合伸缩能力Collections工具类可以把普通集合包装成同步集合、不可修改集合、单元素集合等。比如Collections.unmodifiableList(list)返回一个只读视图底层数据变了视图也跟着变这是另一种“形态伸缩”。2. 常用集合实现类先认识这群“选手”2.1 List 家族ArrayList、LinkedList 与 VectorList是我们平时用得最多的接口。家族里的主力是ArrayList底层是连续数组优势是按下标随机访问极快尾部追加元素也很快。缺点也很明显中间插入或删除元素需要整体搬移后续元素数量一大就很吃力。LinkedList底层是双向链表按理论来说头部和尾部的插入删除是 O(1)但实际业务中我很少推荐它。为什么因为链表每个节点额外存储前后引用内存占用更大而且它不支持高效的随机访问。你用list.get(5000)去取数据它会从头一个个找过去性能很差。现代 CPU 对连续内存的访问又有天然优势所以大多数场景下ArrayList反而是更优选择。Vector是老一代线程安全的动态数组操作方法都加了同步锁本身性能就差而且它保证的线程安全是“每个方法单独安全”组合操作依然要靠外部加锁。现在基本不建议使用。如果你要线程安全的列表读多写少用CopyOnWriteArrayList写多读多则考虑加读写锁、或者换并发队列设计。我用一个简化表格来看复杂度差异操作ArrayListLinkedList尾部添加O(1) 摊还O(1)头部添加O(n)O(1)中间插入/删除O(n)O(n)按下标访问O(1)O(n)内存占用连续数组相对紧凑节点 前后引用更占内存2.2 Map 家族HashMap、LinkedHashMap、TreeMap 与并发替代品Map的价值在于把“查找”从遍历变成直接定位。默认首选当然是HashMap它基于哈希桶实现理想状态下get和put都是 O(1)。Java 8 之后当单个桶里链表长度超过阈值后会自动转成红黑树把最坏情况下的查询复杂度从 O(n) 降到 O(log n)。LinkedHashMap继承自HashMap额外维护了一条双向链表来记录元素顺序。默认按插入顺序迭代还可以开启“访问顺序”模式访问过的节点会被移到末尾。我做过一个本地缓存就是用LinkedHashMap 重写removeEldestEntry来实现 LRU 淘汰代码量很小效果却很直接。TreeMap底层是红黑树按键的自然顺序或自定义Comparator排序。它适合需要范围查询、有序遍历的场景比如按时间区间取记录。但插入删除的复杂度是 O(log n)比HashMap要慢一些所以不要没事就用它。Hashtable和HashMap类似但方法整表加锁并发性能很差属于历史遗留。并发环境下请用ConcurrentHashMap它把锁粒度细化到桶或节点上读操作基本无锁写操作只锁对应桶比简单套一个synchronized高明得多。2.3 Set 家族去重背后的底层秘密Set的语义只有一个不允许重复。实现上HashSet底层其实就是一个HashMap元素放在 key 的位置value 统一放一个占位对象。它靠hashCode()定位桶靠equals()判断是否相同。所以如果你把自定义对象放进HashSet却不重写hashCode()即使两个对象的业务主键相同也会被当成不同元素存进去。LinkedHashSet是LinkedHashMap的 key 视图保留了插入顺序所以你可以一边去重一边按原始顺序遍历。TreeSet底层是TreeMap元素按排序规则排列去重逻辑使用的是compareTo或者Comparator。这里有个坑TreeSet判断重复不看equals而看比较器返回值是否为 0。如果你写了一个只比较部分字段的Comparator那两个字段不同但比较结果为 0 的对象会被当成重复直接丢掉一个。我自己的经验是能用基本类型包装类做 Set 元素就用基本类型包装类比如SetString、SetInteger一旦涉及自定义对象先想清楚业务上的“唯一性标准”到底是哪些字段再重写hashCode和equals测试代码里至少要验证新增重复对象不会导致集合变大。3. 底层原理与扩容机制可伸缩的真相3.1 ArrayList 的扩容1.5 倍的增长逻辑ArrayList内部维护一个Object[] elementData。每次add时会先计算最小需要的容量 minCapacity如果minCapacity elementData.length就触发扩容。扩容时先拿到旧容量 oldCapacity新容量按oldCapacity (oldCapacity 1)计算也就是 1.5 倍左右。如果新容量还是小于 minCapacity就直接用 minCapacity。最后通过Arrays.copyOf把旧数组里的元素完整搬进新数组。我帮你算一个例子假设初始容量 10你连续往里 add 第 11 个元素时扩容到 15之后是 22、33、49、73直到第 100 个元素放进去中间大约经历了 6 次扩容。每次扩容都要复制旧数组里已有的全部元素累加起来大概多做了几百次赋值操作。数据量小无所谓数据量大就很明显。所以使用ArrayList时如果能预估数据量一定要用带初始容量的构造器。比如明确知道要装载 5000 条订单就写new ArrayList(5000)数组直接按 5000 长度分配整个过程零扩容。这里有一个容易误会的点new ArrayList(5000)不是“预留下限”而是“直接分配 5000 长度”哪怕你只放 1 个元素数组也占了 5000 个对象引用的空间。所以不要随意给一个特别夸张的初始容量合理评估即可。3.2 HashMap 的哈希、冲突与扩容HashMap的结构是数组加链表加红黑树。默认初始容量是 16负载因子是 0.75。你可以理解为当元素数量达到当前容量乘以 0.75也就是 12 个时HashMap 就会扩容数组容量翻倍。向 HashMap 里放数据时先计算 key 的hashCode()再将结果的高位也参与运算以此降低哈希碰撞概率。最终用hash (n - 1)定位桶下标之所以用位运算而不是取模是因为容量设计成 2 的幂时位运算结果和取模一致但又快得多。如果两个 key 落到了同一个桶就在链表里追加当链表长度达到 8、且数组总长度不小于 64 时链表转红黑树。为什么选 8这是根据泊松分布算出来的一个几率很小的临界值。正常情况下链表很难超过 8 个节点所以树化不会轻易触发。HashMap扩容时容量变成原来的两倍。元素不需要像数组那样全部搬移到新位置而是根据“原索引”或“原索引 旧容量”重新定位。因为容量是 2 的幂扩容后元素的落位只有这两种情况所以 Java 8 之后的设计非常巧妙地避免了逐一重算哈希值效率提升了很多。这里必须拉响警报HashMap不是线程安全的。多线程同时 put 触发扩容时非常容易出现数据丢失、覆盖甚至把链表改成环状结构导致 CPU 飙升。即便 Java 8 以后换成了尾插法也不代表它就能安全使用。并发场景直接切ConcurrentHashMap。3.3 并发下的伸缩风险我在线上排查问题时遇到过几次集合相关的事故印象很深。一次是某个公共服务用一个HashMap做热点数据缓存平时没问题某个瞬时流量一冲多个线程同时触发扩容结果部分线程读到了已经不存在的 key业务直接抛空指针。还有一次是有人图方便用Collections.synchronizedMap(map)包装 HashMap虽然单方法线程安全但“先判断再 put”这种组合操作不是原子的两个线程同时判断 key 不存在就会同时写入后写覆盖先写数据还是丢了。正确做法很明确并发读多得用ConcurrentHashMap。它内部采用桶级加锁或者 CAS 操作读取不用全表锁能支持的并发量远高于老式的同步容器。如果你的业务是 ListCopyOnWriteArrayList在“读多写极少”时可以接受但写操作是复制整个数组写频繁时别用。4. 性能调优与实操要点让集合更好用4.1 初始容量最容易见效的优化集合性能优化里投入产出比最高的就是“预估初始容量”。先看ArrayListnew ArrayList(expectedSize)直接分配目标容量最简单。再看HashMap这里有个细节很多人以为new HashMap(500)就能装 500 个元素其实不然。HashMap构造函数里的参数只是“希望初始容量”内部会通过tableSizeFor把它调整成最近的 2 次幂。500 调整后实际容量是 512负载因子 0.75那么触发扩容的阈值是 512 × 0.75 384。也就是说当你塞到第 385 个元素时扩容就发生了远远早于 500。要给一个可靠的公式预估数量是 expectedSize构造 HashMap 时传入的容量建议是(int) (expectedSize / 0.75f) 1。比如 expectedSize 是 1000计算得到(int)(1000 / 0.75) 1 1334经内部调整后实际容量为 2048阈值 1536足够覆盖 1000 个元素而不会扩容。虽然多占了点内存但换来的是写入过程中一次扩容都没有综合看还是划算的。4.2 遍历方式与删除操作的讲究集合遍历至少有三种常见写法老式for循环、for-each、Stream。for-each本质上是迭代器访问代码简洁大部分时候用这个就对了。ArrayList上用下标循环确实能快一点但差距在数据量小时可以忽略不计没必要为了那一点性能牺牲可读性。可如果你用的是LinkedList又在循环里写list.get(i)那就会变成每次访问都从头部遍历整体复杂度直接 O(n²)数据一多肯定卡死。遍历中删除元素是高频踩坑点。直接写for (String item : list) { if (condition(item)) { list.remove(item); } }运行起来十有八九抛ConcurrentModificationException。因为迭代器内部维护一个 modCount 校验集合被结构修改后迭代器检测到版本不一致就报错。正确姿势是使用迭代器自己的remove()方法IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); if (condition(item)) { it.remove(); } }或者更彻底一点用list.removeIf(item - condition(item))代码更短底层也是安全的。这个 API 从 Java 8 开始就有我建议优先使用。4.3 不可变集合与空集合的小细节从 Java 9 开始JDK 提供了List.of、Set.of、Map.of系列方法可以轻松创建不可变集合ListString config List.of(a, b, c); SetInteger ids Set.of(1, 2, 3); MapString, Integer score Map.of(java, 100, sql, 90);这些集合的特点是内部不可修改、不可增删并且不接受 null 元素。如果往里面塞 null会立刻抛NullPointerException。为什么会有这个设计因为很多业务数据在初始化之后就应该保持稳定比如配置项、字典表、默认值。用不可变集合可以把“不小心修改”这类问题在编译和运行阶段直接拦下来。还有一个容易忽略的细节返回空集合时不要总写new ArrayList()。如果你明确知道结果是空的用Collections.emptyList()或者List.of()它们内部直接复用同一个空实例不创建新对象还能避免无谓的内存分配。4.4 equals 与 hashCode集合去重和查找的基石HashSet去重、HashMap查找都依赖对象的hashCode()和equals()。第一步调用hashCode()定位到桶第二步才用equals()精确匹配。如果你只重写了equals()没有重写hashCode()那么两个逻辑上相同的对象可能哈希值不同被放进两个桶里Set 里出现“重复”Map 里也查不到原值。更隐蔽的问题是 key 对象可变。如果你把一个自定义对象放进HashMap当 key之后又修改了参与hashCode()的字段那么 key 的哈希值变了但它在桶里的位置还是按旧哈希值放的结果就是再也查不到这个 key甚至可能造成内存泄漏。所以一个重要原则放进 Map 和 Set 的 key尽量使用String、Integer等不可变类型如果必须用自定义对象请把 key 字段设计成初始化之后不可修改。5. 实战一个业务场景下的完整选型过程5.1 场景与需求拆解我拿一个典型的通知中心场景来演示怎么选集合。假设我们要处理某个系统的站内信数据需求如下需要保存一批通知对象按时间倒序展示支持分页。需要统计各类型通知的未读数量比如系统通知、交易通知、活动通知。需要对一天内相同关键词的任务做去重避免重复处理。这三种需求对应三种不同的数据容量、访问方式和约束条件。第一步不是写代码而是把数据结构想清楚。5.2 数据结构选型推演第一个需求要的是列表与分页。通知对象数量在一千左右数据是查询出来的结果集读取频率高于修改频率并且希望按下标访问来支撑分页。我选择ArrayList因为它连续内存、随机访问快而且可以直接用stream().skip(offset).limit(limit)做分页。为什么不选LinkedList因为分页涉及位置定位链表定位是 O(n)不合适。第二个需求要的是按类型统计数量。类型枚举是固定的用MapMessageType, Integer最直观。默认用HashMap不需要顺序也不需要排序。如果之后要按类型顺序输出再换成TreeMap也不难。统计时用merge方法可以一次完成“取旧值加一再放回”的流程避免自己写繁琐的判断。第三个需求要的是去重而且只需要判断“是否已经存在”不需要保留顺序。最合适的结构是HashSetString。我用“类型 冒号 关键词”拼成去重键放入 Set用返回值判断是否首次出现。5.3 代码实现与关键点解读我先定义一个简化版通知对象enum MessageType { SYSTEM, TRADE, ACTIVITY } class Message { private String id; private MessageType type; private String keyword; private long time; private boolean read; }然后是列表数据加载和未读统计int expected 1000; ListMessage messages new ArrayList(expected); // 这里省略从数据源填充消息的代码 MapMessageType, Integer unreadCount new HashMap(); for (Message msg : messages) { if (!msg.isRead()) { unreadCount.merge(msg.getType(), 1, Integer::sum); } }merge的用法值得多说一句第一个参数是 key第二个参数是初始值第三个参数是合并函数。如果 map 里没有对应的 key就把第二个参数作为 value 放进去如果已经存在就调用合并函数把旧值和新值计算后再放回去。前面的代码等价于Integer old unreadCount.get(msg.getType()); unreadCount.put(msg.getType(), old null ? 1 : old 1);但又更简洁、更原子化。注意这里单线程没问题多线程更新 HashMap 依然不安全需要换成ConcurrentHashMap。分页代码用 Stream 实现int page 1; int pageSize 20; int offset (page - 1) * pageSize; ListMessage pageList messages.stream() .sorted(Comparator.comparingLong(Message::getTime).reversed()) .skip(offset) .limit(pageSize) .collect(Collectors.toList());如果messages在填充时已经按时间倒序插入就删掉sorted这行避免无谓排序。这里有一个新手容易踩的坑List.subList(offset, offset pageSize)返回的是原列表的视图不是真正的新列表。如果对返回的 subList 做修改或者之后往原列表里 add、remove会破坏视图一致性甚至抛出异常。所以分页我建议用skip/limit或者干脆新建一个列表。最后是去重逻辑SetString todayKeys new HashSet(); for (Message msg : todayMessages) { String key msg.getType() : msg.getKeyword(); if (todayKeys.add(key)) { // 第一次出现说明今天还没处理过进入后续任务处理队列 } }todayKeys.add(key)返回值是 boolean这个设计非常实用一般我们用来判断是否新增成功。如果返回 false说明集合里已经有这个 key可以跳过。6. 常见问题与避坑清单6.1 高频异常对照表我在带人和排查问题过程中遇到过大量与集合相关的异常这里整理成一张表异常可能原因解决思路ConcurrentModificationException遍历过程中直接调用集合增删方法使用迭代器 remove、removeIf 或收集到新集合UnsupportedOperationException对 Arrays.asList 生成的固定长度列表执行 add/remove或对不可变集合执行修改用 new ArrayList(Arrays.asList(...)) 包装NullPointerException向 Hashtable、ConcurrentHashMap、List.of 传入 null 元素提前校验或根据业务决定是否允许 nullClassCastException没有使用泛型集合里混入不同类型对象全面使用泛型不要用原始类型集合OutOfMemoryError集合无限增长回收逻辑缺失或 key 对象泄漏检查集合清理逻辑考虑 WeakHashMap 或定时清理6.2 排查思路与调试经验遇到集合相关问题重要的是先判断异常出现在“哪个集合、哪一步操作”上。如果是并发异常优先检查是否有多个线程同时修改同一个集合。如果是内存溢出先用内存分析工具抓堆快照看对象到底积累在哪里。我遇过一个典型情况某个 static 集合不断接收新数据却一直没有清空逻辑结果几个月后内存涨到顶服务重启才好。这类问题不在集合本身而是整体生命周期设计错了。调试时也有一个很实用的小技巧把集合的大小、扩容次数等关键信息打出来。比如可以短暂监控一个 ArrayList 的 size 和数组长度如果 size 始终接近数组长度说明频繁扩容就要考虑初始容量是否不够。不过这种监控只建议在排查阶段使用线上长期打印反而影响性能。6.3 我的独家避坑心得最后分享几条我用真金白银换来的经验第一所有集合变量的声明尽量用接口类型。这样替换实现类时不需要改动调用方。我看到过很多代码直接写HashMapString, Object map new HashMap()一旦要换成TreeMap全部声明和构造函数都得改。第二自定义对象放到 Set 或 Map 的 key 之前先问自己三个问题业务唯一键是什么equals 需要比较哪些字段hashCode 是否和 equals 使用相同的字段三个问题想清楚再动手写能少踩很多坑。第三不要为了“万能”把一个集合塞满所有数据。有时候用一个小而精的数组加一个合理索引比套一层复杂集合更高效。集合是工具不是目的。第四写代码时给集合起名时尽量体现出用途比如unreadCountMap、todayDedupKeys这比map1、list2强太多。线上排查时一个好的变量名能帮你节省半小时定位时间。我个人处理线上问题时的体会是集合相关的故障往往不是集合本身写错了而是使用场景和选型不匹配。数据量小的时候什么都很快数据量一上来选错结构的代价就非常明显。如果你能养成“创建集合前先想三秒需求”的习惯绝大多数集合性能问题和并发问题都能在代码阶段直接避免。