Проблемы при переходе на Java 9

В конце мая я выступал на JEEConf 2017, где рассказывал об эффективности и производительности Java библиотек и фреймворков. Любое упоминание о производительности должно ссылаться на результаты измерений, что я и сделал во время доклада. Но я решил пойти дальше и протестировать один и тот же код на двух версиях — Java 8 и Java 9.

Хоть Java 9 и выходит только в июле (уже в сентябре), но она уже перешла в стадию Release Candidate, то есть никаких новых фич больше не будет и все критические баги должны быть исправлены. Я начал пользоваться Java 8 за 5 месяцев до релиза и никаких особых проблем при этом не испытал. В то же время главная фича новой версии — модуляризация (проект Jigsaw) привела к многочисленным изменениям внутри самой JDK. Хорошо было бы протестировать библиотеки на новой версии. В сети есть много статей и видео-докладов на тему проектирования модулей. Но сначала нужно проверить, а будет ли работать существующее приложение, и если будет, то как отразится миграция на скорости работы нашей системы. Не секрет, что многие компании остаются на старых версиях (Java 5 — 7) частично из-за боязни новых ошибок и проблем в производительности. Если же приложение работать не будет, насколько просто это починить?

Поэтому я призываю всех установить JDK 9 и протестировать свое приложение. Разумеется, вы можете и дальше использовать текущую версию Java, но прогресс не стоит на месте. Через пару лет выйдет Java 10, а с ней возможно и свойства, и value types, и все равно придется использовать модульность. Тем более что современные IDE (IntelliJ IDEA) уже поддерживают все фичи Java 9.

Системы сборки. Maven

Сборка проекта — это процесс, который проходит постоянно в течение рабочего дня как на компьютере разработчика, так и на CI сервере. Поэтому здесь требуется максимальное быстродействие. Для тестирования сборки я выбрал проект, который я разрабатываю в рамках книги «Разработка Java приложений», тем более что он поддерживает и Maven, и Gradle. Проект постоянно обновляется, поэтому версии зависимостей в нем 2017-го года либо конца 2016-го года. Вы сами можете скачать проект и протестировать на своей машине.

Поддерживают ли Maven/Gradle Java 9? Это интересный вопрос, тем более что их модель зависимостей чем-то похожа и даже является конкурентом JDK 9.

Итак, я установил JDK Early Access preview build 171 на Windows систему. Результат запуска команды «java -version»:

java version "9-ea"
Java (TM) SE Runtime Environment (build 9-ea+171)
Java HotSpot (TM) 64-Bit Server VM (build 9-ea+171, mixed mode)

Начнем с Maven. Для тестирования я выбрал последнюю версию — 3.5.0. Сам Maven запускается без проблем, выдает корректную версию JDK:

Apache Maven 3.5.0 (ff8f5e7444045639af65f6095c62210b5713f426; 2017-04-03T22:39:06+03:00)
Maven home: C:\Maven\bin\..
Java version: 9-ea, vendor: Oracle Corporation
Java home: C:\Program Files\Java\jdk-9

Теперь запустим сборку проекта: mvn clean install. Сборка падает на этапе запуска тестов с ошибкой:

Caused by: java.lang.ClassNotFoundException: javax.xml.bind.JAXBException
	at ja-va.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:553)
	at ja-va.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:185)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:486)
	... 33 more

Почему-то стандартный класс JAXBException больше не находится. Дело в том, что все, связанное с JAXB, перенесено 9-й Java в модуль java.xml.bind, который автоматически не добавляется в modulepath при запуске JVM, потому что не входит в дефолтный модуль java.se. Как выйти из этого положения? Либо явно указать этот модуль для запуска JVM, либо агрегатный модуль java.se.ee, куда входит все, что связано с Java EE API. Разумеется, добавить агрегатный модуль удобнее, чем каждый модуль по отдельности. Поэтому добавляем переменную окружения MAVEN_OPTS (если у вас ее еще нет), и присваиваем ей значение «—add-modules java.se.ee».

Однако ошибка не исчезает. Все дело в том, что тесты запускает Maven Surfire плагин, который стартует новый JVM процесс. Для того, чтобы указать новые модули, нужно добавить Surfire плагин в главный pom.xml и передать параметры там:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.20</version>
    <configuration>
        <argLine>--add-modules java.se.ee</argLine>
    </configuration>
