Обновление Java с 17 на 21: через тернии к звездам

c5e652b69d391942b9e1dec1cc0faf7c.png

Меня зовут Денис, я тимлид команды 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 кода.

bee4b6a152c1465e4d2a303c011466b1.png

GWT или Google Web Toolkit, если кто не в курсе, — это фреймворк, который позволяет писать фронтендовую часть приложения на чистой Java. На Java пишем логику, которая должна отрабатывать в браузере, в момент компиляции Java-код трансформирует в JavaScript, и уже он отрабатывает в браузере. А так как весь наш фронт в тот момент был написан как раз на GWT, получить такую ошибку из его глубин было неприятно. Ведь, напомню, на Java 20 все отлично работало. 

Начал гуглить, вдруг кто-то уже сталкивался с подобным, все-таки GWT остается достаточно популярным продуктом. И, действительно, почти сразу же нашел ишью на GitHub, где прямо в заголовке озвучена наша проблема. 

4c4db9c0b212edc25acdfa6eb3bc730d.png

В Java 21, как вы знаете, появился новый интерфейс SequencedCollection, который принес с собой несколько удобных и полезных методов. Один из них, — getFirst () — и оказался тем самым виновником, что закрашил GWT. Вместе с ишью уже был готов баг-фикс, причем состоящий буквально из нескольких строк.

f3fca7dd995e0e9560776d76c87115b8.png

Увы, мы не могли ждать свежего релиза 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, то рекомендую посмотреть доклад Пера Минборга, который, надеюсь, убедит вас в моей правоте.

© Habrahabr.ru