Il metodo HashSet <T> .removeAll è sorprendentemente lento


92

Jon Skeet ha recentemente sollevato un interessante argomento di programmazione sul suo blog: "C'è un buco nella mia astrazione, cara Liza, cara Liza" (enfasi aggiunta):

Ho un set - un HashSet, in effetti. Voglio rimuovere alcuni elementi da esso ... e molti di essi potrebbero non esistere. Infatti, nel nostro caso di prova, nessuno degli elementi nella raccolta "rimozioni" sarà nel set originale. Questo suona - e in effetti è - estremamente facile da codificare. Dopotutto, dobbiamo Set<T>.removeAllaiutarci, giusto?

Specifichiamo la dimensione del set "sorgente" e la dimensione della raccolta "rimozioni" sulla riga di comando, e li costruiamo entrambi. Il set di origine contiene solo numeri interi non negativi; il set di rimozioni contiene solo numeri interi negativi. Misuriamo quanto tempo ci vuole per rimuovere tutti gli elementi utilizzando System.currentTimeMillis(), che non è il cronometro più preciso al mondo ma è più che adeguato in questo caso, come vedrai. Ecco il codice:

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

Cominciamo dandogli un lavoro facile: un set sorgente di 100 elementi e 100 da rimuovere:

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

Ok, quindi non ci aspettavamo che fosse lento ... chiaramente possiamo accelerare un po 'le cose. Che ne dici di una fonte di un milione di elementi e 300.000 elementi da rimuovere?

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

Hmm. Sembra ancora abbastanza veloce. Ora sento di essere stato un po 'crudele, chiedendogli di fare tutta quella rimozione. Rendiamolo un po 'più semplice: 300.000 elementi di origine e 300.000 rimozioni:

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

Mi scusi? Quasi tre minuti ? Yikes! Sicuramente dovrebbe essere più facile rimuovere oggetti da una raccolta più piccola di quella che abbiamo gestito in 38 ms?

Qualcuno può spiegare perché sta succedendo? Perché il HashSet<T>.removeAllmetodo è così lento?


2
Ho testato il tuo codice e ha funzionato velocemente. Nel tuo caso, ci sono voluti ~ 12 ms per terminare. Ho anche aumentato entrambi i valori di input di 10 e ci sono voluti 36 ms. Forse il tuo PC esegue alcune attività intense della CPU mentre esegui i test?
Slimu

4
L'ho provato e ho ottenuto lo stesso risultato dell'OP (beh, l'ho fermato prima della fine). Davvero strano. Windows, JDK 1.7.0_55
JB Nizet

2
C'è un biglietto aperto su questo: JDK-6982173
Haozhun

44
Come discusso su Meta , questa domanda è stata originariamente plagiata dal blog di Jon Skeet (ora citata direttamente e collegata nella domanda, a causa della modifica di un moderatore). I futuri lettori dovrebbero notare che il post sul blog da cui è stato plagiato spiega in effetti la causa del comportamento, in modo simile alla risposta accettata qui. Pertanto, invece di leggere le risposte qui, potresti invece voler semplicemente fare clic e leggere l'intero post del blog .
Mark Amery

1
Il bug verrà corretto in Java 15: JDK-6394757
ZhekaKozlov

Risposte:


138

Il comportamento è (in qualche modo) documentato nel javadoc :

Questa implementazione determina quale è il più piccolo di questo set e della raccolta specificata, richiamando il metodo size su ciascuno. Se questo set ha meno elementi , l'implementazione esegue l'iterazione su questo set, controllando a turno ogni elemento restituito dall'iteratore per vedere se è contenuto nella raccolta specificata . Se è così contenuto, viene rimosso da questo set con il metodo di rimozione dell'iteratore. Se la raccolta specificata ha meno elementi, l'implementazione esegue l'iterazione sulla raccolta specificata, rimuovendo da questo set ogni elemento restituito dall'iteratore, utilizzando il metodo remove di questo set.

Cosa significa in pratica, quando chiami source.removeAll(removals);:

  • se la removalsraccolta è di dimensioni inferiori a source, viene chiamato il removemetodo di HashSet, che è veloce.

  • se la removalsraccolta è di dimensioni uguali o maggiori di source, removals.containsviene chiamato, il che è lento per un ArrayList.

Soluzione rapida:

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

Nota che c'è un bug aperto che è molto simile a quello che descrivi. La linea di fondo sembra essere che probabilmente è una scelta sbagliata ma non può essere modificata perché è documentata in javadoc.


Per riferimento, questo è il codice di removeAll(in Java 8 - non ho controllato altre versioni):

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

15
Wow. Ho imparato qualcosa oggi. Questa mi sembra una cattiva scelta di implementazione. Non dovrebbero farlo se l'altra raccolta non è un Set.
JB Nizet

2
@JBNizet Sì, è strano - è stato discusso qui con il tuo suggerimento - non sono sicuro del motivo per cui non è andato a buon fine ...
assylias

2
Grazie mille @assylias ..Ma mi chiedevo davvero come hai fatto a capirlo .. :) Bello davvero bello .... Hai affrontato questo problema ???

8
@show_stopper Ho appena eseguito un profiler e ho visto che ArrayList#containsera il colpevole. Uno sguardo al codice di ha AbstractSet#removeAlldato il resto della risposta.
assylias
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.