O método HashSet <T> .removeAll é surpreendentemente lento

92

Jon Skeet recentemente levantou um tópico de programação interessante em seu blog: "Há um buraco na minha abstração, querida Liza, querida Liza" (grifo nosso):

Eu tenho um conjunto - um HashSet, na verdade. Quero remover alguns itens dele ... e muitos dos itens podem não existir. Na verdade, em nosso caso de teste, nenhum dos itens na coleção de "remoções" estará no conjunto original. Isso parece - e de fato é - extremamente fácil de codificar. Afinal, temos que Set<T>.removeAllnos ajudar, certo?

Especificamos o tamanho do conjunto "fonte" e o tamanho da coleção "remoções" na linha de comando e construímos ambos. O conjunto de origem contém apenas inteiros não negativos; o conjunto de remoções contém apenas inteiros negativos. Medimos quanto tempo leva para remover todos os elementos usando System.currentTimeMillis(), o que não é o cronômetro mais preciso do mundo, mas é mais do que adequado neste caso, como você verá. Aqui está o código:

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"); 
    }
}

Vamos começar dando a ele um trabalho fácil: um conjunto de origem de 100 itens e 100 para remover:

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

Ok, então não esperávamos que fosse lento ... claramente podemos acelerar um pouco as coisas. Que tal uma fonte de um milhão de itens e 300.000 itens para remover?

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

Hmm. Isso ainda parece muito rápido. Agora sinto que fui um pouco cruel, pedindo-lhe para fazer toda aquela remoção. Vamos tornar um pouco mais fácil - 300.000 itens de origem e 300.000 remoções:

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

Desculpe? Quase três minutos ? Caramba! Certamente deveria ser mais fácil remover itens de uma coleção menor do que aquela que gerenciamos em 38ms?

Alguém pode explicar por que isso está acontecendo? Por que o HashSet<T>.removeAllmétodo é tão lento?

Comunidade
fonte
2
Testei seu código e funcionou rápido. No seu caso, demorou ~ 12ms para terminar. Também aumentei os valores de entrada em 10 e demorou 36 ms. Talvez o seu PC execute algumas tarefas intensas da CPU enquanto você executa os testes?
Slimu
4
Eu testei e tive o mesmo resultado que o OP (bem, parei antes do fim). Estranho mesmo. Windows, JDK 1.7.0_55
JB Nizet
2
Há um tíquete aberto para este: JDK-6982173
Haozhun
44
Conforme discutido no Meta , esta questão foi originalmente plagiada do blog de Jon Skeet (agora citado diretamente e linkado na questão, devido à edição de um moderador). Os leitores futuros devem observar que a postagem do blog da qual foi plagiado de fato explica a causa do comportamento, de forma semelhante à resposta aceita aqui. Assim, em vez de ler as respostas aqui, você pode simplesmente clicar e ler a postagem completa do blog .
Mark Amery
1
O bug será corrigido no Java 15: JDK-6394757
ZhekaKozlov

Respostas:

138

O comportamento é (de certa forma) documentado no javadoc :

Essa implementação determina qual é o menor desse conjunto e da coleção especificada, chamando o método de tamanho em cada um. Se este conjunto tiver menos elementos , a implementação itera sobre esse conjunto, verificando cada elemento retornado pelo iterador, por sua vez, para ver se ele está contido na coleção especificada . Se estiver contido, ele será removido deste conjunto com o método remove do iterador. Se a coleção especificada tiver menos elementos, a implementação itera sobre a coleção especificada, removendo desse conjunto cada elemento retornado pelo iterador, usando o método remove deste conjunto.

O que isso significa na prática, quando você liga source.removeAll(removals);:

  • se a removalscoleção for de um tamanho menor que source, o removemétodo de HashSeté chamado, que é rápido.

  • se a removalscoleção for de tamanho igual ou maior que o source, então removals.containsé chamado, o que é lento para um ArrayList.

Conserto rápido:

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

Observe que há um bug aberto que é muito semelhante ao que você descreve. O resultado final parece ser que provavelmente é uma escolha ruim, mas não pode ser alterada porque está documentada no javadoc.


Para referência, este é o código de removeAll(em Java 8 - não verifiquei outras versões):

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;
}
assilias
fonte
15
Uau. Aprendi algo hoje. Isso parece uma escolha de implementação ruim para mim. Eles não deveriam fazer isso se a outra coleção não fosse um Conjunto.
JB Nizet
2
@JBNizet Sim, é estranho - foi discutido aqui com a sua sugestão - não sei por que não foi até o final ...
assylias
2
Muito obrigado @assylias ..Mas realmente me perguntando como você descobriu isso .. :) Legal, realmente bom .... Você enfrentou esse problema ??
8
@show_stopper Acabei de executar um profiler e vi que ArrayList#containsera o culpado. Uma olhada no código de AbstractSet#removeAlldeu o resto da resposta.
Assylias