El método HashSet <T> .removeAll es sorprendentemente lento

92

Jon Skeet recientemente planteó un tema de programación interesante en su blog: "Hay un agujero en mi abstracción, querida Liza, querida Liza" (énfasis agregado):

Tengo un juego HashSet, de hecho. Quiero eliminar algunos elementos ... y es posible que muchos de los elementos no existan. De hecho, en nuestro caso de prueba, ninguno de los elementos de la colección "eliminaciones" estará en el conjunto original. Esto suena, y de hecho es , extremadamente fácil de codificar. Después de todo, tenemos que Set<T>.removeAllayudarnos, ¿verdad?

Especificamos el tamaño del conjunto "fuente" y el tamaño de la colección de "eliminaciones" en la línea de comando, y construimos ambos. El conjunto fuente contiene solo enteros no negativos; el conjunto de eliminaciones contiene solo números enteros negativos. Medimos cuánto tiempo lleva eliminar todos los elementos usando System.currentTimeMillis(), que no es el cronómetro más preciso del mundo, pero es más que adecuado en este caso, como verás. Aquí está el 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"); 
    }
}

Comencemos por darle un trabajo fácil: un conjunto de fuentes de 100 elementos y 100 para eliminar:

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

Bien, no esperábamos que fuera lento… claramente podemos acelerar un poco las cosas. ¿Qué tal una fuente de un millón de elementos y 300.000 elementos para eliminar?

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

Hmm. Eso todavía parece bastante rápido. Ahora siento que he sido un poco cruel, pidiéndole que haga todo eso. Hagámoslo un poco más fácil: 300.000 elementos de origen y 300.000 eliminaciones:

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

¿Perdóneme? ¿Casi tres minutos ? ¡Ay! Seguramente debería ser más fácil eliminar elementos de una colección más pequeña que la que manejamos en 38ms.

¿Alguien puede explicar por qué está sucediendo esto? ¿Por qué el HashSet<T>.removeAllmétodo es tan lento?

Comunidad
fuente
2
Probé tu código y funcionó rápido. En su caso, tardó ~ 12 ms en finalizar. También he aumentado ambos valores de entrada en 10 y me tomó 36 ms. ¿Quizás su PC realiza algunas tareas intensivas de CPU mientras ejecuta las pruebas?
Slimu
4
Lo probé y obtuve el mismo resultado que el OP (bueno, lo detuve antes del final). Ciertamente extraño. Windows, JDK 1.7.0_55
JB Nizet
2
Hay un boleto abierto en esto: JDK-6982173
Haozhun
44
Como se discutió en Meta , esta pregunta fue plagiada originalmente del blog de Jon Skeet (ahora se cita directamente y se vincula en la pregunta, debido a la edición de un moderador). Los futuros lectores deben tener en cuenta que la publicación del blog desde la que fue plagiado explica de hecho la causa del comportamiento, de manera similar a la respuesta aceptada aquí. Como tal, en lugar de leer las respuestas aquí, es posible que desee simplemente hacer clic y leer la publicación completa del blog .
Mark Amery
1
El error se solucionará en Java 15: JDK-6394757
ZhekaKozlov

Respuestas:

138

El comportamiento está (algo) documentado en el javadoc :

Esta implementación determina cuál es el más pequeño de este conjunto y la colección especificada, invocando el método de tamaño en cada uno. Si este conjunto tiene menos elementos , entonces la implementación itera sobre este conjunto, verificando cada elemento devuelto por el iterador a su vez para ver si está contenido en la colección especificada . Si está así contenido, se elimina de este conjunto con el método remove del iterador. Si la colección especificada tiene menos elementos, entonces la implementación itera sobre la colección especificada, eliminando de este conjunto cada elemento devuelto por el iterador, utilizando el método remove de este conjunto.

Lo que esto significa en la práctica, cuando llamas source.removeAll(removals);:

  • si la removalscolección es de un tamaño menor que source, se llama al removemétodo de HashSet, que es rápido.

  • si la removalscolección es de un tamaño igual o mayor que el source, entonces removals.containsse llama, lo cual es lento para ArrayList.

Arreglo rapido:

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

Tenga en cuenta que hay un error abierto que es muy similar a lo que describe. La conclusión parece ser que probablemente sea una mala elección, pero no se puede cambiar porque está documentado en el javadoc.


Como referencia, este es el código de removeAll(en Java 8, no he comprobado otras versiones):

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;
}
Assylias
fuente
15
Guau. Hoy aprendí algo. Esto me parece una mala elección de implementación. No deberían hacer eso si la otra colección no es un Conjunto.
JB Nizet
2
@JBNizet Sí, eso es extraño, se discutió aquí con su sugerencia, no estoy seguro de por qué no pasó ...
Assylias
2
Muchas gracias @assylias ... Pero realmente me pregunto cómo lo resolviste ... :) Muy bien, muy bien ... ¿Has enfrentado este problema?
8
@show_stopper Acabo de ejecutar un generador de perfiles y vi que ese ArrayList#containsera el culpable. Una mirada al código de AbstractSet#removeAlldio el resto de la respuesta.
assylias