Проблемы при переходе на 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 этот флаг все еще не добавлен.

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

Похожие статьи:
Всем привет! Меня зовут Алексей Савченко, я iOS инженер в компании Genesis. Недавно я столкнулся с ситуацией, когда некоторая функция...
Время: сб. вс. 13.00-18.00 Brain Academy — лидер в отрасли комплексной подготовки IT-специалистов, открыла набор на сертифицированный Microsoft...
The gig economy has been celebrated in many ways, claiming to offer freedom to workers everywhere. However, there is a darker side to the gig economy which mustn’t be ignored. As it has grown, various stories have been upheld as...
Время от времени я читаю лекции технической тематики. В этой статье хочу рассказать, зачем я это делаю и зачем это может...
20 января пройдёт первая в новом году встреча обновлённого Kharkiv Mobile Devs. «Engineers To Engineers» или сокращённо E2E, именно так можно...
Яндекс.Метрика