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