Микрооптимизации в Java. Enum  -  хороший, красивый и медленный

Перечисления  —  важная часть каждого приложения, которое представляет собой чуть больше, чем “Hello World”. Они повсюду. И, на самом деле, перечисления очень полезны: они ограничивают ввод, позволяют сравнивать значения по ссылке, обеспечивают проверку во время компиляции и облегчают чтение кода. Однако применение перечислений приводит к некоторым проблемам с производительностью. И мы рассмотрим их в этом посте.

Рассматривайте весь приведенный ниже код с точки зрения производительности. Не зацикливайтесь на цифрах, это просто примеры для демонстрации необходимых утверждений.

Enum.valueOf

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

Blynk.setProperty(v1, “LABEL”, “My New Label”);

Давайте взглянем на существующее перечисление с нашего сервера:

enum WidgetProperty {
 LABEL,
 COLOR,
 ON_LABEL,
 OFF_LABEL,
 MIN,
 MAX 
}

А теперь взгляните на этот базовый код:

String inWidgetProperty;
...
WidgetProperty property = WidgetProperty.valueOf(inWidgetProperty)

Видите, что тут не так?

Давайте перепишем приведенный выше код и создадим наш собственный метод на основе оператора switch:

public static WidgetProperty valueOfSwitch(String value) {
        switch (value) {
            case "LABEL" :
                return WidgetProperty.LABEL;
            case "COLOR" :
                return WidgetProperty.COLOR;
            case "ON_LABEL" :
                return WidgetProperty.ON_LABEL;
            case "OFF_LABEL" :
                return WidgetProperty.OFF_LABEL;
            case "MIN" :
                return WidgetProperty.MIN;
            case "MAX" :
                return WidgetProperty.MAX;
        }
        return null;
    }

Этот метод не совсем такой же, так как он не выбрасывает исключение IllegalArgumentException в случае неизвестного значения. Однако это делается специально, потому что, как вам известно, выбрасывание и создание исключения (из-за трассировки стека)  —  дорогостоящая операция.

Давайте сделаем тест производительности:

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 10, time = 1)
public class EnumValueOf {

    @Param({"LABEL", "ON_LABEL", "MAX"})
    String strParams;

    @Benchmark
    public WidgetProperty enumValueOf() {
        return WidgetProperty.valueOf(strParams);
    }

    @Benchmark
    public WidgetProperty enumSwitch() {
        return WidgetProperty.valueOfSwitch(strParams);
    }

}

Результаты (более низкий балл означает большее быстродействие):

JDK 11.0.8, OpenJDK 64-Bit Server VM, 11.0.8+10

Benchmark                (strParams)  Mode  Cnt   Score   Error  Units
EnumValueOf.enumSwitch         LABEL  avgt   80   7.149 ± 0.030  ns/op
EnumValueOf.enumSwitch      ON_LABEL  avgt   80   7.758 ± 0.077  ns/op
EnumValueOf.enumSwitch           MAX  avgt   80   6.948 ± 0.083  ns/op
EnumValueOf.enumValueOf        LABEL  avgt   80  15.659 ± 0.450  ns/op
EnumValueOf.enumValueOf     ON_LABEL  avgt   80  14.734 ± 0.542  ns/op
EnumValueOf.enumValueOf          MAX  avgt   80  15.153 ± 0.578  ns/op

Хм… Наш пользовательский метод с оператором switch работает в два раза быстрее Enum.valueOf. Что-то здесь не так. Надо нырнуть глубже.

Как вы, вероятно, знаете, классы enum не содержат метод valueOf(). Компилятор генерирует его во время компиляции, поэтому, чтобы разобраться в происходящем, нам нужно заглянуть в байт-код сгенерированного класса:

