- PVSM.RU - https://www.pvsm.ru -
Мы привыкли, что стандартные коллекции в JDK сделаны достаточно хорошо и ведут себя интуитивно-понятно. Но так ли это на самом деле? Вчера Роман Елизаров elizarov [1] опубликовал в твиттере [2] новость о новом интересном косяке, найденном [3] на SO.
Держитесь покрепче: Set.removeAll(list)
в определенных случаях может работать за O(N²). Как же так?
Jon Skeet недавно опубликовал пост «There's a hole in my abstraction, dear Liza, dear Liza» [3], в котором он рассматривает следующий интересный случай.
Допустим, у нас есть HashSet, из которого мы собираемся что-то удалить. Допустим, мы удаляем из одной коллекции A элементы другой коллекции B. Часто так бывает, что многие элементы из B просто не существуют в A. Разберем специальный случай, когда A и B вообще не пересекаются — то есть, делать ничего не нужно.
Напишем простую программку, в которой размеры наших коллекций задаются из командной строки. Чтобы эти множества точно не пересекались, одну из них заполним только положительными, а другую — только отрицательными числами. Потом удалим из первой коллекции все элементы второй и измерим прошедшее время с помощью System.currentTimeMillis()
. Это не самый лучший в мире способ посчитать промежуток времени, но он дает возможность скопипастить код прямо из Хабра в Идею и избавляет нас от необходимости настраивать новый проект Maven для JMH.
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:
$ java Test 100 100
Time taken: 1ms
Пока что все достаточно быстро, но что будет, если увеличить числа?
java Test 1000000 300000
Time taken: 38ms
$java Test 300000 300000
Time taken: 178131ms
Как вам такое: теперь мы ждем три минуты. Интуитивно кажется, что время обработки меньшей коллекции (300 000 элементов во втором случае) должно быть меньше, чем при обработке большей коллекции (миллион элементов в первом случае). Тут же все наоборот. К такому нас жизнь не готовила, верно?
Теперь секрет фокуса.
На самом деле, он описан в открытом виде в соответствующем JavaDoc [4]:
Код реализации устанавливает, что меньше: set или коллекция. Это делается с помощью метода
size
на каждом из них. Если в set элементов меньше, то производится итерация по set-у, и на каждой итерации проверяется, находится ли текущий элемент в коллекции. Если да, то элемент удаляется из set-а с помощью методаremove
итератора. Если же окажется, что коллекция меньше по количеству элементов, тогда нужно будет обойти уже коллекцию, удаляя из set-а все такие элементы с помощью методаremove
из set-а.
На практике это значит, что при вызове source.removeAll(removals)
:
removals
меньше, чем source
, то вызывается метод remove
в HashSet
, и это довольно быстро;removals
больше или равна по размеру, чем source
, то вызывается метод removals.contains
, который очень медленно работает для ArrayList
.В JDK 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;
}
Похоже, в JDK выбран довольно убогий способ справиться с задачей, но поскольку он уже описан в JavaDoc, то пути назад нет.
Или есть?
Роман Елизаров опубликовал этот пост в Twitter с сообщением, что сам обнаружил проблему, когда отлаживал кусок кода, который по удивительной причине работал слишком медленно. Он запустил его во встроенном в IntelliJ IDEA профилировщике и мгновенно увидел на флеймграфе, что все время тратится внутри метода AbstractSet.removeAll
на вызов list.contains
(ссылка на точное место в коде [5]).
Мгновенно в этот твит набежали люди, и Жека Козлов [6] нашел соответствующий баг [7], выставленный на JDK 15 с исполнителем — Стюартом Марксом.
А следом за этим в тред ворвался и сам Стюарт Маркс и подтвердил, что действительно занимается этой проблемой:
Так что свет в конце тоннеля есть. Если вы обновляетесь до свежих версий Java, конечно.
Выводы из этой истории такие: есть проблема, о которой стоит знать. Проблему уже чинят, но починят только для счастливых пользователей свежей Java, и особо надеяться на это не стоит.
В целом, когда вы пытаетесь в коллекциях в Java хранить много данных, у вас могут случаться разные неинтуитивные проблемы, к которым надо быть готовым.
Итак, детки, сегодня мы выучили что-то новое!
В качестве продолжения банкета, рекомендую посмотреть какие-нибудь доклады Романа Елизарова, который помог вытащить на свет божий этот баг. К самому багу эти доклады никакого отношения, конечно, не имеют, но там есть куча разной другой жести:
Можно не только смотреть записи со старого JPoint, но и вживую прийти на новый, который пройдет 15-16 мая в Москве. Программа все еще формируется, и то, что сейчас уже есть, можно посмотреть по ссылке [18].
Автор: olegchir
Источник [19]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/347956
Ссылки в тексте:
[1] elizarov: https://habr.com/ru/users/elizarov/
[2] в твиттере: https://twitter.com/relizarov/status/1232378334346121219
[3] найденном: https://stackoverflow.com/questions/28671903/the-hashsett-removeall-method-is-surprisingly-slow
[4] в соответствующем JavaDoc: http://docs.oracle.com/javase/8/docs/api/java/util/AbstractSet.html#removeAll-java.util.Collection-
[5] точное место в коде: http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/8ed8e2b4b90e/src/share/classes/java/util/AbstractSet.java#l177
[6] Жека Козлов: https://twitter.com/ZhekaKozlov
[7] соответствующий баг: https://bugs.openjdk.java.net/browse/JDK-6394757
[8] February 26, 2020: https://twitter.com/stuartmarks/status/1232810950211670016?ref_src=twsrc%5Etfw
[9] «Корутины в Kotlin»: https://www.youtube.com/watch?v=rB5Q3y73FTo
[10] «Kotlin: Асинхронное программирование с корутинами (часть 1)»: https://www.youtube.com/watch?v=HYhJmK9nKS4
[11] «Kotlin: Асинхронное программирование с корутинами (часть 2)»: https://www.youtube.com/watch?v=fd9EVSxINKw
[12] «Многопоточное программирование — теория и практика»: https://www.youtube.com/watch?v=mf4lC6TpclM
[13] «Жди своего счастья без блокировки!»: https://www.youtube.com/watch?v=XivoUctdPIU
[14] «Почему GC съедает все моё CPU?»: https://www.youtube.com/watch?v=rZclumzMEGs
[15] «Теоретический минимум для понимания Java Memory Model» (JPoint 2014): https://www.youtube.com/watch?v=hxIRyqHRnjE
[16] «Факты и заблуждения о Java-сериализации»: https://www.youtube.com/watch?v=mc9NaoDX5bU
[17] «Миллионы котировок в секунду на чистой Java»: https://www.youtube.com/watch?v=Q-7y1u9kZV0
[18] посмотреть по ссылке: https://jpoint.ru/2020/schedule/?utm_source=habr&utm_medium=490250
[19] Источник: https://habr.com/ru/post/490250/?utm_campaign=490250&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.