Непридуманная история о производительности, рефлексии и java.lang.Boolean

в 8:26, , рубрики: java, java 8, java 9, производительность

Однажды, в студёную зимнюю пору (хотя на дворе был март) мне нужно было покопаться в куче (того, что называется heap dump, а не того, о чём вы подумали). Расчехлив VisualVM я открыл нужный файл и перешел в OQL консоль. Пока суд да дело, моё внимание привлекли запросы, доступные из коробки. Особенно в глаза бросался один из них, озаглавленный "Too many Booleans". В его описании английским по белому сказано:

Check if there are more than two instances of Boolean on the heap (only Boolean.TRUE and Boolean.FALSE are necessary).

Чувствуете, да? Вот и я проникся.

Откуда могут взяться лишние "большие" Boolean, если ява давным давно умеет самостоятельно заворачивать простые типы в обёртки и наоборот? Если код написан правильно, то все приведения boolean к объекту будут использовать Boolean.TRUE/Boolean.FALSE, создающиеся при первом обращении к классу java.lang.Boolean. Именно из этого исходит запрос, на который я обратил внимание:

select toHtml(a) + " = " + a.value from java.lang.Boolean a
    where objectid(a.clazz.statics.TRUE) != objectid(a) &&
          objectid(a.clazz.statics.FALSE) != objectid(a)

Выполнив его я к своему удивлению обнаружил множество отдельных объектов класса j.l.Boolean. Куча ничего не говорила об их происхождении, поэтому захотелось разобраться, откуда они берутся. Профилирование по памяти показало прелюбопытную картину: новые Boolean-ы постоянно появлялись, накапливались и через какое-то время исчезали в пасти GC. В отдельные моменты времени их счёт мог идти на десятки тысяч, а занимали они около 1 Мб памяти.

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 1

Строго говоря, проблемой они не являлись, т. к. утечек не создавали, быстро очищались, да и что такое 1 Мб в наши дни? Однако, механизм появления новых объектов был интересен сам по себе, так что я стал копать.

Для начала давайте посмотрим как получить объект класса Boolean. JDK даёт нам следующие возможности:

/*1*/ Boolean b1 = new Boolean(true);    //@Deprecated начиная с Java 9
/*2*/ Boolean b2 = new Boolean("true");  //@Deprecated начиная с Java 9
/*3*/ Boolean b3 = true;
/*4*/ Boolean b4 = Boolean.valueOf(true);
/*5*/ Boolean b5 = Boolean.valueOf("true");
/*6*/ Boolean b6 = Boolean.parseBoolean("true");

В чём разница между ними? Только первый и второй способы возвращают новый объект (ибо конструктор). Третий способ при сборке приводится к четвёртому, который, как и последние два, возвращает Boolean.FALSE/Boolean.TRUE из наличия.

Итак, причина появления множества одинаковых (по содержимому) объектов заключается в заворачивании простого boolean в обёртку, при чём не вызовом Boolean.valueOf, а прямым обращением к конструктору. Первое подозрение пало на разработчиков библиотек. Ну что же, попробуем найти возможные проколы. Поиск по исходникам подключенных зависимостей (спасибо разработчикам "Идеи"), ничего подозрительного не выявил, так что пришлось встать отладчиком в конструкторе, а там куда кривая выведет.

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

@Transactional(readOnly = true)
public class MyService {
}

В ходе исполнения рефлексия используется для считывания свойств @Transactional (в данном случае readOnly). Происходит это следующим образом (Spring Core 5.0.4.RELEASE):

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 2

Двигаясь по цепочке вверх мы упрёмся в sun.reflect.DelegatingMethodAccessorImpl, исходники которого мы ещё можем прочитать, а вот дальше начинается таинственный GeneratedMethodAccessor13. И хотя, если верить отладчику, данный класс тоже находится в пакете sun.reflect, из "Идеи" его код для нас недоступен, да и само имя как бы намекает, что класс создан на лету. И именно его метод invoke() в конечном счёте и вызывает конструктор Boolean(boolean value).

