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

Непридуманная история о производительности, рефлексии и 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 Мб памяти.

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

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

Вот он:

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

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

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

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

Начало

Продолжение

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

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


x

Ещё Hi-Tech Интересное!

Кеширование данных — Java Spring

Многократно вычитывая одни и те же данные, встает вопрос оптимизации, данные не меняются или редко меняются, это различные справочники и др. информация, т.е. функция получения данных по ключу — детерминирована. Тут наверно все понимают — нужен Кеш! Зачем всякий раз ...

Что умеет СХД — или старые песни о главном

Пару дней назад позвонили мне коллеги с вопросом — старая дисковая полка совсем умирает (у них старый еще IBM), чего делать? Дисков нет, поддержки нет, денег нет зовут Олег. Что покупать, куда бежать, как дальше жить? На хабре же, кроме ...