HashSet<T>.removeAll 方法非常慢

Posted

技术标签:

【中文标题】HashSet<T>.removeAll 方法非常慢【英文标题】:The HashSet<T>.removeAll method is surprisingly slow 【发布时间】:2015-04-24 15:58:33 【问题描述】:

Jon Skeet 最近在他的博客上提出了一个有趣的编程话题:"There's a hole in my abstraction, dear Liza, dear Liza"(添加了重点):

我有一套——事实上是HashSet。我想从中删除一些项目……而且许多项目可能不存在。事实上,在我们的测试用例中,“removals”集合中的 none 项将在原始集合中。这听起来 - 确实 - 非常容易编码。毕竟,我们有 Set&lt;T&gt;.removeAll 来帮助我们,对吧?

我们在命令行上指定“源”集的大小和“移除”集合的大小,并构建它们。源集只包含非负整数;移除集仅包含负整数。我们使用System.currentTimeMillis() 测量删除所有元素所需的时间,这不是世界上最准确的秒表,但在这种情况下已经绰绰有余,正如您将看到的那样。代码如下:

import java.util.*;
public class Test 
 
    public static void main(String[] args) 
     
       int sourceSize = Integer.parseInt(args[0]); 
       int removalsSize = Integer.parseInt(args[1]); 
        
       Set<Integer> source = new HashSet<Integer>(); 
       Collection<Integer> removals = new ArrayList<Integer>(); 
        
       for (int i = 0; i < sourceSize; i++) 
        
           source.add(i); 
        
       for (int i = 1; i <= removalsSize; i++) 
        
           removals.add(-i); 
        
        
       long start = System.currentTimeMillis(); 
       source.removeAll(removals); 
       long end = System.currentTimeMillis(); 
       System.out.println("Time taken: " + (end - start) + "ms"); 
    

让我们从简单的工作开始:包含 100 个项目的源集,还有 100 个要删除:

c:UsersJonTest>java Test 100 100
Time taken: 1ms

好的,所以我们没想到它会很慢......显然我们可以稍微提高一点。 100 万个项目和 300,000 个要删除的项目的来源怎么样?

c:UsersJonTest>java Test 1000000 300000
Time taken: 38ms

嗯。这看起来还是挺快的。现在我觉得我有点残忍,要求它做所有的移除。让我们让它变得更简单一些——300,000 个源项目和 300,000 个移除:

c:UsersJonTest>java Test 300000 300000
Time taken: 178131ms

对不起?将近三 分钟?哎呀!与我们在 38 毫秒内管理的集合相比,从 更小的 集合中删除项目确实应该更容易?

有人可以解释为什么会这样吗?为什么HashSet&lt;T&gt;.removeAll 方法这么慢?

【问题讨论】:

我测试了你的代码,它运行得很快。对于您的情况,完成大约需要 12 毫秒。我还将两个输入值都增加了 10,它花了 36 毫秒。也许您的 PC 在您运行测试时会执行一些密集的 CPU 任务? 我测试了它,结果与 OP 相同(好吧,我在结束前停止了它)。确实很奇怪。视窗,JDK 1.7.0_55 有一张公开的票:JDK-6982173 作为discussed on Meta,这个问题最初是从 Jon Skeet 的博客中抄袭的(由于版主的编辑,现在直接引用并链接到问题中)。未来的读者应该注意,它被剽窃的博客文章实际上解释了行为的原因,类似于这里接受的答案。因此,您可能不想在这里阅读答案,而是希望简单地点击并阅读the full blog post。 该错误将在 Java 15 中修复:JDK-6394757 【参考方案1】:

该行为(某种程度上)记录在javadoc:

此实现通过在每个集合上调用 size 方法来确定该集合和指定集合中的较小者。 如果这个集合有更少的元素,那么实现会迭代这个集合,依次检查迭代器返回的每个元素以查看是否包含在指定的集合中。如果它如此包含,则使用迭代器的 remove 方法将其从该集合中删除。如果指定集合的​​元素较少,则实现对指定集合进行迭代,使用该集合的 remove 方法从该集合中移除迭代器返回的每个元素。

这在实践中意味着什么,当你打电话给source.removeAll(removals);时:

如果removals集合的大小小于source,则调用HashSetremove方法,速度很快。

如果removals 集合的大小等于或大于source,则调用removals.contains,这对于ArrayList 来说很慢。

快速修复:

Collection<Integer> removals = new HashSet<Integer>();

请注意,an open bug 与您所描述的非常相似。底线似乎是它可能是一个糟糕的选择,但无法更改,因为它记录在 javadoc 中。


供参考,这是removeAll的代码(在Java 8中-尚未检查其他版本):

public boolean removeAll(Collection<?> c) 
    Objects.requireNonNull(c);
    boolean modified = false;

    if (size() > c.size()) 
        for (Iterator<?> i = c.iterator(); i.hasNext(); )
            modified |= remove(i.next());
     else 
        for (Iterator<?> i = iterator(); i.hasNext(); ) 
            if (c.contains(i.next())) 
                i.remove();
                modified = true;
            
        
    
    return modified;

【讨论】:

哇。我今天学到了一些东西。对我来说,这看起来是一个糟糕的实现选择。如果其他集合不是 Set,他们不应该这样做。 @JBNizet 是的,这很奇怪 - 您的建议已经在 here 进行了讨论 - 不知道为什么它没有通过... 非常感谢@assylias ..但真的很想知道你是怎么想出来的..:) 很好很好......你遇到过这个问题吗??? @show_stopper 我刚刚运行了一个分析器,发现ArrayList#contains 是罪魁祸首。看一下AbstractSet#removeAll的代码就给出了剩下的答案。

以上是关于HashSet<T>.removeAll 方法非常慢的主要内容,如果未能解决你的问题,请参考以下文章

什么时候应该使用 HashSet<T> 类型?

B.4 集

C# HashSet<T> 只读解决方法

为啥 HashSet<T> 没有实现 ICollection?

HashSet<T>.removeAll 方法非常慢

C#中List怎么转换成hashset