Дело усложняется: теперь необходимо как-то получить код этого метода. Наскоком решить эту задачу мне не удалось, поэтому пришлось идти иным путём: коль нельзя получить сам код, то можно попробовать достоверно раскрыть способ его создания. Для этого поставим простой опыт с вызовом рефлексией метода, возвращающего boolean:

import java.lang.reflect.Method;

public class Main {

  public static void main(String[] args) throws Exception {
    int invocationCount = 20;
    Object[] booleans = new Object[invocationCount];
    Method method = Main.class.getMethod("f");

    for (int i = 0; i < invocationCount; i++) {
      booleans[i] = invoke(method);
    }
  }

  public static Object invoke(Method method) throws Exception {
    return method.invoke(null);
  }

  public static boolean f() {
    return false;
  }
}

Кстати, мы ведь не убрали точку остановки из конструктора j.l.Boolean, верно? Вот только во время первых 16 проходов по циклу в этой точке отладчик не останавливается! Ещё раз: каждое исполнение method.invoke(null) возвращает новый объект (т. е. booleans[i-1] != booleans[i]), при этом конструктор этого самого объекта не вызывается.

Если во время одного из 16 первых проходов мы остановимся внутри DelegatingMethodAccessorImpl.invoke() и двинемся далее, то обнаружим, что теперь в цепочке вызовов появился класс, отсутствовавший ранее, а именно sun.reflect.NativeMethodAccessorImpl:

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 3

Вот он:

class NativeMethodAccessorImpl extends MethodAccessorImpl {
  private final Method method;
  private DelegatingMethodAccessorImpl parent;
  private int numInvocations;

  NativeMethodAccessorImpl(Method method) {
    this.method = method;
  }

  public Object invoke(Object obj, Object[] args) throws IllegalArgumentException, InvocationTargetException {
    // We can't inflate methods belonging to vm-anonymous classes because
    // that kind of class can't be referred to by name, hence can't be
    // found from the generated bytecode.
    if (++numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) {
      MethodAccessorImpl acc = (MethodAccessorImpl)
        new MethodAccessorGenerator().
          generateMethod(method.getDeclaringClass(),
                                 method.getName(),
                                 method.getParameterTypes(),
                                 method.getReturnType(),
                                 method.getExceptionTypes(),
                                 method.getModifiers());
      parent.setDelegate(acc);
    }

    return invoke0(method, obj, args);
  }

  void setParent(DelegatingMethodAccessorImpl parent) {
    this.parent = parent;
  }

