
最近处理线上接口耗时问题排查到最后发现绝大多数瓶颈都出在集合的使用上。Java 集合框架与工具类几乎是每个 Java 开发者每天都在碰的东西但很多人对它们的理解停留在“能用、能跑”的阶段。真正到了性能优化实战ArrayList 和 HashMap 的初始容量、遍历方式、批量操作、工具类的边界条件每一处细节都可能让接口从 10ms 变成 100ms。这篇内容主要写给那些已经在写 Java、但希望把集合用得更明白的开发者包含我实际优化项目时积累的经验和踩过的坑。1. 集合框架总览先搞清楚手里有哪些牌1.1 集合家族的接口与实现对应关系Java 集合框架从顶层设计上分为Collection和Map两条主干。Collection下面又衍生出List、Set、Queue每个接口都有若干具体实现。很多人背过这张图但实际写代码时经常忘记不同实现的特性差异导致选型凭感觉性能靠运气。List家族的三个主要实现各有侧重ArrayList基于动态数组随机访问快尾部插入快但头部和中间插入需要挪动元素时间复杂度为 O(n)。LinkedList基于双向链表头部和中间插入删除理论上快但随机访问是 O(n)而且每个节点比数组元素多存两个指针内存占用更高。CopyOnWriteArrayList读多写少场景下的线程安全列表写操作通过复制底层数组实现读操作不加锁。Set家族里HashSet依赖HashMap元素无序且不重复LinkedHashSet在HashSet基础上增加了链表维护插入顺序TreeSet底层是红黑树元素有序但插入查询是 O(log n)。还有一个容易被忽略的CopyOnWriteArraySet同样适用于读多写少。Map家族是性能优化中的重灾区。HashMap最常用能够提供 O(1) 级别的查询性能LinkedHashMap保留插入顺序或访问顺序可以用作 LRU 缓存TreeMap按键排序但查询降级为 O(log n)ConcurrentHashMap是并发场景的首选WeakHashMap的 key 是弱引用适合做不担心内存泄漏的缓存。初学者最容易犯的一个错误是看到某个需求长了就“觉得”某种集合合适但根本不验证复杂度。实际优化时第一步一定是把现有代码里的集合类型列一遍问自己三个问题查询多还是写入多需要有序还是允许无序是否需要线程安全这三个问题的答案基本决定了集合选型的方向。1.2 选错集合的代价一次查询拖垮接口选错集合的代价在数据量小的时候完全看不出来一旦数据量上来问题非常直观。举个例子我在优化一个标签查询接口时原来代码用了ArrayList保存大约几十万条用户标签接口内部会在循环里通过list.indexOf()判断标签是否存在。这行代码看起来没什么问题但indexOf()在 ArrayList 上是线性扫描几十万条数据扫描几十万次接口耗时就失控了。后来把存储结构换成HashSet每次判断的时间从 O(n) 降到了接近 O(1)整个接口的耗时瞬间掉了一个数量级。这就是“选错集合的代价”。类似的问题在contains()判断里也经常出现List.contains()是 O(n)Set.contains()是 O(1)。如果只是去重用HashSet几乎总是优于“先判断再插入 List”。另一个常见误区是“LinkedList 插入快所以什么都用它”。实际上在大多数业务场景中数据规模远没有达到让链表优势发挥的程度反而因为 LinkedList 的节点对象更分散、CPU 缓存命中率低实际性能往往不如 ArrayList。我自己的经验是除非需要频繁在队列两端操作否则优先选 ArrayList。2. 工具类集合操作的正确打开方式2.1 Collections 工具类的经典用法与边界条件Collections是 Java 集合框架里最常被低估的类。它提供了一批静态工具方法但很多开发者的使用只停留在sort()和reverse()其他方法几乎没有触碰过。排序方面Collections.sort(ListT)底层调用了List.sort()JDK 8 之后实际是TimSort算法最坏时间复杂度是 O(n log n)。如果集合元素是基础数据类型可以考虑直接用Arrays.sort配合parallelSort。这里有个小坑Collections.sort要求 List 内部元素可比较否则必须传入Comparator而且不要忘了处理 null 的情况。synchronizedList()方法很多人用过它返回的 List 在每个方法上加锁但多线程环境下如果同时进行“判断后修改”这类复合操作仍然可能出现问题。理论上这种场景应该改用CopyOnWriteArrayList或ConcurrentLinkedQueue或者在自己的业务代码里加锁。这个坑在面试题里经常出现实战中也容易踩。更值得推荐的是Collections.unmodifiableList()它返回一个只读视图任何修改操作都会抛出UnsupportedOperationException。我在项目里经常用这个方法来暴露内部数据避免外部直接修改集合导致状态不一致。要注意的是unmodifiable 只是不可通过该视图修改如果原始集合本身还能被其他地方修改视图内容还是会变。如果真的需要完全独立快照应该新建集合并拷贝。Collections.binarySearch()是个高效查找方法但前提是 List 必须已经按升序排序。实际项目中有人拿一个未排序的 List 直接 binarySearch结果完全不可预期。这点值得写进代码评审的注意事项里。2.2 Arrays 工具类的隐藏功能与常见误解Arrays工具类大部分人都用过asList()和sort()但这里有几个非常容易踩坑的细节。首先是Arrays.asList()返回的 List 是一个固定大小的数组视图它不支持add()和remove()调用就抛UnsupportedOperationException。很多人把Arrays.asList(a, b)的结果当成普通 ArrayList 操作运行到一半才发现问题。正确做法是new ArrayList(Arrays.asList(...))包装一层。其次Arrays.asList()对于基本类型数组有坑。如果你传一个int[]得到的 List 大小是 1元素是整个数组对象而不是各个 int。要处理基本类型数组通常需要循环逐个加入或者用 Java 8 的Arrays.stream(int[]).boxed().collect(Collectors.toList())。Arrays.copyOf()和Arrays.copyOfRange()是数组扩容和截取的最佳选择。底层调用System.arraycopy()这是一个 native 方法效率远高于手写循环复制。ArrayList 扩容底层正是依赖这套机制。还有一个值得注意的点是Arrays.parallelSort()。对于基础类型数组它使用并行排序算法在数据量较大时比Arrays.sort()明显更快。但是面对小型数组并行化的启动开销反而会让性能变差所以不要不分青红皂白就把所有排序都换成 parallelSort。Objects工具类看起来简单但它提供的requireNonNull、equals、hash等方法能让代码更安全。java.util.Objects.hash()内部用Arrays.hashCode实现计算多个字段的哈希时比手写Objects.hash包装更省事。2.3 Stream API 与集合工具类的协同关系Java 8 的 Stream API 让集合操作变得非常优雅但很多人忽略了它底层依然依赖集合工具类。stream()方法来自Collection接口而Collectors类本质上是一个工厂类帮我们生成各种收集器。实际项目中我发现最值得留意的是收集器选择对可变性的影响。Collectors.toList()返回的是 ArrayListCollectors.toSet()返回的是 HashSet。JDK 16 以后提供了toList()不可变收集器返回结果随即不支持添加元素。如果后续代码需要修改这个列表就必须用Collectors.toCollection(ArrayList::new)否则运行时直接抛异常。Collectors.groupingBy()和Collectors.partitioningBy()在聚合统计场景里非常实用。partitioningBy只分两组groupingBy可以按任意维度分组。使用groupingBy时默认的 Map 实现是 HashMap里面的 List 实现是 ArrayList。如果希望得到有序结果要传TreeMap作为 mapFactory或者用LinkedHashMap保持分组顺序。关于性能优化Stream 本身不是银弹。数据量很小的时候普通循环的性能通常优于 Stream因为 Stream 有额外的方法调用和 lambda 封装开销。数据量大、逻辑简单的时候并行流确实能带来收益但并行流用错了会造成线程竞争和上下文切换反而更慢。我通常只在数据量超过百万级、且 CPU 核数较多时才考虑parallelStream()而且会先做基准测试再决定要不要上。3. 性能优化实战从数据结构和参数层面找突破口3.1 初始容量HashMap 与 ArrayList 扩容带来的隐形损耗性能优化最容易见效的一步是给 HashMap 和 ArrayList 指定合适的初始容量。HashMap 默认容量是 16默认负载因子是 0.75。当元素数量超过容量 * 负载因子时HashMap 会触发扩容重新计算哈希并搬移所有元素。扩容过程涉及数组重建和链表/红黑树的重新布局非常消耗时间。如果提前知道 Map 大概会存多少数据可以在创建时指定容量避免多次扩容。需要注意一点指定容量时不能直接填“元素个数”。HashMap 的容量会向上取到 2 的幂次方而且扩容阈值是容量 * 0.75。假设要存 1000 个元素new HashMap(1000)最终容量是 1024阈值约 768实际存入 1000 个元素时仍会扩容。正确做法是new HashMap((int) (1000 / 0.75f) 1)也就是容量设成 1365 左右。这个细节很反直觉但确实影响性能。ArrayList 扩容同样需要注意。默认初始容量为 10每次扩容大约是原来的 1.5 倍扩容时要System.arraycopy移动元素。如果循环添加一万条数据ArrayList 会触发多次扩容和数组拷贝。提前调用new ArrayList(expectedSize)能直接省掉这些开销。在很多“接口返回大列表”的业务场景里我见过最典型的优化就是从new ArrayList()改成new ArrayList(list.size() * 2)或者精确指定大小整个接口的 GC 次数和分配耗时都明显下降。不要小看这个改动在高频接口里它可能是最划算的一行代码。3.2 遍历方式for-each、迭代器与 removeIf 的正确选择集合遍历方式直接影响性能。最基础的 for-each 语法糖底层编译成迭代器适合绝大多数场景。基于索引的 for 循环在 ArrayList 上效率不错但无法用于 LinkedList因为list.get(i)是 O(n) 操作整体变成 O(n²)。遍历过程中删除元素是最容易踩坑的地方。用 for-each 删除会触发ConcurrentModificationException因为在遍历时修改了集合结构。传统解决方式是使用Iterator并调用iterator.remove()JDK 8 之后又有更干净的removeIf()。removeIf()内部已经有迭代器和少量调优推荐优先使用。还有一点是关于增强 for 循环内重复调用集合方法的坏习惯。比如在循环里反复调用list.size()、map.get(key)其实这些操作不算太昂贵但如果是每次都执行map.get(key)加上后续的二次解析就会造成大量冗余工作。更合理的做法是循环外先获取一次并缓存到局部变量。遍历 Map 也有讲究。map.keySet()配合map.get(key)会造成一次额外的哈希计算。map.entrySet()直接同时拿到 key 和 value在遍历大量数据时更值得推荐。forEach()方法本质上和 entrySet 遍历差不多但 lambda 表达式可能带来一定的额外成本数据量极大时可以优先尝试 entrySet。3.3 批量操作优于循环单操作集合性能优化的另一条铁律是批量操作优先于循环单操作。比如大量数据加入集合很多人写 for 循环一个一个add()实际上可以用addAll()一次传入整个集合。addAll()在 ArrayList 内部会先确保容量再批量复制性能优于逐个添加。删除同理。removeAll()的实现逻辑简单直接但要注意如果传入的集合是 HashSet判断每个元素是否在删除集合里就是 O(1)整体复杂度接近 O(n)。如果传入的是 ArrayListremoveAll()内部会变成嵌套循环复杂度退化到 O(n²)所以务必把待删除元素放到 Set 里再调用。另一个不太被人注意的方向是减少中间集合的创建。链式 Stream 操作里每一步都会产生新的中间结果集比如filter().map().collect()在数据量大的时候会生成很多临时对象。如果只需要数据子集可以用subList视图非常节省内存。但subList返回的是视图修改它会反映到原 List同时原 List 一旦结构被修改再次使用 subList 会抛ConcurrentModificationException。想省内存就承担视图的风险想彻底独立就新建 List这个取舍要按场景来。computeIfAbsent()是批量初始化的高效写法。当你要维护MapString, ListLong时不要先判断 key 是否存在再决定新建 List 还是 get 已有的直接用map.computeIfAbsent(key, k - new ArrayList())。它只在 key 不存在时才执行映射函数减少了锁竞争和重复计算。3.4 并发场景下集合的选择同步容器与并发容器国内最常见的性能优化场景之一就是接口出现线程安全问题时直接把 HashMap 换成 Hashtable或者给 HashMap 套上Collections.synchronizedMap()。这两种做法都能保证“单个方法”的线程安全但性能表现并不理想因为它们的锁粒度过大所有操作都竞争同一把锁。并发场景首选应该是ConcurrentHashMap。它采用分段锁设计JDK 8 之后进一步改成了 CAS synchronized 锁单个节点读操作基本不需要加锁并发度明显优于 Hashtable 和 synchronizedMap。如果你有“缓存数据到 Map”的需求只需要保证最终一致可以直接使用 ConcurrentHashMap 替代同步容器。读多写少的场景比如配置列表、只读白名单等推荐CopyOnWriteArrayList或CopyOnWriteArraySet。它们的每次写操作都会完整复制底层数组所以写成本很高但读操作可以完全无锁。选择之前要确认业务是读多写少否则写频繁时会平白无故产生大量复制对象。并发容器的使用也不是一劳永逸的。ConcurrentHashMap只能保证单个方法原子性多个方法的组合操作仍然需要额外同步。经典场景是“先检查后插入”两个线程同时查不到某个 key然后同时写入即使每个操作本身都安全整体业务还是可能出问题。要实现这种原子性应使用putIfAbsent()或compute()系列方法。4. 实操过程与代码改造实录4.1 场景大量数据去重与排序的改造有个内部接口需要处理大约几十万个 ID这些 ID 来自多个上游模块重复率很高最终要输出去重后并且按时间排序的结果。最初代码是这样的ListLong ids service.fetchAllIds(); ListLong distinctSorted ids.stream() .distinct() .sorted() .collect(Collectors.toList());这段代码功能上完全没问题运行时也能正确返回。但在处理百万级数据时最终结果列表用到ids.stream().distinct()实际上底层也是用 Set 去重之后sorted()又会对整个流进行排序整体链路没什么性能问题。真正的隐患在于ids列表本身就巨大并且原始数据全部留在内存里。我的改造思路是直接在源头收敛。如果上游数据源支持分页或去重就在查询阶段调用DISTINCT或LIMIT这样传到 Java 层的数据量就已经很小。如果无法改上游则在 Java 层用HashSet接收所有数据这样天然去重并压缩了后续处理的数据总量SetLong uniqueSet new HashSet(service.fetchAllIds()); ListLong distinctSorted new ArrayList(uniqueSet); distinctSorted.sort(null);对比两种方式后者的好处是HashSet构造函数在建立时只需要哈希一次而distinct().sorted()会先生成中间流再收集中间对象多一些。数据量越大后者的优势越明显。这种差异在百万级数据下能拉开几十毫秒的差距在低延迟接口里已经值得重视。4.2 场景频繁查询的映射结构优化另一个真实案例是用户属性映射。系统里有上千万条用户数据每条数据需要根据用户 ID 快速查询对应的用户名称和头像。最初我用了HashMapLong, UserProfile但在内存和 GC 方面表现一般原因是Long和UserProfile都会产生大量对象包装。初次优化很容易想到指定容量new HashMap(expectedSize)。这个改动确实有效减少了扩容带来的损耗。但难点在于HashMapLong, UserProfile里每个 Long 对象都要被装箱每个 Entry 节点也是单独对象整体内存占用非常大。如果用户量达到千万级光是包装对象就能吃掉几百 MB 堆内存。进一步优化可以考虑使用基本类型集合库比如 Trove 的TLongObjectMapUserProfile它在内部使用基本类型 long 作为 key避免了 Long 装箱对象。对于高频查询场景长期运行后 GC 压力会明显下降。不过我通常只在用户量超过百万、且内存受限的情况下才考虑引入这类库因为引入第三方依赖会增加维护成本普通 HashMap 在大多数场景已经够用。如果不想引入第三方库还有一个技巧是自定义索引映射。把用户 ID 映射到数组下标ListUserId和UserProfile[]用同一个下标对应。查询操作变成两次数组访问速度比 HashMap 还要快但需要保证 ID 单调或可压缩限制比较多。4.3 场景集合分页与大列表截取的正确做法业务中经常需要把一个大列表按照每页 20 条切片。有人会写成for (int i 0; i list.size() / pageSize; i) { ListT sub new ArrayList(list.subList(i * pageSize, (i 1) * pageSize)); // 处理sub }这个写法有两个问题。第一new ArrayList(list.subList(...))会复制一份数据内存翻倍。第二subList是视图一旦操作过程中原列表被其他线程修改循环里再次访问 subList 就会抛ConcurrentModificationException。更稳妥的方式是不复制视图直接用原始列表的索引操作。比如分页数据本来就只读可以直接把subList当作只读列表传递或者用List.copyOf生成不可变副本。如果后续确要修改再显式复制。在处理超大列表时还有一个容易被忽略的点List.equals()和contains()都依赖元素equals()方法如果元素是一个有复杂结构的对象equals 比较的成本可能非常高。优先考虑 id 字段参与比较或者用 map 快速映射。4.4 实测数据与基准测试的注意事项我特别喜欢在优化完集合代码后做一次 JMH 基准测试把优化前后的吞吐量和工作时间对比出来。下面是一组在常见环境下获得的参考数据具体数值与 JDK 版本、数据规模、机器配置强相关请勿直接照搬结论操作ArrayList 100万次LinkedList 100万次HashMap 100万次尾部添加较快较快记录 key/value头部添加很慢快-随机访问极快极慢-contains线性扫描线性扫描O(1) 级别删除末尾元素极快较慢serve写这块数据是想说明一个观点集合性能高度依赖具体操作不要只凭记忆中的复杂度结论判断。真要判断一个优化方案是否有效必须针对自己的数据量和场景写 JMH 用例。测试时要注意预热和防止 JIT 优化绕过你所测的代码否则测出来的数据没有任何参考意义。5. 常见问题与排查技巧实录5.1 高频问题速查表现象大概率原因解决方案遍历时删除元素抛 ConcurrentModificationException在 for-each / Iterator 过程中直接调用 list.remove()改用 Iterator.remove() 或 list.removeIf()List.add() 抛 UnsupportedOperationException列表是 Arrays.asList() 返回的固定大小视图用 new ArrayList(Arrays.asList(...))unmodifiableList / toList() 的 add 抛异常集合本身不可变确认需求必要时改成可变集合Map.get() 在高并发下资源争抢严重使用了 Hashtable 或 synchronizedMap换 ConcurrentHashMap内存使用持续增长缓存用了 HashMap 但没做上限管理使用基于访问顺序的 LinkedHashMap 做 LRU或引入专用缓存组件接口慢但 SQL 不慢可能在做 list.contains() 或 Map 大量扩容用 Set 替代 List.contains设置初始容量这张表很多内容是老生常谈但在实际代码评审时我几乎每次都能看到类似代码。最好是把这些问题硬性变成团队的代码规范比如“禁止在 for 循环里调用 list.contains()”“禁止对复杂业务 map 用 synchronizedMap 代替 ConcurrentHashMap”等等。5.2 如何定位集合相关的性能热点排查集合性能问题最直接的方式是看线程堆栈。接口响应变慢时先抓一个线程 dump观察线程卡在哪个方法。如果停在HashMap.resize()或ArrayList.grow()基本可以判断是容量规划不足或数据量超预期。停在Collections.sort()里说明排序数据量非常大或者比较器过于复杂。GC 日志也是重要突破口。如果你发现 GC 频率特别高可以看一眼新生代对象分配速率集合扩容和中间集合创建是对象频繁分配的主要来源。一个 trick 是观察 Young GC 中的对象年龄和分配占比连续多次出现大量数组分配多半是集合容量设置不合理。如果内存问题上来了可以用 JFR 或 Arthas 查看堆内存中哪些集合占用最高。Arthas 的sc和dump命令可以快速查看集合对象的元素数量与类型。不要靠肉眼猜测直接看数据分布再决定优化点。5.3 排查过程中容易忽略的细节排查集合性能时很多人忽略了一个问题集合里的元素对象本身才是内存大头。HashMap存的是引用但对象对象比如一个包含大量字段的实体类才是真正消耗内存的地方。优化集合结构只能减少容器本身的开销如果元素对象过于庞大还是要从数据结构设计入手。另一个细节是避免把集合当全局垃圾桶。有些项目喜欢把接口结果缓存到static List这会造成单机内存无限膨胀。启用基于访问顺序的LinkedHashMap并重写removeEldestEntry()方法可以实现简单的 LRU 缓存但要注意线程安全。更彻底的做法是用成熟缓存组件代替手写缓存。还有一个容易被遗漏的点是WeakHashMap。某些人用它做缓存指望 key 不再被引用时自动清除但实际使用时如果 key 被强引用持有在外部WeakHashMap根本不会发挥清理作用最终还不如不用。缓存场景优先考虑显式控制容量的方案。6. 我对集合性能优化的几点体会做这轮优化最大的体会是集合性能问题通常不是靠某一个“魔法参数”解决的而是选型、容量、操作方式共同决定的。先把业务逻辑里的重复查询去掉再考虑换集合类型和加初始容量最后才轮到微调遍历方式。这个顺序不能乱因为错误的数据结构选型可能会引入更大的复杂度。另一个实际技巧是建立自己的“集合使用检查清单”。每次接新项目或做代码评审时我会快速扫描几个点是否有人在循环里用了 containsMap 是否指定了容量读写多的容器是否有必要用并发容器大列表是否反复用 subList 又修改原列表。这些检查做完性能隐患基本能减少一大半。最后想说一个个人观点不要盲目追求“看到集合就换并发容器”“看到遍历就换 stream”。性能优化的前提是理解业务特征。有些代码虽然写的“不漂亮”但数据量很小优化收益微乎其微还不如保持可读性。真正的优化精力应该花在那些热点路径上通过 profile 找到真正的瓶颈而不是把每一段集合代码都重写一遍。方向对了一行容量设置的改动都可能带来肉眼可见的收益。