LDC Lstring/WidgetProperty;.class
 ALOAD 0
 INVOKESTATIC java/lang/Enum.valueOf (Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
 CHECKCAST string/WidgetProperty
 ARETURN

Похоже, что метод java.lang.Enum.valueOf вызывается внутри метода WidgetPtoperty.valueOf. К счастью, этот метод присутствует в коде Java. Давайте проверим это:

public static <T extends Enum<T>> T valueOf(Class<T> enumType,
                                                String name) {
        T result = enumType.enumConstantDirectory().get(name);
        if (result != null)
            return result;
        if (name == null)
            throw new NullPointerException("Name is null");
        throw new IllegalArgumentException(
            "No enum constant " + enumType.getCanonicalName() + "." + name);
    }

И enumConstantDirectory:

Map<String, T> enumConstantDirectory() {
        Map<String, T> directory = enumConstantDirectory;
        if (directory == null) {
            T[] universe = getEnumConstantsShared();
            if (universe == null)
                throw new IllegalArgumentException(
                    getName() + " is not an enum type");
            directory = new HashMap<>((int)(universe.length / 0.75f) + 1);
            for (T constant : universe) {
                directory.put(((Enum<?>)constant).name(), constant);
            }
            enumConstantDirectory = directory;
        }
        return directory;
    }
    private transient volatile Map<String, T> enumConstantDirectory;

Ага, значит, все, что у нас здесь есть,  —  это обычная изменчивая хэш-карта HashMap с именами перечислений в качестве ключей. И похоже, что в этом конкретном случае оператор switch просто превосходит метод HashMap.get().

Добавим valueOfс собственной реализацией HashMap и тоже протестируем:

private final static Map<String, WidgetProperty> cache;
static {
    cache = new HashMap<>();
    for (WidgetProperty widgetProperty : values()) {
        cache.put(widgetProperty.name(), widgetProperty);
    }
}

public static WidgetProperty enumMap(String value) {
    return cache.get(value);
}

Результаты (более низкий балл означает большее быстродействие, результаты enumMapприкреплены к предыдущему тесту):

JDK 11.0.8, OpenJDK 64-Bit Server VM, 11.0.8+10

Benchmark                (strParams)  Mode  Cnt   Score   Error  Units
EnumValueOf.enumMap            LABEL  avgt   10  11.749 ± 0.761  ns/op
EnumValueOf.enumMap         ON_LABEL  avgt   10  11.778 ± 0.306  ns/op
EnumValueOf.enumMap              MAX  avgt   10  10.517 ± 0.056  ns/op
EnumValueOf.enumSwitch         LABEL  avgt   80   7.149 ± 0.030  ns/op
EnumValueOf.enumSwitch      ON_LABEL  avgt   80   7.758 ± 0.077  ns/op
EnumValueOf.enumSwitch           MAX  avgt   80   6.948 ± 0.083  ns/op
EnumValueOf.enumValueOf        LABEL  avgt   80  15.659 ± 0.450  ns/op
EnumValueOf.enumValueOf     ON_LABEL  avgt   80  14.734 ± 0.542  ns/op
EnumValueOf.enumValueOf          MAX  avgt   80  15.153 ± 0.578  ns/op

Похоже, собственная реализация HashMap работает быстрее,чем обычный Enum.valueOf(), но все же медленнее, чем оператор switch.

Перепишем тест, чтобы получить случайный ввод:

@BenchmarkMode(Mode.AverageTime)
@Fork(5)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 10, time = 1)
public class EnumValueOf {

    String[] names;
    int index;

    @Setup
    public void init() {
        WidgetProperty[] values = WidgetProperty.values();
        names = new Random(0).ints(4096, 0, values.length)
                .mapToObj(i -> values[i].name())
                .toArray(String[]::new);
        index = 0;
    }

    private String nextName() {
        return names[index++ & (names.length - 1)];
    }

    @Benchmark
    public WidgetProperty enumValueOf() {
        return WidgetProperty.valueOf(nextName());
    }

    @Benchmark
    public WidgetProperty enumSwitch() {
        return WidgetProperty.valueOfSwitch(nextName());
    }

    @Benchmark
    public WidgetProperty enumMap() {
        return WidgetProperty.valueOfMap(nextName());
    }

}

Результаты (более низкий балл = большее быстродействие):

JDK 11.0.8, OpenJDK 64-Bit Server VM, 11.0.8+10