  private static native Object invoke0(Method m, Object obj, Object[] args);

Вот и ответ на вопрос, почему мы не видели вызов конструктора: вместо него вызывается платформенно-зависимый метод invoke0() создающий объект где-то в недрах ВМ. Этот же код объясняет, почему на 17-ом проходе в цепочке вызовов появляется конструктор, а NativeMethodAccessorImpl исчезает: после того как количество вызовов метода f() превышает значение, возвращаемое ReflectionFactory.inflationThreshold() (для JDK 8/9/10/11 это 15), MethodAccessorGenerator на лету создаёт для него посредника, который в виде объекта MethodAccessorImpl передаётся на уровень выше DelegatingMethodAccessorImpl-у.

Начиная с 17-го прохода наблюдаем привычную нам картину (выделена вновь созданная реализация MethodAccessorImpl):

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 4

Таким образом, обнаружены два места, возвращающие новые объекты: "родной" метод NativeMethodAccessorImpl.invoke0() и код, созданный на лету с помощью new MethodAccessorGenerator().generateMethod(). Пойдём по пути наименьшего сопротивления и пока останемся на стороне явы. Т. к. из коробки (в случае JDK 8, с которым собрано приложение) нам доступен только скомпилированный класс (из rt.jar), а декомпиляция даёт маловразумительные лжеисходники с var123 вместо имён переменных и без каких-либо пояснений, то придётся смотреть в репозитории.

Ознакомление с исходниками MethodAccessorGenerator ставит всё на свои места: здесь создаётся байт-код (да, именно байт-код в первозданном виде, а именно в виде массива байтов). Ключевой для нас метод называется emitInvoke(), именно в нём находим нужное нам:

if (!isConstructor) {
 // Box return value if necessary
 if (isPrimitive(returnType)) {

  cb.opc_invokespecial(ctorIndexForPrimitiveType(returnType), typeSizeInStackSlots(returnType), 0);

 } else if (returnType == Void.TYPE) {
  cb.opc_aconst_null();
 }
}

Строка 663: что называется, проглядели при вычитке. Вместо вызова valueOf() для заворачивания простых возвращаемых значений вписали вызов конструктора. Очевидно, что это поправимо: всего-то и делов, что вызов invokespecial нужно заменить на invokestatic, а вместо конструктора передавать фабричный метод.

Увы, ознакомление с исходниками вишнёвой "девятки" показало, что (очень внезапно) не один я такой умный, и лавров в этом деле мне не снискать, т. к. всё уже исправлено до нас:

if (!isConstructor) {
 // Box return value if necessary
 if (isPrimitive(returnType)) {

  cb.opc_invokestatic(boxingMethodForPrimitiveType(returnType), typeSizeInStackSlots(returnType), 0);

 } else if (returnType == Void.TYPE) {
  cb.opc_aconst_null();
 }
}

Вот так нагляднее (JDK 9 слева):

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 5

Проблема была обнаружена давно, а соответствующая задача существует ещё с 2004 (!) года: https://bugs.openjdk.java.net/browse/JDK-5043030

По теме есть обсуждение:

Начало: http://mail.openjdk.java.net/pipermail/core-libs-dev/2014-May/027041.html

Продолжение: http://mail.openjdk.java.net/pipermail/core-libs-dev/2014-June/027076.html

Давайте теперь проверим, стало ли лучше. Переключившись на "девятку" и повторив наш опыт увидим вот это:

Непридуманная история о производительности, рефлексии и java.lang.Boolean - 6

После 16 обращений создан код, использующий Boolean.valueOf() и возвращающий Boolean.TRUE/Boolean.FALSE. Правда, осталась ещё проблема с методом NativeMethodAccessorImpl.invoke0(), который упорно возвращает новые объекты (даже в 10-ке). Делать нечего, нужно лезть в исходники ВМ и смотреть, можем ли мы с этим что-то сделать.

Прямых упоминаний invoke0 я не обнаружил, однако в обсуждениях по теме всплыл файл reflection.cpp и похоже, что наш конструктор вызывается методом invoke(). В этом методе важнейшей для нас является последняя строка:

return Reflection::box((jvalue*)result.get_value_addr(), rtype, THREAD);

Код Reflection::box:

oop Reflection::box(jvalue* value, BasicType type, TRAPS) {
  if (type == T_VOID) {
    return NULL;
  }
  if (type == T_OBJECT || type == T_ARRAY) {
    // regular objects are not boxed
    return (oop) value->l;
  }

  oop result = java_lang_boxing_object::create(type, value, CHECK_NULL);

  if (result == NULL) {
    THROW_(vmSymbols::java_lang_IllegalArgumentException(), result);
  }
  return result;
}

Главное выделено пустыми строками. Теперь код java_lang_boxing_object::create

oop java_lang_boxing_object::create(BasicType type, jvalue* value, TRAPS) {

  oop box = initialize_and_allocate(type, CHECK_0);

  if (box == NULL)  return NULL;
  switch (type) {
    case T_BOOLEAN:
      box->bool_field_put(value_offset, value->z);
      break;
  //.... case-case-case
  return box;
}

oop java_lang_boxing_object::initialize_and_allocate(BasicType type, TRAPS) {
  Klass* k = SystemDictionary::box_klass(type);
  if (k == NULL)  return NULL;
  instanceKlassHandle h (THREAD, k);
  if (!h->is_initialized())  h->initialize(CHECK_0);
  return h->allocate_instance(THREAD);
}

Как видим, ВМ сперва создаёт новый пустой объект, а уже потом прошивает в него значение и возвращает наружу. Это объясняет появление нового объекта без вызова конструктора. Возможно, для типа T_BOOLEAN можно было бы кэшировать два значения на уровне ВМ, но тут непонятно, стоит ли игра свеч.

В сухом остатке

Сколько мы выиграем после перехода на "девятку"? Посчитаем:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms1g", "-Xmx1g"})
public class ReflectiveCallBenchmark {

  @Benchmark
  public Object invoke(Data data) throws Exception {
    return data.method.invoke(data);
  }

  @State(Scope.Thread)
  public static class Data {
    Method method;

    @Setup
    public void setup() throws Exception {
      method = getClass().getMethod("f");
    }

    public boolean f() {
      return true;
    }
  }
}

JDK 8 JDK 9 JDK 10 JDK 11
Benchmark Mode Cnt Score Score Score Score Unit
invoke avgt 30 9,9 7,0 7,6 7,7 ns/op
invoke:·gc.alloc.rate.norm gcprof 30 32 16 16 16 B/op

Здесь измеряются все затраты на рефлексивный вызов. Если же нужно измерить разницу между заворачиванием boolean с помощью конструктора и valueOf, то можно использовать замер попроще:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms1g", "-Xmx1g"})
public class BooleanInstantiationBenchmark {

  @Benchmark
  public Boolean constructor(Data data) {
    return new Boolean(data.value);
  }

  @Benchmark
  public Boolean valueOf(Data data) {
    return Boolean.valueOf(data.value);
  }

  @State(Scope.Thread)
  public static class Data {
    @Param({"true", "false"})
    boolean value;
  }
}

JDK 8 JDK 9 JDK 10 JDK 11
Benchmark Mode Cnt Score Score Score Score Unit
valueOf avgt 30 3,7 3,4 3,6 3,5 ns/op
constructor avgt 30 7,4 5,0 5,5 5,9 ns/op
valueOf:·gc.alloc.rate.norm gcprof 30 0 0 0 0 B/op
constructor:·gc.alloc.rate.norm gcprof 30 16 16 16 16 B/op

Итого: -16 байт и -2..3 нс на один рефлексивный вызов метода, возвращающего boolean. Неплохо, как для простого изменения, особенно учитывая частоту использования рефлексии в кровавом Ынтерпрайзе, а также тот факт, что улучшение распространяется также на остальные примитивы. Обратите внимание, что измеряется производительность исполнения кода, созданного с помощью new MethodAccessorGenerator().generateMethod(), а не создание объекта внутри ВМ.

В качестве вывода: описанное улучшение само по себе очень незначительное, и его влияние почти незаметно. Хотя именно такие мелочи собранные воедино дают рост производительности новых изданий явы.

P. S. Значение, возвращаемое методом ReflectionFactory.inflationThreshold() можно переопределить с помощью свойства -Dsun.reflect.inflationThreshold, передаваемого аргументом при запуске ВМ. Таким образом, если вы уже переехали на "девятку", то с помощью этого флага можно снизить порог создания байт-кода для рефлексивного вызова. Это может несколько замедлить запуск приложение, но оно будет меньше "мусорить". В документации объясняется, зачем придуман этот механизм.

P. P. S. Рассматриваемые классы (MethodAccessorGenerator, NativeMethodAccessorImpl, DelegatingMethodAccessorImpl, MethodAccessorImpl) начиная с "девятки" перенесены в пакет jdk.internal.reflect.

P. P. P S. Обратите внимание, что в рамках описанного улучшения изменениям подверглось значительное количество классов, а не только MethodAccessorGenerator.

P. P. P. P. S. Устройство j.l.Boolean можно немного упростить и выиграть на нём пару-тройку нс ;)

Автор: Сергей Цыпанов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js