</plugin>

Перезапускаем сборку и получаем новое исключение:

java.lang.NoClassDefFoundError: javax/transaction/SystemException
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:374)
	at org.jboss.logging.Logger$1.run(Logger.java:2554)
	at java.base/java.security.AccessController.doPrivileged(Native Method)
	at org.jboss.logging.Logger.getMessageLogger(Logger.java:2529)

Эта проблема не имеет очевидного решения, потому что JTA библиотека (где находится класс SystemException) присутствует в class-path. Поэтому придется все же в Surfire плагине использовать модуль java.xml.bind:

<configuration>
        <argLine>--add-modules java.xml.bind</argLine>
</configuration>

Перезапускаем сборку Maven и получаем новое исключение:

Caused by: java.lang.reflect.InvocationTargetException
	at org.itsimulator.germes.app.persistence.repository.hibernate.HibernateCityRepositoryTest.setup(HibernateCityRepositoryTest.java:18)
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final ja-va.lang.Class ja-va.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to un-named module @5807efad

В чем заключается ошибка? Hibernate пытается через reflection сделать публичным метод, который является protected или private. До Java 9 это было небезопасно, но возможно. Сей-час это запрещено, если только модуль, в котором находится метод, не объявлен открытым (open). Однако есть лазейка, помогает обойти это ограничение. Нужно добавить к аргументам JVM строку —add-opens java.base/java.lang=ALL-UNNAMED

Здесь мы указываем название модуля, затем пакет и тот модуль, для которого мы открываем доступ. Так как наш проект не использует модули, то мы указываем константу ALL-UNNAMED, которой открыт доступ для всех анонимных модулей. Анонимные модули — это обычные jar файлы, в которых нет файла конфигурации module-info.class. Они были добавлены, чтобы разработчикам библиотек было легче перейти на Java 9.

Добавляем эту строку к аргументам командной строки для плагина Surfire и перезапускаем сборку Maven. Получаем новое исключение, но уже при запуске других тестов:

Caused by: java.io.IOException: Can not attach to current VM
        at jdk.attach/sun.tools.attach.HotSpotVirtualMachine.<init>(HotSpotVirtualMachine.java:75)
        at jdk.attach/sun.tools.attach.VirtualMachineImpl.<init>(VirtualMachineImpl.java:48)
        at jdk.attach/sun.tools.attach.AttachProviderImpl.attachVirtualMachine(AttachProviderImpl.java:69)
        at jdk.attach/com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:207)
        at mockit.internal.startup.AgentLoader.attachToRunningVM(AgentLoader.java:146)
        ... 20 more

Как вы видите из stacktrace, проблема происходит на стадии инициализации JMockit. Что можно сделать? Можно обновить версию Jmockit с 1.30 на 1.32. Но это не помогает. Уже готовится 2-я версия JMockit, но она все еще в стадии beta. Анализ исходников JDK позволяет обнаружить системную переменную JVM, которая позволяет присоединиться к текущей JVM из нее самое. Нужно лишь установить этот флаг в true: -Djdk.attach.allowAttachSelf=true. Подробнее можно прочесть здесь

Добавляем флаг к параметрам в Surefire плагине и перезапускаем сборку Maven. Получаем новую ошибку:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-war-plugin:2.6:war (de-fault-war) on project germes-presentation: Execution default-war of goal org.apache.maven.plugins:maven-war-plugin:2.6:war f12:37
: Unable to load the mojo 'war' in the plugin 'org.apache.maven.plugins:maven-war-plugin:2.6' due to an API incompatibility: org.codehaus.plexus.component.repository.exception.ComponentLookupException: null

Подробнее об этой проблеме можно прочесть здесь.

А лечится она обновлением версии war-плагина с 2.6 на 3.1.0:

<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-war-plugin</artifactId>
				<version>3.1.0</version>
				<configuration>
					<failOnMissingWebXml>false</failOnMissingWebXml>
				</configuration>
			</plugin>