```
Benchmark                Mode  Cnt   Score   Error  Units
EnumValueOf.enumMap      avgt   50   7.800 ± 0.097  ns/op
EnumValueOf.enumSwitch   avgt   50  19.734 ± 0.350  ns/op
EnumValueOf.enumValueOf  avgt   50   9.292 ± 0.826  ns/op
```

Любопытно… Теперь, при случайном вводе, оператор switch работает в два раза медленнее. Похоже, что разница связана с предсказанием переходов. И самым быстрым вариантом будет наша собственная реализация хэша карт, так как она влечет за собой меньше проверок по сравнению с Enum.valueOf.

Enum.values()

Теперь рассмотрим другой пример. Он не так популярен, как приведенный первым, но все же присутствует во многих проектах:

for (WidgetProperty property : WidgetProperty.values()) {
   ...
}

Видите, что здесь не так?

Опять же, метод Enum.values() генерируется во время компиляции, поэтому, чтобы разобраться, что там не так, мы должны еще раз взглянуть на байт-код:

GETSTATIC string/WidgetProperty.$VALUES : 
[Lstring/WidgetProperty;
 INVOKEVIRTUAL [Lstring/WidgetProperty;.clone 
()Ljava/lang/Object;
 CHECKCAST [Lstring/WidgetProperty;
 ARETURN

Не нужно быть экспертом по байт-коду, чтобы увидеть вызов метода .clone(). Таким образом, каждый раз, когда вы вызываете метод Enum.values(), всегда будет копироваться массив со значениями enum.

Решение довольно очевидно  —  мы можем кэшировать результат метода Enum.values() следующим образом:

public static final WidgetProperty[] values = values();

И взамен использовать поле кэша. Давайте проверим это с помощью теста производительности:

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 10, time = 1)
public class EnumValues {

    @Benchmark
    public void enumValuesMethod(Blackhole bh) {
        for (WidgetProperty property : WidgetProperty.values()) {
            bh.consume(property);
        }
    }

    @Benchmark
    public void enumValuesVariable(Blackhole bh) {
        for (WidgetProperty property : WidgetProperty.values) {
            bh.consume(property);
        }
    }

}

Я применил в этом тесте цикл, потому что обычно в цикле используется Enum.values().

Результаты (более низкий балл означает большее быстродействие):

JDK 11.0.8, OpenJDK 64-Bit Server VM, 11.0.8+10

Benchmark                      Mode  Cnt   Score   Error  Units
EnumValues.enumValuesMethod    avgt   10  21.576 ± 0.225  ns/op
EnumValues.enumValuesVariable  avgt   10  16.811 ± 0.095  ns/op

Разница в производительности здесь около 30%. Это потому, что итерация цикла также требует кое-каких ресурсов ЦПУ. Однако, что более важно: после внесенного исправления никакого распределения не происходит, это даже более важно, чем улучшение показателей скорости.

Вы можете задаться вопросом: почемуEnum.values() реализован именно таким образом? Объяснение довольно простое. Без метода copy() любой желающий может изменить содержимое возвращаемого массива. А это может привести ко многим проблемам. Поэтому возвращать одну и ту же ссылку на массив со значениями, которые можно легко заменить,  —  не самая лучшая идея. Неизменяемые массивы могли бы решить эту проблему, но в Java их нет.

Да, такое изменение немного снижает читабельность, и вовсе не нужно применять его ко всякому перечислению. Это необходимо только для действительно горячих путей.

Для примера можете ознакомиться с этим пулл-реквестом jdbc-драйвера для Apache Cassandra или этим пулл-реквестом для HTTP-сервера.

Заключение

  • Рассмотрите возможность воспользоваться собственным кэшем карт метода Enum.valueOf(). В качестве дополнительного бонуса можно избежать возникновения исключения при неправильном вводе. Это может изменить правила игры для некоторых потоков.
  • Также подойдет Enum.values() с кэшированным полем.Это позволит вам уменьшить скорость распределения.

Вот исходный код тестов производительности  —  можете попробовать их самостоятельно.

Спасибо за внимание!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Dmytro Dumanskiy: “Micro optimizations in Java. Good, nice and slow Enum”