Проблемы при переходе на 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. Проект постоянно обновляется, поэтому версии зависимостей в нем
Поддерживают ли 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 java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:553) at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:185) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:486) ... 33 more
Почему-то стандартный класс JAXBException больше не находится. Дело в том, что все, связанное с JAXB, перенесено
Однако ошибка не исчезает. Все дело в том, что тесты запускает 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 java.lang.Class java.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. Но это не помогает. Уже готовится
Добавляем флаг к параметрам в 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: java.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: java.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 java.lang.Package[] java.lang.ClassLoader.getPackages() accessible: module java.base does not "opens java.lang" to unnamed module @57c03d88 at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:337) at java.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. Для этого нужно в скрипте сборки указать конфигурацию под
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: javax/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 java.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 java.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. Здесь к выводу предупреждения выводится еще и stacktrace операции.
- deny. Планируется сделать дефолтным в Java 10. Запрещает доступ через reflection для всех пакетов в именованных модулях, кроме указанных явно в —add-opens.
Подробнее можно прочесть здесь. Интересно, что на момент сборки b173 этот флаг все еще не добавлен.
Я надеюсь, что вам было полезно это руководство и миграция ваших приложений будет более простой и быстрой.