Меняем версию и перезапускаем сборку. Получаем новую ошибку:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.6.1:compile (default-compile) on project germes-admin: Fatal error compiling: ja-va.lang.ExceptionInInitializerError: Unable to make field private com.sun.tools.javac.processing.JavacProcessingEnvironment$DiscoveredProcessors com.sun.tools.javac.processing.JavacProcessingEnvironment.discoveredProcs accessible: module jdk.compiler does not "opens com.sun.tools.javac.processing" to unnamed module @79296ce0

С похожей проблемой мы уже сталкивались. Нужно лишь «открыть» доступ к пакетам, добавив параметры в MAVEN_OPTS:

--add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED

Перезапускаем сборку Maven и получаем новую ошибку:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.6.1:compile (default-compile) on project germes-admin: Fatal error compiling: ja-va.lang.NoClassDefFoundError: com/sun/tools/javac/file/BaseFileObject: com.sun.tools.javac.file.BaseFileObject

Stacktrace говорит об ошибке более подробно:

Caused by: java.lang.ClassNotFoundException: com.sun.tools.javac.file.BaseFileObject
        at lombok.launch.ShadowClassLoader.loadClass(ShadowClassLoader.java:422)

Проблема в том, что такого класса больше нет в JDK. Можно вручную добавить необходимые классы, как сделали здесь.

А можно обновить версию Lombok (c 1.16.14 до 1.16.16):

<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.16.16</version>
		</dependency>

Перезапускаем сборку Maven и получаем новое исключение при запуске тестов:

java.lang.ClassCastException: java.base/jdk.internal.loader.ClassLoaders$AppClassLoader cannot be cast to java.base/java.net.URLClassLoader

После небольшого исследования выяснилось, что проблема исходит от библиотеки тестирования JGlue, однако его последняя версия была выпущена в июле 2016 года и неизвестно, насколько быстро выйдет новая версия.

На этом мое тестирование Maven завершилось. Часть проблем удалось решить, но в целом, их довольно много как для одного проекта. Проект собирается успешно, но часть тестов при этом не проходит.

Системы сборки. Gradle

Теперь проверим, насколько совместим Gradle с JDK 9. Возьмем последнюю версию первого — 3.5.0, обновим Lombok зависимость и запустим сборку: gradle clean install.

Получим довольно малоинформативную ошибку: java.lang.ExceptionInInitializerError (no error message). Поэтому добавим флаг —stacktrace, который даст больше информации:

Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected ja-va.lang.Package[] java.lang.ClassLoader.getPackages() accessible: module java.base does not "opens java.lang" to unnamed module @57c03d88
        at ja-va.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:337)
        at ja-va.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:281)
        at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:197)
        at java.base/java.lang.reflect.Method.setAccessible(Method.java:191)
        at org.gradle.internal.reflect.JavaMethod.<init>(JavaMethod.java:42)
        at org.gradle.internal.reflect.JavaMethod.<init>(JavaMethod.java:32)

Для того, чтобы избавиться от этой ошибки, добавляем знакомый аргумент add-opens в системную переменную GRADLE_OPTS:

--add-opens java.base/java.lang=ALL-UNNAMED

Подробнее можно прочитать здесь и здесь.

Перезапускаем сборку Gradle, получаем новую ошибку:

* Exception is:
org.gradle.api.GradleException: Unable to start the daemon process.
This problem might be caused by incorrect configuration of the daemon.
        at org.gradle.launcher.daemon.bootstrap.DaemonGreeter.parseDaemonOutput(DaemonGreeter.java:34)
        at org.gradle.launcher.daemon.client.DefaultDaemonStarter.startProcess(DefaultDaemonStarter.java:152)
        at org.gradle.launcher.daemon.client.DefaultDaemonStarter.startDaemon(DefaultDaemonStarter.java:135)
        at org.gradle.launcher.daemon.client.DefaultDaemonConnector.startDaemon(DefaultDaemonConnector.java:208)
        at org.gradle.launcher.daemon.client.DefaultDaemonConnector.connect(DefaultDaemonConnector.java:130)
        at org.gradle.launcher.daemon.client.DaemonClient.execute(DaemonClient.java:127)

