Главная » Хабрахабр » Непридуманная история о производительности, рефлексии и 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 Интересное!

Создание приложения на .NET Core и Kubernetes: наш опыт

Всем привет! Мы решили реализовать новое приложение под Linux с использованием . Сегодня расскажем об опыте одного из наших DevOps проектов. Net Core на микросервисной архитектуре. Поэтому он должен легко масштабироваться как по функционалу, так и по производительности. Мы рассчитываем, ...

База данных штрихкодов скачать бесплатно без регистрации (и прочей хурмы)

Добрый день. В открытом доступе наконец-то появился огромный справочник штрихкодов с наименованиями товаров, категориями и брендами. Мы работаем над ним лет 8 и теперь в нем около 3 миллионов штрихкодов в стандартах EAN (EAN-13, EAN-8) и UPC (UPC-A, UPC-E). Что ...