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

Однажды, в студёную зимнюю пору (хотя на дворе был март) мне нужно было покопаться в куче (того, что называется 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 Мб памяти.


ldydvgbdcgk9nvgk-c-eshnlips.png


Строго говоря, проблемой они не являлись, т. к. утечек не создавали, быстро очищались, да и что такое 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):


ml2enob20ftbk_51nhed2yhmlrq.png


Двигаясь по цепочке вверх мы упрёмся в 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:


0mewi658lrjjjzfav8-nyd-nyfy.png


Вот он:


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):


qlwav414lrenjl8svb9c2qlfq4a.png


Таким образом, обнаружены два места, возвращающие новые объекты: «родной» метод 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 слева):


0qe27jq_nhhx4szfz5fi2qzpr6m.png


Проблема была обнаружена давно, а соответствующая задача существует ещё с 2004 (!) года.


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


Начало


Продолжение


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


odnduvnlai-hyalx4ynospjdsc4.png


После 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 можно немного упростить и выиграть на нём пару-тройку нс ;)

© Habrahabr.ru