Обновление Java с 17 на 21: через тернии к звездам
Меня зовут Денис, я тимлид команды R&D в Naumen Service Management Platform.
Так как наш продукт написан в основном на Java, мы с большим нетерпением ждали очередной LTS релиз в прошлом году, предвкушая мощь виртуальных потоков и крутизну доработанного pattern matching.
Вот для сравнения один и тот же код: один написан на Java 17, второй — на Java 21.
Код, написанный на Java 17:
public Double convert(Object value)
{
if (null == value)
{
return null;
}
if (value instanceof Double)
{
return (Double)value;
}
else if (value instanceof String)
{
if (((String)value).trim().isEmpty())
{
return null;
}
return Double.parseDouble((String)value);
}
else if (value instanceof Number)
{
return ((Number)value).doubleValue();
}
throw new AdvImportException("Can't convert value " + value + " to double");
}
Код, написанный на Java 21:
public Double convert(@Nullable Object value)
{
return switch (value)
{
case null -> null;
case Double doubleValue -> doubleValue;
case String stringValue when stringValue.isBlank() -> null;
case String stringValue -> Double.parseDouble(stringValue);
case Number numberValue -> numberValue.doubleValue();
default -> throw new IllegalArgumentException("Can't convert value" + value + " to double");
};
}
Чтобы осознать первый код и понять, что тут происходит, нужно пробраться сквозь все if«ы, instanceof«ы и закопаться во внутреннюю логику. Тот, что написан на Java 21, выглядит гораздо лучше: его и читать приятнее, и понимать быстрее.
В общем, преимущества Java 21 очевидны. Но оказался ли путь миграции систем на Java 21 таким же простым и приятным? Если кратко: нет.
В этой статье расскажу, с какими препятствиями столкнулась наша команда, что мы получили после обновления и стоит ли вообще обновляться.
Статья будет полезна разработчикам и техлидам, которые задумываются или уже планируют миграцию их систем на Java 21.
Обновление на Java 20: вызов первый — изменение final static полей не работает через рефлексию
Итак, август 2023 года: Java 20 уже давно вышла, и мне пришла идея попробовать обновить наш продукт, который к тому времени уже несколько лет был на Java 17. Хотелось убедиться, что никаких проблем нет, и через пару месяцев, когда релизнится Java 21, обновление пройдет спокойно.
Обновление на Java 20 должно было состоять из шести довольно простых шагов:
sdk install java 20.x.x
sdk d java 20.x.x
change pom.xml
mvn clean install
tomcat → cargo: run
profit
План был такой: устанавливаю Java 20 на свою локальную машину, делаю ее дефолтной, вношу изменения в файл pom.xml. Затем собираю проект на Java 20, запускаю его и радуюсь, что обновление прошло успешно.
Все шло по плану до того момента, пока я не начал собирать проект на Java 20. На этом этапе сборка проекта закрашилась. Я понял, что настало время вспомнить о главных помощниках программиста, логах. Собственно, сюда я и полез. Так нашел виновника — изменение final static полей через рефлексию.
Кратко объясню, зачем нам нужно. Мы, как и многие, используем популярное ORM-решение Hibernate, но немного его хачим. А именно: докидываем во внутренний class Environment собственную реализацию BytecodeProvider. Это нужно не просто так. Наше приложение может генерировать огромное количество коротких запросов, например, getUUID, toString, hashCode и другие. Для них нам не нужно делать обращения в базу, пытаться распроксировать объект и выполнять другую потенциально тяжелую работу. Достаточно сконструировать ответ из данных, которые уже есть в аргументах запроса. Это позволяет справляться с очень большой нагрузкой в подобных местах.
Поэтому мы написали собственную реализацию BytecodeProvider«а и подкинули ее в это поле классическим способом через рефлексию. Для этого мы временно исключили модификатор final и записали нужное нам значение в переменную. Все это прекрасно работало на Java 8, 11 и 17. На Java 20 вдруг перестало работать.
Можно ли это сделать не через рефлексию, например, внутри самого Hibernate осуществить подобную замену, найти более простой способ? Нет, увы, на 4 и 5 Hibernate таких способов нет.
Ниже реализация получения BytecodeProvider«а в Hibernate 4. Даже если вы подкините ему собственный providerName, Hibernate его проигнорирует и вернет свою реализацию BytecodeProvider«а. В 5 и начальных версиях 6 Hibernate ситуация аналогичная. Только вместо javassist«а теперь используется byte-buddy. Кстати, в свежих версиях 6 Hibernate эту проблему пофиксили — снова появилась возможность подкидывать свою реализацию BytecodeProvider«а. Вот тут и вот тут на GitHub можно узнать подробнее.
public static BytecodeProvider buildBytecodeProvider(Properties properties) {
String provider = ConfigurationHelper.getString(BYTECODE_PROVIDER, properties, defaultValue: "javassist");
LOG.bytecodeProvider(provider);
return buildBytecodeProvider(provider);
}
private static BytecodeProvider buildBytecodeProvider(String providerName) {
if ("javassist".equals(providerName)) {
return new org.hibernate.bytecode.internal.javassist.BytecodeProviderImpl();
}
LOG.unknownBytecodeProvider(providerName);
return new org.hibernate.bytecode.internal.javassist.BytecodeProviderImpl();
}
Итак, отправился в интернет искать причину — почему наш обходной путь на Java 20 перестал работать. Виновником оказался JEP 416: Reimplement Core Reflection with Method Handles. Его комитер Мэнди Чанг в обсуждении к ее пул-реквесту заявила, что возможность править final static поля через рефлексию была хаком, который не доходили руки починить.
Более того, этот JEP закатился еще в Java 18. Так что начиная с Java 18 обращаться к final static полям через рефлексию больше нельзя. В итоге решили не искать еще более изощренные пути, а просто взяли и форкнули Hibernate, выкинув тот самый модификатор final из этого поля. Мы практически не изменили наш код, но проблема была решена. Пересобрав Hibernate и внеся очередные небольшие изменения в pom-файл, я продолжил двигаться по плану.
Обновление на Java 20: вызов второй — JEP 418 сломал систему
Снова попытался собрать проект, снова словил »синий экран» и снова полез в логи. На этот раз наткнулся на следы JEP 418: Internet-Address Resolution SPI. Этот JEP тоже закатился в Java 18. Здесь была проведена большая оптимизация механизма резолвинга адресов. Понадобилось это для виртуальных потоков, в частности, для проекта Loom, потому что до этого JEP«а работа с нативными вызовами была не совсем корректна. В ходе этой оптимизации ребята отломали наш древний хак. У нас был вот такой код:
private void initAddressResolver() {
try {
Class> clazz = Class.forName("java.net.InetAddressImplFactory");
Method create = clazz.getDeclaredMethod("create");
create.setAccessible(true);
addressResolver = create.invoke(null);
Class> inetAddressClass = addressResolver.getClass();
getHostMethod = inetAddressClass.getMethod("getHostByAddr", byte[].class);
getHostMethod.setAccessible(true);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
Мы брали внутренний InetAddressImplFactory class, дергали его метод create, чтобы получить текущий addressResolver — у него вызывали нативный метод getHostByAddr:
final class Inet4AddressImpl implements InetAddressImpl {
public native String getLocalHostName() throws UnknownHostException;
public InetAddress[] lookupAllHostAddr(String hostname, LookupPolicy lookupPolicy) throws UnknownHostException {
if ((lookupPolicy.characteristics() & IPV4) == 0) {
throw new UnknownHostException(hostname);
}
return lookupAllHostAddr(hostname);
}
private native InetAddress[] lookupAllHostAddr(String hostname) throws UnknownHostException;
public native String getHostByAddr(byte[] addr) throws UnknownHostException;
private native boolean isReachable0(byte[] addr, int timeout, byte[] ifaddr, int ttl) throws IOException;
}
Зачем мы так делали? Все просто — этот код был написан чуть ли не на заре проекта, в году 2010, вероятно, еще на Java 5. А в то время сделать подобную логику иным способом, видимо, не получилось. Поэтому этот старый код спокойно кочевал из версии в версию Java и прекрасно работал более 10 лет.
Но пришел новый JEP и все нам поломал. Благо исправление оказалось простейшим. Мы не только Java обновили, но еще и от легаси-кода избавились.
Вместе с JEP 418 появилась возможность дернуть статистический метод getHostName, внутри которого дернется lookupByAddress — в нем вызывается нужный нам getHostByAddr. Так что мне оставалось выкинуть пару десятков строк нашего старого кода и заменить его на одну строчку:
InetAddress inetAddress = ...
inetAddress.getHostName();
Обновление на Java 20: вызов третий — Degrade Thread.stop () крашнул код
Наконец, после этих двух исправлений проект на Java 20 собрался. Но через пару секунд после запуска меня снова ждал »синий экран».
Причина оказалась в улучшении, которое закатилось в Java 20, — Degrade Thread.stop (). В этом фиксе ребята из JDK сделали то, что давно обещали. Теперь все вызовы, которые позволяли управлять тредами из Java приложения стали forRemoval и выбрасывают UnsupportedOperationException, что делает работу с ними абсолютно бесполезной.
В нашем коде было несколько таких мест, где мы использовали подобные методы. Пришлось потратить время, чтобы переписать все эти места на правильную работу с потоками.
После исправления этой проблемы проект на Java 20 запустился. Оставалось лишь закатить в продукт те исправления, которые я сделал, и ждать релиза Java 21.
Обновление на Java 21: вызов первый — Ignite 2.15 не поддерживает новую версию
19 сентября состоялся релиз Java 21, но в этот день, конечно, мы обновляться не стали. Решили дождаться, когда Java 21 выйдет на всех платформах, появятся Docker-образы и релизнится версия Java 21.0.1. За это время мы подготовили свою тестовую систему и инфраструктуру. В общем, потратили еще примерно 2–3 недели на ожидание.
Когда все было готово, я приступил к продуктовому обновлению нашего продукта на Java 21. Ориентировался на чек-лист, который использовал при обновлении на Java 20. Думал, в этот раз никаких проблем не будет. Так что установил Java 21, сделал ее дефолтной, внес изменения в pom-файл, собрал проект, начал его запускать, и… снова »синий экран».
Все дороги снова вели в логи. Нашел виновника — тонкий клиент Ignite версии 2.15. Оказалось, что он пока еще не поддерживает Java 21.
Проблему обещали починить в Ignite 2.16. К этому моменту уже было ишью на GitHub, причем созданное еще в мае 2023 года. Кто-то на предрелизных сборках Java 21 обнаружил проблему и создал ишью, которое было открытым в октябре, и никто ничего с этим не делал.
Так что мы понятия не имели, сколько нам предстоит ждать версию, которая будет поддерживать Java 21. Был выбор: либо откладываем обновление, либо ищем решение. Выбрали второе — отказались от использования Ignite в приложении, потому что никто из наших клиентов его не использовал. К тому же, пока он был в нашей кодовой базе, мы столкнулись с некоторыми багами, которые приходилось чинить и тратить на это время.
Ignite 2.16, кстати, релизнулся в декабре 2023 года.
В итоге я удалил пять строчек из pom-файла и без проблем запустил проект. Пришло время его тестировать. Отправил ветку в тестирующую систему, ожидая, что спустя некоторое время, получу заветную зеленую галочку, но нет, снова »синий экран».
Обновление на Java 21: вызов второй — java.util.SequencedCollection#getFirst завалил GWT
Снова полез в логи и обнаружил вот такую »прелесть» — StackOverflowException из глубин GWT кода.
GWT или Google Web Toolkit, если кто не в курсе, — это фреймворк, который позволяет писать фронтендовую часть приложения на чистой Java. На Java пишем логику, которая должна отрабатывать в браузере, в момент компиляции Java-код трансформирует в JavaScript, и уже он отрабатывает в браузере. А так как весь наш фронт в тот момент был написан как раз на GWT, получить такую ошибку из его глубин было неприятно. Ведь, напомню, на Java 20 все отлично работало.
Начал гуглить, вдруг кто-то уже сталкивался с подобным, все-таки GWT остается достаточно популярным продуктом. И, действительно, почти сразу же нашел ишью на GitHub, где прямо в заголовке озвучена наша проблема.
В Java 21, как вы знаете, появился новый интерфейс SequencedCollection, который принес с собой несколько удобных и полезных методов. Один из них, — getFirst () — и оказался тем самым виновником, что закрашил GWT. Вместе с ишью уже был готов баг-фикс, причем состоящий буквально из нескольких строк.
Увы, мы не могли ждать свежего релиза GWT, где этот баг-фикс будет включен, так как их релизный цикл очень длинный. Новая версия GWT, как правило, выходит раз в год, в январе или феврале. Поэтому мы поступили проще — черипикнули этот коммит в наш уже существующий форк.
После чего я пересобрал GWT, внес очередные изменения в pom-файл и продолжил тестировать.
Обновление на Java 21: вызов третий — PMD зафейлился
В этот раз результат был гораздо лучше. Еще не успех, но уже значительное продвижение к нему. Вместо зеленой галочки от тестовой системы я получил »оранжевую», значит большинство тестов пройдено, но проблемы все еще есть. И этой проблемой оказался PMD — крутейший статический анализатор кода. Главная особенность — возможность его расширения. Вы можете самостоятельно написать кастомные правила, которые будут покрывать особенности вашего кода. Мы активно используем PMD: примерно 20–30 кастомных правил чекают нужные нам особенности.
Итак, я начал разбираться, почему PMD зафейлился. Выяснил, что Java 21 еще не поддерживается самой последней на тот момент версией 6.55. Но, что очень удачно, уже был заявлен релиз-кандидат 7 версии, в котором есть поддержка Java 21.
Однако для запуска PMD мы используем maven-pmd-plugin. И его последняя на тот момент версия 3.21.2, к огромному сожалению, еще не была адаптирована под работу с 7 версией PMD. То есть поддержка Java 21 вроде как есть, потому что появился релиз-кандидат PMD, который обещал, что все будет работать, но запустить его нормально в проекте текущими средствами невозможно.
Тут замаячила неприятная перспектива делать непростой форк этого плагина, затачивать его под наш продукт и под новый PMD, затем долго тестировать и разбираться, что не работает. Но практически ничего из этого мне делать не пришлось. Разработчик из Мюнхена сделал все за меня. Андреас Дэнжел — основной комитер PMD и Maven PMD Plugin — еще летом 2023 года сделал экспериментальную ветку Maven PMD Plugin«а с поддержкой 7 PMD. Так что мне оставалось просто взять эту веточку и адаптировать ее под наши условия.
В результате сам PMD отработал: запустил свои встроенные правила и попробовал проверить наш код. Но абсолютно все наши кастомные правила отказались работать. В конце концов, изучив объемный Migration Guide с 6 на 7 PMD и разобравшись со всеми изменениями в AST, мне удалось справиться и с этой проблемой.
Но, надо сказать, борьба с PMD оказалась самым серьезным вызовом на пути обновления нашей платформы с Java 17 на 21. Когда же, наконец, все тесты были пройдены, наш проект был полностью адаптирован под Java 21.
Обновление на Java 20 и Java 21: итоги
Вот, что мы сделали, обновляя нашу платформу с Java 17 на Java 21:
форкнули Hibernate ради хака с BytecodeProvider;
избавились от legasy со InetAddress Resolution;
переписали работу с потоками;
выкинули Ignite;
обновили несколько библиотек: byte-buddy, spotbugs и др.;
форкнули Maven PMD Plugin, обновили PMD и переписали все наши правила.
Но последнее к настоящему времени снова изменилось. Весной 2024 релизнулась официальная версия 7 PMD и официальный Maven PMD Plugin, который поддерживает работу 7 PMD. Так что в текущей версии мы уже используем нативную реализацию плагина вместо собственного форка.
Ну и пришло время главного вопроса: стоит ли обновляться? Или можно спокойно жить на Java 17, 11 или даже 8 и ничего не делать?
Наш ответ: определенно стоит. И это не только потому, что вместе с каждой версией вы получаете большой пакет уже готовых новых языковых фич, но и из-за непрерывного улучшения перформанса и безопасности языка, которые вы получаете просто обновляясь.
На весеннем Naumen Java Meetup #3 я подробнее рассказал о пути миграции на Java 21.
Если же хотите еще больше фактических доказательств буста перформанса просто обновлением с Java 17 на 21, то рекомендую посмотреть доклад Пера Минборга, который, надеюсь, убедит вас в моей правоте.