Статьи

Улучшения производительности HashMap в Java 8

HashMap<K, V> — это быстрая, универсальная и вездесущая структура данных в каждой Java-программе. Сначала немного основ. Как вы, вероятно, знаете, он использует методы ключей hashCode() и equals() для разделения значений между сегментами. Количество сегментов (корзин) должно быть немного больше, чем количество записей в карте, чтобы в каждом блоке содержалось лишь небольшое (предпочтительно одно) значение. При поиске по ключу мы очень быстро определяем bucket (используя hashCode() по модулю number_of_buckets ), и наш элемент доступен в постоянное время.

Это должно было быть уже известно вам. Вы, вероятно, также знаете, что коллизии хэшей имеют катастрофические последствия для производительности HashMap . Когда несколько значений hashCode() оказываются в одном сегменте, они помещаются в специальный связанный список. В худшем случае, когда все ключи сопоставлены одному и тому же сегменту, вырождается хеш-карта в связанный список — от O (1) до O (n) времени поиска. Давайте сначала HashMap поведение HashMap в обычных условиях в Java 7 (1.7.0_40) и Java 8 (1.8.0-b132). Чтобы иметь полный контроль над поведением hashCode() мы определяем наш пользовательский класс Key :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Key implements Comparable<Key> {
  
    private final int value;
  
    Key(int value) {
        this.value = value;
    }
  
    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }
  
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }
  
    @Override
    public int hashCode() {
        return value;
    }
}

Key класс хорошо себя ведет: он переопределяет equals() и предоставляет приличный hashCode() . Чтобы избежать чрезмерного GC, я кэширую неизменные экземпляры Key а не создаю их с нуля снова и снова:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class Keys {
  
    public static final int MAX_KEY = 10_000_000;
    private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
  
    static {
        for (int i = 0; i < MAX_KEY; ++i) {
            KEYS_CACHE[i] = new Key(i);
        }
    }
  
    public static Key of(int value) {
        return KEYS_CACHE[value];
    }
  
}

Теперь мы готовы немного поэкспериментировать. Наш бенчмарк просто создаст HashMap разных размеров (степени 10, от 1 до 1 миллиона), используя непрерывное пространство клавиш. В самом бенчмарке мы будем искать значения по ключам и измерять, сколько времени это займет, в зависимости от размера HashMap :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import com.google.caliper.Param;
import com.google.caliper.Runner;
import com.google.caliper.SimpleBenchmark;
  
public class MapBenchmark extends SimpleBenchmark {
  
    private HashMap<Key, Integer> map;
  
    @Param
    private int mapSize;
  
    @Override
    protected void setUp() throws Exception {
        map = new HashMap<>(mapSize);
        for (int i = 0; i < mapSize; ++i) {
            map.put(Keys.of(i), i);
        }
    }
  
    public void timeMapGet(int reps) {
        for (int i = 0; i < reps; i++) {
            map.get(Keys.of(i % mapSize));
        }
    }
  
}

Результаты подтверждают, что HashMap.get() действительно является O (1):

1

Интересно, что Java 8 в среднем на 20% быстрее, чем Java 7 в простом HashMap.get() . Общая производительность не менее интересна: даже с одним миллионом записей в HashMap один просмотр занял менее 10 наносекунд, что означает около 20 циклов ЦП на моей машине * . Очень впечатляет! Но это не то, что мы собирались измерять.

Предположим, что у нас очень плохой ключ карты, который всегда возвращает одно и то же значение. Это наихудший сценарий, который полностью отвергает цель использования HashMap :

1
2
3
4
5
6
7
8
9
class Key implements Comparable<Key> {
  
    //...
  
    @Override
    public int hashCode() {
        return 0;
    }
}

Я использовал точно такой же эталонный тест, чтобы увидеть, как он ведет себя при различных размерах карты (обратите внимание, что это лог-лог масштаб):

2

Ожидается результатов для Java 7. Стоимость HashMap.get() растет пропорционально размеру самого HashMap . Поскольку все записи находятся в одном и том же сегменте в одном огромном связанном списке, поиск одного требует в среднем обхода половины такого списка (размера n). Таким образом, O (n) сложность, как показано на графике.

Но Java 8 работает намного лучше! Это логарифмическая шкала, поэтому мы на самом деле говорим на несколько порядков лучше. Тот же самый тест, выполненный в JDK 8, дает O (logn) производительность в наихудшем случае в случае катастрофических коллизий хэшей, как показано на рисунке лучше, если JDK 8 визуализируется отдельно в логарифмическом масштабе:

3

В чем причина такого огромного улучшения производительности, даже с точки зрения обозначения Big-O? Ну, эта оптимизация описана в JEP-180 . В основном, когда TREEIFY_THRESHOLD = 8 становится слишком большой (в настоящее время: TREEIFY_THRESHOLD = 8 ), HashMap динамически заменяет ее специальной реализацией древовидной карты. Таким образом, вместо пессимистического O (n) мы получаем намного лучше O (logn). Как это работает? Что ж, ранее записи с конфликтующими ключами просто добавлялись в связанный список, который позже нужно было просмотреть. Теперь HashMap преобразует список в двоичное дерево, используя хеш-код в качестве переменной ветвления. Если два хэша различны, но попали в одно и то же ведро, один считается больше и идет вправо. Если хэши равны (как в нашем случае), HashMap надеется, что ключи Comparable , чтобы он мог установить некоторый порядок. Это не требование ключей HashMap , но, видимо, хорошая практика. Если ключи несопоставимы, не ожидайте каких-либо улучшений производительности в случае сильных коллизий хешей.

Почему все это так важно? Вредоносное ПО, осведомленное о алгоритме хэширования, которое мы используем, может обработать пару тысяч запросов, что приведет к массовым коллизиям хешей. Постоянный доступ к таким ключам значительно повлияет на производительность сервера, что приведет к атаке типа «отказ в обслуживании». В JDK 8 удивительный переход от O (n) к O (logn) предотвратит такой вектор атаки, а также сделает производительность более предсказуемой. Я надеюсь, что это наконец убедит вашего босса в обновлении.


* Тесты производительности выполняются на Intel Core i7-3635QM с частотой 2,4 ГГц, 8 ГБ ОЗУ и SSD-накопителем, в 64-разрядной версии Windows 8.1 и настройках JVM по умолчанию.

Ссылка: Улучшения производительности HashMap в Java 8 от нашего партнера JCG Томаша Нуркевича из блога Java и соседей .