Метод HashSet <T> .removeAll напрочуд повільний


92

Джон Скіт нещодавно підняв цікаву тему програмування у своєму блозі: "У моїй абстракції є дірка, дорога Ліза, дорога Ліза" (курсив додано):

У мене є набір - HashSetнасправді. Я хочу видалити з нього деякі предмети ... і багато з них цілком можуть не існувати. Насправді, у нашому тестовому випадку жоден елемент у колекції "видалення" не буде в оригінальному наборі. Це звучить - і на самому справі є - дуже легко коду. Зрештою, ми маємо Set<T>.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

Гаразд, отже, ми не очікували, що це буде повільно ... чітко, ми можемо трохи наростити ситуацію. Як щодо джерела з одного мільйона предметів та 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<T>.removeAllметод так повільний?


2
Я протестував ваш код, і він працював швидко. Для вашого випадку це зайняло ~ 12 мс. Я також збільшив обидва вхідні значення на 10, і це зайняло 36 мс. Можливо, ваш ПК виконує якісь інтенсивні завдання з процесором під час запуску тестів?
Slimu

4
Я тестував його і мав такий самий результат, як і OP (ну, я зупинив його до кінця). Справді дивно. Windows, JDK 1.7.0_55
JB Nizet

2
На це є відкритий квиток: JDK-6982173
Хаожун,

44
Як обговорювалося на Мета , це питання спочатку було плагіатом із блогу Джона Скіта (тепер це безпосередньо цитоване та посилання на це питання у зв'язку з редагуванням модератора). Майбутнім читачам слід зауважити, що публікація в блозі, з якої було здійснено плагіат, насправді пояснює причину такої поведінки, подібно до прийнятої тут відповіді. Таким чином, замість того, щоб читати відповіді тут, ви можете замість цього просто натиснути і прочитати повну публікацію в блозі .
Марк Амері

1
Виправлено помилку в Java 15: JDK-6394757
ZhekaKozlov

Відповіді:


138

Поведінка (дещо) задокументована у javadoc :

Ця реалізація визначає, який менший із цього набору та вказаної колекції, викликаючи метод розміру для кожного. Якщо цей набір має менше елементів , тоді реалізація перебирає цей набір, перевіряючи кожен елемент, повернутий ітератором, по черзі, чи не міститься він у зазначеній колекції . Якщо він так міститься, він видаляється з цього набору методом видалення ітератора. Якщо вказана колекція має менше елементів, тоді реалізація перебирає вказану колекцію, видаляючи з цього набору кожен елемент, повернутий ітератором, використовуючи метод видалення цього набору.

Що це означає на практиці, коли ви телефонуєте source.removeAll(removals);:

  • якщо removalsколекція має менший розмір, ніж source, викликається removeметод HashSet, який є швидким.

  • якщо removalsколекція має рівний або більший розмір, ніж source, тоді removals.containsвикликається, що є повільним для ArrayList.

Швидке виправлення:

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

Зверніть увагу, що існує відкрита помилка , дуже схожа на те, що ви описуєте. Суть в тому, що це, мабуть, поганий вибір, але його неможливо змінити, оскільки це задокументовано у 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;
}

15
Ого. Я сьогодні чогось навчився. Для мене це здається поганим вибором реалізації. Вони не повинні цього робити, якщо інша колекція не є Набором.
JB Nizet

2
@JBNizet Так дивно - це було обговорено тут із вашою пропозицією - не впевнений, чому це не
пройшло

2
Велике спасибі @assylias .. Але справді цікаво, як ти це зрозумів .. :) Приємно, справді приємно .... Чи стикався ти з цією проблемою ???

8
@show_stopper Я щойно запустив профайлер і побачив, що ArrayList#containsв цьому винен. Погляд на код AbstractSet#removeAllдав решту відповіді.
assylias
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.