Швидша альтернатива Java Reflection

У статті "Специфікація шаблону", заради розумності, я не згадував про базовий компонент, який би міг пригадати цю річ. Тепер я трохи детальніше розгляну навколо класу JavaBeanUtil, який я встановив для зчитування значення для даного fieldNameз конкретного javaBeanObject, яке в такому випадку виявилося FxTransaction.

Ви можете легко стверджувати, що я міг в основному використовувати Apache Commons BeanUtils або одну з його альтернатив для досягнення того самого результату. Але мені було цікаво забруднити власні руки чимось іншим, що, як я знав, було б набагато швидшим за будь-яку бібліотеку, побудовану на вершині широко відомого Java Reflection.

Активістом техніки, яка використовується для уникнення дуже повільного відображення, є invokedynamicінструкція байт-коду. Коротко кажучи, invokedynamic(або “indy”) було найбільшим, що було введено в Java 7, щоб прокласти шлях для реалізації динамічних мов поверх JVM за допомогою виклику динамічного методу. Пізніше це також дозволило лямбда-вираження та посилання на методи в Java 8, а також об'єднання рядків у Java 9, щоб скористатися цим.

У двох словах, техніка, яку я збираюся краще описати нижче, використовує LambdaMetafactory та MethodHandle, щоб динамічно створювати реалізацію функції. Його єдиний метод делегує виклик фактичному цільовому методу з кодом, визначеним всередині лямбда-тіла.

Метод цілі, про який йдеться тут, - це власне метод отримання, який має прямий доступ до поля, яке ми хочемо прочитати. Крім того, я повинен сказати, що якщо ви добре знайомі з приємними речами, які виникли в Java 8, ви знайдете наведені нижче фрагменти коду досить простими для розуміння. В іншому випадку це може бути складно з першого погляду.

Загляньте на саморобний JavaBeanUtil

Наступним методом є утиліта, яка використовується для зчитування значення з поля JavaBean. Він бере об’єкт JavaBean і одне fieldAабо навіть вкладене поле, розділене крапками, наприклад,nestedJavaBean.nestedJavaBean.fieldA

Для оптимальної роботи я кешую динамічно створену функцію, яка є фактичним способом читання вмісту заданого fieldName. Отже, всередині getCachedFunctionметоду, як ви можете бачити вище, є швидкий шлях, що використовує ClassValue для кешування, і є повільний createAndCacheFunctionшлях, який виконується лише в тому випадку, якщо до цього часу нічого не кешовано.

Повільний шлях в основному буде делегований createFunctionsметоду, який повертає список функцій, які слід зменшити, з'єднавши їх за допомогою Function::andThen. Коли функції зв’язані ланцюгами, ви можете уявити собі якісь вкладені виклики, як getNestedJavaBean().getNestedJavaBean().getFieldA(). Нарешті, після ланцюжка ми просто поміщаємо зменшену функцію в cacheAndGetFunctionметод виклику кешу .

Детальніше вивчивши повільний шлях створення функції, нам потрібно індивідуально переміщатися по pathзмінній поля , розділивши її, як показано нижче:

Вищевказаний createFunctionsметод делегує особу fieldNameта тип власника класу на createFunctionметод, який визначатиме необхідний геттер на основі javaBeanClass.getDeclaredMethods(). Після того, як він знайдений, він перетворюється на об’єкт Tuple (об’єкт з бібліотеки Vavr), який містить тип повернення методу getter та динамічно створену функцію, в якій буде діяти так, ніби це власне метод getter.

Це відображення кортежу виконується createTupleWithReturnTypeAndGetterспільно з createCallSiteметодом наступним чином:

У двох вищезазначених методах я використовую константу LOOKUP, яка викликається , що є просто посиланням на MethodHandles.Lookup. Завдяки цьому я можу створити прямий дескриптор методу на основі раніше розташованого методу отримання. І нарешті, створений MethodHandle передається createCallSiteметоду, за допомогою якого лямбда-тіло для функції створюється за допомогою LambdaMetafactory. Зрештою, ми можемо отримати екземпляр CallSite, який є власником функції.

Зауважте, що якби я хотів мати справу із сеттерами, я міг би застосувати подібний підхід, використовуючи BiFunction замість Function.

Орієнтир

Для того, щоб виміряти приріст продуктивності, я використав дивовижний JMH (Java Microbenchmark Harness), який, швидше за все, буде частиною JDK 12. Як ви вже знаєте, результати прив'язані до платформи, тому для довідки я використовувати один 1x6 i5-8600K 3.6GHzі Linux x86_64а також Oracle JDK 8u191і GraalVM EE 1.0.0-rc9.

Для порівняння я використовував Apache Commons BeanUtils, відому бібліотеку для більшості розробників Java, та одну з її альтернатив - Jodd BeanUtil, яка стверджує, що майже на 20% швидша.

Тестовий сценарій встановлюється наступним чином:

Тест визначається тим, наскільки глибоко ми збираємось отримати якесь значення відповідно до чотирьох різних рівнів, зазначених вище. Для кожного fieldNameJMH виконує 5 ітерацій по 3 секунди кожна, щоб зігріти речі, а потім 5 ітерацій по 1 секунду кожна для фактичного вимірювання. Потім кожен сценарій повторюватиметься 3 рази, щоб розумно зібрати показники.

Результати

Почнемо з результатів, зібраних із JDK 8u191пробігу:

Найгірший сценарій з використанням invokedynamicпідходу набагато швидший, ніж найшвидший сценарій з двох інших бібліотек. Це величезна різниця, і якщо ви сумніваєтесь у результатах, ви завжди можете завантажити вихідний код і пограти як завгодно.

Тепер давайте подивимося, як працює той самий бенчмарк GraalVM EE 1.0.0-rc9

Повні результати можна переглянути тут за допомогою приємного візуалізатора JMH.

Спостереження

Величезна різниця полягає в тому, що JIT-компілятор знає CallSiteі MethodHandleдуже добре, і знає, як вставити їх досить добре, на відміну від підходу до рефлексії. Крім того, ви бачите, наскільки перспективним є GraalVM. Його компілятор виконує справді надзвичайну роботу, здатну значно покращити продуктивність підходу до роздумів.

Якщо вам цікаво і ви хочете грати далі, я закликаю вас витягнути вихідний код із мого Github. Майте на увазі, я не закликаю вас робити власну саморобку JavaBeanUtilта використовувати її на виробництві. Швидше за все, моя мета полягає в тому, щоб просто продемонструвати свій експеримент і можливості, які ми можемо отримати invokedynamic.