В результат длительного исследования оказывается, что ситуация не так проста, как для Maven. Дело в том, что во время сборки в Gradle запускается несколько JVM процессов, одни процессы могут порождать другие. Можно сделать fork процессов и главное, чтобы все они использовали одни и те же аргументы JVM. С помощью GRADLE_OPTS добиться этого невозможно. Поэтому была придумана новая системная переменная JDK_JAVA_OPTIONS, которая используется всеми JVM процессами в Gradle (подробней о ней здесь). Более того, начиная со сборки b157 появилась системная переменная JDK_JAVAC_OPTIONS для передачи параметров в Java компилятор (javac). Установим JDK_JAVA_OPTIONS в следующее значение:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED

И перезапустим сборку. У нас выпало исключение:

> java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor (in unnamed module @0x2d8aa614) cannot access class com.sun.tools.javac.util.Context (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.util to unnamed module @0x2d8aa614

Такой ошибки у нас еще не было. Суть ее в том, что мы хотим получить доступ к классам из JDK, которые находятся в пакетах, не указанных модулем как экспортируемых (exported) в module-info.java. Это можно обойти, если добавить в JAVA_JDK_OPTIONS «—add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED»

Теперь сборка прошла успешно. Запустим тесты: gradle test. Тесты посыпались с теми же ошибками, что и в Maven. Это можно исправить, просто указав аргументы JVM в блоке test:

   test {
     jvmArgs '--add-modules', 'java.xml.bind', '-Djdk.attach.allowAttachSelf=true'
  }

Мы все еще используем source/target compatibility 1.8 в нашем проекте. Если мы поменяем их на 1.9, то при компиляции получим ошибку:

error: package javax.annotation is not visible
import javax.annotation.PreDestroy;
            ^
  (package javax.annotation is declared in module java.xml.ws.annotation, which is not in the module graph)
1 error

Все потому что мы перешли на 1.9, а значит должны оформлять наши проекты в модули, писать module-info.java и в них указать, чтобы нам нужен (requires) тот или иной модуль из JDK. Ситуация становится интересней для Maven/Gradle зависимостей. Здесь можно прочесть о том, как конфигурировать модули, если они содержат одинаковые пакеты.

Однако пока Gradle еще не полностью готов к Java 9, вы можете запускать его под Java 8, а компилировать проект под Java 9. Для этого нужно в скрипте сборки указать конфигурацию под 9-ю версию:

compileJava {
    sourceCompatibility = '9'
    targetCompatibility = '9'
    options.with {
        fork = true
        forkOptions.javaHome = new File ('c:\Program Files\Java\jdk-9')
        compilerArgs.addAll(['--add-exports', 'jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED'])
    }
}

Тестирование REST-контейнеров

Так как я в своем докладе рассказывал про REST-контейнеры и веб-сервера, то интересно будет их проверить на совместимость с Java 9, тем более что это достаточно сложные технологии.

Начнем с Ratpack. Это довольно новый сервер, полностью написанный на Java 8 и основанный на Netty. Если мы запустим его, то получим исключение:

Exception in thread "main" java.lang.NoClassDefFoundError: ja-vax/activation/MimetypesFileTypeMap
	at rat-pack.file.internal.ActivationBackedMimeTypes.<clinit>(ActivationBackedMimeTypes.java:34)
	at ratpack.server.internal.ServerRegistry.buildBaseRegistry(ServerRegistry.java:99)
	at ratpack.server.internal.ServerRegistry.serverRegistry(ServerRegistry.java:63)

Нам нужно лишь добавить модуль java.activation при старте сервера, чтобы это починить:

--add-modules java.activation

Теперь перейдем к Apache CXF. Для него нужно указать другой модуль:

--add-modules java.xml.bind

Для запуска приложения с Jersey (JAX-RS) я использую Jetty сервер. Jersey тоже требует предыдущий модуль.

Я думаю, что вы знаете либо слышали, что летом этого года выходят Spring Framework 5 и Spring Boot 2.0, специально приуроченные к выходу JDK 9 и даже использующие некоторые ее возможности. Пока они не вышли, интересно проверить текущие версии (Spring Boot 1.5.3 и Spring Framework 4.3.8) на «запускаемость».

Spring Boot поддерживает три встроенных сервера: Tomcat, Jetty и Undertow. К сожалению, у меня не получилось запустить REST-приложение ни на одном из них. Оно просто зависало на стадии загрузки.

JMeter — одна из наиболее популярных утилит для тестирования веб-приложений. Она может работать как в консольном варианте, так и в режиме с UI. Готово ли оно к Java 9? Если мы возьмем последнюю версию (3.2) и запустим jmeter.bat, то увидим в консоли ошибку:

Not able to find Java executable or version. Please check your Java installation.
errorlevel=2

Это достаточно странное сообщение, которое судя по всему печатает не JVM. Если мы заглянем в исполняемый файл, то увидим целые блоки кода, где сначала выполняется команда «java-version», потом ее результат парсится и из него выделяется старшая и младшая версии. Причем JMeter предполагает, что результат будет в формате 1.8.0.

Так как в Java 9 результат уже в формате 9.0.0 (openjdk.java.net/jeps/223), то это приводит к катастрофическим последствиям и аварийному завершению приложения. Причем JMeter даже не пишет, что версия неправильная, а просто говорит, что не может найти Java.

Выход заключается в том, чтобы удалить все эти проверки и тогда утилита запустится корректно. Однако при загрузке конфигурации мы получим исключение:

Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field protected ja-va.lang.reflect.InvocationHandler java.lang.reflect.Proxy.h accessible: module java.base does not "opens java.lang.reflect" to unnamed module @63fbfaeb
	at java.lang.reflect.AccessibleObject.checkCanSetAccessible(Unknown Source) ~[?:?]
	at java.lang.reflect.AccessibleObject.checkCanSetAccessible(Unknown Source) ~[?:?]

Для того, чтобы избежать подобных ошибок, нужно лишь добавить в JVM аргументы в скрипте строку:

--add-opens java.base/java.util=ALL-UNNAMED --add-opens ja-va.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.desktop/java.awt.font=ALL-UNNAMED

Заключение и спасительный флаг

Как вы видите, в последний год вендорами была проделана большая работа по обеспечению совместимости с Java 9. Это тем более было трудно, что даже в этом году были некоторые изменения в API JDK. Тем не менее многие библиотеки уже успешно работают над Java 9, некоторые без каких-либо ухищрений, некоторые с дополнительными аргументами для JVM.

Тем не менее, выяснять и разбираться в настройках не так приятно, поэтому в конце марта 2017 года руководство Oracle решило упростить жизнь разработчикам и добавить новый спасительный флаг, который упростит миграцию на Java 9, но будет удален в Java 10.

Этот флаг —permit-illegal-access разрешает доступ всем анонимным модулям через reflection к коду именованных модулей и позволяет не писать многочисленные —add-opens. Единственный минус этого флага — он будет выводить предупреждения в консоль обо всех случаях несанкционированного доступа.

Однако оказалось, что, хотя этот флаг и избавляет от необходимости указывать многочисленные —add-opens, он не избавляет от необходимости указывать самого себя. Поэтому начиная со сборки b172 его предполагается заменить на флаг —illegal-access. У этого флага будет четыре значения:

  • permit. Это значение по умолчанию, которое открывает доступ через reflection из всех анонимных модулей во все пакеты именованных модулей. При этом значении только один раз печатается предупреждение о попытке доступа.
  • warn. Каждая попытка получить доступ через reflection приводит к выводу предупреждения.
  • debug. Здесь к выводу предупреждения выводится еще и stack-trace операции.
  • deny. Планируется сделать дефолтным в Java 10. Запрещает доступ через reflection для всех пакетов в именованных модулях, кроме указанных явно в —add-opens.

Подробнее можно прочесть здесь. Интересно, что на момент сборки b173 этот флаг все еще не добавлен.

Я надеюсь, что вам было полезно это руководство и миграция ваших приложений будет более простой и быстрой.

Похожие статьи:
Вслед за американским рынком устройство Samsung Gear VR поступило в продажу на европейском рынке. И также как в США этот гаджет является самым...
Всім привіт! Редакція DOU запускає програму лояльності для авторів технічних статей та блогів на 2022 рік. Щомісяця ми оголошуватимемо...
В статье не будет информации о методиках и способах написания требований. Я хочу сделать акцент на правилах, без соблюдения...
Весельная галера под ритмичные удары барабанов медленно двигалась сквозь утренний туман фьорда. Периодически то один,...
Освітня платформа Codefinity відкрила безплатний доступ до понад 70 курсів із програмування, 20 з яких доступні українською...
Яндекс.Метрика