
做Java开发的兄弟应该都有这种体验写业务代码时判断一个元素在不在集合里第一反应就是contains()要遍历集合第一反应就是写个增强for循环或者直接掏出Iterator。这两个操作太常见了以至于很少有人停下来想contains()到底依赖什么规则增强for循环和Iterator是什么关系为什么遍历的时候删除元素会抛ConcurrentModificationException直到某天线上接口突然变慢或者面试官问一句ArrayList的contains和HashSet的contains时间复杂度一样吗才发现自己其实一直停留在会用但没吃透的层面。这篇文章就把Java集合操作里从contains到iterator这条链路完整串一遍先看contains背后的相等性判定再看Iterator的底层协议然后用一个真实业务场景演示怎么从contains切换到iterator做安全的遍历删除最后补上性能对比和几个典型踩坑事故。适合正在学Java集合的初学者也适合准备面试时被追问源码、想系统性巩固集合知识的开发者。1. contains的相等性判定List与Set的分水岭1.1 contains为什么依赖equals很多人以为contains就是个简单的有没有查询其实它的查询规则完全取决于底层数据结构。拿ArrayList举例它的contains实现就是遍历内部数组对每个元素调用equals比较// ArrayList.contains 的等价逻辑 for (int i 0; i size; i) { if (elementData[i].equals(target)) { return true; } } return false;这段代码里藏着两个容易被忽略的细节。第一contains查询的对象本身不会被拿来调用equals被调用的是集合里已有的元素。也就是说判断在不在看的是集合元素equals方法的实现质量。如果集合里存的是自定义对象比如一个Product类而它没有重写equals那么contains用的就是Object.equals即比较两个对象的引用地址。这会导致一个非常反直觉的结果两个内容完全一样的Product对象list.contains(productA)照样返回false。第二contains对null的处理也跟集合类型有关。ArrayList.contains(null)是允许的HashSet.contains(null)也可以但如果你用的是TreeSet调用contains(null)会直接抛NullPointerException。因为TreeSet依赖比较器排序它根本不知道该怎么把null放进红黑树里。这个差异在写通用工具方法时很容易踩到。1.2 HashSet.contains的两次筛选hashCode和equals如果contains出现在HashSet上流程就完全不同了。HashSet底层是HashMapcontains实际调用的是map.containsKey。整个查找过程分两步先计算传入对象的hashCode通过哈希运算定位到桶位置。在那个桶里把链表或红黑树上的元素逐个用equals比较。正因为有第一步的哈希定位HashSet.contains的平均时间复杂度是O(1)和ArrayList.contains的O(n)有着量级上的差距。这里就出现了一个关键契约两个对象如果equals相等那么它们的hashCode必须相等。反过来不成立hashCode相等不代表equals相等这只会导致多个对象落到同一个桶里查询效率下降但结果不会错误。最怕的是反过来equals相等但hashCode不同。这两个对象会被分到不同的桶里contains第一步就找不到桶equals根本没机会执行结果就是逻辑上相等集合却说没有这个元素。1.3 重写equals不重写hashCode的经典事故我见过不止一次这样的代码class Person { private String name; private int age; Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Person)) return false; Person p (Person) o; return age p.age Objects.equals(name, p.name); } // hashCode 没重写 }然后用它构造两个内容相同的对象放进HashSetSetPerson people new HashSet(); people.add(new Person(张三, 20)); people.contains(new Person(张三, 20)); // 返回 false原因就是上面说的两次new出来的对象默认hashCode不同被放进了不同桶equals根本没机会参与判断。这个例子和Java面试题里为什么重写equals必须同时重写hashCode是同一个问题只是一个在教科书里一个出现在你的线上Bug里。所以一旦自定义对象要放进HashSet、HashMap、LinkedHashSet这类基于哈希的集合并且要使用contains、get这些查询方法就必须保证equals和hashCode的一致性。用IDE自动生成的版本是最稳妥的做法它们会基于相同字段生成保证契约成立。2. iterator遍历的底层协议与增强for循环的秘密2.1 增强for循环编译后就是iterator很多Java开发者把Iterator当作一个老古董接口觉得现在都在用增强for或者Stream了谁还手写Iterator。但如果反编译增强for循环的字节码你会发现它本质上就是迭代器// 你写的代码 for (String s : list) { System.out.println(s); } // 编译后等价逻辑 for (IteratorString it list.iterator(); it.hasNext(); ) { String s it.next(); System.out.println(s); }也就是说增强for循环是Iterator的语法糖。理解这一点你才能解释为什么在增强for里调用list.remove()会报ConcurrentModificationException为什么自定义类实现了Iterable接口后就能直接写增强for循环。这里还要区分两个接口Iterable和Iterator。Iterable是集合层面的能力表示这个集合可以被迭代它只有一个方法iterator()返回一个新的迭代器。Iterator是单个遍历过程的状态机它记录着当前游标走到哪了。一个集合可以被迭代很多次每次iterator()都会得到一个全新的迭代器。把这两层分开设计才能支持同一个集合同时被多个线程各自遍历互不干扰。2.2 hasNext和next为什么要分开Iterator接口里有这么几个方法hasNext()、next()、remove()以及Java 8加入的forEachRemaining()。很多人不理解hasNext和next为什么要分开直接给一个next()不就行了原因是next()需要返回值而调用方在拿到值之前必须确认确实还有下一个元素。如果不做检查直接调用next()在游标越界时会抛NoSuchElementException。分开设计的好处是调用方可以先判断是否继续再决定是否取出元素。这在遍历未知长度的数据结构时特别有用比如链表、数据库游标你可以在不知道总数的情况下边判断边移动。另外remove()方法有个特殊规则它删除的是上一次next()返回的元素而且必须在next()之后调用否则会抛IllegalStateException。这个设计其实是在约束状态机的顺序保证你不会在还没取元素或已经删过的状态下误操作。2.3 为什么遍历时删除元素会抛ConcurrentModificationException这是整个Iterator机制里最经典的坑。ArrayList内部维护了一个modCount字段每一次结构上的修改add、remove、clear等都会让这个计数器加1。迭代器创建时会记住当时的modCount记为expectedModCount。每次调用迭代器的hasNext或next都会检查这两个值是否一致。一旦检测到集合被外部方法修改就会立刻抛出ConcurrentModificationException。看我之前踩过的代码ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (a.equals(s)) { list.remove(s); // 抛 ConcurrentModificationException } }增强for循环编译后用的是迭代器而list.remove修改了modCount迭代器的expectedModCount没变下一次next()就发现对不上直接炸了。那为什么iterator.remove()安全因为remove()方法会在删除元素后同步更新expectedModCount让迭代器内部计数保持同步。这也是官方推荐遍历时删除必须用迭代器的原因。注意一点并不是所有集合都会立刻抛异常。比如CopyOnWriteArrayList的迭代器操作的是快照遍历期间集合被修改也不会抛异常但你也看不到新增元素。这种集合设计上就叫fail-safe和ArrayList的fail-fast正好是两种不同的并发策略。3. 从contains到iterator一个真实业务的代码演进3.1 业务场景批量移除失效商品讲完原理来看一个实际例子。假设电商后台需要处理这样一件事供应商传来一个名单说某些商品已经停止合作平台要从当前商品池里把这些商品移除。商品池productList大概有10万个元素待移除名单expiredList有2万个元素。刚接手这个需求的程序员第一版很可能这样写productList.removeAll(expiredList);这句代码有问题吗功能上没错但性能上很致命。removeAll的内部实现会遍历productList对每个元素调用expiredList.contains()来判断要不要删除。如果expiredList是ArrayList它的contains是O(m)整体复杂度就是O(n * m)也就是10万乘以2万结果是20亿次比较。这个数量级在线上接口里就是灾难接口超时是必然的。3.2 第一版contains加外部标记删除如果把代码写得更加明确通常会变成这样ListProduct toRemove new ArrayList(); for (Product p : productList) { if (expiredList.contains(p)) { toRemove.add(p); } } productList.removeAll(toRemove);这版的逻辑是先遍历productList用contains判断当前元素是否在失效名单里如果在就放进待删除列表最后统一removeAll。问题有两层。第一层仍然是时间复杂度expiredList.contains(p)在ArrayList上是O(m)整体还是O(n*m)。第二层是额外开销为了删除元素我们先复制了一份待删除列表然后又调了一次removeAll白白多了一次遍历和一次内存占用。更麻烦的是如果productList本身结构特殊一边遍历一边标记再删除很容易出现误删和索引错位。3.3 第二版iterator遍历加HashSet判断对比之下我会把方案改成这样SetProduct expiredSet new HashSet(expiredList); for (IteratorProduct it productList.iterator(); it.hasNext(); ) { Product p it.next(); if (expiredSet.contains(p)) { it.remove(); } }这一步改动同时解决了两件事。第一件把expiredList换成expiredSet。构建HashSet需要一次O(m)的遍历但之后每个contains都是O(1)。于是整个删除过程的时间复杂度从O(n*m)直接降到O(n m)。同样是10万个商品和2万个名单最坏情况从20亿次比较降到大约12万次运算差距接近四位数倍。构建HashSet的成本也很快会被多次contains节省下来的时间覆盖。第二件把外部标记再删除换成Iterator.remove()。这样既避免了复制一份toRemove列表也避免了遍历过程中修改集合结构带来的ConcurrentModificationException。因为iterator.remove()会同步更新迭代器内部的expectedModCount整个删除过程是安全且可控的。从contains到iterator这句话在这里的实战含义就是先让contains变快再用iterator完成安全删除。两者不是互斥的而是配套使用的。3.4 第三版深入理解removeIf到了Java 8之后其实可以写得更简洁SetProduct expiredSet new HashSet(expiredList); productList.removeIf(expiredSet::contains);removeIf是Collection接口的默认方法它内部就是用一个隐式迭代器遍历所有元素对满足条件的元素调用迭代器的remove()。代码量更少编译器帮你把迭代器的创建、遍历、删除全部封装好了也不容易踩CME的坑。我仍然建议先手写一遍第二版因为你只有理解Iterator的机制才知道removeIf到底替你做了什么。直接跳到removeIf当然没问题但如果哪天需要在遍历过程中做更复杂的操作比如删除后立即统计删除数量或者删除的同时修改另一个集合手写迭代器依然是必要的。这个例子还依赖一个前提Product类必须正确重写equals和hashCode。否则无论是HashSet的contains还是removeIf的判断都会因为自定义对象的默认equals比较失败而失效。这就是第1章讲的原理在第3章直接换成真金白银的Bug。4. contains与iterator的性能对比与选型4.1 不同数据结构下的时间复杂度先把基本的复杂度盘清楚。操作集合类型平均时间复杂度说明containsArrayListO(n)线性遍历逐个equalscontainsHashSetO(1)哈希定位后equalscontainsTreeSetO(log n)红黑树查找依赖比较器containsLinkedHashSetO(1)哈希定位同时维护链表顺序遍历任意Collectioniterator/增强forO(n)无论什么集合遍历都要经过每个元素removeIfArrayListO(n)遍历加删除每删除一个位移若干从这张表能得出一个关键结论contains的性能高度依赖数据结构而iterator的遍历性能在所有集合里都是O(n)。所以遇到频繁判断某个元素是否存在的需求第一选择应该是把数据放进HashSet遇到要把所有元素过一遍做处理的需求迭代器和增强for并没有本质差别选哪个需要看操作复杂度。4.2 用代码实测contains的成本有时光看复杂度不够团队里有人抬杠说数据量不大无所谓。我建议当场写个简单的计时验证而不是争辩。ListInteger list new ArrayList(); for (int i 0; i 200_000; i) { list.add(i); } SetInteger set new HashSet(list); long start System.nanoTime(); for (int i 0; i 10_000; i) { list.contains(i); } System.out.println(ArrayList contains 耗时: (System.nanoTime() - start) / 1000 us); start System.nanoTime(); for (int i 0; i 10_000; i) { set.contains(i); } System.out.println(HashSet contains 耗时: (System.nanoTime() - start) / 1000 us);注意这种简单的System.nanoTime()测试只适合做定性验证不能当严谨基准。有几件事必须提醒第一轮运行会触发JIT编译和类加载耗时虚高需要先跑几轮预热。GC的发生会让单次测量抖动要多跑几轮取中位数。如果真想出标准数据用JMH写Benchmark才是正经方案。但即便用最粗糙的方式也能测出明显的量级差距。在20万元素的集合里做1万次查询ArrayList的耗时通常是HashSet的几十倍到上百倍。记住了contains不慢慢的是你把contains用在了错误的数据结构上。4.3 什么时候优先contains什么时候该考虑iterator我现在的选型习惯基本遵循这几条规则需求是判断某个东西是否存在而且只判断一次直接用contains没必要提前构建HashSet因为构建集合本身也要花时间。比如一个只有几十个元素的Listcontains的开销可忽略。需求是批量判断大量元素是否存在先构建HashSet再批量判断这个收益非常明显。需求是从现有集合中删除、过滤、替换部分元素默认考虑迭代器或removeIf不要在增强for里手动调用list.remove。需求是既要判断存在又要拿到元素本身做后续处理contains加二次get会浪费一次查询更好的做法是用Map索引一次拿到值。还有一条经验如果你的数据量只有几百真正的问题通常不是contains的性能而是代码的可读性。别为了微秒级的差异引入复杂的集合结构先保证逻辑正确再通过数据说话去优化。5. contains不一定最优其他判断与遍历方案5.1 用Set做一次构建、多次判断contains最典型的优化场景就是一次构建多次查询。假设一个接口要判断请求里传进来的10个ID是否都在黑名单里你完全可以SetString blackIds new HashSet(loadBlackList()); for (String id : requestIds) { if (blackIds.contains(id)) { // 命中黑名单 } }这里loadBlackList()返回的可能是数据库查出来的List但在判断前先转成HashSet。如果黑名单本身有几千条只查一次时转换成本可能比直接contains还高查多次时转换成本很快被摊薄收益是实打实的。5.2 containsAll的真相与集合交集替代containsAll也是从contains延伸出来的方法。它判断集合A是否包含集合B里的所有元素。但它的实现逻辑是遍历参数集合B对每个元素调用当前集合的contains。如果A是ArrayList、B也很大复杂度又是O(n*m)。遇到求交集、差集的场景我更喜欢直接用retainAllSetString setA new HashSet(listA); SetString common new HashSet(listA); common.retainAll(listB); // common 变成交集retainAll内部同样依赖contains但因为你把A做成了HashSet每个判断变成O(1)整体复杂度也能控制在O(nm)级别。这个思路和从contains到iterator的内在逻辑是一致的先看影响每个元素判断的底层结构再决定遍历方式。5.3 用Map建立索引代替多次contains有些场景里你反复调用contains并不是真的想做集合运算而是想按某个业务字段查元素。比如按商品SKU判断某个商品存不存在Product p findProductIfPresent(productList, SKU-001);如果productList是ArrayList函数内部大概率是遍历加equals。更合理的方案是维护一个MapString, ProductMapString, Product productMap productList.stream() .collect(Collectors.toMap(Product::getSku, Function.identity())); if (productMap.containsKey(sku)) { Product p productMap.get(sku); // do something }这样不仅有了O(1)的containsKey还顺手解决了判断之后拿元素的二次查询问题。因为Map.get一次就能把元素取出来。迭代器在这种方案里的角色体现在遍历Map.entrySet()做批量处理时for (Map.EntryString, Product entry : productMap.entrySet()) { Product p entry.getValue(); if (p.getStatus() 0) { // 处理所有失效商品 } }从contains到iterator在这里体现为从逐元素判断到建立索引后按需遍历的思路升级。5.4 Stream的anyMatch和Iterator的关系Stream流行之后很多人会把contains直接改写成anyMatchboolean exists productList.stream() .anyMatch(p - p.getSku().equals(SKU-001));但要说清楚anyMatch的语义和contains并不完全相同它是用Predicate匹配而contains依赖equals匹配。功能上可以互相模拟但anyMatch的优势在于可以链式组合boolean exists productList.stream() .filter(p - p.getStatus() ! -1) .anyMatch(p - p.getSku().equals(SKU-001));同时Stream的迭代底层其实是Spliterator而不是传统的Iterator。不过两者可以互相转换list.stream().iterator()和list.spliterator()都是合法的。实际开发中如果只是为了遍历输出用增强for完全够如果要做过滤、映射、聚合Stream会更自然如果要在遍历时删除元素removeIf或手写Iterator才是正解。三者不是替代关系而是不同场景下的工具。6. 踩坑实录contains与iterator的五个真实事故6.1 自定义对象集合contains永远返回false这是我见过最高频的坑。集合里放的是自定义对象直接调用contains判断是否存在相同内容的对象结果一直是false。原因就是第1章说的默认equals比较引用。解决办法就是重写equals和hashCode。判断规则很简单对象要进HashSet、HashMap或任何依赖哈希的集合两者缺一不可对象只进ArrayList只重写equals也能让contains正常工作但为了未来不踩坑建议仍然把hashCode一起重写了。6.2 增强for循环里调用list.remove报ConcurrentModificationException第2章讲过原理这里再补充一个真实场景很多人以为删除一个之后就break不继续遍历了应该没事吧。实际测试下来如果在next()之前删除下一次next()依然会检查到modCount不一致照样抛异常。换句话说break前面的那次remove已经把modCount改了异常发生在下一轮迭代器状态检查时而不是删除那一刻。可靠的写法只有三种// 方式一手写迭代器 IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (condition(s)) { it.remove(); } } // 方式二removeIf list.removeIf(condition); // 方式三先收集再删除数据量小时可接受 ListString collected list.stream().filter(condition).collect(Collectors.toList()); list.removeAll(collected);6.3 迭代器remove的调用时机错误Iterator.remove()必须在next()之后调用而且只能调用一次。如果你连续调用两次remove()第二次会抛IllegalStateException。因为它删除的是上一次next返回的元素第二次调用时上一次的游标状态已经被消费掉了。IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(a)) { it.remove(); // it.remove(); // 再调一次就抛 IllegalStateException } }如果你的需求是取到元素后先判断再删除再对结果做累加务必把累加逻辑放在remove()之后不要在remove()之后还依赖那个元素值做集合修改操作。6.4 fail-safe集合的弱一致性问题CopyOnWriteArrayList这类并发集合的contains和iterator基于快照实现。迭代器创建的那一刻会拿到一个数组快照之后无论集合怎么修改迭代器看到的数据都不会变。这种设计让你在并发读时不抛ConcurrentModificationException但也意味着你可能读不到其他线程刚刚添加的元素。如果业务要求强一致比如遍历时必须看到此刻所有已提交的数据那就要在外部加锁或改用synchronizedList。但要注意Collections.synchronizedList的迭代器遍历仍然需要手动同步不然会出现数据不一致。这个点面试里也爱问fail-fast和fail-safe的取舍本质上是尽早暴露并发错误和保证读操作不中断的取舍没有绝对正确只有场景合适。6.5 contains判断失败其实是数据规范化没做最后这个坑跟集合无关但和业务强相关。有时你调contains明明该返回有结果返回没有——不是equals和hashCode的问题而是数据本身不一致数据库里存的字符串前后有空格传入的比较串没trim大小写不统一比如用户输入iphone和库存里的iPhone数字类型不一致比如一个是Long一个是Integerequals天然返回false。这类问题用equals重写解决不了必须在数据写入时做规范化。我现在的习惯是在实体类里把业务主键字段的处理集中在相同的方法里比如统一trim()和大小写规则然后基于这个字段生成equals和hashCode这样contains的判断口径才不会漂移。把五个事故列成一张表收一下现象根因解决方向contains恒为false自定义对象未重写equals/hashCode重写两个方法并保持契约遍历删除抛ConcurrentModificationExceptionmodCount校验失败用Iterator.remove或removeIf连续remove抛IllegalStateExceptionremove必须在next后只调一次严格遵守迭代器状态调用规则并发遍历读不到新数据fail-safe快照设计自行加锁或改用同步集合contains判断不准确数据未规范化统一写入时的格式、大小写、空格结尾一点个人体会我在实际项目里吃过几次亏之后现在拿到任何集合相关的需求都会先问自己三个问题这个集合到底有多大我是要判断存在还是要遍历并修改元素相等性这个问题的定义是不是已经明确把这三个问题想清楚contains、Iterator、removeIf、Stream之间的选择就会变得很自然。比如最近重构一个订单批量标记功能时我把原来ArrayList的contains 多次遍历重构成了HashSet判断 Iterator安全删除接口耗时从几百毫秒降到十几毫秒代码可读性也没变差。回头再看这其实就是标题里那条链路的价值先用contains的原理选对数据结构再用Iterator的机制安全高效地操作集合。希望这篇文章能帮你把这条链路彻底想明白。