Мы начинаем цикл статей, в котором рассказываем, с чем придётся столкнуться разработчику при переходе между LTS-версиями Java. В этой статье рассмотрим основные изменения, которые ждут программиста, если он решит перейти с Java 8 на Java 11.

Когда выходила Java 25, мы выпустили об этом статью. Её автор рассмотрел основные изменения и порассуждал об их удобстве и крутости для разработчика. После публикации один из читателей написал нам и выразил желание увидеть материал о том, с чем сталкивается разработчик при переходе с одной LTS-версии Java на следующую, начиная с Java 8.
Мы подумали и решили: "А почему бы и нет?!". Всё же, читая различные блоги, приходится сталкиваться с комментариями, в которых разработчики рассказывают о том, что до сих пор сидят на Java 8. И для них такая статья будет пищей для размышления об осуществлении перехода. Ну, а для других — просто приятной ретроспективой.
И начинаем мы этот цикл со сравнения Java 8 со следующей LTS-версией — Java 11.
LTS (Long Term Supported) — это модель выпуска программного обеспечения, где определённые стабильные версии получают расширенную поддержку (обновления безопасности, исправления ошибок, техническую поддержку) в течение более длительного периода, чем стандартные релизы.
Но сначала считаю нужным задать немного фактуры Java 8. Она принесла с собой достаточно передовые изменения (раз находятся те, кто спустя 11 лет до сих пор на ней сидит), и я хочу напомнить вам про самые главные из них.
Лично мне первыми на ум приходят Stream API, лямбды и ссылки на методы/конструкторы, которые превращают вот такие конструкции:
List<User> activeUsers = new ArrayList<>();
for (User user : users) {
if (user.isActive()) {
activeUsers.add(user);
}
}
activeUsers.sort(new Comparator<User>() {
@Override
public int compare(User u1, User u2) {
return u1.getCreatedAt().compareTo(u2.getCreatedAt());
}
});
List<UserDto> result = new ArrayList<>();
for (User user : activeUsers) {
result.add(UserMapper.toDto(user));
}
В более лаконичные:
List<UserDto> result = users.stream()
.filter(User::isActive)
.sorted(Comparator.comparing(User::getCreatedAt))
.map(UserMapper::toDto)
.collect(Collectors.toList());
Не стоит забывать и множество функциональных интерфейсов, благодаря которым появление вышеупомянутых конструкций стало возможным. Среди них:
И именно в Java 8 появились реализации default методов в интерфейсах.
Первые шаги Java в сторону функционально-парадигменных конструкций действительно произвели фурор в 2014 году. Но время не стоит на месте, и спустя 4 года мир увидел следующую LTS-версию — Java 11.
Вероятно, между 8 и 11 версией языка тоже произошло немало важных изменений? Давайте посмотрим.
Сразу хочу предупредить, статья — не полный сборник изменений, а лишь агрегация тех моментов, на которые Java-программисту нужно обратить внимание в первую очередь. Некоторые из них могут встать поперёк горла, поскольку в особых случаях из-за них ваш проект, написанный на Java 8, может просто так не запуститься в окружении 11-ой. Другие же помогают сделать код более лаконичным за счёт новых возможностей и конструкций языка. Итак, приступим.
На всякий случай напомню, что Java — строго типизированный язык, то есть все типы должны быть известны на этапе компиляции. И до Java 10 каждый раз при инициализации переменной необходимо было полностью указывать её тип.
Но ведь компилятор может определить тип этой переменной при инициализации по выражению, а нам всё равно приходится писать её тип, даже если он длинный и неудобный. Так почему бы просто не оставить лишнее представление за кадром, использовав специальное ключевое слово?
JEP 286 предоставил нам такую возможность. Теперь вместо какого-нибудь абстрактного StateDatabaseHelperContainerMapMessage мы можем использовать лаконичный var:
var stateDbHelper = new StateDatabaseHelperContainerMapMessage();
P.S. За enterprise название спасибо ему.
Отличная практика, которую определённо стоит взять в оборот. Но стоит помнить о том, что по инициализирующему выражению должно быть понятно, с каким объектом мы имеем дело. Ниже будут вредные советы, так пользоваться var не стоит:
var x = foo();
var data = get();
Если по имени переменной и инициализирующему выражению тип однозначно непонятен —лучше использовать явную типизацию!
До 9-ой версии Java-проект, будь то приложение или библиотека, представлял собой лишь набор классов, загружаемых через classpath — просто список необходимых программе классов, JAR-файлов и директорий с ними. В рамках такого архитектурного подхода мы сталкивались со следующими проблемами:
Система модулей в Java (JPMS, Java Platform Module System), представленная в JEP 261, дала возможность представить ваше Java-приложение или библиотеку не как набор классов, а как набор модулей, которые:
Здесь я приведу краткий пример того, как этим можно пользоваться. Предположим, у нас есть следующая структура библиотеки:
src/
└─ com.example.lib/
├─ com/example/lib/api/LibPublicApi.java
└─ com/example/lib/internal/InternalClass.java
Мы хотим, чтобы пользователи библиотеки взаимодействовали с ней только через классы из пакета api, а классы из пакета internal, несмотря на то, что они объявлены как public, были недоступны извне. Этого можно добиться, определив библиотеку как отдельный Java-модуль.
Для этого нужно в корне пакета создать файл module-info.java и настроить соответствующим образом:
module com.example.lib {
exports com.example.lib.api;
}
Наличие файла module-info.java и конструкции module com.example.lib { .... } сообщают о том, что этот пакет и его подпакеты являются Java-модулем. А конструкция exports открывает пакет com.example.lib.api для всех, кто этот модуль использует. Это что касается инкапсуляции. Остальные пакеты, явно не экспортированные, будут недоступны вне этого модуля.
Если же наш модуль нуждается в другом модуле, в конфигурации к нашему необходимо явно об этом сообщить, добавив строку:
requires com.example.lib
Тогда, если мы будем запускать наше приложение, а нужный модуль не будет обнаружен JVM — приложение/библиотека упадёт на старте, а не во время выполнения.
К слову, для стандартной библиотеки Java модульная система оказалась особенно полезной. Разделив JDK на модули, разработчики платформы смогли чётко зафиксировать, какие части являются публичным API, а какие — внутренней реализацией. Это позволило постепенно ограничить доступ к внутренним API и предоставить официальные замены, не опасаясь, что изменения внутренней реализации сломают пользовательский код. Частичная хронология всех этих действий отражена в:
Ещё одним важным следствием появления системы модулей стал инструмент jlink, предназначенный для создания кастомизируемых runtime-образов Java. Поскольку стандартная библиотека Java была разбита на модули, стало возможным формировать минимальное окружение, включающее только те части платформы, которые действительно требуются конкретному приложению.
jlink анализирует зависимости модулей приложения и собирает самодостаточный runtime, содержащий лишь необходимые модули стандартной библиотеки. Это позволяет значительно уменьшить размер дистрибутива, ускорить запуск приложения.
Предположим, у нас есть модульное приложение com.example.app. Свой runtime для него можно собрать одной командой:
jlink \
--module-path $JAVA_HOME/jmods:mods \
--add-modules com.example.app \
--output app-runtime
В результате будет создан каталог app-runtime, содержащий минимальный runtime-образ Java, включающий только модули, которые нужны нашему com.example.app. Запуск осуществляется с помощью JVM из этого каталога, без использования установленной в системе Java. Так Java-приложение может поставляться вместе с собственным runtime.
Подробнее с системой модулей в Java можно ознакомиться здесь.
В Java 8 сборщик мусора Parallel GC по умолчанию был ориентирован на максимальную пропускную способность, которая обеспечивала редкие, но длительные Stop-The-World паузы.
С развитием экосистемы Java и переходом к Java 11 требования к приложениям заметно изменились. Выросли объёмы используемой кучи JVM, распространились микросервисная архитектура и контейнеризация. В этих условиях высокая пропускная способность GC перестала быть единственным приоритетом: даже редкие секундные паузы начали восприниматься как недопустимые для онлайн-сервисов несмотря на то, что суммарное время, проведённое в GC, оставалось небольшим.
Именно поэтому, начиная с Java 9, G1 стал сборщиком мусора по умолчанию. В отличие от Parallel GC, G1 сознательно жертвует частью пропускной способности ради более коротких и предсказуемых пауз. Он выполняет сборку мусора чаще и стремится удерживать длительность остановок в заданных пределах. В результате общее время GC может увеличиться, но влияние на время отклика приложения становится более стабильным и управляемым.
Так что если вы при запуске вашего приложения намеренно выставляли JVM флаг -XX:+UseG1GC, после ухода с Java 8 вам этого делать не придётся.
Создавать неизменяемые коллекции с константными значениями приходится довольно часто. До Java 9 для этого действия не было удобного API. Приходилось городить что-то такое:
Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c");
set = Collections.unmodifiableSet(set);
Или:
Set<String> set =
Collections.unmodifiableSet(Stream.of("a", "b", "c").collect(toSet()));
Начиная с Java 9 и по сей день у вас есть возможность определять неизменяемые коллекции таким образом:
Set<String> set = Set.of("a", "b", "c");
Вот это уже совсем другое дело!
Важно помнить, что эти методы не поддерживают в качестве аргументов null и дубликаты. Иначе столкнётесь с NullPointerException и IllegalArgumentException соответственно.
Такие методы представлены для всех коллекций и ассоциативных массивов (то есть для Map). При переходе с Java 8 обязательно не забудьте про это!
До Java 9 внутренним представлением строки являлся массив char, поскольку строки в Java представлены в кодировке UTF-16, где под каждый символ требуется два байта. Однако в JEP 254 говорится, что:
А каждый символ латинского алфавита можно уместить в один байт. Поэтому ради экономии памяти внутренне представление строк изменили с char[] на byte[] и добавили флаг, который сообщает о том, в какой кодировке строка представлена:
И раз мы заговорили про строки, не могу не рассказать о новых методах в классе String:
repeat — создаёт новую строку, повторяя исходную заданное количество раз;strip — удаляет пробельные символы в начале и конце строки;stripLeading — удаляет пробельные символы только в начале строки;stringTralling — удаляет пробельные символы на конце строки;isBlank — проверяет, содержит ли строка что-то кроме пробельных символов;lines — разделяет одну строку на несколько по символам-разделителям.При переходе на Java 11 не забудьте об этом. В собственном проекте теперь не придётся городить все эти методы или же тащить ради них какую-нибудь стороннюю зависимость.
Начиная с JDK 11, из стандартного дистрибутива Java были удалены некоторые крупные модули. В частности JavaFX был исключён из состава JDK и переведён в отдельный проект OpenJFX, который теперь распространяется и развивается независимо.
Также в рамках JEP 320 из JDK были удалены модули Java EE и CORBA, поскольку они устарели и фактически не развивались. Это решение позволило упростить платформу Java и сосредоточить её развитие на базовых возможностях, передав Enterprise и UI-решения во внешние экосистемы.
Если в вашем проекте был один из вышеперечисленных модулей, теперь его нужно подключить отдельно.
При разработке собственного API очень важно информировать пользователей, когда жизненный цикл определённой его части подходит к концу. Если его методы устаревают, на них больше не стоит завязываться, а уже существующие использования перевести на альтернативные, более подходящие. И для этого в Java есть аннотация Deprecated.
До Java 9 эта аннотация не несла вместе с собой какой-либо дополнительной информации. Это порождало некоторую неопределённость в вопросе того, что она значит в конкретной ситуации. Сейчас же, чтобы разработчик имел больше информации об устаревшем API, в Deprecated добавили два параметра:
since — строковый параметр, показывающий с какой версии размеченное API признано устаревшим;forRemoval — boolean параметр, который является маркером для разработчика, будет API удалено в следующих версиях или нет.Благодаря этим уточняющим параметрам есть возможность сделать API более понятным для тех, кто им пользуется.
До Java 11 работа с Http-запросами осуществлялась через HttpUrlConnection. Отправка GET-запроса и вывод результата ответа выглядели следующим образом:
URL url = new URL("https://api.example.com/data");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.setRequestProperty("Accept", "application/json");
int status = connection.getResponseCode();
InputStream inputStream;
if (status >= 200 && status < 300) {
inputStream = connection.getInputStream();
} else {
inputStream = connection.getErrorStream();
}
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder responseBody = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
responseBody.append(line);
}
reader.close();
connection.disconnect();
System.out.println(responseBody);
К моменту выпуска Java 11 в JEP 321 появился HttpClient, выставив в противовес HttpUrlConnection следующие преимущества:
Вот так выглядит простой пример запроса через HttpClient:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
И, конечно же, sendAsync, позволяющий формировать цепочку асинхронных действий:
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println)
.exceptionally(ex -> {
ex.printStackTrace();
return null;
});
Очень важное изменение, о котором стоит помнить, чтобы реализовывать новые фичи, не завязываясь на не самом актуальном HttpUrlConnection.
Небольшие изменения, связанные с чтением и записью файлов. В Java 11, чтобы прочитать содержимое файла или записать в него что-то, достаточно воспользоваться методами Files.readString и Files.writeString соответственно:
var fileContent = Files.readString(Path.of("file.txt"));
var content = "Hello, File!";
Files.writeString(Path.of("file.txt"), content);
До этого самым лаконичным вариантом был:
String text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
К слову, определение пути через Path.of теперь считается более предпочтительным вариантом. Paths.get, который до этого использовался, с Java 11 считается устаревшим.
Данное изменение будет полезно тем, кому в приложении необходимо с помощью механизмов Java десериализовывать данные, поступающие извне.
До Java 9, чтобы контролировать, какие классы поступают на десериализацию, приходилось писать собственные классы. К примеру, такие:
public class ObjectInputStreamWithClassCheck extends ObjectInputStream {
private final static List<String> ALLOWED_CLASSES = Arrays.asList(
User.class.getName()
);
public ObjectInputStreamWithClassCheck(InputStream in) throws .... {
super(in);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws .... {
if (!ALLOWED_CLASSES.contains(desc.getName())) {
throw new NotSerializableException(
"Class is not available for deserialization"
);
}
return super.resolveClass(desc);
}
}
И десериализовывать через них:
var ois = new ObjectInputStreamWithClassCheck(externalData);
Object obj = ois.readObject();
Сейчас же, благодаря JEP 290, такая возможность появилась на уровне стандартной библиотеки. Для того, чтобы указать, какие объекты доступны для десериализации, достаточно воспользоваться фильтром ObjectInputFilter:
ObjectInputFilter myFilter =
ObjectInputFilter.Config.createFilter("java.util.Date;!*");
ObjectInputStream ois = new ObjectInputStream(externalData);
ois.setObjectInputFilter(myFilter);
Object obj = ois.readObject();
Фильтры настраиваются через специальные строки-выражения. В примере выше мы разрешили десериализовывать только объект класса java.util.Date. Если что-то кроме него поступит на десериализацию, мы столкнёмся с исключением InvalidClassException.
К слову, мы выпускали статью о том, к чему может привести небезопасная десериализация. Рекомендую к прочтению.
Статья подходит к концу. Безусловно, всё охватить не получилось, но мы постарались осветить то, с чем разработчик может столкнуться в первую очередь. Украдкой хотелось бы всё же упомянуть про появление JShell и возможность командой java сразу скомпилировать и запустить одиночный файл. Ну и, конечно, не стоит забывать про то, что 8-ая версия была последней, которая имела префикс 1.*: начиная с 9-ой, версии Java представляют собой просто целое число.
А здесь мы будем с вами прощаться! В дальнейшем мы продолжим эту рубрику и расскажем вам про переход на следующую LTS-версию — Java 17. Так что, если вам было интересно, подписывайтесь на наш блог! До скорых встреч!
0