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