ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

12-集合避坑 12 条:性能调优与那些年踩过的雷

12-集合避坑 12 条:性能调优与那些年踩过的雷

12 条避坑 + 上线 checklist,拦掉多数事故

把生产环境最高频的 12 个坑和对应的调优手段集中摆出来。很多性能问题和诡异 Bug,根子都在集合使用习惯上——容量设错、hashCode 写错、遍历时删除、并行流乱用……每一条都配代码和后果,照着排查能省无数加班。

坑一:不预设容量,反复扩容

ArrayList、HashMap默认容量小(10 / 16),批量数据会一路扩容 + 拷贝。

// 反例:循环 add 十万条,触发几十次 1.5 倍扩容List<User>list=newArrayList<>();for(Useru:users)list.add(u);// 正解:预估容量,一次到位List<User>list=newArrayList<>(users.size());// 或 HashMap:(int)(expectedSize / 0.75f + 1)Map<String,User>map=newHashMap<>((int)(users.size()/0.75f+1));

扩容对 ArrayList 是Arrays.copyOfO(n) 整体搬,对 HashMap 是重建桶 + 高低位拆链。大数据量下预设容量,性能可差数倍。

坑二:hashCode/equals 契约破坏,去重和查找失效

放进 HashSet/HashMap 的 key 的对象,必须正确实现equals和hashCode,且参与哈希的字段不可变。

classUser{Stringid;// 必须同时重写 equals 和 hashCode,且只用不可变字段@Overridepublicbooleanequals(Objecto){...}@OverridepublicinthashCode(){returnObjects.hash(id);}}

只重写其一、或改了参与 hashCode 的字段,对象就从集合里"消失"——contains/get找不到、remove删不掉,成了游离垃圾。这是最隐蔽的 Bug 之一:不报错,但逻辑悄悄错。

坑三:遍历时删除,ConcurrentModificationException

// 反例:for-each 里直接 remove → 抛 CME(fail-fast)for(Strings:list){if(s.isEmpty())list.remove(s);}// 正解 1:用迭代器自己的 removeIterator<String>it=list.iterator();while(it.hasNext()){if(it.next().isEmpty())it.remove();}// 正解 2:Java 8 + removeIflist.removeIf(String::isEmpty);// 正解 3:先收集再删(并发集合或需复杂判断时)list.removeAll(list.stream().filter(String::isEmpty).collect(toList()));

fail-fast机制靠modCount和迭代器的expectedModCount比对:你在迭代器外部改了结构,下次next()立刻抛ConcurrentModificationException。并发容器是弱一致不抛,但语义不同,别混。

坑四:subList 是视图,改原列表会炸

List<Integer>sub=list.subList(0,3);sub.set(0,999);// 原 list 第 0 位也变list.add(100);// 改了原列表结构sub.get(0);// 抛 ConcurrentModificationException

要独立副本:new ArrayList<>(list.subList(0, 3))。Map 的keySet()/values()/entrySet()同样返回视图,结构改动后视图行为未定义,要快照就new HashSet<>(map.keySet())。

坑五:Arrays.asList 返回定长 List,add 抛异常

List<String>l=Arrays.asList("a","b");l.add("c");// UnsupportedOperationException!// 要可变:List<String>l2=newArrayList<>(Arrays.asList("a","b"));

同理List.of返回的是不可变(且不允许 null),也别 add。需要可变集合,永远从new ArrayList<>/new HashMap<>起手。

坑六:选错结构,性能腰斩

  • 随机访问多却用 LinkedList →get(i)O(n),整段 O(n²)。
  • 查重/映射却用 List +contains→ O(n),应换 HashSet/HashMap 的 O(1)。
  • 频繁中间插入却用 ArrayList → 整体搬移 O(n),应换 LinkedList(持迭代器)或别的結構。
  • 需要排序范围查询却用 HashMap → 应换 TreeMap/ConcurrentSkipListMap。

选型口诀:随机读 ArrayList,去重查重 HashMap/HashSet,两端操作 ArrayDeque,排序范围 TreeMap,并发 CHM/COW/跳表。

坑七:并行流 parallelStream 乱用

// 反例:共享可变集合 + 并行 → 数据竞争、结果错List<Integer>out=newArrayList<>();list.parallelStream().forEach(out::add);// 非线程安全,丢元素// 正解:用收集器,或并发集合List<Integer>out2=list.parallelStream().collect(Collectors.toList());

parallelStream不是银弹:① 收集到非线程安全集合会丢数据;② 小数据量、有 IO/锁 时并行反而更慢(线程调度开销);③ 共用 ForkJoinPool,一个慢任务拖垮全局。要并行就把结果交给collect或ConcurrentHashMap,别手填普通集合。

坑八:把 ConcurrentHashMap 当精确计数器 / 缓存

// size() 弱一致,不能当精确计数if(map.size()==0){...}// 并发下不可靠// 高并发计数用 LongAdderLongAddercounter=newLongAdder();counter.increment();longc=counter.sum();// 精确、高争用下远快于 AtomicLong

CHM 不提供过期淘汰,做缓存要自己加 TTL(DelayQueue 清理)或换 Caffeine/Guava Cache。计数请用LongAdder,别用AtomicLong在高争用下硬扛。

坑九:Integer 自动装箱的隐性开销与缓存陷阱

// 大循环里反复装箱,产生大量临时 Integer 对象List<Integer>list=newArrayList<>();for(inti=0;i<1000000;i++)list.add(i);// 每次 new Integer(或取缓存)

百万次 add 产生百万个 Integer 对象,GC 压力大。热点循环尽量用int[]/TIntArrayList(第三方原始类型集合)避免装箱。另注意Integer缓存 -128~127:范围外new Integer(200) != new Integer(200),HashMap用 Integer 作 key 时按 equals 比,不受影响,但若用==比较会踩坑。

坑十:equals 比较用 == 而非 equals

集合的contains/remove(Object)/indexOf全靠equals。若你用==比较对象(尤其包装类型、自定义类),会因"不是同一个引用"而判断失败:

List<Integer>list=List.of(200,300);list.contains(200);// true(equals)Integera=200,b=200;a==b;// false!范围外不缓存,是两个对象

一律用equals(或集合自带的 contains),别用==比对集合元素。

坑十一:HashMap 初始容量算错,触发中途扩容

// 想装 1000 个元素,直接 new HashMap<>(1000) 不够:// 阈值 = 1000 * 0.75 = 750,第 751 个就扩容// 正确:/(0.75)+1newHashMap<>((int)(1000/0.75f+1));// ≈ 1334,向上取 2 的幂 = 2048,全程不扩容

不校准则在填充中途扩一次,白拷贝一次。批量灌数据前务必按(size/0.75+1)算初始容量。

坑十二:迭代器遍历大集合做重活,阻塞/超时

// 反例:在 for-each 里同步调远程、写 DB,单线程串行进, 慢且易超时for(Ordero:bigList){rpc.call(o);}// 正解:分批 + 并行 + 超时,或推到消息队列List<List<Order>>batches=partition(bigList,100);batches.parallelStream().forEach(b->processBatch(b));

大集合遍历里塞同步重 IO,会把处理时间线性拉长。要么分批异步、要么丢给线程池/队列,别在主线程"一条龙"扫完。

调优检查清单(上线前过一遍)

项检查
容量预估 size 的 ArrayList/HashMap 是否预设容量?HashMap 是否/(0.75+1)?
哈希作 key 的对象是否重写了 equals+hashCode?参与字段是否不可变?
遍历删除是否用迭代器/removeIf?有无 for-each 里 remove?
视图陷阱subList / keySet / entrySet 是否误当独立集合?
结构选型随机读/去重/排序/并发 是否选对?
并行parallelStream 是否收集到线程安全容器?是否有共享可变状态?
计数高并发计数是否用 LongAdder 而非 size()/AtomicLong?
装箱热点循环是否避免 Integer 装箱?
比较集合元素比较是否用 equals 而非 ==?
大遍历是否避免在主线程同步扫大集合做重 IO?

面试追问

  • Q:fail-fast 和 fail-safe 区别?fail-fast(HashMap/ArrayList)检测到结构变更(modCount 不一致)立刻抛 CME,迭代中不允许并发改;fail-safe(CHM、COW 的迭代器)基于快照/弱一致,不抛但可能看不到最新变更。
  • Q:为什么推荐 LongAdder 做高并发计数?AtomicLong 所有线程 CAS 同一个变量,高争用下大量自旋重试;LongAdder 把计数分散到多个 Cell,争用低时合并,吞吐高得多。
  • Q:HashMap 预设容量公式为什么是 /0.75+1?要让填充中途不触发resize:阈值cap*0.75 ≥ size,故cap ≥ size/0.75,+1 留余量,再向上取 2 的幂。

总结

集合避坑 12 条:① 预设容量(HashMap 用 size/0.75+1);② 正确实现不可变 key 的 equals/hashCode;③ 遍历删除用迭代器/removeIf;④ 别把 subList/keySet 当独立集合;⑤ 按随机读/去重/排序/并发选对结构;⑥ 并行流收集到线程安全容器;⑦ 高并发计数用 LongAdder;⑧ 避免 Integer 装箱与 == 比较;⑨ CHM 的 size 弱一致、无 TTL;⑩ 大遍历别塞同步重 IO。把这 12 条当上线 checklist,能拦掉绝大多数集合相关的性能事故和诡异 Bug。整个集合框架系列到此收口——从总览到源码到并发到调优,你已拿到完整地图。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进