Русский
Русский
English
Статистика
Реклама

Jpoint

JPoint 2020 новый формат, новые возможности

04.07.2020 20:20:46 | Автор: admin
С 29 июня по 3 июля 2020 года в онлайн-формате прошла Java-конференция JPoint 2020. Информация о докладах, спикерах, особенностях проведения, впечатления от конференции всё это можно прочитать далее.



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

В предверии летнего блока конференций участники команды JUG Ru Group проделали титанический объём работы как административного, так и технического характера. Была создана онлайн-платформа для трансляции митапов и конференций. Также было проведено множество онлайн-встреч, в том числе Java-серия Первая чашка кофе с JPoint с интервью с участниками программного комитета и спикерами: Владимиром Ситниковым, Маргаритой Недзельской, Тагиром Валеевым, Олегом Докукой, Иваном Углянским и Алексеем Шипилёвым.

В блоге компании JUG Ru Group до летних конференций появилось множество интересных статей и интервью:

В проведении самой конференции произошли изменения:
  • стенды компаний превратились в виртуальную выставку с информацией о компании и возможностью назначения онлайн-встреч;
  • дискуссионные зоны для задания вопросов и общения после докладов тоже стали онлайновыми;
  • изменилось положение ведущих треков они стали играть значительно большую роль, помогая спикерам усилить впечатление от их докладов.

Открытие


Непосредственно перед открытием Алексей Фёдоров предоставил возможность увидеть пультовую Всевластья, помогающую организовать онлайн-конференции. После вводных слов и приветствия спонсоров конфереция была объявлена открытой.



Первый день


Прекрасным предисловием к интервью с James Gosling, отцом языка Java, стала статья, написанная phillennium. Беседу вели и задавали вопросы Андрей Дмитриев и Volker Simonis. Интервью получилось живое и эмоциональное, вызвавшее большой интерес у самого Джеймса. Было задано множество вопросов, от касающихся подробностей его прошлых проектов до отношения к популярному в настоящее время JVM-языку Kotlin. Безусловно, Джеймс является личностью, колоссальным образом повлиявшей на индустрию и внёсшей огромный вклад. Его присутствие в числе спикеров большая удача для конференции.



В перерыве между большими докладами можно было посмотреть познавательные интервью, одним из которых стало ML и AI: Как сейчас выглядит разработка решений в крупных компаниях Андрея Дмитриева с Дмитрием Бугайченко про машинное обучение и искусственный интеллект. Достаточно интересно было послушать мнение Дмитрия, являющегося экспертом в этой области и докладчиком этой и других конференций JUG Ru Group.



Доклад Precomputed data access with Micronaut Data от Graeme Rocher, автора Micronaut Framework. У данного спикера на конференции два доклада (доклад Micronaut deep dive был в этот же день чуть раньше, его я ещё планирую посмотреть). Очень полезным оказалось предварительное ознакомление с интервью, взятым недавно. В данном докладе было рассказано про Micronaut Data, легковесное решение для доступа к базам данных, выглядящее чрезвычайно привлекательно. После доклада Грэму вопросы слушателей и свои задавал Антон Архипов. На интересующий многих заданный Антоном вопрос, возможно ли использование Micronaut Data без всего остального из Micronaut Framework, был дан положительный ответ.



Второй день


В нативный код из уютного мира Java: Путешествие туда и обратно блестящий доклад Ивана Углянского на тему возможностей вызова из Java-кода процедур и функций нативных (native) библиотек. Всеобъемлющая ретроспектива существовавших до JNI альтернатив (JDK 1.0 NMI, RNI, JRI), популярных существующих сейчас (JNA, JNR, JavaCPP) и перспективных пока что экспериментальных (Panama, Sulong). Подробное сравнение всего современного вышеперечисленного (начиная с JNI) с большим количеством слайдов говорит об огромной проделанной работе. Очень удачные выбранные аналогии на тему произведений Толкиена: левый слайд (Шир) иллюстрирует милый и безопасный Java-код, правый слайд опасный нативный код (Мордор).



How to develop a successful Kubernetes native application using Quarkus небольшой пятнадцатиминутный доклад Alex Soto Bueno от компании RedHat, спонсора конференции. Доклад о разработке микросервисов с использованием Kubernetes и фреймворка Quarkus, детища RedHat.



Олег Шелаев является одним из тех спикеров, доклады которых всегда можно смело выбирать, зная, что совершенно точно будет интересно, увлекательно и полезно. Обладает редкой способностью просто объяснять очень сложные с технической точки зрения вещи. Доклад под названием Polyglot done right with GraalVM не стал исключением в этом смысле. В нём Олег продолжил раскрывать тему GraalVM, являясь developer advocate проекта GraalVM в OracleLabs. В данном докладе более полно была раскрыта направленность продукта на возможность одновременного применения различных языков программирования: API, шаблоны взаимодействия и прочие детали GraalVM. Ожидания от прослушанного полностью оправдались, отличный доклад.



Третий день


Всеволод Брекелов входит в команду JUG Ru Group, активно участвуя в проведении летнего блока конференций, к которому относится и конференция JPoint. Тем интереснее, регулярно видя его в роли ведущего конференций, было посмотреть доклад в его исполнении под названием Contract testing: Should or shouldn't? Ему очень удачно помогали Андрей Дмитриев, Владимир Плизга и Алексей Виноградов например, представление Владимиром докладчика в самом начале просто восхищает оригинальностью. Обсуждение было посвящено контрактным тестам, были последовательно продемонстрированы несколько подходов с использованием Spring Cloud Contract, Pact и Protocol Buffers. Получилось зажигательно и интересно.



Доклад Страх и ненависть в Scala и Kotlin interop от Маргариты Недзельской был посвящён проблемам взаимодействия кода, написанного на двух JVM-языках Kotlin и Scala. Название доклада является аллюзией на фильм Fear and Loathing in Las Vegas, им же достаточно оригинально был проиллюстрирован весь рассказ. Проблемы вызвали искреннее сочувствие, технические подробности были приведены весьма убедительные. Маргарите помогали Паша Финкельштейн и Евгений Мандриков, ведя беседу, озвучивая результаты голосований и задавая вопросы слушателей.



Четвёртый день


Ещё немного маленьких оптимизаций стал своеобразным продолжением доклада, сделанным на конференции Joker 2019 тем же автором, Тагиром Валеевым. Доклад первой части был посвящён улучшениям в строках, коллекциях и операциям с числами, в этот раз уже другим оптимизациям тоже в строках, коллекциях и теперь ещё и в reflection. Изменения, о которых было рассказано, произошли в версиях Java с 9 по 16. Традиционное глубокое понимание темы, множество результатов сравнений, характерные для докладов Тагира всё это было и в этот раз.



На Интервью и Q&A с Алексеем Шипилёвым интервьюеры Алексей Фёдоров и Иван Крылов поговорили и задали вопросы Алексею Шипилёву об особенностях работы в Red Hat, про используемые инструменты performance-инженера, про различия сборщиков мусора в Java, историю создания Shenandoah GC, об отношении к статьям с замерами производительности, мнении о GraalVM, про совместное использование jmh и async-profiler, о советах для молодых разработчиков и инженеров.



Пятый день


Совместный доклад настоящих звёзд конференций Баруха Садогурского и Евгения Борисова, озаглавленный ими Вырасти своего работодателя в условиях коронавируса, Или как сделать так, чтобы вас не уволили в кризис об особенностях удалённой работы, типах руководителей, проблемах при человеческих коммуникациях с рекомендациями для решения всех возникающих при этом проблем. Хороший нетехнический доклад в завершающий день конференции, демонстрация помех для работы при участии семьи Евгения Борисова в конце доклада была просто великолепна.



Внедрение open source-решений на примере Одноклассников: интервью Дмитрия Чуйко с Андреем Паньгиным. Одной из тем разговора стал переход компанией Одноклассники на использование дистрибутива Liberica JDK компании BellSoft, поэтому представляющий BellSoft Дмитрий Чуйко в качестве берущего интервью был весьма уместен. Также были упомянуты популярные проекты Андрея one-nio и async-profile, тоже являющиеся open source-решениями и вызывающие интерес и уважение.



Доклад Valhalla is coming от Сергея Куксенко был продолжение его же предыдущего доклада, сделанного им на Joker 2019. С конца октября 2019 года в разработке инлайн-типов произошли значительные изменения, подробно о которых было рассказано примерно с середины данного доклада. Сергей харизматичный спикер и высококвалифицированный инженер, доклады которого безошибочно всегда можно выбирать. Отлично дополнил доклад Иван Углянский, задававший вопросы и помогавший Сергею во взаимодействии со слушателями.



Прочие события


Кроме впечатляющей онлайн-платформы для стриминга конференций, всевозможных активностей во время их проведения к летним конференциям была выпущена новая версия веб-приложения, о котором ранее уже писалось в обзорах про конференции TechTrain 2019 и Joker 2019. Приложение доступно по ссылке, в репозитории на GitHub (ставьте звёздочки) имеется описание с информацией, включающей актуальную ссылку на веб-сайт.

Приложение, ранее бывшее только игрой по угадыванию спикера, теперь разделено на две части. В первой из них можно произвести поиск и просмотр информации обо всех конференциях JUG Ru Group, а также митапах Java-сообществ JUG.ru, JUG.MSK, JUGNsk. Содержится абсолютно та же информация, что и представленная на сайтах конференций и митапов. Доступны для удобного просмотра уже опубликованные видео и презентации докладов (ниже для примера показано отображение сведений об Антоне Архипове и об одном из его докладов).



В разделе со статистикой приведены сведения, которые могут заинтересовать как организаторов конференций, так и их участников: с какого времени проводится каждая из конференций или каждый из митапов, общая их длительность, количество конференций, докладов и спикеров, сколько из спикеров удостоено звания Java Champion или Most Valuable Professional (MVP). Можно щёлкнуть по картинкам для их увеличения (или посмотреть то же самостоятельно в веб-приложении по ссылке, приведённой выше).

Второй и третий скриншоты ниже показывают топ спикеров по количеству сделанных ими докладов (скриншот слева без учёта митапов, справа конференции вместе с митапами). Уверенную победу в обоих случаях (только конференции и конференции с митапами) одерживает Барух Садогурский, на втором месте Евгений Борисов. Третье месте в случае только конференций Кирилл Толкачёв, конференции с митапами Алексей Шипилёв.



В игре Угадай спикера, второй части веб-приложения, после загрузки данных обо всех конференциях и митапах стало возможным использовать все ранее доступные режимы угадывания для конкретной конференции (например, JPoint 2020). По умолчанию для угадывания предлагается в данный момент идущая либо ближайшая конференция. Дополнительно были реализованы возможности попытаться угадать Twitter, GitHub спикеров и, наоборот, спикера по представленному их Twitter, GitHub.



Закрытие


Процедура закрытия, сопровождавшаяся ранее полным зрителей залом и традиционным выходом на сцену спикеров, участников программного комитета и организаторов, в связи с онлайн-форматом претерпела существенные изменения. Кроме закрывающих конференцию Всеволода Брекелова и Алексея Фёдорова на экране можно было увидеть в прямом эфире многих организаторов и спикеров.



Совершенно точно, что в сложившейся сегодня ситуации команда JUG Ru Group сделала абсолютно всё возможное (мне кажется, что даже чуточку больше), чтобы праздник, которым конференция всегда бывает и для спикеров, и для обычных участников, состоялся. В какой-то степени, праздник можно продлить у каждого осталось много замечательных докладов, оставшихся пока непросмотренными, и имеется возможность поделиться своими впечатлениями от конференции в комментариях к статье.

Сезон летних конференций JUG Ru Group продолжается по-прежнему можно успеть присоединиться к оставшимся двум онлайн-конференциям DevOops (6-10 июля 2020 года) и Hydra (6-9 июля 2020 года). Есть возможность купить единый билет на все восемь конференций, видео докладов в этом случае становятся доступны сразу же после завершения конференций.
Подробнее..

Маленькие оптимизации в Java 9-16

30.09.2020 16:04:54 | Автор: admin

Когда выходит новая версия Java, главные фичи всегда активно обсуждают. Но есть и работа, которая обычно остается невидимой: небольшие оптимизации в стандартной библиотеке. Они помогают нам, незаметно ускоряя наш код и ничего не требуя взамен, а мы даже ничего о них не знаем!


Эту ситуацию исправляет Тагир Валеев (lany), рассказывая о таких оптимизациях. Сначала он выступил на Joker 2019 с докладом Java 9-14: маленькие оптимизации, можете посмотреть его видеозапись. Затем, поскольку зрителям очень понравилось, на JPoint 2020 он развил тему. А теперь мы решили сделать из второго доклада пост для Хабра, чтобы его можно было не только увидеть, но и прочитать.



Далее под катом текст пойдет от лица спикера.


Вступление


Мы посмотрим только на самые базовые вещи, которыми прямо или косвенно пользуются все: строки, коллекции и рефлексию. Мы не рассматриваем API, появившиеся после Java 8. Все улучшения производительности вы получите бесплатно, если будете запускать ваш Java 8-код на более новой JVM.


Проведем замеры производительности кода. Я не буду делать вид, что они очень научные, но надеюсь, что показательные и выводы из них правильные. Тесты проводились на Intel Core i7-6820HQ под Windows 10. Все они однопоточные, поэтому количество ядер процессора не принципиально. Чтобы нивелировать эффект от смены сборщика мусора по умолчанию, всегда использовалась опция +UseParallelGC. На всех иллюстрациях производительность измеряется по среднему времени выполнения, то есть чем меньше, тем лучше.


Если вы знаете доклад Алексея Шипилёва о производительности, то должны помнить Кривую имени Ш. В качестве упражнения можете про каждую оптимизацию в этом докладе прикинуть, где она на этой кривой.



String.hashCode


Начнем с замера производительности String.hashCode. Напишем бенчмарк, в котором будем вычислять хеш-код строки Бегавшая через бары:


@Benchmarkpublic int calcHashCode() {  return "Бегавшая через бары".hashCode();}

При сравнении Java 12 и Java 13 получаем следующее:



Как же удалось ускорить вычисление хеш-кода более чем в 4 раза? Все дело в самой строке, она не случайная. Вы скорее выиграете в лотерею, чем наткнётесь на такую. Сравним производительность вычислений двух строк: Бегавшая через бары и Бегавший через бары:


@Benchmarkpublic int calcHashCode() {  return "Бегавшая через бары".hashCode();}@Benchmarkpublic int calcHashCode2() {  return "Бегавший через бары".hashCode();}


Оказывается, в Java 12, когда он уже бегал через бары быстро, она ещё бегала медленно. Но в Java 13 наконец наступило равенство полов. В чём дело?
В значении хеш-кода. С Java 9 по Java 12 метод hashCode() выглядел так (до этого чуть по-другому, потому что не было Compact Strings):


/** Cache the hash code for the string */private int hash; // Default to 0public int hashCode() {    int h = hash;    if (h == 0 && value.length > 0) {        hash = h = isLatin1() ? StringLatin1.hashCode(value)                              : StringUTF16.hashCode(value);    }    return h;}

Формула хеш-кода давно специфицирована, и ее нельзя менять. В одном случае из 4 миллиардов значением хеш-кода получается 0. И в этом случае полученный хеш-код нельзя закешировать. Чтобы различать ситуации, когда хеш-код подсчитан и еще нет, во втором случае как раз используют 0 как его значение. А если у вашей строки он такой и оказался ну что ж, вы неудачник, будете пересчитывать его всегда.


Об этом на JPoint 2015 рассказывал Алексей Шипилёв в докладе Катехизис java.lang.String. На Java 8 его пример сверхинструментом пренебрегшая также давал хеш-код 0, а строка пренебрегшая сверхинструментом давала уже другое значение. Он также раскритиковал мнение перфекционистов, что необходимо добавить специальный флаг для таких случаев, потому что размер объекта String вырос бы для всех.


Но теперь некоторые перфекционисты победили, причем Алексей им помог.


Посмотрим на layout объекта String в Java 8. Оказывается, на 64-битной JVM в String и так терялось 4 байта, причем хоть со сжатыми ссылками, хоть без. То есть можно занять 1 байт.



С другой стороны, в 32-битной JVM потерь не было. Может быть, еще в каких-то экзотических конфигурациях с другим выравниванием в строках тоже не пропадало место, поэтому добавление флага заняло бы дополнительную память.


Однако в Java 9 появились компактные строки, большую часть которых делал сам Алексей Шипилёв, и все это стало неважно. Для их обозначения уже требуется дополнительный байт, который показывает, влезает ли строка в кодировку Latin-1, или же в ней есть UTF-16 символы. Теперь полезная нагрузка строки теряет 3 байта, и вряд ли можно представить такую конфигурацию VM, в которой 3 лишних байта превратятся в 0. А раз они свободны, почему же тогда не занять еще один из них?


Это и сделали в Java 13, добавив булево поле String.hashIsZero:



И еще два байта осталось для потомков. Теперь вычисление хеш-кода выглядит так:


private int hash; // Default to 0private boolean hashIsZero; // Default to false;public int hashCode() {    int h = hash;    if (h == 0 && !hashIsZero) {        h = isLatin1() ? StringLatin1.hashCode(value)                       : StringUTF16.hashCode(value);        if (h == 0) {            hashIsZero = true;        } else {            hash = h;        }    }    return h;}

Но почему добавили именно поле hashIsZero? Кажется, логичнее было бы завести поле hashIsCalculated и устанавливать его в true, когда хеш вычислен.


Оказывается, метод hashCode() никак не синхронизирован, поэтому в нем возможна гонка по данным. Когда поле одно, подобное можно делать аккуратно, так как благодаря гарантии атомарности каждый из потоков увидит либо вычисленный хеш-код, либо 0 (тогда он вычислит вручную). Но запись двух полей неатомарна, и другие потоки могут увидеть одну из этих записей. И если сделать поле hashIsCalculated и не добавить синхронизации, то другой поток может увидеть в hashIsCalculated значение true и хеш, равный 0, и вернет неправильный хеш.


Поэтому придумали такое изящное решение, при котором никогда не пишут более одного поля в объект String: когда одно поле меняется, другое всегда имеет значение по умолчанию, поэтому атомарность сохраняется.


Когда это отправили на ревью, Алексей Шипилёв долго всех убеждал, что так делать не надо, потому что это неоправданная сложность. Но перфекционизм победил.


String.concat


Конкатенацию строк можно провести не только через +, но и через метод String.concat(). Выясним, какой из этих способов быстрее. Напишем бенчмарк:


@Param({"", "is a very very very very very very very very cool conference!"})String data;@Benchmarkpublic String concat() {  return "JPoint ".concat(data);}@Benchmarkpublic String plus() {  return "JPoint " + data;}

Сначала проверим на конкатенацию пустой строки с непустой:



Оказывается, метод String.concat() работает в Java 8-14 за ~3 нс, а конкатенация через + занимает больше 15 нс, причем замедляется в новых версиях до ~19 нс. Почему?


Проблема кроется в спецификации Java. Результатом конкатенации строк всегда должна быть новая строка. Это требование идет с ранних версий спецификации, и убрать его было бы breaking change. А String.concat() не скован этим ограничением, даже наоборот: в его спецификации четко прописано, что если длина строки-аргумента равна 0, то возвращается исходная строка. Это тоже странно, поскольку это правило в обратную сторону не работает: если строка слева пустая, тогда аргумент копируется в новую строку.


Пустые строки мы не так часто конкатенируем, давайте посмотрим на непустые.



С бенчмарком маленьких оптимизаций в непустых строках есть трудности, потому что были и большие оптимизации. В Java 8 String.concat() отработал вдвое медленнее, чем конкатенация через +, а в Java 9 он заметно ускорился благодаря внедрению компактных строк. До Java 12 картина была примерно одинаковой, но в Java 13 и Java 14 String.concat() работает быстрее конкатенации через + примерно на 10%.


Но не спешите переходить на String.concat(). В Java 9 был реализован JEP 280 конкатенация строк через invokedynamic-вызовы. Однако для конкатенации через + необходимо перекомпилировать ваше приложение, а метод String.concat() будет использовать код из JEP 280 даже без перекомпиляции.


Перекомпилируем бенчмарк с таргетом Java 9 и запустим заново:



Если конкатенация была с пустой строкой, то она стала заметно быстрее и деградация в свежих версиях Java перестала наблюдаться. Конкатенация с непустой строкой также всегда быстрее, чем до перекомпиляции, и сравнялась по производительности со String.concat(). Что же стало решающим фактором в таком скачке производительности?


Посмотрим на код метода String.concat() в Java 12:


public String concat(String str) {    if (str.isEmpty()) {        return this;    }    if (coder() == str.coder()) {        byte[] val = this.value;        byte[] oval = str.value;        int len = val.length + oval.length;        byte[] buf = Arrays.copyOf(val, len);        System.arraycopy(oval, 0, buf, val.length, oval.length);        return new String(buf, coder);    }    int len = length();    int olen = str.length();    byte[] buf = StringUTF16.newBytesFor(len + olen);    getBytes(buf, 0, UTF16);    str.getBytes(buf, len, UTF16);    return new String(buf, UTF16);}

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


public String concat(String str) {    if (str.isEmpty()) {        return this;    }    return StringConcatHelper.simpleConcat(this, str);}static String simpleConcat(Object first, Object second) {    String s1 = stringOf(first);    String s2 = stringOf(second);    // start "mixing" in length and coder or arguments, order is not    // important    long indexCoder = mix(initialCoder(), s2);    indexCoder = mix(indexCoder, s1);    byte[] buf =(indexCoder);    // prepend each argument in reverse order, since we prepending    // from the end of the byte array    indexCoder = prepend(indexCoder, buf, s2);    indexCoder = prepend(indexCoder, buf, s1);    return newString(buf, indexCoder);}

В Java 13 метод стал делегировать вспомогательному методу simpleConcat(), который теперь используется в обоих способах конкатенации строк. Кажется, что веток теперь меньше, однако они спрятаны в методы mix() и prepend(). Улучшение производительности кроется в реализации внутреннего метода newArray():


static byte[] newArray(long indexCoder) {    byte coder = (byte)(indexCoder >> 32);    int index = (int)indexCoder;    return (byte[]) UNSAFE.allocateUninitializedArray(byte.class, index << coder);}

Метод выделяет неинициализированный массив, то есть еще не забитый нулями. На этом и экономим. А в Java 12 вместо внутреннего метода newArray() использовался публичный метод copyOf(), который должен занулить массив.


Конкатенация пустых строк


График конкатенации пустой строки мне долго не давал покоя.



Да, нужно создать новый объект, но 11 нс это много. Я подумал над этим, и это вылилось в новый патч JDK-8247605, причем он попадет в Java 16.
Посмотрим снова на код метода simpleConcat():


static String simpleConcat(Object first, Object second) {    String s1 = stringOf(first);    String s2 = stringOf(second);    // start "mixing" in length and coder or arguments, order is not    // important    long indexCoder = mix(initialCoder(), s2);    indexCoder = mix(indexCoder, s1);    byte[] buf =(indexCoder);    // prepend each argument in reverse order, since we prepending    // from the end of the byte array    indexCoder = prepend(indexCoder, buf, s2);    indexCoder = prepend(indexCoder, buf, s1);    return newString(buf, indexCoder);}

Предположим, что строка s1 или s2 пустая. Мы не можем просто вернуть другую строку, потому что нас ограничивает спецификация. Но спецификация не обязывает выделять под нее новый массив. Ведь массив с байтами внутри строки не меняется и спрятан, а значит, его можно переиспользовать и больше ничего не делать. Сделать это весьма просто можно делегировать к конструктору строки от строки:


...String s1 = stringOf(first);String s2 = stringOf(second);if (s1.isEmpty()) {    // newly created string required, see JLS 15.18.1    return new String(s2);}   if (s2.isEmpty()) {    // newly created string required, see JLS 15.18.1    return new String(s1);}  ...

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


Патч добрался до ранних сборок Java 16, и их уже можно скачать и протестировать:



Теперь конкатенация любой строки (причем неважно, справа или слева находится пустая строка) уже не требует копирования массива, а значит, занимает гораздо меньше времени ~6 нс.


Вдобавок мы экономим память: если и исходная строка, и результат конкатенации оказались долгоживущими, то они будут сидеть на одном массиве навсегда. Но это работает, если включена target-версия Java 9 и выше.


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


TreeMap.computeIfAbsent и другие методы коллекций


Речь пойдет о пяти методах, которые появились в Java 8 в интерфейсе map:


  • putIfAbsent()
  • computeIfAbsent()
  • computeIfPresent()
  • compute()
  • merge()

Все они модифицируют не больше одной записи в map, и все, кроме putIfAbsent(), принимают лямбду, которая тем или иным образом говорит, как мы будем модифицировать значение. Так как эти методы были добавлены в интерфейс, они имеют реализацию по умолчанию, чтобы не сломать уже существующий код. Посмотрим, как выглядит реализация по умолчанию метода computeIfAbsent():


default V computeIfAbsent(K key,        Function<? super K, ? extends V> mappingFunction) {    Objects.requireNonNull(mappingFunction);    V v;    if ((v = get(key)) == null) {         V newValue;        if ((newValue = mappingFunction.apply(key)) != null) {            put(key, newValue);            return newValue;        }    }    return v;}

Метод computeIfAbsent() позволяет вычислить значение и поместить в map, если там ничего не было. Если же значение было, и не было равно null, то метод возвращает старое значение.


В реализации по умолчанию есть очевидный недостаток. Если записи не было, то придется ее искать дважды: сперва при вызове get() ищем запись в хеш-таблице или дереве, не находим, а затем при вызове put() нужно найти, куда положить запись. То есть мы 2 раза делаем одно и то же, а операция может быть недешевой.


Понятно, что мы ничего не сделаем, не зная устройства конкретного map. В противном случае мы можем предоставить специализированную реализацию. Если посмотреть на реализацию данного метода в Java 8, то видно, что во многих map специализация имеется:



А для TreeMap реализации нет. А ведь поиск в нем недешевая операция с логарифмической сложностью, потому что там красно-чёрное дерево. В реализации по умолчанию поиск приходится делать дважды, хотя в оптимальной можно пройти по дереву всего один раз, найти подходящий узел и после вызова пользовательской функции не искать узел повторно.


Сергей Куксенко предложил оптимальную реализацию еще в 2017 году, когда шла работа над Java 10. Было несколько комментариев на Code Review, но потом дело заглохло. В 2019 году я подхватил патч и довел до конца, попутно исправив один баг в исходной реализации и добавив реализацию метода merge(), тесты и бенчмарки. Патч добрался до Java 15, и бенчмарки показывают, что работа не была напрасной:



Это специальный показательный показательный бенчмарк, в котором каждый раз происходит два обращения к ключу. То есть в map в computeIfAbsent() изначально значений не было, и они туда добавляются. А в computeIfPresent() значения уже были, и их модифицировали. Для маленьких map эффект будет меньше, потому что глубина дерева меньше, но 10-20 % мы выигрываем даже на них.


У патча есть и обратная сторона. Чтобы ее проиллюстрировать, сделаем то, что рано или поздно многие программисты делают, только стесняются говорить подсчет чисел Фибоначчи.


public BigInteger fibo(int arg) {    if (arg < 1) {        throw new IllegalArgumentException();    }    if (arg <= 2) {        return BigInteger.ONE;    }    return fibo(arg - 1).add(fibo(arg - 2));}public static void main(String[] args) {    Fibo fibo = new Fibo();    System.out.println(fibo.fibo(100));}

Совершенно без стыда напишем наивный рекурсивный алгоритм и запустим вычисление с аргументом 100. Программа надолго задумалась, и ждать смысла нет. Закэшируем:


Map<Integer, BigInteger> map = new HashMap<>();private BigInteger calcFibo(int arg) {    if (arg < 1) {        throw new IllegalArgumentException();    }    if (arg <= 2) {        return BigInteger.ONE;    }    return fibo(arg - 1).add(fibo(arg - 2));}public BigInteger fibo(int arg) {    BigInteger value = map.get(arg);    if (value == null) {        value = calcFibo(arg);        map.put(arg, value);    }    return value;}

Делаем метод приватным, переименовываем его, а в публичный метод вставляем HashMap. При этом calcFibo() рекурсивно обращается к кэшу, поэтому каждое предыдущее число считается один раз, и мы не получаем экспоненциального взрыва, так что программа быстро выдаст правильный ответ.


Проверим, работает ли наш кэш:


public static void main(String[] args) {    Fibo fibo = new Fibo();    System.out.println(fibo.fibo(100));    // 354224848179261915075    System.out.println(fibo.map.get(100));    // 354224848179261915075}

Там действительно лежит наше число. Если мы второй раз будем считать число Фибоначчи от 100, то программа просто возьмет значение из map. Посчитаем, сколько записей в нашей map:


System.out.println(fibo.map.size());// 100  

И действительно в ней всего 100 записей. Приятно, когда программа работает, как ожидаешь.


Однако это код в стиле Java 7. Давайте используем более современные методы. Сама IntelliJ IDEA предлагает заменить if в методе fibo() на computeIfAbsent(), что мы и сделаем:


public BigInteger fibo(int arg) {    return map.computeIfAbsent(arg, this::calcFibo);}

В итоге весь метод свернулся в одну строку, и программа продолжает быстро работать. Однако поиск нашего числа в кеше теперь возвращает null. Странно. Размер map теперь составляет 185. Программа ведет себя совсем не так, как ожидалось.


На самом деле наша map оказалась сломанной, и такова цена оптимизации. Когда мы находимся внутри метода, мы должны сделать get() и put(), но не хотим проходить по хеш-таблице два раза.


У метода computeIfAbsent() довольно сложный код, поэтому посмотрим лишь на его алгоритм:


  1. Найти место в хэш-таблице.
  2. Если там есть запись, вернуть значение из неё.
  3. Иначе вызвать функцию mappingFunction().
  4. Если функция вернула null, вернуть null.
  5. Иначе создать запись и поместить её в ранее найденное место.
  6. При необходимости увеличить хеш-таблицу.
  7. Увеличить size на 1.
  8. Вернуть то, что вернула функция на шаге 3.

Это хорошо работает, если наша функция не модифицирует map, однако в редких случаях это не так. В случае с Фибоначчи функция рекурсивно вычисляет одно из предыдущих чисел и кэширует его тоже. В результате на предыдущем расчете хеш-таблица могла быть увеличена, то есть мог быть выделен новый массив, а старый уже никому не нужен. А здесь на шаге 5 мы создаем запись в старом массиве, который уже не проверяем.


Также могло оказаться, что мы уже на предыдущих шагах рекурсии уже посчитали текущее значение и засунули его в map, увеличив размер массива на 1. Поэтому HashMap стал сломанным. При этом алгоритм устроен так, что поломка HashMap не приводит к неправильному результату.


В Java 9, к счастью, эту проблему решили. Ну как решили: теперь код стал выкидывать ConcurrentModificationException. Зато благодаря этому все HashMap остаются целыми.


Если мы всё-таки очень хотим computeIfAbsent(), мы можем использовать TreeMap вместо HashMap, ведь, как мы выяснили, метод имеет в TreeMap неоптимизированную реализацию по умолчанию. Это хорошо работает в Java 8-14, но в Java 15 снова выскакивает та же ошибка, ведь теперь у метода есть моя оптимизированная реализация.


Получается, в таких сценариях метод computeIfAbsent() не подходит, и идея провалилась. Даже в документации указано, что


The mapping function should not modify this map during computation.

ArrayList.removeIf


Метод removeIf() появился в Java 8 у всех коллекций, и его реализация по умолчанию выглядит так:


default boolean removeIf(Predicate<? super E> filter) {    Objects.requireNonNull(filter);    boolean removed = false;    final Iterator<E> each = iterator();    while (each.hasNext()) {        if (filter.test(each.next())) {            each.remove();            removed = true;        }    }    return removed;}  

Мы создаем итератор, бежим им по коллекции, затем с помощью Iterator.remove() удаляем элементы, которые успешно прошли фильтр, а затем обновляем булеву переменную removed, чтобы сообщить, удалось ли удалить элемент. Такой код до Java 8 мы писали вручную, и теперь это не нужно.


Прелесть default-методов и в том, что их можно переопределить в конкретных классах и сделать более оптимальную реализацию. В ArrayList этот метод был переопределен еще в Java 8, но в Java 9 его улучшили. Измерим производительность, а затем посмотрим на реализацию.


Бенчмарк будет простым: создадим ArrayList, закинем в него числа от 0 до size-1 и будем удалять элементы:


data = new ArrayList<>();for (int i = 0; i < size; i++) {  data.add(i);}

Проведем несколько бенчмарков, в которых будем копировать массив из эталона, а затем удалять элементы. Результаты удаления могут зависеть от количества удаляемых элементов и их расположения, поэтому сделаем несколько тестов:


removeAll:   list.removeIf(x -> true);removeHalf:  list.removeIf(x -> x % 2 == 0);removeLast:  list.removeIf(x -> x == size - 1);removeFirst: list.removeIf(x -> x == 0);removeNone:  list.removeIf(x -> false);

Посмотрим, сколько времени занимает выполнение ArrayList.removeIf() в Java 8 со средним размером 1000 элементов:



Выглядит разумно, что removeAll победил removeHalf и removeFirst. Мы помним, что внутри ArrayList лежит обычный массив. Если удалить первый элемент, то весь массив необходимо сдвигать. В случае же удаления всего массива необходимо просто занулить все элементы. Однако удаление последнего элемента заняло больше всего времени, хотя кажется, что достаточно занулить последний элемент.


Обернем наш ArrayList в subList, содержащий все элементы, и снова замерим производительность:



Теоретически картина не должна была сильно меняться. Но на практике мы видим существенную разницу во всех тестах. В removeAll мы проигрываем более чем в 20 раз, в removeHalf примерно в 6 раз. Но зато removeLast и removeFirst с subList работает в разы быстрее.


В чем секрет длинных графиков? В Java 8 subList(0, size).removeIf() не реализован вообще, и используется default-реализация с итераторами. Поэтому когда мы удаляем много элементов, каждое удаление двигает хвост массива, и в итоге мы имеем квадратичную сложность.


Посмотрим на результаты тех же тестов в Java 9:



Значения с subList и без него почти одинаковы, и все отличия в рамках погрешности, потому что в Java 9 о subList тоже подумали. Приятно, что исчезли огромные выбросы. Да и логика восторжествовала: removeLast перешел на второе место после removeNone. Хоть это решение выглядит более оптимальным, но оно проигрывает по производительности subList в removeFirst в Java 8. Разгадаем загадку таких странных графиков.


Реализация removeIf() в Java 8 состоит из двух кусков:


public boolean removeIf(Predicate<? super E> filter) {    Objects.requireNonNull(filter);    // figure out which elements are to be removed    // any exception thrown from the filter predicate at this stage    // will leave the collection unmodified    int removeCount = 0;    final BitSet removeSet = new BitSet(size);    final int expectedModCount = modCount;    final int size = this.size;    for (int i=0; modCount == expectedModCount && i < size; i++) {        @SuppressWarnings("unchecked")        final E element = (E) elementData[i];        if (filter.test(element)) {            removeSet.set(i);            removeCount++;        }    }    if (modCount != expectedModCount) {        throw new ConcurrentModificationException();    }    // shift surviving elements left over the spaces    // left by removed elements    final boolean anyToRemove = removeCount > 0;    if (anyToRemove) {        final int newSize = size - removeCount;        for (int i=0, j=0; (i < size) && (j < newSize); i++, j++) {            i = removeSet.nextClearBit(i);            elementData[j] = elementData[i];        }        for (int k=newSize; k < size; k++) {            elementData[k] = null; // Let gc do its work        }            this.size = newSize;        if (modCount != expectedModCount) {            throw new ConcurrentModificationException();        }        modCount++;    }    return anyToRemove;}

В первом куске мы пробегаем по массиву, опрашиваем фильтр и заполняем BitSet, выставляя биты тем элементам, которые хотим удалить. Казалось бы, мы могли бы удалять сразу из массива без BitSet, но тогда в промежутках ArrayList был бы поломан. Так как в процессе мы вызываем пользовательскую функцию, поломанный ArrayList могут увидеть извне. Поэтому было решено делать все модификации после фильтрации. Тогда наш предикат будет видеть исходное состояние списка при чтении. Это отличается от поведения default-метода, где предыдущие модификации предикату видны.


Потом мы пробегаем по сформированному BitSet и стандартным методом двух курсоров выкидываем удаленные элементы. Затем циклом зануляем хвост, чтобы GC смог собрать удаленные объекты.


Становится понятно, почему removeLast был медленнее всего. В этом случае мы должны пробежать по всему BitSet в цикле:


for (int i=0, j=0; (i < size) && (j < newSize); i++, j++) {    i = removeSet.nextClearBit(i);    elementData[j] = elementData[i];}

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


А removeAll в первый цикл вообще не заходит, потому что newSize равен 0. Он просто зануляет весь массив во втором цикле, который к тому же хорошо векторизуется.


for (int k=newSize; k < size; k++) {    elementData[k] = null; // Let gc do its work}

Что произошло в Java 9? Во-первых, реализация переехала в закрытый метод, которому передается начало и конец массива, который надо обойти. Благодаря этому метод может использоваться и для subList.


boolean removeIf(Predicate<? super E> filter, int i, final int end) {    Objects.requireNonNull(filter);    int expectedModCount = modCount;    final Object[] es = elementData;    // Optimize for initial run of survivors    for (; i < end && !filter.test(elementAt(es, i)); i++)        ;    // Tolerate predicates that reentrantly access the collection for    // read (but writers still get CME), so traverse once to find    // elements to delete, a second pass to physically expunge.    if (i < end) {            } else {        if (modCount != expectedModCount)            throw new ConcurrentModificationException();        return false;    }}

Заметьте, что параметр i нещадно используется как счетчик цикла внутри метода вопреки всем канонам красивого программирования, но ради производительности.


Из этого кода видно, что сделана очень важная оптимизация мы проматываем серию элементов в начале списка в случае, если фильтр возвращает false. В частности, если удаления нет, то цикл просто проматывается до конца, и больше ничего не делаем.


Основная часть метода проходит в условии, если фильтр что-то нашел:


if (i < end) {    final int beg = i;    final long[] deathRow = nBits(end - beg); // new long[((n - 1) >> 6) + 1];    deathRow[0] = 1L; // set bit 0    for (i = beg + 1; i < end; i++)        if (filter.test(elementAt(es, i)))            setBit(deathRow, i - beg); // bits[i >> 6] |= 1L << i;    if (modCount != expectedModCount)        throw new ConcurrentModificationException();    modCount++;    int w = beg;    for (i = beg; i < end; i++)        if (isClear(deathRow, i - beg)) // (bits[i >> 6] & (1L << i)) == 0;            es[w++] = es[i];    shiftTailOverGap(es, w, end);    return true;} else {  }

Чтобы избежать лишнего объекта, здесь сделан BitSet на коленке. С помощью статических элементов nBits(), setBit() и isClear мы напрямую выделяем массив long и оперируем в нем битами. Да, экономия на спичках, но для базового класса это допустимо.


Наш примитивный BitSet начинается уже с первого найденного элемента, а не с начала списка. Поэтому в removeLast мы выделим всего один элемент, заодно упростив жизнь сборщику мусора.


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


Подведем итог бенчмарков в Java 9. В Java 9 быстродействие более ровное и меньше зависит от того, что мы удаляем. Мы выигрываем, если удаляем элементы ближе к концу либо если удаляем очень много элементов. И существенное улучшение в одних случаях привело к небольшому ухудшению в других.


hashSet.removeIf()


Попробуем теперь удалять не из ArrayList, а из HashSet. Сперва нужно добавить что-то ненужное. Положим в HashSet списки целых чисел: [], [0], [0, 1] и т. д.:


HashSet<List<Integer>> set;@Setuppublic void setup() {    set = IntStream.range(0, 1000)        .mapToObj(i -> IntStream.range(0, i).boxed().collect(Collectors.toList()))        .collect(Collectors.toCollection(HashSet::new));}

Так как мы хотим удалять много раз, нам нужно восстанавливать HashSet в исходное положение. Поэтому бенчмарк будет выглядеть так:


@Benchmarkpublic Set<List<Integer>> removeHalf() {    Set<List<Integer>> copy = new HashSet<>(set);    copy.removeIf(list -> list.size() > 500);    return copy;}@Benchmarkpublic Set<List<Integer>> noRemove() {    return new HashSet<>(set);}

Второй бенчмарк только копирует, но ничего не удаляет. Оценим скорость копирования в Java 8:



Получаем, что копирование + удаление длится вдвое дольше, чем просто копирование. Следовательно, удаление по расходу ресурсов близко к копированию, что довольно накладно.



В Java 9 удаление стало значительно быстрее, и уже около 10 % времени уходит на удаление. Как же этого добились? Наверное, снова хитрую реализацию HashSet.removeIf() сделали? Однако специализированной реализации вообще нет. Вместо этого используется стандартная реализация Collection.removeIf(), которая делегирует к HashSet.iterator().remove(). HashSet.iterator().remove() делегирует далее к HashMap.keySet().iterator().remove(). Как мы помним, в HashSet лежит обычный HashMap с default-значением, где мы используем ключи как set. Далее мы переходим к HashMap.KeyIterator.remove(), но в нем нет метода remove(). Дело в том, что по HashMap можно создавать разные итераторы: keySet().iterator(), valueSet().iterator(), entrySet().iterator(), но удаление будет работать одинаково, поэтому оно вынесено в общий суперкласс.



Разгадка кроется в родительском классе HashIterator. Что же там оптимизировали, что удаление стало быстрее? В нем удалили две строчки и добавили одну:



Внутреннему методу removeNode(), который и выполняет удаление, передается хеш ключа. До Java 9 хеш вычислялся заново по ключу, хотя в этом особого смысла не было, потому что хеш уже хранится в доступном узле.


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


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


HashMap.containsKey


Схожая оптимизация появилась в Java 15 и касается пустых Map. Бенчмарк будет такой:


HashMap<List<Integer>, String> emptyMap;HashMap<List<Integer>, String> nonEmptyMap;List<Integer> key;@Setuppublic void setup() {    emptyMap = new HashMap<>();    nonEmptyMap = new HashMap<>();    nonEmptyMap.put(Collections.emptyList(), "");    key = IntStream.range(0, 500).boxed().collect(Collectors.toList());}@Benchmarkpublic boolean containsInEmpty() {    return emptyMap.containsKey(key);}@Benchmarkpublic boolean containsInNonEmpty() {    return nonEmptyMap.containsKey(key);}

Мы создали два HashMap. Один из них будет пустой, а в другой сложим один элемент. Мы проверяем наличие ключа со сложным хеш-кодом, например, список из 500 чисел. В результате мы получаем:



В Java 14 мы тратим около 500 нс и с пустым, и с непустым списком, а в Java 15 с пустым списком уходит всего около 3 нс. Очевидно, что не нужно считать хеш-код, если список пустой.


Здесь исправление очень простое, хоть и нетривиальное. Важной частью реализации HashMap является метод getNode(), который ищет элемент в хеш-таблице по ключу. До Java 15 он принимал сразу хеш и ключ, то есть хеш должен был вычислить тот, кто вызывает этот метод.


final Node<K,V> getNode(int hash, Object key) {    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;    if ((tab = table) != null && (n = tab.length) > 0 &&        (first = tab[(n - 1) & hash]) != null) {            }    return null;}

А в Java 15 расчет хеш-кода перенесли внутрь getNode(). Это не только упростило использование метода, но и позволило отложить вычисление хеш-кода до того, как оно потребуется. А хеш-код может вообще не потребоваться, если таблица пустая.


final Node<K,V> getNode(Object key) {    Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;    if ((tab = table) != null && (n = tab.length) > 0 &&        (first = tab[(n - 1) & (hash = hash(key))]) != null) {            }    return null;}

Соответственно поменялись все точки вызова, в результате чего ускорился не только containsKey(), но и get() и getOrDefault(). Но за эту оптимизацию тоже пришлось заплатить цену.


Метод computeIfPresent() применяет функцию для существующего ключа и заменяет ее значение. А если функция вернула null, то значение надо удалить. И в этом редком случае ключ теперь нужно вычислять дважды.


Для демонстрации напишем бенчмарк. Чтобы он был стабильным, мы добавляем в пустой map один элемент и тут же удаляем его через computeIfPresent().



Конечно, computeIfPresent() тоже можно переписать, заинлайнив getNode(), чтобы повторного вычисления не требовалось, но это усложнит код, и на эту деградацию скрепя сердце пошли. Все-таки computeIfPresent() используется редко, а возвращает null еще реже.


Class.getSimpleName()


Поговорим про Reflection. У класса есть имя, и так сложилось, что оно может быть разное. Есть три разных метода получения имени: class.getName(), class.getCanonicalName(), class.getSimpleName(). Class.getName() возвращает имя, разделенное точками, а потом имя JVM-класса. Class.getCanonicalName() возвращает имя класса, где вложенный класс отделяется точками, как в исходниках Java-программы. Class.getSimpleName() возвращает имя класса без пакета и окружающих классов. Проверим, какой же из этих методов быстрее:



В Java 8 getName() работает почти мгновенно, и кажется, что имя кешируется. Методы getCanonicalName() и getSimpleName() возвращаются не так быстро, причем скорость зависит от того, вложенный ли класс или же верхнего уровня. Явно значения считаются на лету, а не берутся из кеша.


В Java 11 все стало гораздо быстрее, и явно значения стали кешировать. Однако getCanonicalName() и getSimpleName() все равно работают на 1,3 нс медленнее, чем getName(). Разберемся, откуда взялась разница.


public String getName() {    String name = this.name;    if (name == null)        this.name = name = getName0();    return name;}// cache the name to reduce the number of calls into the VMprivate transient String name;private native String getName0();

В getName() мы видим обычную процедуру кеширования в поле. Даже в случае гонки несколько потоков просто вызовут getName(), который вернет одно и то же значение.


public String getCanonicalName() {    if (isArray()) {        String canonicalName = getComponentType().getCanonicalName();        if (canonicalName != null)            return canonicalName + "[]";        else            return null;    }    if (isLocalOrAnonymousClass())        return null;    Class<?> enclosingClass = getEnclosingClass();    if (enclosingClass == null) { // top level class        return getName();    } else {        String enclosingName = enclosingClass.getCanonicalName();        if (enclosingName == null)            return null;        return enclosingName + "." + getSimpleName();    }}

Если посмотреть на реализацию getCanonicalName() в Java 10, то никакого кеширования нет, и строки формируются на лету, делегируя к другим методам. В методе getSimpleName() все аналогично. Здесь авторы не захотели добавлять два поля в класс, потому что в Java 8 была проведена достаточно большая работа, направленная на уменьшения потребления памяти Java. В рамках этой работы, в частности, ужимали java.lang.Class, который кушал слишком много.


До Java 8 на 32-битной JVM объект класс съедал 112 байт, потому что в нем содержались SoftReference на данные reflection. Если класс использовался через reflection, то они заполнялись, в противном случае оставались пустыми. А в Java 8 решили эти данные перетащить в отдельный вложенный объект ReflectionData и повесить на один SoftReference. В результате мы экономим на 32-битной JVM 32 байта, когда класс не использует Reflection вообще, и 224 байта, когда используются все 8 кешей Reflection.


После этой оптимизации не хотелось откатываться назад и добавлять два новых поля в каждый класс. Все-таки getSimpleName() и getCanonicalName() для большинства классов никогда не вызываются. Они могут потребоваться для каких-либо фреймворков при обработке аннотаций, но обычно классу они не нужны. В итоге эти поля добавили в ReflectionData. Посмотрим подробнее на ReflectionData до добавления.


// reflection data that might get invalidated// when JVM TI RedefineClasses() is calledprivate static class ReflectionData<T> {    volatile Field[] declaredFields;    volatile Field[] publicFields;    volatile Method[] declaredMethods;    volatile Method[] publicMethods;    volatile Constructor<T>[] declaredConstructors;    volatile Constructor<T>[] publicConstructors;    // Intermediate results for getFields and getMethods    volatile Field[] declaredPublicFields;    volatile Method[] declaredPublicMethods;    volatile Class<?>[] interfaces;    // Value of classRedefinedCount when we created this ReflectionData instance    final int redefinedCount;    ReflectionData(int redefinedCount) {        this.redefinedCount = redefinedCount;    }}

Мы видим различные кэши в массивах. Тонкость с этим объектом состоит в том, что любой класс может быть переопределен JVMTI-агентом, и тогда ReflectionData устареет, что усложняет кэширование ReflectionData.


private transient volatile SoftReference<ReflectionData<T>> reflectionData;// Incremented by the VM on each call to JVM TI RedefineClasses()// that redefines this class or a superclass.private transient volatile int classRedefinedCount;// Lazily create and cache ReflectionDataprivate ReflectionData<T> reflectionData() {    SoftReference<ReflectionData<T>> reflectionData = this.reflectionData;    int classRedefinedCount = this.classRedefinedCount;    ReflectionData<T> rd;    if (reflectionData != null &&        (rd = reflectionData.get()) != null &&        rd.redefinedCount == classRedefinedCount) {        return rd;    }    // else no SoftReference or cleared SoftReference or stale ReflectionData    // -> create and replace new instance    return newReflectionData(reflectionData, classRedefinedCount);}

Во-первых, оно кэшируется в SoftReference, чтобы при нехватке памяти можно было пустить под нож. Во-вторых, есть внутреннее поле redefinedCount, которое сама виртуальная машина обновляет при переопределении класса объектом. Интересно, что поле типа int, поэтому может переполниться за вполне реалистичное время.


В общем, пересоздавать ReflectionData нужно не только когда GC добрался до прошлой версии, но и если classRedefinedCount не совпадает.


private ReflectionData<T> newReflectionData(                              SoftReference<ReflectionData<T>> oldReflectionData,                                            int classRedefinedCount) {    while (true) {        ReflectionData<T> rd = new ReflectionData<>(classRedefinedCount);        // try to CAS it...        if (Atomic.casReflectionData(                        this, oldReflectionData, new SoftReference<>(rd))) {            return rd;        }        // else retry        oldReflectionData = this.reflectionData;        classRedefinedCount = this.classRedefinedCount;        if (oldReflectionData != null &&            (rd = oldReflectionData.get()) != null &&            rd.redefinedCount == classRedefinedCount) {            return rd;        }    }}

Само пересоздание происходит в CAS-цикле, потому что два потока могут обращаться одновременно, а в третьем потоке агент может пересоздавать класс.


И в Java 11 в ReflectionData добавили новые поля:


private static class ReflectionData<T> {    volatile Field[] declaredFields;        volatile Class<?>[] interfaces;+   // Cached names+   String simpleName;+   String canonicalName;+   static final String NULL_SENTINEL = new String();    }

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


public String getCanonicalName() {    ReflectionData<T> rd = reflectionData();    String canonicalName = rd.canonicalName;    if (canonicalName == null) {        rd.canonicalName = canonicalName = getCanonicalName0();    }    return canonicalName == ReflectionData.NULL_SENTINEL ?                            null : canonicalName;}

В Java 11 в реализации getCanonicalName() все переехало в скрытые методы. Аналогично изменилась реализация getSimpleName(). Понятно, что если обратиться через ReflectionData, возникают дополнительные накладные расходы, где и теряются 1,3 нс.


Class.getConstructor


Последняя оптимизация в этом докладе связана с getConstructor(). Создадим классы с разными конструкторами:


public static class X {  public X() {}}public static class X1 {  public X1() {}  public X1(int p1) {}  public X1(int p1, int p2) {}  public X1(int p1, int p2, int p3) {}  public X1(int p1, int p2, int p3, int p4) {}  public X1(int p1, int p2, int p3, int p4, int p5) {}}public static class X2 {  public X2() {}  public X2(int p1) {}  public X2(int p1, int p2) {}  public X2(int p1, int p2, int p3) {}  public X2(int p1, int p2, int p3, int p4) {}  public X2(int p1, int p2, int p3, int p4, int p5) {}  public X2(int p1, int p2, int p3, int p4, int p5, int p6) {}  public X2(int p1, int p2, int p3, int p4, int p5, int p6, int p7) {}  public X2(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8) {}  public X2(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8, int p9) {}  public X2(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8, int p9, int p10) {}}

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


@Benchmarkpublic Constructor<?> getConstructorX() throws NoSuchMethodException {  return X.class.getConstructor();}@Benchmarkpublic Constructor<?> getConstructorX1() throws NoSuchMethodException {  return X1.class.getConstructor();}@Benchmarkpublic Constructor<?> getConstructorX2() throws NoSuchMethodException {  return X2.class.getConstructor();}

В Java 8, оказывается, существует зависимость от количества конструкторов в программе:



90 нс кажутся перебором, и это заметили разработчики JDK и немного переделали код в Java 9:



Операции стали существенно быстрее, и это особенно заметно в классе с кучей конструкторов. Зависимость осталась, но существенно менее выраженная. Посмотрим, сколько памяти выделяется за одну операцию:



Выделяемая память в этом тесте неплохо коррелирует с временем работы. В Java 8 она зависела от других конструкторов, а в Java 9 стала меньше и уже не зависит. Что же изменилось?


После проверок прав доступа getConstructor() делегирует к приватному методу getConstructor(), который в Java 8 выглядел так:


private Constructor<T> getConstructor0(Class<?>[] parameterTypes, int which) throws NoSuchMethodException{    Constructor<T>[] constructors = privateGetDeclaredConstructors((which == Member.PUBLIC));    for (Constructor<T> constructor : constructors) {        if (arrayContentsEq(parameterTypes, constructor.getParameterTypes())) {            return getReflectionFactory().copyConstructor(constructor);        }    }    throw new NoSuchMethodException(getName() + ".<init>" + argumentTypesToString(parameterTypes));}

Метод принимает желаемые аргументы и уровень доступа конструкторов. Они запрашиваются из ReflectionData с помощью privateGetDeclaredConstructors(). Затем в обычного цикле вида find-first ищем подходящий конструктор, сравнивая типы аргументов. Интересно, что конструкторы и вообще методы возвращаются reflection вовсе не в том порядке, как они объявлены в исходниках, а в произвольном. Поэтому хотя мы во всех трех классах нужный конструктор объявили первым, не факт, что он будет первым в массиве, и нам может потребоваться несколько итераций.


Проблема кроется здесь в методе getParameterTypes(): именно тут выделяется память в цикле. Это публичный метод, который возвращает массив (вообще весь Reflection API возвращает массивы, а не списки, по историческим причинам).


Понятно, что в классе Constructor публичный метод не может взять и вернуть внутренний массив, поэтому он его клонирует:


public final class Constructor<T> extends Executable {        @Override    public Class<?>[] getParameterTypes() {        return parameterTypes.clone();    }     }

Кажется, что проблемы нет, и можно сделать еще один непубличный метод, который не будет копировать, и будем вызывать его. Но не все так просто. Class объявлен в пакете java.lang, а Constructor объявлен в java.lang.reflect, и права доступа необходимо соблюдать. Эту проблему давно решают в стандартной библиотеке, и между определенными методами вырыты кротовые норы, которые позволяют их вызывать. Так поступили в Java 9 с getConstructor0():


private Constructor<T> getConstructor0(Class<?>[] parameterTypes, int which) throws NoSuchMethodException{    ReflectionFactory fact = getReflectionFactory();    Constructor<T>[] constructors = privateGetDeclaredConstructors((which == Member.PUBLIC));    for (Constructor<T> constructor : constructors) {        if (arrayContentsEq(parameterTypes, fact.getExecutableSharedParameterTypes(constructor))) {            return constructor;        }    }    throw new NoSuchMethodException(methodToString("<init>", parameterTypes));}

Здесь мы спускаемся в кротовую нору через вспомогательный приватный класс ReflectionFactory:


package jdk.internal.reflect;public class ReflectionFactory {        private final JavaLangReflectAccess langReflectAccess;    private ReflectionFactory() {        this.langReflectAccess = SharedSecrets.getJavaLangReflectAccess();    }         public Class<?>[] getExecutableSharedParameterTypes(Executable ex) {        return langReflectAccess.getExecutableSharedParameterTypes(ex);    }    }

Класс появился ранее, но в нем сделали новую дырку. Дырка делегирует к разделенным пакетам доступа с JavaLangReflect, и все это уже секретная часть java.base. Пакеты jdk.internal не экспортируются, поэтому простым смертным ковыряться в них не положено.


SharedSecrets, в свою очередь, объявлен в jdk.internal.access и содержит статические поля для различных пакетов, для которых необходимо проковыривать дырки в JDK, в том числе для JavaLangReflect:


package jdk.internal.access;public class SharedSecrets {    private static JavaLangReflectAccess javaLangReflectAccess;    public static void setJavaLangReflectAccess(JavaLangReflectAccess jlra) {        javaLangReflectAccess = jlra;    }    public static JavaLangReflectAccess getJavaLangReflectAccess() {        return javaLangReflectAccess;    }     }

JavaLangReflectAccess это интерфейс в неэкспортируемом пакете, в который добавлен новый метод:


package jdk.internal.access;/** An interface which gives privileged packages Java-level access tointernals of java.lang.reflect. */public interface JavaLangReflectAccess {    /** Gets the shared array of parameter types of an Executable. */public Class<?>[] getExecutableSharedParameterTypes(Executable ex);    }

А сеттер вызывает статический инициализатор класса AccessibleObject:


package java.lang.reflect;public class AccessibleObject implements AnnotatedElement {    static {        // AccessibleObject is initialized early in initPhase1        SharedSecrets.setJavaLangReflectAccess(new ReflectAccess());    }    }

Класс AccessibleObject инициализируется достаточно рано в процессе запуска JVM, поэтому в нужный момент сеттер уже вызван.


Реализация соответствующего интерфейса это уже непубличный класс внутри пакета java.lang.reflect, который наконец позволяет запустить непубличный метод публичного класса в том же самом пакете:


package java.lang.reflect;/** Package-private class implementing the    jdk.internal.access.JavaLangReflectAccess interface, allowing the java.lang    package to instantiate objects in this package. */class ReflectAccess implements jdk.internal.access.JavaLangReflectAccess {        public Class<?>[] getExecutableSharedParameterTypes(Executable ex) {        return ex.getSharedParameterTypes();    }    }

В общем, такой поток данных это слишком скучно:



А вот так окольными путями гораздо интереснее:



Все ради того, чтобы не копировать массив. Кстати, этого нельзя было сделать нормально, пока в Java не появились модули.


Итого


Мы посмотрели 9 оптимизаций, которые были добавлены в Java 9-16, изучили проблемы с производительностью, которые бывают у JDK, и какие подходы используются для их решения. И надеюсь, что данный доклад замотивирует перейти с Java 8 на новые версии тех, кто еще этого не сделал. Спасибо!


Напоследок минутка рекламы. Несмотря на все сложности, Joker 2020 обязательно пройдет, хоть и впервые в онлайн-формате. А это значит, что присоединиться к конференции можно из любой точки мира. Joker 2020 пройдет с 25 по 28 ноября, покупка билетов уже открыта.
Подробнее..

В нативный код из уютного мира Java путешествие туда и обратно (часть 1)

05.10.2020 16:20:50 | Автор: admin


Java и другие управляемые языки просты и удобны во многих случаях, но иногда их возможностей недостаточно например, если нужна библиотека, написанная только на C или C++. Иногда хочется позвать пару методов из системного API, или попытаться улучшить производительность для модуля и тогда прямой путь в нативный код.


Но тут возникают подводные камни: написать нативный метод и вызвать библиотеку может быть и легко, но JVM начинает крашиться в случайных местах, производительность падает, сборщик мусора перестает справляться с работой, а в репозитории царствуют бесконечные C-шные файлы с буквами JNI. Что же могло пойти не так?


Иван Углянский (dbg_nsk) из Huawei разбирается со всем по порядку: что необычного в интеропе между Java и нативным кодом, как оно работало раньше и что нужно делать для их нормальной совместной работы (и можно ли это вообще сделать). Иван рассказывает, как избежать просадок производительности, внезапных OOM и размышляет на тему будущего в контексте проектов Panama и Sulong.


Мы подготовили текстовую версию доклада о работе с нативами в Java. В первой части:


  • Зачем вообще работать с нативным кодом в Java.
  • С какими ошибками и проблемами придётся столкнуться при работе с нативами.

Во второй части подробнее расскажем, какие есть варианты, что из них быстрее и лучше, и есть ли универсальная библиотека всё с примерами кода и подсказками.


Далее повествование от лица спикера.


Сегодня мы говорим про нативный код, про путешествие из Java в него и обратно. Дело в том, что я JVM-инженер, 7.5 лет работал в Excelsior, где мы делали собственную виртуальную машину Excelsior JET, а вот уже чуть больше года работаю в компании Huawei, в команде Excelsior@Huawei, где мы продолжаем заниматься своим любимым делом: компиляторами, JVM и новыми языками программирования.


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


В Java есть такая интересная фича вы можете написать методы без тел, зато со специальным ключевым словом native:


public class JavaToNative {    static native void goNative();    static native void goThere(Callback andBackAgain);}

Это означает, что реализацию этих методов стоит искать где-то ещё, например, в подгружаемых динамических библиотеках. И написана она может быть на каких-то других языках, например, на C/C++ или любом другом языке, где можно сделать C-like бинарные интерфейсы.


Бывают как простые сценарии, так и более сложные, что показывают уже методы на примере выше. Если вызываете первый метод goNative, то просто переходите из Java в C. А вот метод goThere позволяет перейти из Java в C, передать туда Java-объект callback и вызвать от него уже Java-метод.



Таким образом, во время исполнения вашего приложения в call stack могут чередоваться java и нативные фреймы.


Зачем нам нужны нативы


Java замечательный managed-язык, в котором очень много всего сделано для вашего удобства.


Там есть автоматическое управление памятью, и вы, наверное, уже отвыкли от проблем, типа утечек памяти, висячих ссылок и прочего всё это осталось где-то в районе C, а в Java есть GC, который с этим хорошо справляется.


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


Получается, что Java это такой Шир из Средиземья: абсолютно безопасное, удобное, приятное для жизни место, где все стараются сделать так, чтобы у вас всё было хорошо, и ничего не ломалось.


Если вы не будете выходить за его границы, то, скорее всего, ничего плохого действительно не произойдет.


А вот нативный код это его полная противоположность. Это Мордор, где шаг влево-вправо, и вас сжирает горный тролль.


Но знаете, иногда нужно выходить из уютного Шира и идти в путешествие к Роковой горе.


На то могут быть различные причины. Первый тип мотивации в мире огромное количество библиотек, написанных не на Java или подобных, а на C/С++ языках. От компьютерной графики OpenGL до машинного обучения Tensorflow, от огромного количества матбиблиотек до сертифицированной ФСБ библиотеки криптографии. Всё это не Java, а, скорее всего, С. И чтобы всё это использовать, нужно полагаться на механизмы нативных методов.


Кроме того, вы можете захотеть получить что-то напрямую от операционной системы. Допустим, вы хотите узнать, какой прокси стоит у вашего пользователя напрямую из Java вы этого не сделаете, вам опять-таки нужно опуститься на уровень нативного кода и дёрнуть метод, например, из WinAPI в случае Windows.


Есть ещё одна мотивация. Многие люди привыкли думать, что Java тормозит, а вот C++ это очень быстро. Поэтому если взять и переписать самый performance critical модуль проекта на плюсы, связать всё это через нативы, то получится огромное ускорение производительности. Почему эта мотивация довольно сомнительная, я покажу ниже, но в любом случае она присутствует.


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


И вот вы полны энтузиазма, написали своё приложение наполовину на С, наполовину на Java, запускаете, ожидаете, что сейчас всё ускорится, а в результате получаете SIGSEGV, Exception_Access_Violation или ещё один SIGSEGV.

В общем, ваше путешествие из Шира в Мордор заканчивается очень быстро, как у Боромира. Развал страшный, выглядит так, будто вообще сломалась сама виртуальная машина. Некоторые даже репортят баги, мол, JVM развалилась.


На самом деле чаще всего проблема в том, что они неправильно используют нативы.


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


По ходу повествования мы будем все время сверяться вот с такой картой Как позвать натив?

Если вы идете из Шира в Мордор, вам нужно ответить на три вопроса:


  1. Где виртуальная машина должна взять реализацию тех самых нативных методов?
  2. Если вы перешли в натив, то как вы можете взаимодействовать с Java-миром?
  3. Как сборщик мусора должен реагировать на тот факт, что какой-то поток взял и ушёл в натив, что с этим нужно делать?

Ответы на эти вопросы подсветят нам самые больные места в механизме нативных вызовов и помогут избежать проблем.


История до нашей эры


Сначала чуть-чуть истории.

Нативы можно было вызывать в Java ещё в самом начале, буквально в JDK 1.0 уже был Native Method Invocation, который позволял вызывать C-шные методы. Но он был заточен на детали реализации одной конкретной виртуальной машины, а именно на Sun JVM. На то, как там лежат объекты в памяти, какой сборщик мусора там используется.


Были и альтернативы. Например, Microsoft предлагала свой Raw Native Interface. Он был в чем-то лучше, в чем-то хуже, но тоже работал только с одной виртуальной машиной теперь уже Microsoft J++.


Были попытки сделать нейтральные решения, как у Netscape, но в целом это были тёмные времена. Когда вы писали натив, вы не могли быть уверены, что это будет работать на всех JVM или хотя бы на каких-то.


Наша эра: JNI Java Native Interface


Наша эра начинается с появления знаменитого Java Native Interface или JNI. Это был единый интерфейс, чтобы править всеми, и он был прекрасен, потому что был JVM нейтрален.


Он никак не затачивался на то, как сделана конкретная виртуальная машина, не важно, какая раскладка по объектам в памяти, неважно какой GC.


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


Давайте посмотрим, как это работает.


Со стороны Java всё выглядит довольно мило, вы это уже видели.


public class JavaToNative {    static native void goNative();    static native void goThere(Callback andBackAgain);    public static void main(String[] args) {        System.LoadLibrary(NativeLib);        goThere (new Callback(Eagles));}

Пишем методы без тела, пишем где искать реализацию, например, в System.LoadLibrary говорим подгрузить dll-ку, и после этого просто вызываем этим методы и переходим в С или С++.


Callback это просто класс, у которого есть метод call, ничего не возвращающий, который печатает строку Ok, we are in Shire again!, в моём случае мы вернулись в Шир на орлах.


Как получить заголовку функций?


Теперь давайте попробуем написать нативную часть на языке С.


Здесь всё будет уже не так красиво, но нам нужно это сделать.


public class JavatoNative {    static native void goThere (Callback and BackAgain);}

Допустим, у вас был какой-то класс, где вы описали нативный метод без тела. Чтобы получить реализацию вы в первую очередь компилируете его с помощью javac с флажком -h.


javac JavaToNative. java -h .

В таком режиме javac ищет все нативы и генерирует сишный .h-файл с заголовками соответствующих уже сишных функций.


В результате мы получаем JavaToNative.h со всеми заголовками, но при этом то, что там написано, не очень-то похоже на нашу функцию.


JNIEXPORT void JNICALL Java_JavaToNative_goThere    (JNIEnv *, jclass, jobject);

Здесь появились какие-то заклинания типа JNICall. Здесь совсем другое имя метода: оно содержит еще и package и имя класса. И сигнатуры отличаются! У нас был 1 аргумент типа Callback, а здесь их уже три и они совсем другие.

Что за аргументы?


jclass появился, потому что натив был статическим и этим параметром передается Java-класс, чей статический метод вызывается. Callback превратился в jobject и появился новый JNIEnv со звёздочкой (про него чуть позже).


Правила, по которым генерируются заголовки, очень четкие и описаны в JNI-спецификации. Все примитивные типы превращаются в соответствующие примитивные C-шные (заданные макросами и базирующиеся на С-шных примитивных типах), все референс-типы превращаются в jobject или в редких исключениях в его наследников jclass, jstring, jthrowable, jarray.


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

Даже если вы хорошо знаете JNI спецификацию, не советую вам руками писать эти заголовки, javac замечательно справляется сам, если указать ему -h. Так что лучше использовать его, чтобы свести к минимуму человеческий фактор.


Что за JNIEnv?


Аргумент JNIEnv * это указатель на таблицу из 214 специальных функций, которая называется JNINativeInterface.


Вот некоторые из них:

А вот некоторые важные из них, которые, скорее всего, чаще всего используются.

JNINativeInterace помогает нам программировать на метауровне как будто бы на Java, но используя мета-сущности: handle для классов, методов и так далее. Например здесь вы можете получить handle Java-класса, через него создавать его экземпляры (Java объекты), вызывать Java методы через специальные функции Call*Method, выбрасывать исключения.


Это очень похоже на рефлексию, только вы занимаетесь этим не в Java-коде, а в C.


Все эти функции JNI-интерфейса единственный способ хоть как-то взаимодействовать с Java-миром: либо с объектами, либо просто получить информацию от виртуальной машины.


И это ответ на второй вопрос в нашей карте: как взаимодействовать с JVM. Вот так через 214 функций, которые являются вратами в Шир.


Теперь давайте напишем нашей функции тело.


/* * Class:   JavaToNative * Method:  goThere * Signature: (LCallback;)V */JNIEXPORT void JNICALL JAVA JavaToNative goThere (JNIEnv * env, jclass klass, jobject andBackAgain) {    printf("ok, we are in Mordor now! \n");    jclass cls = (*env) ->GetObjectClass (env, andBackAgain);    jmethodID method = (*env)->GetMethodID(env, cls, "call", "()V");    (*env)->CallVoidMethod(env, andBackAgain, method);}

Делаем .c файл, копируем туда наш заголовок из .h, открываем фигурные скобки и начинаем писать обычный C-шный код. В первую очередь я printf-ом напечатаю, что мы пришли в Мордор, а потом я хочу вернуться в Java, позвать Callback.


Для этого я получаю jclass, соответствующий классу моего аргумента, нахожу в нём метод, который называется call, возвращающий void, и вызываю этот метод с помощью JNI-функции CallVoidMethod. Должна напечататься строка, что мы вернулись на орлах и всё ок.


Как все это собрать?


Наконец, давайте обсудим, как все полученное ранее собрать.
Я использую Windows, поэтому гуглю заклинание, как собрать нативную библиотеку для JNI на этой системе:

В результате у нас получается библиотека NativeLib.dll.


Это, конечно, довольно неприятно с точки зрения кроссплатформенности. Потому что, если вы собираете библиотеку для Linux или macOS заклинания будут другими.


К счастью, есть замечательные тулы, которые позволяют от всего этого абстрагироваться. Например, Nokee plugins. Это кроссплатформенное решение, которое позволяет удобно добавить таргет в gradle скрипт и в результате собрать библиотеку под интересующие вас платформы.


Окей, тем или иным способом мы библиотеку собрали, после чего запускаем наше Java приложение, и получаем

Ура, мы только что совершили свое первое путешествие в Мордор и вернулись обратно. Теперь давайте поговорим, что же при этом может пойти не так. Кроме того, что нам пришлось пописать на не самом приятном языке C, да и выглядит это все довольно ужасно.


Что может пойти не так?


А пойти не так может очень много вещей


В первую очередь, когда вы переходите в нативный код, вы теряете статическую типовую информацию.


Да, вы передавали объект callback, но он превратился в jobject, и какой был тип изначально сходу не видно.


Допустим, у меня был бы какой-то другой аргумент, теперь уже java.lang.Object. И он бы тоже представлялся в нативном коде, как jobject, а потом я могу совершенно случайно по невнимательности позвать CallVoidMethod, передав туда в качестве аргумента не Callback, а какой-то java.lang.Object и попытаться из него позвать метод call (которого там, конечно, нет).

Меня не остановит компилятор, не остановит runtime ровно до тех пор, пока не случится развал из-за попытки позвать call от java.lang.Object.

Абсолютно похожая история с тем, какую конкретно JNI-функцию вы вызываете. Никто не проконтролирует, что вы используете именно СallVoidMethod, а не CallBooleanMethod или CallStaticVoidMethod или ещё что-то это будет ваша ответственность. Если вы ошиблись, то случается неопределенное поведение (прям как в плохих программах на С), что начнет делать виртуальная машина неизвестно.


Еще один момент, на который стоит обратить внимание: когда вы вызываете из натива Java-метод, он вполне может выбросить исключение, после чего исполнение возвращается в натив. В Java мы привыкли, что необработанное исключение автоматически пролетает дальше, ничего дополнительного делать не нужно. Но в данном случае это снова ваша ответственность! Вы должны проверить, а не случилось ли при вызове Java-метода исключения (с помощью функций ExceptionCheck или ExceptionOccurred), и если так, то обработать его здесь (с помощью ExceptionDescribe и ExceptionClear). Если же вы этого не сделаете, то в следующий раз, когда исполнение придет в Java-код, это исключение полетит уже совсем из другого неожиданного для вас места, и вы снова получите некорректное поведение.


К счастью, 90% подобных проблем с нативами помогает решить волшебная опция: -Xcheck:jni.


Возьмем наш пример, где мы подставляли неправильный аргумент, и запустим с -Xcheck:jni.

Вместо страшного и ужасного развала вы получите привычное исключение с понятным stacktrace, где будет написано, что вы перепутали MethodID или же используете не тот объект при вызове (что, собственно, у нас и происходит!).

Это помогает быстро понять проблему и разобраться в большем проценте ошибок с нативами.


Конечно, не стоит всегда добавлять -Xcheck:jni. Это утяжеляет реализацию JNI-функций, но я очень рекомендую хотя бы в отладочных билдах прогнать приложение с ней. Она напишет вам кучу warning, возможно, сколько-то ошибок, и, после того, как вы все это поправите, вы получите относительно надежное приложение, работающее с нативным кодом.


Это не означает, что будут вылечены все проблемы, но все самые простые да.


Garbage Collector и Native-код


А теперь поговорим про последний пункт в нашей карте как GC должен взаимодействовать с нативным кодом.


Почему про это вообще нужно говорить? Дело в том, что в Java коде, когда JVM нужно пособирать мусор, она приостанавливает Java потоки в специальных сгенерированных компилятором точках, которые называются GC safepoints. Давайте для простоты рассматривать случай StopTheWorld-коллекторов. В таком сценарии только после того, как все Java-потоки достигли ближайших safepoints и приостановились, начинают работать GC-треды, которые, собственно, собирают мусор.

Это важно, потому что GC может двигать объекты во время своей работы. Для компактизации кучей, для своих каких-то целей неважно. Если в этот момент кто-то из Java-тредов будет смотреть и взаимодействовать с Java хипом читать или записывать поля некоторого Java объекта, то может случится неприятная ситуация: этот объект просто украдут у него из-под носа и перенесут в другую часть памяти. В результате вы получите некорректное поведение (например, развал).


Так вот проблема с safepoints в том, что в нативном коде такой фокус не пройдет.


Safepoints вставляют компиляторы из JVM, а если это какой-то внешний код, например на C или C++, скомпилированный clang-ом, то там нет никаких safepoint! В результате, мы просто не сможем остановить наши потоки, которые исполняют натив, чтобы пособирать мусор. Поэтому мы вынуждены смириться с тем, что нативы будут работать параллельно со сборкой мусора.


И тогда схема меняется так: появляются новые действующие лица, треды, исполняющие нативный код. Допустим, они ушли в натив до того, как нам потребовалось пособирать мусор, и вот они спокойно будут работать параллельно с GC-тредами.

Есть ограничения. На входе в натив нам нужно сказать сборщику мусора: мы ушли в натив, не жди нас, спокойно собирай мусор. На выходе надо проверить, а не идет ли сейчас сборка мусора, и если идёт приостановиться.


Но при этом всё ещё возникает проблема: даже в нативе вы не имеете права трогать Java-объекты, которые сейчас может взять и двигать GC.


Как вы помните, все наши Java-объекты в нативах почему-то превратились в jobject.


Оказывается, что jobject не просто маппинг для Java-ссылок, а специальные низкоуровневые хендлы, которые внутри инкапсулируют адрес на реальный Java-объект.

Гарантируется, что Java-машина поддерживает связь этого адреса с реальным адресом объекта. То есть, если мы подвинули объект, то соответствующий jobject тоже будет пропатчен автоматически.

С другой стороны, единственный способ повзаимодействовать с Java-миром из натива это JNI-функции, которые также работают с jobject. Почти во всех из них стоит синхронизация с GC, так что вы не сможете сделать с объектами ничего плохого, пока идет сборка мусора.

Если последним использованием ваших объектов была передача их в нативный код, то гарантируется, что за время исполнения этого натива их никто не соберет. Эти jobject являются своеобразными GC-root, что гарантирует выживание объекта.


Поговорим о том, какие проблемы это может вам доставить.


JNI References


Первая и главная проблема в том, что для хендлов реализована альтернативная система управления памятью. Это не похоже ни на Java, ни на C, скорее, что-то среднее между ними. Всё, что вы в коде видите, как jobject, на самом деле является сложным объектом JNI Reference, причем они бывают трех разных типов.


Во-первых, local references.


Они называются так, потому что они существуют не дольше, чем исполняется нативный метод, в котором был создан local reference (полная аналогия с локальными переменными).


Они интересны, во-первых, тем, что большинство JNI-reference это именно LR. Передали какие-то Java-аргументы в натив они автоматически заворачиваются в локалрефы, вызываете JNI-функцию, создающую объект из нее тоже вернется локалреф. А во-вторых, с этими штуками, несмотря на, казалось бы, очень естественную и простую схему очистки, чрезвычайно легко получить утечку памяти.


Продемонстрирую это на небольшом примере:


JNIEXPORT void JNICALL Java_JavaToNative_objectsAllocationTest (JNIEnv *env, jclass klass) {    jclass cls = (*env)->FindClass(env, "BornInNative");    jmethodID init = (*env)->GetMethodID(env, cls, "<init>", "(I)V");    jmethodID check = (*env)->GetMethodID(env, cls, "areYouReady", "()Z");    int ready = 0, id = 0;    while (!ready) {        jobject obj = (*env)->NewObject(env, cls, init, id++);        ready = (*env)->CallBooleanMethod(env, obj, check) == JNI_TRUE;    }    printf ("finally ready after %d objects created!\n", id);}

Здесь мы будем аллоцировать в огромных количествах объекты прямо из нативного кода. Для этого находим соответствующий класс BornInNative с помощью JNI-функции FindClass, а получаем конструктор и метод-предикат, который будет говорить по соответствующему инстансу, нужно ли создавать следующий объект или нет. А потом просто в нативном коде с помощью JNI-функции NewObject начинаем эти объекты создавать.



NewObject аллоцирует память, вызывает конструктор, который создает объект и возвращает в нативный код ту самую local reference, которую затем сохраняем в переменную obj типа jobject. От неё вызываем предикат, чтобы понять, нужно ли дальше аллоцировать объекты или нет.


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


На Java бы всё работало, но в нативном коде вам такого никто не гарантирует. Про Local reference гарантируется, что они умирают не позже, чем возврат из нативного метода. Но это и все: сами по себе от того, что вы переназначили переменную на другую LR, они умирать не обязаны и не будут.



Чтобы это показать, давайте залогируем, получилась ли очередная аллокация или нет, и запустим всё это на hotspot с -Xmx 1 ГБ.



Через несколько сотен миллионов итераций мы заметим, что аллокации стали фейлиться. JVM сейчас в коматозном состоянии, она пытается выкинуть out of memory, но ничего не получается, ведь в нативе мы это не обрабатываем. Обратите внимание на потребление памяти.



Вы заказывали 1 ГБ, но потребление на самом деле уже 2 ГБ, потому что а) все Java-объекты удерживаются в heap, б) сами неумирающие jobject тоже занимают (нативную) память. В результате реальное потребление памяти вашим приложением превысило указанный лимит на дополнительный гигабайт.


Чтобы это починить, есть специальная функция DeleteLocalRef, которая говорит JVM, что локальная ссылка больше не нужна, ее можно уничтожить, а соответствующий объект собрать во время GC.



Исправленная программа будет работать с любым разумным Xmx.


Так что с local Reference легко получить memory leak, но также легко получить и висящую ссылку. Попробуйте сохранить LR в static-поле, выйти из натива, вернуться и прочитать это поле. Получите некорректное значение.


Кроме LR есть другие хендлы, например Global Reference. Такие ссылки существуют до тех пор, пока вы явно их не освободите. Здесь ещё легче получить утечку памяти (достаточно просто забыть вызвать DeleteGlobalRef), но с другой стороны они более прямолинейны, нет неожиданностей. Забыли позвать DeleteGlobalRef значит, будет утечка.


Наконец есть Weak Global Reference, это GR, но в них не гарантируется, что GC не соберет ваш объект. Это полная аналогия со слабыми ссылками из Java. Таким образом, все проблемы с ними актуальны и для нативов тоже.



Еще больше сложностей с GC


Кроме проблем с JNI Reference стоит упомянуть, что у некоторых функций JNI-интерфейса есть очень интересные отношения со сборщиками мусора. Допустим, вы передаете в натив массив, он завернется в jobject, но получать доступ к каждому элементу по одному через jni-функции это очень долго.


Вместо этого вы наверняка захотите получить доступ ко всему региону данных из массива за раз. Для этого есть специальные функции, например, GetIntArrayElements. Однако у нас опять есть проблема: мы не можем получить доступ к объекту, если в этот момент его может подвинуть GC. С этим нужно что-то сделать.


Есть две техники, как это можно реализовать. Во-первых, можно запинить объект, сказать сборщику мусора давай мы не будем двигать пока массив, ты собирай мусор, а его не двигай.


Вторая тактика просто скопировать его в нативную память, в нативе поработаем с копией, а потом обновим соответствующий массив.


JNI функции типа GetIntArrayElements даже поддерживают такую двойственность решения этой проблемы: у них есть третий аргумент указатель на флажок. Если виртуальная машина решилась скопировать, то туда запишется true, если нет, то false, так что вы узнаете, что конкретно произошло.


Подводный камень здесь в том, что большинство виртуальных машин и сборщиков мусора не умеют pin-ать объекты по одному. Есть исключения, но скорее всего, как бы вы не надеялись на то, что копирования не случилось, оно произойдет. Так что при работе в нативе с массивом на 2 ГБ вы столкнетесь с копированием его в нативную память, что, конечно, может ударить и по производительности, и по общему потреблению памяти вашим приложением.

Конечно, есть особенные JNI-функции типа GetArrayElementsCritical (и другие функции с суффиксом Critical), они всячески стараются не скопировать массив.


Пиннинга в большинстве GC нет, как они выходят из ситуации?


Они говорят: Давайте на время исполнения этой функции вообще не будет сборки мусора, пусть GC подождёт. Это может сработать и дать хорошую производительность, вы поработаете без копий, но есть и обратная сторона медали.


Вы отодвигаете GC на неопределенный срок, что уже плохо само по себе, но при самом плохом сценарии вы можете просто получить дедлок и зависание вашего приложения. Подробнее про это можете почитать в посте Алексея Шипилёва.


Производительность нативных методов


И наконец, нельзя говорить про нативы и не обсудить их производительность. Раз вы вызываете C-код, то, конечно, кажется, что это должно чертовски быстро работать по сравнению с обычной Java. На самом деле это большое заблуждение. Дело в том, что сам вызов нативных методов это серьезная сложность для виртуальной машины. Давайте измерять!


Все замеры будем проводить на машине: Intel Core i7-7700 @ 3.60 GHz;16GB RAM, Linux Ubuntu 18.04


Начнем с простого примера. Мы из Java вызываем другой Java метод без параметров и обязательно без инлайна. Мерим это с помощью JMH, получаем 696 попугаев, (больше лучше).

Проведем другой эксперимент и вызовем из Java нативный метод, тоже пустой, без параметров и возвращаемого значения. И получаем просадку производительности в 3,3 раза на jdk8u252.

При этом на jdk11 вы уже получаете просадку уже в 6 раз. Причины такой разницы в поведении разных версий Java рассмотрим в конце доклада, а сейчас продолжим наши измерения.

Теперь давайте проведем более зловещий эксперимент и вызовем из Java натив, а оттуда через callback позовём пустой Java-метод. Логично предположить, что здесь случится проседание раза в два (ведь стало в два раза больше работы). На самом деле просадка будет в 10 раз.

Т.е.возвращаться обратно из Java в натив дороже, чем просто уходить в натив.


Почему так происходит?


Если вы вызываете нативный метод, то, конечно, в сгенерированном коде хочется увидеть просто инструкцию call, вызывающую этот метод по какому-то адресу.

И вы этот call получите, но вокруг него есть ещё некоторое количество работы для подготовки к вызову и обработки результата.

Более конкретно кроме самого вызова нам нужно:


  1. Синхронизироваться с GC, объявить, что вы ушли в натив или вернулись.
  2. Если есть аргументы, то завернуть их в Local References. При этом один параметр-то есть всегда, даже в нашем случае это либо jclass для статического метода, либо объект, от которого вызвали метод в случае instance-метода.
  3. На выходе нужно сделать exception check, проверять, не полетело ли исключение.
  4. Есть системная работа: выравнивание стека, перекладывание параметров с одних регистров на другие, и так далее.

И всё это даёт просадку производительности в шесть раз.


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


Ну и про возвращение обратно в Java так медленно работает из-за реализации конкретной виртуальной машины Hotspot. Когда вы делаете callback, происходит много лишней и тяжелой работы, в других виртуальных машинах результат мог бы быть гораздо лучше.


На этой позитивной ноте мы заканчиваем разговор про JNI, и вот список практических советов по первой части доклада, следуя которым, вы скорее всего избежите неприятных проблем и развалов.

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


В следующей части поговорим про сегодняшние альтернативы JNI, их сильные и слабые стороны, а также обсудим будущие проекты, которые вполне могут кардинально поменять все наше представление о нативах в Java: проекте Panama и Sulong.


Минутка нативной рекламы в тексте про нативный код. Раз вы здесь похоже, вы Java-разработчик, который не боится покидать уютную хоббичью нору и покорять что-то новое для себя. В таком случае на конференции Joker (25-28 ноября, онлайн) наверняка будет интересное для вас можете сами посмотреть программу на сайте.
Подробнее..

Project Loom Современная маcштабируемая многопоточность для платформы Java

19.02.2021 18:05:42 | Автор: admin


Эффективное использование многочисленных ядер современных процессоров сложная, но всё более важная задача. Java была одним из первых языков программирования со встроенной поддержкой concurrency. Ее concurrency-модель, основанная на нативных тредах, хорошо масштабируется для тысяч параллельно выполняющихся стримов, но оказывается слишком тяжеловесной для современного реактивного программирования с сотнями тысяч параллельных потоков.


Ответ на эту проблему Project Loom. Он определяет и реализует в Java новые легковесные параллельные примитивы.


Алан Бейтман, руководитель проекта OpenJDK Core Libraries Project, потратил большую часть последних лет на проектирование Loom таким образом, чтобы он естественно и органично вписывался в богатый набор существующих библиотек Java и парадигм программирования. Об этом он и рассказал на Joker 2020. Под катом запись с английскими и русскими субтитрами и перевод его доклада.



Меня зовут Алан Бейтман, я работаю в группе Java Platform в Oracle, преимущественно над OpenJDK. Сегодня я буду говорить о Project Loom.


Мы занялись этим проектом в конце 2017 года (точнее, технически в начале 2018-го). Он появился как проект в OpenJDK для того, чтобы упростить написание масштабируемых многопоточных приложений. Цель в том, чтобы позволить разработчикам писать масштабируемые многопоточные приложения в так называемом синхронном стиле. Это достигается путем доведения базовой единицы многопоточности потока до такой легковесности, чтобы им можно было представлять любую параллельную задачу. Даже задачи, которые блокируются или выполняются в течение длительного времени.


Кто-то говорит: чем больше несвязанных запросов поступает в серверное приложение, тем большей степени многопоточности можно достичь.


План выступления такой:


  1. Начну с пары слов о мотивации этого проекта.
  2. Поговорю о том, как мы имплементировали эти так называемые легкие потоки.
  3. Переключусь на IDE и покажу несколько демо, напишу немного кода.
  4. Наконец, рассмотрю другие аспекты проекта.

Потоки


Платформа Java (и язык, и JVM) во многом построена на концепции потоков:


  • Если вы сталкиваетесь с исключением, то получаете трассировку стека определенного потока.
  • Вы можете связать некоторые данные с потоками, используя ThreadLocal.
  • Если вы находитесь в отладчике и выполняете пошаговое выполнение кода, вы шагаете по выполнению потока. Когда вы нажимаете step over, это означает переход к следующей инструкции в потоке, с которым вы работаете.
  • А когда вы находитесь в профайлере, профайлеры обычно группируют данные по потокам, сообщают вам, какие потоки выполняются и что они делают.

В общем, всё, что касается платформы и инструментов, связано с потоками.


В Java API поток означает java.lang.Thread. В реализации JDK есть только одна реализация потока, которая фактически основана на потоке операционной системы. Между java.lang.Thread и потоком ОС существует связь один-к-одному. Те из вас, кто уже давно работает с платформой Java, могут вспомнить зелёные потоки в ранних выпусках JDK. Я немного расскажу об этом позже. Но по меньшей мере последние 20 лет, когда мы говорим о java.lang.Thread, мы говорим о тонкой оболочке вокруг потока ОС.


Cами потоки ОС ничего не знают о Java. Они очень общие. Обычно они должны поддерживать множество разных языков и сред выполнения. Они ничего не знают о том, как эти языки и программы используют стек, и им обычно приходится выделять очень большой фиксированный стек, чтобы поддерживать универсальность
программ, которые должны выполняться.


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


Еще одна особенность потоков операционной системы заключается в том, что ядро ОС должно иметь некоторую форму планирования. Ему нужно выбрать, какой поток
запускать на каждом ядре процессора. Веб-серверы ведут себя совсем иначе, чем, например, поток с вычислениями для воспроизводения видео. Планировщик ОС должен быть очень общим и в некотором роде компромиссным, чтобы поддерживать все различные варианты использования.


Давайте немного поговорим об использовании потоков. Можно писать многопоточные приложения в стиле, который мы называем синхронным. Простой, императивный блокирующий синхронный код. Задачи выполняются в одном потоке от начала до конца. Код обычно очень легко читать и писать. Именно так большинство из нас, я думаю, учились его писать. Мы научились работать так с одним потоком до того, как узнали, что такое поток.


Этот синхронный стиль очень хорошо сочетается с дизайном языка Java. Он очень хорошо сочетается с инструментами. И в целом, как любит говорить мой коллега Рон Пресслер, гармонично сочетается с платформой. Но поскольку поток по сути представляет собой тонкую оболочку вокруг потока ОС, это ограниченный ресурс.


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


А с современным сервером теоретически вы можете иметь миллионы сетевых подключений. Я видел, как Хайнц Кабуц делает демо с Project Loom, где он фактически использовал два миллиона соединений. И серверы могут поддерживать подобное, если у них достаточно памяти.


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


Ладно, что нам с этим делать?


Если потоки дорогой ресурс, почему бы нам ими не делиться? Это означает пулы потоков. Вместо создания потока для каждого запроса или транзакции мы заимствуем поток из пула, выполняем транзакцию, а затем возвращаем его в пул.


Но у пулов много проблем. Думаю, большинство тех, кто их использовал, знает о проблеме того, как они засоряют ThreadLocals. Эта так называемая утечка памяти с пулами потоков существует долгое время. Также проблематична отмена: если я хочу отменить транзакцию, но она почти завершена, я, возможно, прерываю поток после того, как он был взят для другой транзакции.


И даже если бы мы решили приведенные проблемы, этого все равно недостаточно, потому что мы все еще выполняем транзакцию от начала до конца в одном потоке. Это занимает его на протяжении всей операции, а уровень многопоточности по-прежнему ограничен количеством потоков, которые может обработать ОС.


Мы можем заметить, что для большого количества запросов и транзакций,
которые не упираются в CPU, поток проводит большую часть своего времени в ожидании, в блокировке, ожидая базу данных, IO, что-то еще. А нам, на самом деле, не нужен поток, когда он ожидает.


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


Это приводит нас к созданию новых API, по существу, несовместимых со старыми. Или в итоге у нас есть синхронные и асинхронные версии API.


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


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


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


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


Что приводит нас к дилемме.



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


Итак, как нам решить эту дилемму? Что, если бы мы могли снизить стоимость потоков и иметь их неограниченное количество? Тогда мы могли бы написать простой синхронный код, который гармонирует с платформой, полностью использует оборудование и масштабируется как асинхронный код. Project Loom именно об этом.


API


Давайте пойдем дальше и поговорим немного об API.


Если Project Loom снижает стоимость потоков, то как это будет отражаться на разработчиках и на API? Эта проблема сложнее, чем кажется на первый взгляд, и мы потратили более двух лет на борьбу с ней.


Один из вариантов, с которого мы начали и к которому в итоге вернулись, это
использование для легких форм потоков java.lang.Thread. Это старый API, который существует с JDK 1.0. Проблема в том, что у него много багажа. Там есть такие вещи, как группы потоков, загрузчик классов контекстов потоков. Есть множество полей и других API, которые связаны с потоками, которые просто не интересны.


Другой вариант начать все сначала и ввести совершенно новую
конструкцию или новый API. Если вы с самого начала интересовались Project Loom, возможно, вы видели некоторые из ранних прототипов, где мы представили для дешевых легких потоков совершенно новый API под названием fiber.


Помимо изучения API, мы также много изучали, как люди используют потоки. Оказалось, что одни их части используются очень широко, другие в меньшей степени. Thread.currentThread() используется и прямо и косвенно везде, например, для блокировки.


Вопрос, который часто возникает в викторинах: Сколько раз Thread.currentThread() используется при первом использовании популярной библиотеки логирования? Люди, не знающие ответа на этот вопрос, могут ответить 2 или 5. Правильный ответ 113.


Другой широко используемый аспект потока это ThreadLocals. Они используются везде, что иногда не радует. Если сломать Thread.currentThread() или ThreadLocals, то в контексте этих новых более дешевых потоков будет не запустить много уже существующего кода. Поэтому вначале, когда у нас был fiber API, нам пришлось эмулировать Thread API, чтобы существующий код запускался в контексте того, что называлось в то время fiber. Таким образом, мы могли уйти от кода, использующего Thread, не повредив нарыв.


Итак, .currentThread() и Threadlocals очень широко используются. Но в потоках есть и редко используемый багаж. И здесь нам немного помогает расширенная политика депрекации. Если некоторые из этих старых областей со временем могли бы исчезнуть, подвергувшись депрекации, окончательной депрекации и, в конечном итоге, удалению тогда, может быть, удастся жить с java.lang.Thread.


Два года исследований, около пяти прототипов и мы пришли к выводу, что избежать
гравитационного притяжения 25 лет существующего кода невозможно. Эти новые дешевые потоки будут представлены с существующим API java.lang.Thread. То есть java.lang.Thread будет представлять и потоки ОС, и новые дешевые потоки.


Мы также решили дать этим новым потокам имя. Оно появилось благодаря Брайану Гетцу, он придумал название виртуальный поток (virtual thread).


Использование привычного Thread хорошая новость для разработчиков. Нет новой модели программирования, нет новых концепций для изучения, вместо этого вам фактически придется отучиться от некоторых старых привычек. Когда я говорю отучиться, я имею в виду такие вещи, как пулы потоков, ThreadLocals и так далее.


Как реализованы эти виртуальные потоки?



Они мультиплексируются поверх небольшого пула потоков операционной системы. Я сказал потоки во множественном числе, и вот тут уместно вспомнить уже упомянутые green threads. Ранние выпуски JDK, особенно 1.0.1.1 с классической виртуальной машиной, поддерживали модель, где потоки мультиплексировались в один-единственный поток ОС. То, что мы делаем теперь, перекликается с этим, но сейчас речь о более чем одном потоке ОС.


Итак, у нас есть набор потоков, на которые эти виртуальные потоки мультиплексируются. Под капотом виртуальная машина HotSpot была обновлена для поддержки новой конструкции: scoped stackful one-shot delimited continuations. Виртуальные потоки объединяют континуации в HotSpot с планировщиками в библиотеке Java. Когда код, выполняющийся в виртуальном потоке, блокируется, скажем, в операции блокировки или в блокирующей IO-операции, соответствующая континуация приостанавливается, стек потока, на концептуальном уровне, вымещается в кучу Java, а планировщик выберет и возобновит другой виртуальный поток в этом же потоке ОС. Исходный виртуальный поток может быть возобновлен в том же потоке ОС или в другом.


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


Пользовательский код, использующий API Java, не знает о распределении, которое
происходит под капотом, а yield и resume происходит глубоко в библиотеках JDK, поэтому мы говорим, что планирование является вытесняющим и не требует сотрудничества со стороны кода пользователя.


С точки зрения стоимости, поток ОС слева, виртуальный поток справа.



Обычно операционная система резервирует около мегабайта стека для потока операционной системы. Некоторые ядра выделяют дополнительные данные ядра, и 16КБ не редкость. Это то, что операционная система имеет на поток ОС. Кроме того, виртуальная машина HotSpot добавляет к этому пару КБ метаданных.


Виртуальные потоки намного дешевле, текущий прототип составляет около 256 байт на виртуальный поток. Еще есть стек, он уменьшается и увеличивается по мере необходимости и обычно составляет пару КБ в этом главное преимущество
виртуальных потоков перед потоками ОС.


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


Самое время перейти от слайдов к IDE и показать вам несколько примеров в коде.


Демо


У меня открыта IDE с пустым методом, и мы начнем с самого начала.


import ...public class Demo {    public static void main(String[] args) throws Exception {...}    void run() throws Exception {    }}

Я упомянул, что мы ввели новый фабричный метод, и начну с использования фабричного метода Thread.startVirtualThread().


import ...public class Demo {    public static void main(String[] args) throws Exception {...}    void run() throws Exception {        Thread thread = Thread.startVirtualThread(() -> System.out.println("hello"));        thread.join();    }}

Вывел сообщение hello, ничего особенного. Это немного отличается от использования конструкторов и метода start(), здесь всего лишь один фабричный метод.


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


void run() throws Exception {    Thread thread = Thread.startVirtualThread(Thread::dumpStack);    thread.join();}

Этот референс-метод просто вызывает дамп стека в контексте виртуального потока.



Возможно, это выглядит немного иначе, чем то, что вы видели бы с обычным java.lang.Thread, потому что фреймы, которые вы видите здесь, не те, что вы видите в обычном JDK. Это своего рода эквивалент запуска потока, потому что виртуальный поток запускает континуацию. Это дает представление о том, в чем вы можете увидеть разницу.


Давайте рассмотрим еще один из аспектов API. Что делает этот startVirtualThread()?


В дополнение к введению фабричных методов для создания виртуальных потоков, текущий прототип имеет новый билдер для создания потоков. Создадим билдер с помощью метода Thread.builder(). Мы можем при этом вызвать несколько методов, позволяющих настроить поток: является ли он потоком-демоном, какое у него имя и некоторые другие аспекты.



В числе этих методов есть virtual(). Создание виртуального потока cо startVirtualThread(), было, по сути, тем же самым. Вот длинная форма того, что я сделал минуту назад:


void run() throws Exception {    Thread thread = Thread.builder().virtual().task(() -> {        System.out.println("hello");    }).start();    thread.join();    }}

Мы снова сделали то же самое многословнее, но теперь использовали билдер потоков. А он избавляет нас от того, чтобы сначала использовать конструктор для создания потоков, а затем вызывать setDaemon() или setName(). Это очень полезно.


Это хорошее улучшение API для тех, кто в конечном итоге использует Thread API напрямую. Запускаем и получаем то же, что и в случае с startVirtualThread().


Еще мы можем создать ThreadFactory.


void run() throws Exception {    ThreadFactory factory = Thread.builder().name(prefix:"worker-", start:0).factory();}

Это создает фабрику потоков она создает потоки, которые называют себя worker-0, worker-1, worker-2 и так далее. На самом деле worker это только начальный аффикс, который добавляется к префиксу. Это еще один полезный способ создания фабрик потоков.


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


Большинство людей фактически не используют Thread API напрямую. Начиная с JDK 5, они перешли на использование ThreadExecutor и других API из java.util.concurrent.


Я хочу показать вам использование одного из этих ThreadExecutor. Мы создадим множество потоков и покажем вам, что на самом деле происходит.


Я собираюсь создать ExecutorService executor:


try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {}

Этот фабричный метод для Executors создает виртуальные потоки. Обратите внимание, что здесь я использую try-with-resources. Одна из вещей, которые мы сделали в Loom, мы модернизировали ExecutorService для расширения AutoCloseable, чтобы вы могли использовать их с конструкцией try-with-resources.


Приятная вещь в том, что когда он закрыт, как на примере сверху, он гарантирует, что все задачи, которые являются потоками в этом случае, будет завершены до того, как завершится закрытие. На самом деле закрытие будет ждать, пока все задачи или потоки не будут завершены, что очень полезно. Этот тип Executor'а создает поток для каждой задачи, поэтому он сильно отличается от пулов потоков, которые вы обычно создаете с помощью фабричного класса Executors.


Давайте создадим здесь миллион потоков.


import ...  public class Demo {      public static void main(String[] args) throws Exception {...}      void run() throws Exception {          try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {              IntStream.range(0, 1_000_000).forEach(i -> {                  executor.submit(() -> { });              });          }      }      String fetch(String url) throws IOException {...}      void sleep(Duration duration) {...}  }

Я использую IntStream.range(), вместо цикла for. Это вызовет метод executor.submit() один миллион раз, он создаст миллион потоков, которые ничего не делают. Если это запустить, ничего интересного не произойдет Process finished with exit code 0.


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


import ...public class Demo {    public static void main(String[] args) throws Exception {...}    void run() throws Exception {        AtomicInteger counter = new AtomicInteger();        try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {            IntStream.range(0, 1_000_000).forEach(i -> {                executor.submit(counter::incrementAndGet);            });        }        System.out.println(counter.get());    }    String fetch(String url) throws IOException {...}    void sleep(Duration duration) {...}  }

Мы создаем Executor, отправляем миллион задач, каждая из этих задач будет увеличивать счетчик, и когда все закончится, выводится значение счетчика. Если все работает правильно, как у нас, должен быть выведен миллион.


Отрабатывает быстро как видите, эти потоки очень дешевы в создании.


Давайте покажу вам, что еще мы можем делать с Executor'ами. У меня есть метод, который просто принимает байты из определенного URL-адреса, создает из него строку. Это не очень интересно разве что то, что это блокирующая операция.


String fetch(String url) throws IOExpection {    try (InputStream in = URI.create(url).toURL().openStream()) {        byte[] bytes = in.readAllBytes();        return new String(bytes, charsetName:"ISO-8859-1");    }}

Он создает сетевые подключения, HTTP-соединение будет ожидать результатов от сервера, мы просто воспользуемся этим как частью примера.


Давайте посмотрим вот на что:


void run() throws Exception {       try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/");           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/en");           String first = executor.invokeAny(List.of(task1, task2));           System.out.println(first.length());       }     }

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


Мы используем executor.invokeAny() и даем ему две задачи.
ExecutorService имеет несколько комбинаторов, invokeAny(), invokeAll(), они существуют уже давно. Мы можем использовать их с виртуальными потоками.


В данном случае мы подставим в first результат, в зависимости от того, какая из этих задач будет выполнена первой.


Я запущу два виртуальных потока. Один из них получит первую страницу, другой вторую, в зависимости от того, что вернется первым, я получу результат в String first. Другой будет отменен (прерван). Запускаем и получаем результат: 200160, то есть одна из страниц размером 200 КБ.


Итак, что произошло: были созданы два потока, один выполнял блокирующую операцию получения данных с первого URL-адреса, другой со второго URL-адреса, и я получил то, что пришло первое. Если запущу еще пару раз, буду получать разные значения: одна из страниц всего 178 КБ, другая 200 КБ.


Это один из комбинаторов. На самом деле, я бы мог хотеть обе страницы и что-то с ними сделать, в этом случае я мог бы использовать invokeAll().


void run() throws Exception {       try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/");           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/en");           executor.invokeAll(List.of(task1, task2)); List>Future>String>>                   .stream() Stream<Future<String>>                   .map(Future::join) Stream<String>                   .map(String::length) Stream<integer>                   .forEach(System.out.println);       }     }

Как видите, это не слишком интересно всё, что мы здесь делаем, это invokeAll(). Мы выполним обе задачи, они выполняются в разных потоках. InvokeAll() блокируется до тех пор, пока не будет доступен результат всех задач, потому что вы получаете здесь Future, которые гарантированно будут выполнены. Создаем поток, получаем результат, получаем длины, а затем просто выводим их. Получаем 200 КБ и 178 КБ. Вот что вы можете делать с ExecutorService.


Покажу вам еще кое-что. В рамках Loom мы немного поработали над CompletableFuture, чтобы вы могли делать подобное. Добавить задачу и получить CompletableFuture, а не Future, и тогда я смогу написать такой код:


void run() throws Exception {       try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/");           Callable<String> task1 = () -> fetch(url:"https://jokerconf.com/en");           CompletableFuture<String> future1 = executor.submitTask(task1);           CompletableFuture<String> future2 = executor.submitTask(task2);           CompletableFuture.completed(future1, future2) Stream<CompletableFuture<String>>                   .map(Future::join) Stream<String>                   .map(String::length) Stream<integer>                   .forEach(System.out.println);       }     }

Я вызываю в CompletableFuture-метод под названием completed(). Это возвращает мне стрим, который заполняется Future в ленивом режиме по мере их завершения. Это намного интереснее, чем invokeAll(), который я показал ранее, поскольку метод не блокируется, пока не будут выполнены все задачи. Вместо этого поток заполняется результатом в ленивом режиме. Это похоже на стримо-подобную форму CompletionService, если вы когда-нибудь такое видели.


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


Еще одна вещь, которую я хочу сделать, забегая вперед. Мы еще поговорим об этом подробнее после демо. В прототипе есть ограничение. Виртуальные потоки делают то, что мы называем закреплением потока ОС, когда мы пытаемся выполнить IO-операции, удерживая монитор. Я объясню это лучше после демо, но пока у меня открыта IDE, покажу вам это на практике и объясню, на что это влияет.


import ...public class Demo {    public static void main(String[] args) throws Exception {...}    void run() throws Exception {        Thread.startVirtualThread(() ->            sleep(Duration.ofSeconds(2));        }).join();    }    String fetch(String url) throws IOException {...}    void sleep(Duration duration) {...}  }

Виртуальный выполняет блокирующую операцию, спит две секунды, завершается. Пока не слишком интересно. Теперь предположим, что он должен спать, пока держит монитор.


void run() throws Exception {        Thread.startVirtualThread(() -> {            Object lock = new Object();            synchronized (lock) {                sleep(Duration.ofSeconds(2));            }        }).join();}

Я запускаю это с диагностическим свойством, которое даст мне трассировку стека, когда поток закреплен.



Мы видим трассировку стека, которая говорит мне, что поток был закреплен. Она снабжена примечаниями, которые говорят мне, где удерживается монитор. Это ограничение в нашем текущем прототипе. По сути, потоки могут блокироваться, удерживая монитор. Поток закрепляет соответствующий поток ОС, эта скорее качество сервиса текущей реализации, чем ошибка. Я вернусь к тому, что мы делаем c этим, через несколько минут. Это своего рода базовое демо.


У меня есть более полная демонстрация, переключусь для этого на немного другой проект.


package demo;import ...@Path("/")public class SleepService {    @GET    @Path("sleep")    @Producers(MediaType.APPLICATION_JSON)    public String sleep(@QueryParam("millis") long millis) throws Exception {        Thread.sleep(millis);        return "{ \"millis\": \"" + millis + "\" };    }}

Уже существуют несколько серверов, которые работают с виртуальными потоками.
Есть сервер Helidon MP. Я думаю, MP означает MicroProfile. Helidon настроен, они недавно внесли некоторые изменения, теперь вы можете запустить его со свойством, при котором он будет запускать каждый запрос в отдельном виртуальном потоке. Мой код может выполнять операции блокировки, и они не будут закреплять базовый поток ОС. У меня может быть намного больше запросов, чем потоков, работающих одновременно и выполняющих блокирующие операции, это действительно очень полезно.


Первый сервис, который я вам покажу, что-то вроде эквивалента hello world при использовании подобных служб. Запускаем код из примера выше, переходим в окно терминала и вводим curl-команду.



Curl-команда кодирует параметр миллисекунд обратно в JSON, который возвращается.
Не слишком интересно, потому что все, что было сделано, это сон. Остановлю сервер и вставлю Thread.dumpStack():


public String sleep(@QueryParam("millis") long millis) throws Exception {    Thread.dumpStack();    Thread.sleep(millis);    return "{ \"millis\": \"" + millis + "\" };}

Снова запущу сервер. Я снова выполняю команду curl, которая устанавливает HTTP-соединение с сервером, она подключается к эндпоинту сна, параметр millis=100.


curl http://localhost:8081/sleep?millis=100


Посмотрим на вывод: печатается трассировка стека, созданная Thread.dumpStack() в сервисе.



Огромная трассировка стека, мы видим здесь кучу всего: код Helidon, код Weld, JAX-RS Довольно интересно просто увидеть это всё. Это сервер, который создает виртуальный поток для каждого запроса, что довольно интересно.


Теперь посмотрим на более сложный сервис. Я показал вам комбинаторы
invokeAny и involeAll в простом демо в самом начале, когда показывал новый ExecutorService.


import ...@Path("/")public class AggregatorServices {    @GET    @Path("anyOf")    @Produces(MediaType.APPLICATION_JSON)    public String anyOf(@QueryParam("left") String left,                        @QueryParam("right") String right) throws Exception {        if (left == null || right == null) {            throw new WebApplicationException(Response.Status.BAD_REQUEST);        }        try (var executor :ExecutorService = Executors.newVirtualThreadExecutor()) {            Callable<String> task1 = () -> query(left);            Callable<String> task2 = () -> query(right);            // return the first to succeed, cancel the other            return executor.invokeAny(List.of(task1, task2));        }    }    @GET    @Path("allOf")    @Produces(MediaType.APPLICATION_JSON)    public String allOf(@QueryParam("left") String left,                        @QueryParam("right") String right) throws Exception {        if (left == null || right == null) {            throw new WebApplicationException(Response.Status.BAD_REQUEST)        }        try (var executor :ExecutorService = Executors.newVirtualThreadExecutor()) {            Callable<String> task1 = () -> query(left);            Callable<String> task2 = () -> query(right);            // if one falls, the other is cancelled            return executor.invokeAll(List.of(task1, task2), cancelOnException: true) List<Future<String>>                    .stream() Stream<Future<String>>                    .map(Future::join) Stream<String>                    .collect(Collectors.joining(delimiter:", ", prefix:"{", suffix:" }"));        }    }    private String query(String endpoint) {...}}

Здесь у нас несколько сервисов, они находятся в этом исходном файле под названием AggregatorServices. Здесь есть две службы, два метода я бы сказал: anyOf и allOf. anyOf выполняет левый и правый запросы и выбирает тот, который возвращается первым, а другой отменяет.


Начнем с anyOf. Я вызвал curl-команду:


curl http://localhost:8081/anyOf?left=/greeting\&right=/sleep?millis=200

localhost:8081 это текущий порт, эндпоинт anyOf, и я дал ей два параметра left и right. Я выполняю это и получаю hello world:


{"message":"Hello World!"}$


Причина в том, что сервис приветствия просто выводит hello world, а сервис сна спит 200 мс. Я предполагаю, что большую часть времени hello world будет быстрее, чем 200 мс, и всегда будет возвращаться hello world.


Если я уменьшу сон до 1 мс, то, возможно, сервис сна завершится раньше, чем другой сервис.


Теперь давайте изменим запрос на allOf, который объединит два результата:


curl http://localhost:8081/allOf?left=/greeting\&right=/sleep?millis=1

Запускаю и получаю два результата.


{ {"message":"Hello World!"}, { "millis": "1" } }$


Что интересно в allOf, он делает два запроса параллельно.


private String query(String endpoint) {        URI uri = URI.create("http://localhost:8081").resolve(endpoint);        return ClientBuilder.newClient() Client                    .target(uri) WebTarget                    .request(MediaType.APPLICATION_JSON) Invocation.Builder                    .get(String.class);    }

Кстати, это блокирующий код. Он использует клиентский API JAX-RS для подключения к этому эндпойнту. Он использует вызов invokeAll(), а затем .stream (), .map для получения результата, а затем Collectors.joining(), для объединения в JSON.


Это простой пример разветвления. Интересно то, что тут invokeAll() это вариант, в котором есть параметр cancelOnException. Если вы хотите вызвать несколько задач одновременно, но если одна из них не работает, вы отменяете все остальные. Это важно сделать, чтобы не застрять в ожидании завершения всех остальных задач.


В этих примерах я использую сборку для раннего доступа Loom. Мы очень близки к первому рампдауну JDK 16, поэтому код, над которым я работаю это JDK 16, каков он сейчас, плюс вся реализация Loom.


Ограничения


Поговорим об ограничениях.


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


Первый сценарий возникает, когда вы используете нативные фреймы. Если у вас есть код JNI, вы вызываете код JNI, он обращается обратно в код Java, а затем этот код Java пытается заблокироваться или совершить IO-операцию. На континуации есть нативный фрейм, мы мало что можем сделать. Это потенциально постоянное ограничение, но подобное должно происходить очень и очень редко.


Второй случай более проблематичен, и это то, что я показал в демонстрации: попытка
парковки, удерживая монитор. Скорее всего, первая версия будет с этим ограничением, это особенность реализации, влияющая на качество сервиса. В настоящее время ведутся исследования по реимплементации мониторов, чтобы преодолеть это ограничение. Но это серьезный объем работы, на это потребуется время.


На самом деле это не очень критично. По той простой причине, что всё, что сегодня использует мониторы Java, можно механически преобразовать из использования synchronized и wait-notify в использование блокировок из java.util.concurrent. Так что существуют эквиваленты мониторов в блокировках java.util.concurrent и различные формы блокировок, самый простой из которых ReentrantLock, они очень хорошо работают с виртуальными потоками.


Что вы можете сделать при подготовке к Loom?


Если вам нравится Loom и вы заинтересованы в том, чтобы подготовить код для работы с Loom, есть пара вещей, о которых стоит подумать.


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


Распространенным является кэширование объектов SimpleDateFormat.
SimpleDateFormat может быть дорогостоящим в создании, они не являются потокобезопасными, поэтому люди повсеместно кэшируют их в ThreadLocals.


В JDK мы заменили кэширование SimpleDateFormats на новый неизменяемый формат даты java.date dateformatter. Он неизменяем, вы можете сохранить его
в static final поле, это достаточно хорошо. Мы удалили ThreadLocals и из некоторых других мест.


Другая сложность сводится к масштабированию приложения и обработке десятков тысяч запросов. Если у вас много данных на запрос или на транзакцию, это может занимать много места. Если у вас миллион TCP-соединений, это миллион буферов сокетов. Если вы оборачиваете каждый из них в BufferedOutputStream, PrintStream или что-то в этом роде, это много памяти. Мы работали над подобными вещами и в JDK, но я уверен, что дальше по стеку у людей будет много данных на запрос или на транзакцию.


Третье, как я уже говорил, переход от мониторов к java.util.concurrent позволяет избежать краткосрочных проблем.


Я говорил в основном о виртуальном потоке как о потоке в коде, но давайте поговорим о нескольких других вещах.


Расскажу немного об отладчике.



При отладке действительно важно, чтобы при движении по шагам, вы
работали в каком-то контексте. Обычно отладчики Java (в IntelliJ, NetBeans, Eclipse) используют интерфейс отладчика под названием JDI, где под капотом находится wire protocol, а в виртуальной машине есть интерфейс инструментов, называемый JVM Tool Interface или JVM TI, как мы его иногда называем. Это все необходимо обновить, чтобы иметь возможность поддерживать виртуальные потоки.


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


Кроме того, наш подход к поддержке подобного в отладчиках оказался сбивающим с толку. Отладчик видит два потока: основной носитель (поток нашей ОС) и виртуальный поток. С этим было связано с множеством проблем, поэтому мы решили остановиться и пересмотреть все это. Так что это область сейчас работает не очень хорошо, но мы близки к тому, чтобы решить эти проблемы. Так что в это время ведется гигантский объем работы для повторной реализации и перестройки частей JVM TI для лучшей поддержки виртуальных потоков.


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


И последнее, что нужно сказать об отладчиках: кто-то спрашивает, когда мы все это доделаем, будут ли имеющиеся отладчики просто работать? Мы ожидаем, что в отладчики и инструментарий потребуется внести хотя бы некоторые небольшие изменения, чтобы они могли работать с виртуальными потоками. В основном на фронте масштабируемости, не имеет смысла пытаться визуализировать миллион потоков в отладчике. Такие вещи, как группы потоков это уже легаси, и попытки визуализировать что-либо в группах потоков, вероятно, не будут работать хорошо.


Перейдем к виртуальным потокам в профилировщике.


Это тоже очень важная область. Java Flight Recorder был обновлен в сборках Loom для поддержки виртуальных потоков. Я не был уверен, что во время доклада успею продемонстрировать использование JFR с виртуальными потоками, поэтому вместо этого я просто зафиксировал вывод команды print в JFR, просто чтобы показать вам, на что он способен.


В данном случае я сделал запись с JFR.



Он просто называется server.jfr, это имя файла записи. Эта конкретная запись была сделана при запуске сервера Jetty, настроенного для использования виртуальных потоков. Выходные данные показывают одно событие, чтение сокета. И оно произошло в виртуальном потоке. JFR по умолчанию имеет порог, кажется, около 200 мс, он может захватывать медленную операцию чтения, которая занимает больше времени, чем время порога.


Давайте расскажу, что именно здесь запечатлено. virtual = true указывает на то, что это виртуальный поток. Я распечатал всю трассировку стека, поэтому вы можете увидеть, что это действительно работает в виртуальном потоке, мы видим все фреймы, тут используются java.net.url и HTTP для чтения сокета, и это блокирует более чем на 200 мс. Это записано здесь в этой трассировке стека. Это то, что вы можете делать с JFR, что весьма полезно.


Помимо Flight Recorder, поддерживающего виртуальные потоки, существует множество других инструментов и профилировщиков, использующих JVM TI, поэтому нам приходится работать над множеством вещей, чтобы иметь возможность поддерживать профилировщики на основе JVM TI, работающие с виртуальными потоками.


В этой области есть проблемы, о которых я упоминал в контексте отладчиков, попытки визуализировать тысячи и тысячи потоков.


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


Serviceability


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


Я показал вам довольно простую распечатку трассировки стека, когда потоки закреплены. Будут и другие сценарии, значимые для разработчиков. Они не смогут идентифицировать, например, запущенные виртуальные потоки, выполняющие вычислительные задачи (упирающиеся в CPU), они никогда не блокируются. Было бы полезно идентифицировать их.


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


Текущий статус того, где мы находимся с Loom


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


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


Производительность также высока, был достигнут значительный прогресс в производительности за последний год, потому что это критически важно.


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


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


Что еще нужно сделать для нашего первого Preview: нам необходимо выполнить перенос на ARM64 или Aarch64, мы были сосредоточены на 64-разрядной версии Intel на сегодняшний день; и нам нужно что-то сделать с дампом потоков.


Направления для будущего развития


Я должен упомянуть еще несколько аспектов этого проекта. Это такой список из будущего: мы не ожидаем, что перечисленное в нём будет в первой версии.


Во многих случаях для запуска задачи создается виртуальный поток, который выполняет одно действие и дает один результат. В других случаях вам может понадобиться, чтобы виртуальные потоки производили стрим результатов или общались через сообщения той или иной формы.


Как вы видите в других моделях программирования, CSP или Actors. У других языков есть каналы, у Erlang есть почтовые ящики. В Java есть вещи, близкие к этому: есть BlockingQueues, SynchronousQueue, у которой нет емкости, LinkedTransferQueue, у которой есть емкость.


Профессор Даг Ли работал с нами над этим проектом, и он обновил реализации блокирующих очередей в java.util.concurrent, так что они дружелюбны к виртуальным потокам. Он также изучает то, что ближе к каналам. Текущее рабочее название этого проекта conduits, а не каналы, потому что у нас есть каналы в пакете java.nio.channels. Посмотрим, как это пойдет.


Другая область это структурированная многопоточность. При структурированном программировании последовательное выполнение ограничено каким-либо четко
определенным блоком кода. Структурированная многопоточность заключается в распространении контроля на многопоточную среду. По сути, если задача управления разделяется на несколько задач или потоков в некоторой области, то им необходимо снова объединиться, прежде чем выйти из этой области.


В ранних прототипах Project Loom у нас действительно были первые прототипы для исследований в этой области. И мы вернемся к этому, есть проблемные области, связанные с этим. Распространение исключений и ошибок, отмена выполнения и так далее. На данный момент мы сделали, и я показывал это в демонстрациях, модифицировали ExecutorService, чтоб он расширял AutoCloseable, чтобы, по крайней мере, мы могли иметь некоторую конструкцию, которая гарантирует, что все потоки завершатся до продолжения основного потока.


Со структурированной многопоточностью связано то, что мы называем структурированным serviceability или структурированным observability. Я упоминал о проблемах профилировщиков, отладчиков и других инструментов, пытающихся
визуализировать миллионы потоков. Что, если бы эти инструменты каким-то образом исследовали бы структуру или отношения между потоками. Тогда, возможно, нам лучше бы удалось это визуализировать. На это мы готовы потратить больше времени.


Последний пункт в этом списке отмена. Мы сделали несколько прототипов в этой области, несколько прототипов кооперативной отмены. В Java есть механизм прерывания, это устаревший механизм, но на самом деле он очень хорошо работает с виртуальными потоками. Для большинства разработчиков этот механизм находится на слишком низком уровне. Мы хотим понять, сможем ли мы сделать что-то лучше. У нас были механизмы отмены в ранних прототипах. Я полагаю, что основная проблема заключается в том, что наличие двух механизмов одновременно может сбивать с толку, поэтому необходимо подумать над этим немного времени, прежде чем принимать какие-то решения по этому поводу.


Главные выводы


Основные выводы из этого доклада:


Виртуальный поток это поток в коде, во время выполнения, в отладчике, профилировщике и других инструментах.


Виртуальный поток это не оболочка вокруг потока ОС, а, по сути, просто объект Java.


Создание виртуального потока действительно очень дешево, у вас их могут быть миллионы, их не надо объединять в пулы.


Блокировка в виртуальном потоке стоит мало, что позволяет использовать синхронный стиль.


Немного дополнительной информации


Один из замечательных способов внести свой вклад в такой проект, как Project Loom, это загружать его сборки (или делать их самостоятельно из кода) и пробовать его с реальными приложениями. Отправляйте отзывы о производительности, сообщениях об ошибках, проблемах и опыте. Такие вещи очень полезны для такого проекта.


Вот ссылки на сборки раннего доступа: https://jdk.java.net/loom
Список рассылки: loom-dev@openjdk.java.net
И вики-страница: https://wiki.openjdk.java.net/display/loom/Main


У нас не очень получается поддерживать вики-страницу, поэтому список рассылки лучшее место для поиска чего-либо.


Это все, что я хотел рассказать.


Напоследок традиционный слайд Safe harbor: не верьте ничему, что я говорю.



Как можно понять по этому докладу, на наших Java-конференциях хватает хардкора: тут про Java-платформу порой рассказывают те люди, которые её и делают. В апреле мы проведём JPoint, и там тоже будет интересный состав спикеров (многие знают, например, Джоша Лонга из VMware). Часть имён уже названа на сайте, а другие позже появятся там же.
Подробнее..

Обзор программы JPoint 2021 воркшопы, Spring, игра вдолгую

24.03.2021 18:04:11 | Автор: admin


Близится новый JPoint, и мы готовы подробно рассказать о его программе. В этом посте мы разделили доклады по тематическим блокам: можно и быстро понять что вообще будет, и узнать конкретику. А во вступлении упомянем отдельные моменты:


  • Пришла весна, то есть самое время поговорить о Spring. О нём будет четыре доклада, в том числе большое двухчастное выступление Евгения Борисова. Для него мы даже продлили JPoint на пятый день получился специальный день Борисова :)
  • Онлайн-формату подходят воркшопы. Поэтому в отдельных случаях можно будет не просто любоваться слайдами: спикер будет выполнять конкретные задачи на практике, объясняя всё происходящее и отвечая на вопросы зрителей.
  • Есть доклады не строго про Java, а про то, как успешно разрабатывать на длинной дистанции (чтобы всё радовало не только на стадии прототипа, а годы спустя): как делать проекты поддерживаемыми, не плодить велосипеды, работать с легаси.
  • Ну и никуда не девается привычное. Знакомые темы: что у Java внутри, тулинг/фреймворки, языковые фичи, JVM-языки. Спикеры, посвятившие теме годы жизни: от технического лида Project Loom Рона Пресслера до главного Spring-адвоката Джоша Лонга. Возможность как следует расспросить спикера после доклада. И уточки для отладки методом утёнка!

Оглавление


Воркшопы
VM/Runtime
Тулинг и фреймворки
Spring
JVM-языки
Люби свою IDE
Жизнь после прототипа




Воркшопы


Воркшоп: Парное программирование, Андрей Солнцев, Антон Кекс


Парное программирование знакомо многим, но вот много ли вы знаете людей, использующих его на постоянной основе? А вот Андрей Солнцев и Антон Кекс действительно практикуют его постоянно и уверены, что подобные практики из мира экстремального программирования помогают работать эффективнее.


Они и раньше об этом говорили, а теперь попробуют показать наглядно. В ходе воркшопа они вдвоём напишут небольшое приложение по TDD (с помощью тестов) и в ходе этого вы лично убедитесь, что с таким подходом разработка идёт быстрее и проще. Или не убедитесь. Интересно будет посмотреть, какую часть аудитории получится убедить.




Воркшоп: Строим Бомбермена с RSocket, Олег Докука, Сергей Целовальников


Олег Докука и Сергей Целовальников на небольшом игровом примере продемонстрируют практический опыт использования RSocket-Java и RSocket-JS.


Кому, как не им, демонстрировать возможности RSocket: Олег лидер этого проекта, а Сергей ведущий разработчик компании Canva, которая одной из первых внедрила его в продакшн. Если у вас есть вопросы по этой технологии у них точно есть ответы.




VM/Runtime


CRIU and Java opportunities and challenges, Christine H Flood


Зачем каждый раз тратить драгоценные секунды на запуск приложения, если можно один раз запустить, сохранить чекпоинт и открывать его уже в запущенном виде, в полной боевой готовности? Причём открывать при необходимости на нескольких машинах сразу?


О том, как использовать Checkpoint Restore в Java, расскажет Кристин Флад из Red Hat, которая работает над языками и рантаймами уже более двадцати лет.




Real World JFR: Experiences building and deploying a continuous profiler at scale, Jean-Philippe Bempel


JDK Flight Recorder позволяет профилировать непрерывно и прямо в продакшне. Но это же не может быть бесплатно, да? Важно понимать, чем придётся пожертвовать и какие будут накладные расходы.


Разобраться в этом поможет Жан-Филипп Бемпель он принимал непосредственное участие в реализации непрерывной профилировки в JFR.




GC optimizations you never knew existed, Igor Henrique Nicacio Braga, Jonathan Oommen


Какой JPoint без докладов про сборщики мусора! Тут выступление для тех, кто уже что-то знает по теме объяснять совсем азы не станут. Но и загружать суперхардкором с первой минуты тоже не станут. Сначала будет подготовительная часть, где Игор Брага и Джонатан Оммен рассмотрят два подхода к GC в виртуальной машине OpenJ9: balanced и gencon.


А вот потом полезут глубже и рассмотрят оптимизации, которые там применяются. Для обычного разработчика эти оптимизации могут быть незаметными, но они способны ощутимо сказываться на производительности поэтому стоит понимать, как они работают и в чём выгода от них для Java-приложений.




Adding generational support to Shenandoah GC, Kelvin Nilsen


И ещё о сборке мусора. На JPoint 2018 о Shenandoah GC рассказывал Алексей Шипилёв (Red Hat), а теперь доклад от совсем другого спикера Келвина Нилсена из Amazon, где тоже работают над этим сборщиком мусора.


Подход Shenandoah позволяет сократить паузы на сборку мусора менее чем до 10 миллисекунд, но за это приходится расплачиваться большим размером хипа (потому что его утилизация оказывается заметно ниже, чем у традиционных GC). А можно ли сделать так, чтобы и волки были сыты, и овцы целы? В Amazon для этого решили добавить поддержку поколений, и в докладе поделятся результатами.




Производительность: Нюансы против очевидностей, Сергей Цыпанов


Производительность тема, где с простым соседствует сложное. Даже в простых с виду частях кода могут обнаруживаться сюрпризы и подводные камни, а то, что казалось очевидным улучшением, может оборачиваться ухудшением производительности. Всё это и будет рассматривать в своём докладе Сергей Цыпанов, в своём профессиональном развитии сосредоточенный как раз на задачах производительности.




Why user-mode threads are (often) the right answer, Ron Pressler


Многопоточное программирование в Java поддерживается с версии 1.0, но за 25 лет в этой части языка почти ничего не поменялось, а вот требования выросли. Серверам требуется работать с сотнями тысяч, и даже миллионами потоков, а стандартное решение в JVM на тредах операционной системы не может так масштабироваться, поэтому Project Loom это одна из самых долгожданных фич языка.


Ранее у нас уже был доклад про Loom от Алана Бейтмана (мы делали расшифровку для Хабра), а теперь и technical lead этого проекта Рон Пресслер рассмотрит разные решения для работы с многопоточностью и подход, который используется в Loom.




Тулинг и фреймворки


Как мы строили высокопроизводительную систему на Akka с нуля: Джентльменский набор и грабли, Кирилл Данилов


Кирилл расскажет про опыт создания платежной системы с использованием Akka от обучения с нуля до построения кластера и интеграции этой платформы с более привычными и удобными в своей нише технологиями, например, Spring Boot, Hazelcast, Kafka.


Будет полезно тем, кто хочет ознакомиться с моделью акторов и с Akka и тем, кому интересны нюансы разработки финансовых систем с большой пропускной способностью.




Jakarta EE 9 and beyond, Ivar Grimstad, Tanja Obradovi


Jakarta EE 9 несет множество изменений, которые затронут большое количество библиотек и фреймворков для Java. Чтобы понять, как эти изменения отразятся на ваших проектах, приходите на доклад Ивана Гримстада и Тани Обрадович.


Ивар Jakarta EE Developer Advocate, а Таня Jakarta EE Program Manager, поэтому вы узнаете о самых важных изменениях и планах на будущее из первых рук.




Чтения из Cassandra внутреннее устройство и производительность, Дмитрий Константинов


В качестве хранилища данных очень часто используется Cassandra, и всем, кто ее использует, будет полезно знать, как она устроена внутри, и от чего зависит её производительность на чтение.


Об этом расскажет системный архитектор и практикующий разработчик из NetCracker Дмитрий Константинов.




The DGS framework by Netflix GraphQL for Spring Boot made easy, Paul Bakker


В Netflix разработали DGS Framework для работы с GraphQL. Он работает поверх graphql-java и позволяет работать с GraphQL, используя привычные модели Spring Boot. И, что приятно, он опенсорсный, стабильный и готов к использованию в продакшне.


Пол Баккер один из авторов DGS. Он расскажет и про GraphQL, и про то, как работать с DGS, и про то, как это используется в Netflix.




Качественный код в тестах не просто приятный бонус, Sebastian Daschner


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


Предыдущие доклады Себастьяна на наших конференциях были англоязычными, но участвуя в российских мероприятиях, он так влился в местное Java-сообщество, что теперь попробует провести доклад на русском!




Why you should upgrade your Java for containers, Ben Evans


Статистика от New Relic говорит, что примерно 62% Java на продакшне в 2021 запущено в контейнерах. Но в большинстве из этих случаев до сих пор используют Java 8 а эта версия подходит для контейнеризации не лучшим образом. Почему? Бен Эванс рассмотрит, в чём проблемы с ней, что улучшилось с Java 11, и как измерить эффективность и расходы.


Хотя в основном речь пойдёт о проблемах, актуальных именно в случае с контейнерами, часть сказанного будет применима и к Java-разработке в целом.




Разошлись как в море корабли: Кафка без Zookeeper, Виктор Гамов


Совсем скоро придет тот день, о котором грезили Kafka-опсы и Apache Kafka больше не будет нуждаться в ZooKeeper! С KIP-500 в Kafka будет доступен свой встроенный механизм консенсуса (на основе алгоритма Raft), полностью удалив зависимость от ZooKeeper. Начиная с Apache Kafka 2.8.0. вы сможете получить доступ к новому коду и разрабатывать свои приложения для Kafka без ZooKeeper.


В конечном счете, удаление ZooKeeper упрощает общее проектирование инфраструктуры и рабочие процессы вокруг Кафки. В этом докладе Виктор сделает краткий обзор того, какие ограничения Zookeeper накладывал на архитектуру Кафки и приложений, что будет изменено, а также текущий статус удаления Zookeeper.




Spring


Spring Data Рostроитель (Spark it!), Евгений Борисов


Товарищ, знай! Чтоб использовать Spark,
Scala тебе не друг и не враг.
Впрочем, и Spark ты можешь не знать,
Spring-data-spark-starter лишь надо создать!


Этот доклад не про Spark и не про Big Data. Его скорее можно отнести к серии потрошителей и построителей. Что будем строить и параллельно потрошить сегодня? Spring Data. Она незаметно просочилась в большинство проектов, подкупая своей простотой и удобным стандартом, который избавляет нас от необходимости каждый раз изучать новый синтаксис и подходы разных механизмов работы с данными.


Хотите разобраться, как Spring Data творит свою магию? Давайте попробуем написать свой аналог. Для чего ещё не написана Spring Data? JPA, Mongo, Cassandra, Elastic, Neo4j и остальные популярные движки уже имеют свой стартер для Spring Data, а вот Spark, как-то забыли. Давайте заодно исправим эту несправедливость. Не факт, что получится что-то полезное, но как работает Spring Data мы точно поймём.




Spring Cloud в эру Kubernetes, Алексей Нестеров


Когда-то давно, много JavaScript-фреймворков назад, когда микросервисы еще были монолитами, в мире существовало много разных инструментов для разработки Cloud Native приложений. Spring Cloud был одним из главных в реалиях Spring и объединял в себе целый набор полезных проектов от Netflix, команды Spring и многих других вендоров.


Казалось бы, в наши дни, когда весь мир захвачен Kubernetes и он уже давно стал универсальным решением любой проблемы, важность Spring Cloud должна неизбежно сойти на нет. Но не все так просто, и в этом докладе Алексей покажет, какие компоненты Spring Cloud могут быть полезны в Kubernetes, чем эти два проекта друг друга дополняют, в каких аспектах пересекаются, ну и самое главное, постарается ответить на вопрос в чем же ценность Spring Cloud в эру Kubernetes?




Reactive Spring, Josh Long


Джош Лонг расскажет про фичи Spring Framework 5.0 для реактивного программирования: Spring WebFlux, Spring Data Kay, Spring Security 5.0, Spring Boot 2.0, Spring Cloud Finchley и это только часть!


Может показаться многовато для одного доклада, но мы-то знаем, что Джош Spring Developer Advocate с 2010 года. Уж кто-кто, а он-то знает, как рассказать всё быстро и по делу.




Inner loop development with Spring Boot on Kubernetes, David Syer


Мы живем во время облачных технологий и чтобы эффективнее перейти от принципа works on my machine к works on my/dev cluster нужен набор инструментов для автоматизация загрузки кода на лету.
Доклад Дэвида Сайера будет про то, как и с помощью каких инструментов Spring Boot и Kubernetes построить этот процесс удобно.
Ускорение первой фазы доставки это тот DevOps, который нужен разработчикам, поэтому всем, кто живет в k8s или хотя бы делает системы из нескольких компонентов этот доклад пригодится.




Люби свою IDE


IntelliJ productivity tips The secrets of the fastest developers on Earth, Victor Rentea


Если впервые ведёшь автомобиль без инструктора и навигатора, не очень получается думать о самом эффективном маршруте с учётом пробок: справиться бы с тем, что видишь вокруг прямо сейчас. С IDE похоже: поначалу требуется освоиться с главным, но есть ещё скрытые возможности, до которых можно дорасти позже.


Виктор Рента покажет много трюков для работы с IntelliJ IDEA, которые помогут писать код быстрее, экспериментировать больше и делать откат результатов неудачных экспериментов проще.




Многоступенчатые рефакторинги в IntelliJ IDEA, Анна Козлова


В IntelliJ IDEA есть ограниченное количество основных рефакторингов: Rename, Move, Inline, Extract. Пользователи часто просят добавить еще, но чаще всего это можно сделать комбинацией уже существующих, просто это не всегда очевидно.


На JPoint 2021 вы сможете получить практические рекомендации по рефакторингу от человека, который разрабатывает рефакторинги: о самых важных приемах расскажет коммитер 1 в IntelliJ IDEA Community Edition Анна Козлова.




С какими языками дружат IDE?, Петр Громов


Разработчики всё чаще выбирают языки с выразительным и кратким синтаксисом, совсем не задумываясь на тем, как долго их код будет компилироваться, и сможет ли IDE помочь им с рефакторингом. В докладе Петра Громова будут интересные идеи о сложности современных языков программирования с точки зрения разработчиков IDE.


Рекомендуем всем, кому интересны механизмы IDE, языки, парсеры, DSL и сложные синтаксические конструкции в современных языках программирования.




Java и JVM-языки


Type inference: Friend or foe?, Venkat Subramaniam


Не все могут объяснять так, как это делает Венкат Субраманиам, поэтому мы любим приглашать его на конференции.


На JPoint 2021 он выступит с докладом про type inference. Хотя тема сама по себе не новая, нюансов в ней хватает, а развитие языков делает её лишь более актуальной (вспоминается доклад Романа Елизарова с TechTrain, где он рассматривал, как ситуация с типами и их выводом менялась со временем). Так что стоит лучше понять, в чём вывод типов помогает, а в чём мешает для этого и рекомендуем сходить на этот доклад.




Babashka: A native Clojure interpreter for scripting, Michiel Borkent


Babashka интерпретатор Clojure для скриптов. Он мгновенно запускается, делая Clojure актуальной заменой для bash. У Babashka из коробки есть набор полезных библиотек, дающих доступ из командной строки к большому количеству фич Clojure и JVM. Сам интерпретатор написан на Clojure и скомпилирован с помощью GraalVM Native Image. В докладе работу с ним наглядно покажут с помощью демо.




Getting the most from modern Java, Simon Ritter


Недавно вышла JDK 16, и это значит, что мы получили 8 (прописью: ВОСЕМЬ) версий Java менее чем за четыре года. Разработчики теперь получают фичи быстрее, чем когда-либо в истории языка.


Так что теперь попросту уследить бы за всем происходящим. Если вы до сих пор сидите на Java 8, на что из появившегося позже стоит обратить внимание и чем это вам будет полезно? В этом поможет доклад Саймона Риттера, где он поговорит о некоторых нововведениях JDK 12-15 и о том, когда их исследовать, а когда нет:


  • Switch expressions (JDK 12);
  • Text blocks (JDK 13);
  • Records (JDK 14);
  • Pattern matching for instanceof (JDK 14);
  • Sealed classes and changes to Records (JDK 15).


Про Scala 3, Олег Нижников


Обзор языка Scala 3 и грядущей работы по переходу. Обсудим, в какую сторону двигается язык, откуда черпает вдохновение, и пройдёмся по фичам.




Java Records for the intrigued, Piotr Przybyl


В Java 14 появились в превью-статусе Records, а с Java 16 они стали стандартной фичей. Для многих это было поводом сказать что-то вроде Lombok мёртв или не нужна больше кодогенерация JavaBeans. Так ли это на самом деле? Что можно сделать с помощью Records, а чего нельзя? Что насчёт рефлексии и сериализации? Разберём в этом докладе.




Жизнь после прототипа


Восстанавливаем утраченную экспертизу по сервису, Анна Абрамова


Представьте ситуацию: вы приходите на работу в компанию, где до вас уже написано многое на Java и вспомогательных технологиях. У вас горят глаза, чешутся руки и вы хотите работать. И тут вас кидают в море легаси, где вместо документации крошки на клавиатуре от вашего предшественника.


Анна Абрамова на примере вымышленной системы расскажет, какими методами можно восстановить сакральные знания.




Что такое Работающий Продукт и как его делать, Антон Кекс


Антон Кекс регулярно сталкивается с проектами, у которых проблемы даже с элементарным запуском. Поэтому целый доклад на JPoint он посвятит тому, как делать на самом деле работающие продукты, которыми можно пользоваться и легко поддерживать как создателю, так и его коллегам.


Если ваша точка зрения не совпадет можно будет всё обсудить с Антоном в дискуссионной зоне. Вероятно, там будет жарко.




Enum в API коварство иллюзорной простоты, Илья Сазонов и Фёдор Сазонов


Вы уверены, что если добавить один маленький enum в API, то ничего страшного не произойдет? Или наоборот уверены, что так делать не стоит, но никто вас не слушает?
Рекомендуем вам доклад Ильи и Федора Сазоновых, пропитанный тяжелой болью по поводу бесконечных обновлений контрактов микросервисов.
Обычно подобные темы не выходят за пределы локального холивара в курилке, но нельзя же вечно добавлять новые значения в enum?




Dismantling technical debt and hubris, Shelley Lambert


Многие разработчики любят строить велосипеды, потому что свой велосипед он же надежнее, быстрее и удобнее, чем всё остальное. А потом все эти велосипеды складываются в один большой технический долг, с которым нужно что-то делать.
Шелли Ламберт научит вас не писать собственные решения для всего подряд, а правильно выбирать существующие.




Подводя итог


Надеемся, по программе ясность появилась, но напоследок подчеркнём, что онлайн-конференция это не только слушать доклады. Это ещё и:
Вопросы спикерам в чатах и видеосозвонах
Общение зрителей (на нашей виртуальной площадке можно подойти к другому зрителю и увидеть его по видеосвязи)
Виртуальные стенды партнёров с различными активностями


Напоминаем, поучаствовать во всём этом можно будет с 13 по 17 апреля в онлайне. Вся дополнительная информация и билеты на сайте.

Подробнее..

Используем Xtend для прикладной кодогенерации сеанс чёрной магии с разоблачением

25.06.2020 10:15:49 | Автор: admin

Привет Хабр! Меня зовут Когунь Андрей. В КРОК я руковожу группой разработчиков Java (у нас большая распределённая по всей стране команда). Ещё я провожу встречи московского сообщества Java разработчиков JUG.MSK. Делаю это исключительно в корыстных целях: фотографируюсь там со всеми докладчиками, и однажды открою галерею с самыми интересными людьми в мире Java-разработки. Также помогаю делать конференции для разработчиков: JPoint, Joker и DevOops в качестве члена программного комитета. Ну и для души, так сказать, преподаю Java-технологии студентам.


В КРОК мы с коллегами в основном занимаемся заказной разработкой. Одно из наших направлений так называемые учётные системы. Их надо делать по возможности быстро. Они типовые, различия обычно наблюдаются только в доменной модели. Поэтому мы постоянно боремся за то, чтобы писать меньше бойлерплейт-кода, будь то тривиальные геттеры-сеттеры, конструкторы и т.п. или CRUD-репозитории и контроллеры. Мы для этого активно пользуем кодогенерацию.


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



О чём статья и чего в статье не будет


За много лет работы с Java мы перепробовали много чего интересного:


  • поддержка генерации в IDE,
  • генерация байт-кода при помощи Lombok,
  • процессоры аннотаций, порождающие новый код,
  • фреймворки, позволяющие по описанию модели получить готовое (почти) приложение,
  • и много чего ещё, в том числе новые и не очень JVM-языки, которые позволяют писать более лаконичный код и реализовывать DSL для решения прикладных задач.

В какой-то момент, проанализировав сильные и слабые стороны всех этих подходов, их ограничения и практическую применимость, мы пришли к тому, что в нашем собственном фреймворке для быстрой разработки (jXFW) будем использовать Xtend. Использовать для кодогенерации исходного Java-кода по доменной модели и для аккумулирования того опыта, который мы накопили в работе с различными технологиями. Сейчас расскажу, как в jXFW это всё работает и покажу, как вы можете сделать то же самое для своих нужд. Причём первую версию вы сможете изготовить буквально за пару дней и дальше начать применять подход know-how как код.


Рассказывать буду на примере упрощённого демо-проекта, который был реализован в рамках доклада на JPoint.


Ремарка: чего в статье не будет:


  1. Я не хочу, чтобы мы в итоге делали выводы про то, что технология А лучше технологи Б. Или что там Eclipse лучше IDEA или наоборот. Поэтому я не буду напрямую сравнивать какие-то языки, технологии. Всё что упоминаю, это лишь для того, чтобы какую-то аналогию объяснить на понятных примерах.
  2. Я не буду делать введение в Spring и Spring Boot. Исхожу из того, что вы имеете хотя бы какой-то опыт работы с этими технологиями. Мне кажется, сейчас сложно найти джависта, который не работал с ними. Но если вы вдруг слышите о Spring и Spring Boot впервые, вам срочно надо посмотреть доклады и тренинги Евгения Борисова и Кирилла Толкачева, там мои коллеги рассказали об этих технологиях очень подробно.
  3. Не буду очень сильно погружаться в Xtend. Но поскольку, как показывает мой опыт выступления на Java-конференциях, эта технология мало кем используется, сделаю небольшой ликбез. Чтобы вы уже дальше могли для себя решить, нужен вам Xtend или нет.

Короткий ликбез по Xtend


Xtend это статически типизированный язык программирования, приемник Xpand, построенный с использованием Xtext и компилирующийся в исходный код Java. Технология Xtext нужна для того, чтобы реализовывать всевозможные DSL. По сути, Xtend это такой своеобразный DSL.


Xtend совсем не новый язык программирования. Его создали ещё в 2011, примерно тогда же, когда появлялось большинство JVM-языков. Интересно, что у Xtend был слоган: Java 10 сегодня! Да, сегодня Java 10 у нас уже есть, слоган морально устарел. Но, похоже, люди что-то знали про Java, когда создавали Xtend, и некоторые фичи, заложенные в Xtend, они вот как раз прямо в Java 10 и появились. В частности, вывод типа локальной переменной (var). Но есть в Xtend и такие фичи, которых у Java пока ещё нет:


  • активные аннотации,
  • шаблонные выражения,
  • Switch Expressions.

Как работает кодогенератор в jXFW


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


Запускаю Eclipse.



Как видите, здесь практически ничего нет. Только application.java (конфигурация для Spring Boot) и собственно исходник на Xtend, в нём реализована доменная модель.



Как видите, Xtend-исходник очень похож на Java. Здесь нет ничего особенного. Просто класс с полями и несколько аннотаций. А что в итоге? jXFW генерирует два приложения (см. рисунок ниже): одно выполняется на сервере (тот самый Spring Boot) и даёт нам апишечку, а другое на клиенте.



Если мы что-нибудь введём в клиентской части (например, как зовут спикера) и сохраним...



то получим соответствующую запись и на клиенте, и на сервере.



То есть всё по-честному.


Мы просто описали одну сущность доменной модели, и всё автоматически заработало.


Что за магия здесь под капотом? И как в ней замешан Xtend? Рассказываю. У нас есть класс, на нём проставлены аннотации, вернее активные аннотации. Вся магия скрывается в них. Аннотации в Xtend очень похожи на аннотации в Java. Просто в Xtend для них есть отдельное ключевое слово:annotation.



Активной аннотация становятся, если её, в свою очередь, пометить другой аннотацией: @Active, а в ней указать класс процессора, который активируется, когда эта аннотация поставлена над каким-то элементом.



Дальше всё как обычно.


Xtend из коробки имеет некоторое количество таких аннотаций.



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


Если вдруг вам ещё пока не понятно, о чём тут идёт речь, то теперь я буду рассказывать про кодогенерацию медленно и подробно. Результат, как уже сказал, доступен тут.


Как активные аннотации помогают писать меньше кода


Открываем проект jp-boot-xtend-demo. Я его получил при помощи Spring Initializr.



Дополнительных зависимостей здесь практически нет (см. файл pom.xml). Есть только spring-boot-starter-data-jpa и spring-boot-starter-data-rest. Плюс, подключен модуль jp-boot-xtend-demo-compile, в котором реализована наша активная аннотация. Если вам доводилось работать с процессорами аннотаций, вы наверно в курсе, что сам процессор определяется в отдельном модуле. Xtend в этом смысле не исключение.


И уже здесь, в jp-boot-xtend-demo-compile (см. файл pom.xml), мы подключаем все Xtend-зависимости, которые нам нужны: org.eclipse.xtend.lib, org.eclipse.xtend.lib.macro. Плюс, подключаем плагин xtend-maven-plugin. На случай если захотим тестировать наш Xtend-код, нам понадобится ещё несколько зависимостей: org.eclipse.xtend.core, org.eclipse.xtext.testing, org.eclipse.xtext.xbase.testing.


Кроме того, в Eclipse, я соответственно подключил плагин, который называется Xtend IDE. Актуальная инструкция как установить плагин тут. Ещё один вариант: сразу взять дистрибутив, в котором этот плагин предустановлен Eclipse for Java and DSL Developers.


Давайте смотреть как тут всё работает. Как и в случае с jXFW здесь есть приложение (см. файл DemoApplication.java), а также Java-класс, который будет нашей Entity, на базе которой мы будем всё строить (см. файл Country.xtend).



При необходимости мы можем сразу посмотреть на то как выглядит Java-файл, сгенерированный из этого Xtend-исходника. Он нам сразу же доступен, и мы можем им пользоваться во всём остальном коде.



Например, в нашем DemoApplication есть кусок кода, который пытается вызывать метод setName. Но пока он красненький.



Я добавляю в Xtend-исходник активную аннотацию @Accessors, и у меня в сгенерированном Java-коде автоматически появляются геттеры и сеттеры, в том числе setName.



Возможностей управлять активной аннотацией у меня конечно не много, но по крайней мере, я могу сказать что мне нужны только геттеры.



Тут я ещё вписал в Xtend-файл аннотации @ToString и @EqualsHashCode, и в итоге получил Java-исходник прямо такой, как и хотел.


Небольшой лайфхак, который избавит вас от необходимости после каждой правки Xtend-исходника отыскивать в target сгенерированный Java-файл. В Eclipse есть специальная оснастка: Generated Code. Что она делает? Встаньте на любую строчку в Xtend-исходнике, и увидите в окне Generated Code Java-код, который для неё сгенерирован. А оттуда при необходимости уже можете пойти непосредственно в Java-исходник. Вот такая удобная штука.


Самый маленький кодогенератор на основе аннотаций


В принципе, всё хорошо работает. Но как только мы начинаем работать с кодогенерацией, тут же возникает вопрос: А можно такой же, но только с перламутровыми пуговицами? Так Что бы я ещё хотел? Я бы хотел наверно, чтобы у меня сеттеры мои вызывались в цепочке т.е. не просто устанавливалось значение, но ещё, чтобы и сам объект возвращался из этого сеттера, и я мог на нём следующий позвать.


Из коробки в Xtend такой аннотации нет. Поэтому нам придётся её делать ручками. И какие тут есть варианты?


В принципе, мы знаем, что существует аннотация @Accessors мы посмотрим на её исходный код, увидим, что там есть Accessors Processor, специально написанный. И вот мы уже смотрим на Xtend-код и пытаемся понять, а в каком месте мы могли бы здесь что-то подкрутить, чтобы у нас работало так, как надо. Но это не очень продуктивный путь. Мы по нему не пойдём.


Мы будем писать полностью новую аннотацию. И вот почему. Дело в том, что в активных аннотациях, которые применяются в Xtend, есть возможность привязаться к конкретной фазе компиляции. Т.е. в тот момент, когда AST у нас уже есть, а исходных файлов ещё нет мы можем как угодно этот наш AST менять. И это очень удобно.


Соответственно, вот эта наша аннотация (это я уже зашёл в проект jp-boot-xtend-demo-compile; см. файл EntityProcessor.xtend) @Active она нам говорит про те самые четыре фазы, к которым мы можем привязываться. На каждой фазе работает свой собственный Participant-вариант, и мы можем реализовать тот, который нам нужен.


В принципе, есть базовый специальный класс AbstractClassProcessor, в котором мы оверрайдим метод doTransform. В него нам будут приходить соответственно описания наших классов, помеченных этой аннотацией. И дальше мы, соответственно, что-то делаем.


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



Мне кажется, это самый маленький по объёму код для генерации при помощи аннотаций который я видел в жизни.


Что здесь делает Xtend? У него есть вот эти самые шаблонные выражения. Мы ставим три одинарные кавычки, и дальше пишем то, что хотим получить на выходе. И при этом форматируем так, как нам удобно.



А когда ставим фигурные кавычки, и пользуем simpleName от нашего филда в данном случае, который по совместительству является аргументом, то сюда подставляется нужное значение.


Код написан на Xtend. Мне кажется читать его, с одной стороны легко, потому что его мало. С другой стороны, он может мне быть понятен сходу. Например, почему мы позвали метод modifySetter, который я определил чуть ниже, и передали в него всего один аргумент?


Дело в том, что в Xtend есть такая вещь как Extension-методы. И у объекта того типа, которым является первый аргумент, можно этот Extension-метод позвать. Хорошо, а почему мы тогда его здесь не указали? Да потому что мы внутри лямбды, а в ней есть переменная it. Когда у нас есть переменная it, к лямбде можно обращаться, не указывая её. То же самое вот с it, который мы указали в качестве аргумента. Поэтому declaredFields-property у MutableClassDeclaration мы зовём напрямую, безо всяких префиксов.


Это вот всё, что в принципе придётся знать про Xtend.


А можно такой же, но только с перламутровыми пуговицами?


Давайте теперь посмотрим как это работает. Я определяю аннотацию @Entity. Затем иду вот в этот наш класс.



Заменяю текущую @Entity с javax.persistence на свою на активную аннотацию.



И вот теперь сеттер у нас такой как надо. Т.е. из Country возвращается this мы возвращаемое значение поменяли с void на тип объекта, над которым стоит аннотация: @Id Long id.


Но, допустим, я хочу, чтобы айдишник сеттился немножко по-другому (всё к той же идее хочу такое же, но с перламутровыми пуговицами). Тогда я вписываю в свой класс setID. И оно даже отчасти работает. Ведь сеттер появился в нужном месте сразу после id.



Вот только Eclipse мне здесь немножко подсвечивает и возмущается: ты мне, вообще, о чём здесь говоришь?



И хотя в том коде джавовом, который получился, ошибки нет, он скомпилируется и всё будет работать в коде есть проблема. Она заключается в том, что мы подменяем тело метода, который определили в Xtend-исходнике.


Поэтому нам надо внимательно следить за тем, чтобы таких казусов не возникало, когда пишем что-то на Xtend. Как такое отследить? Например, можно у того Transformation-контекста, который сюда приходит, прописать метод isThePrimaryGeneratedJavaElement, и соответственно передать туда сеттер. Получается прямо в таком же стиле, как мы обычно пишем на Java.



То же самое можно написать и по-другому, если вам так привычней.



Теперь всё работает как надо. Ошибки компиляции больше нет, а сеттерный айдишник стал такой как я и хотел.



Насколько это сложно прикручивать новые улучшения? Не увеличивают ли они сложность кода?


Давайте на примере посмотрим. Допустим, мы хотим ещё кое-что улучшить: добавить специальный филд, который можно будет проставлять в сеттере, и который будет признаком того, что наш объект изменился.



Не знаю, для чего в жизни это может пригодиться вам, но лично мне в работе такая штука нужна. Что мы тут указываем? Мы здесь указали имя филда. И дальше опять вот эта наша квадратная скобочка открываем лямбду; здесь дальше соответственно указываем, что нас интересует. Причём, нам важно, чтобы поле было транзиентное.


И теперь, вот здесь в сеттере тоже важно, чтобы такое поведение было на все поля, кроме транзиентных. Потому что когда мы меняем транзиентное поле, мы в общем-то не ожидаем, что наш объект будет dirty ведь эти значения не попадают в хранилище.



Давайте посмотрим, насколько это нам помогло.



Да, всё хорошо! Причём dirty написано ровно в том месте, где и должно. Нет никаких выкрутасов с отступами и т.д. Код выглядит хорошо и там, и там. Несмотря на то, что получился в результате кодогенерации. Плюс, как видите, код всё ещё остался простым для понимания.


Пишем процессор на смешанном диалекте Xtend и Java


@Entity больше мучить не будем. Убираю её в комментарии. И объявляю ту же самую аннотацию, но на Java (см. файл Entity.java). Здесь, как и на Xtend, всё просто, только чуть больше букв.



Процессор тоже можно писать на Java (см. файл JavaEntityProcessor.java).



Что я тут сделал? Я добавил обработчик для ещё одной фазы: doRegisterGlobals и докинул в контекст классы, которые мне понадобятся: Service и Repository. Плюс, заоверрайдил метод doTransform тот самый doTransform, который написал чуть раньше на Xtend. Причём я тут нормально навигируюсь по коду. Могу попадать в Xtend-код



и обратно в Java-код.



Дальше (см. метод doTransform) я добавляю к нашей entity аннотацию. Обратите внимание, здесь, в отличие от Xtend все методы надо вызывать явно через context.


Затем идёт метод, который создаёт репозиторий: createRepository. Важный момент: для всего того что мы генерируем, важно указывать PrimarySource: context.setPrimarySourceElement(repositoryType, entity);. Зачем? Чтобы при кодогенерации, когда у нас появляется Java-файл, он был связан со своим Xtend-источником.


Дальше немного скучного кода: пользую типы из Spring Data, чтобы указать какой у репозитория должен быть интерфейс.



Дальше прохожусь по всем филдам, и выбирай из них те, которые не транзиентные и не являются айдишниками.



И смотрите, здесь видно, что лямбды в Java очень хорошо дружат с лямбдами в Xtend. Одно на другое взаимозаменяется. Т.е. функциональные интерфейсы все здесь работают. И API был задизайнен так, что сюда джавововые лямбды нормально встают.


Дальше добавляем к нашим филдам всякие разные findBy-методы. Причём смотрим на аннотацию @Column, которая стоит над филдом. Если она имеет установленный атрибут признака уникальности значения (isUnique), просто возвращаем entityType. Если нет, возвращаем List. В конце ставим аннотацию @Param, которая нужна для того чтобы работал Spring Data Rest.



Всё! Для Repository генератор готов. Теперь если откроем Xtend-исходник, на основе которого будет генерироваться Java-код, и посмотрим на Gentrated Code, то здесь у нас добавился ещё и репозиторий. Мы можем смотреть на него, вот он такой.



Дальше пишем генератор для Service. Там всё почти всё так же как и с Repository.



Вот и всё. Процессор готов. Можно запускать сгенерированное приложение.


Ещё несколько улучшений, и запускаем сгенерированное приложение


Хорошо, сервис и репозиторий есть. Но как нам узнать, что у нас с моделью нашей всё хорошо? Добавим ещё одну фазу фазу валидации. Я добавляю два валидатора.



Теперь, если разработчик, который пишет Extend-код, вдруг забудет поставить перед своим классом аннотацию @ToString, валидатор выведет на экран Warning.



Или если разработчик поставит аннотацию @ManyToOne, а под ней ещё и @Column, то это уже ошибка. А ошибиться-то очень легко. Мы же программируем очень часто на копи-пасте, особенно когда есть возможность всё в один и тот же файл писать, как в Xtend. Скопировали, вроде работает успокоились. Но можно нарваться на коварную ошибку.


Допустим, у меня в Country.xtend у филда lastName прописано nullable = false, и я хочу, чтобы у Country тоже было nullable = false. Так неправильно. Поэтому Eclipse предупреждает меня. Но при этом генерируется Java код, в котором вроде как нет проблем.



Я меняю на @JoinColumn(nullable = false), и теперь всё хорошо. Можно запускать приложение.



Давайте наберём в браузере localhost:8080



затем localhost:8080/users/search.



Все наши findBy на месте. Приложение работает!


Пишите меньше кода, делайте меньше ошибок, применяйте технологии правильно


Ну вот и всё. Теперь вы тоже можете брать кодогенерацию под контроль, эффективно использовать её в своей работе. То есть проводить время с пользой: пару дней потерять на то, чтобы создать кодогенаратор, а потом за 5 минут долететь. Будете писать меньше кода, будете делать меньше ошибок.


Вы теперь умеете создавать собственные активные аннотации, писать и отлаживать код процессора. Причём делать всё это на смешанном диалекте Java и Xtend, без необходимости переносить всю свою кодовую базу на Xtend.


Демо-проект, который мы с вами прямо в этой статье сейчас разработали, я заопенсорсил на гитхабе. Скачивайте, изучайте, пользуйте. А если информацию легче воспринимаете на слух и с видео, вот мой доклад с конференции JPoint, где рассказываю всё то же самое, что и здесь в статье.
У меня всё. Пишите меньше скучного кода, делайте меньше ошибок, применяйте технологии осознанно. Буду рад ответить на ваши вопросы. Можете писать мне на akogun@croc.ru. Кстати, помните, я в начале статьи говорил, что участвую в подготовке конференций для джавистов? JPoint 2020 из-за известных причин будет проходить онлайн, но это даже совсем неплохо, у нас много отличных спикеров, которые не смогли бы приехать и выступить очно, а сама конференция будет идти целых 5 дней! С 29 июня по 3 июля jpoint.ru. Приходите!

Подробнее..

Делимся докладами-2020 и анонсируем конференции-2021

21.12.2020 14:15:23 | Автор: admin


Недавно мы завершили сезон из восьми конференций для разработчиков от Joker до Mobius. И теперь хотим сделать три вещи:


  • Подвести итоги: рассказать и о победах, и о проколах. В том числе про нашу новую виртуальную площадку
  • Анонсировать конференции 2021-го: JPoint, HolyJS, Heisenbug и другие
  • Поделиться записями 14 отличных свежих докладов



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


Сначала расскажем о том, что затрагивало всех, а затем про игровое.


Классический вид


Чем этот сезон отличался от предыдущего для тех, кто игровой вид запускать не хотел?



Воркшопы
Мы обнаружили, что для онлайна хорошо подходят практические выступления. Сидя перед экраном, удобно смотреть на то, что спикер делает на своём экране. Правда, есть сложность: воркшоп особенно хорошо работает, когда его смотрят в прямом эфире, а в записи уже не совсем то. Поэтому, например, на DotNext спикер Макс Аршинов попросил для его выступления вообще не делать запись.


Победа: как показывают отзывы, зрители действительно рады воркшопам.


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


Форматы
Помимо воркшопов, были и другие эксперименты с форматами, не подходящими для офлайна. Например, на Mobius одновременно в трёх треках три разных спикера верстали один и тот же экран с помощью трёх разных технологий декларативного UI, а затем вместе сравнивали и обсуждали результаты. Это хорошо работает именно в онлайне, где можно легко переключаться между треками и сравнивать у кого как идут дела, и не требуется перебегать между залами.


И хотя большинство докладов были в прямом эфире, в отдельных случаях спикеры воспользовались возможностями, которые даёт предзапись. Яркий пример Свен Рупперт, который провёл свой доклад в лесу. Когда мы писали в анонсах в онлайн-конференции можно участвовать хоть из леса, сами не думали, что кто-то так и сделает!



Стабильность
Самое главное в онлайн-трансляции чтобы она не падала. Так что мы проводили работу над этим.


Победа: жалоб на технические проблемы в этом сезоне было меньше, чем в прошлом.


Прокол: всё-таки не обошлось без проблемных ситуаций. На HolyJS из-за апдейта у нас развалился продакшн и начать конференционный день по плану не получилось. В итоге на ходу приняли решение сдвинуть расписание на час, разобрались за это время с проблемой, провели всё со сдвигом на час, опубликовали постмортем и тоже извлекли уроки.


Качество
Если со стабильностью всё в порядке и картинка не пропадает, дальше можно думать о том, чтобы она была как можно лучше. Мы ставим себе планкой 4K, и тут кто-то может спросить: зачем онлайн-конференции вообще столько, когда у большинства зрителей даже нет 4K-монитора? Ответ можно найти в старом докладе Одноклассников об их live video: мы сделали поддержку 4K на вырост, потому что если отдебажить для неё плеер и разобраться с производительностью, то 1080p даже на слабых устройствах будет играть прекрасно.


Победа: работая над 4K, мы в процессе тоже разгребли кучу всего, что сказывается на пользователях в том числе тех, у кого разрешение ниже.


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




Игровой вид


Мы уже рассказывали, как весной спешно разрабатывали видеоплатформу для онлайн-конференций за 90 дней. А вот после этого, когда базовая функциональность уже была реализована, задумались как сделать платформу лучше.


Зрители часто говорили нам с докладами всё отлично, но вот нет того общения и того духа приключения, что был в офлайне. Конечно, полностью офлайн-общение не воспроизвести но это не значит, что нельзя сделать вообще никакого приключения. И мы сделали игровой режим с виртуальной площадкой.


Идея в следующем: есть 2D-пространство, позволяющее ходить между разными докладами и партнёрскими стендами, как в играх. Там можно общаться: когда подходишь к другому зрителю, под игровым пространством вы видите друг друга с помощью веб-камер. То есть можно разговаривать с теми, кто рядом, как на офлайновых конференциях. И возможно взаимодействовать с различными объектами.



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


Для виртуальной платформы мы не использовали какое-то общедоступное решение, а запилили своё собственное. И Сева vbrekelov Брекелов, участвовавший в работе над ним, рассказал подробнее:


Мы хотели сделать нетворкинг интересным и рассматривали разные варианты и браузерные, и VR. Решили, что 2D-игрушки это интересно, изучили доступные решения, пообщались со Spatial Chat и Gather.town. Но обнаружили, что их не получится интегрировать как следует. Например, возникает сложность с точки зрения авторизации: доступ к самой конференции есть только у зрителей с билетами, и требуется, чтобы доступ на виртуальную площадку тоже был только у них. Со сторонними решениями это сложно или невозможно, и при этом они зачастую ещё и дорогие. И мы поняли, что надо делать что-то своё.



В итоге сделали свою виртуальную площадку с помощью PixiJS. Если коротко, то PixiJS это такой JS-движок для управления Canvas, позволяющий делать всякие штуки с передвижениями. Но надо понимать, что это далеко не Unreal Engine. Это удобная прослойка между Canvas и кодом, но многое надо реализовывать самостоятельно: отображение карты, нескольких людей на ней одновременно, демонстрацию всех перемещений. Поэтому у нас Коля Молчанов делал поверх PixiJS наш игровой движок. А мы с Кириллом Толкачёвым (tolkkv) в это время занимались нашим видеорешением на WebRTC (и поняли, что WebRTC это боль).


Затем я стал имплементировать это видеорешение в игру, и там тоже оказалось много сложностей. Выглядит это просто как несколько маленьких видеоокошек под игровой картой, и кажется, что реализовать это тривиально, но по факту написать там приходится очень много. Например, выбор устройства в Chrome, Firefox и Safari сделан по-разному. Поэтому мы сначала поддерживали только Chrome, но затем занялись и другими браузерами.


А помимо разработческих задач, были и продуктовые. Не было какого-то человека со стороны, который пришёл бы и сказал рисуем всё вот так. Нам самим надо было понять, как всё должно выглядеть, какие нам нужны локации. Обсуждали это с дизайнером, составили ТЗ. Решили в дизайне отталкиваться от атмосферы пиксельных игр что думаете о результате?


Затем был ещё один большой пласт работы. Виртуальная площадка конференции это целый ряд разных локаций. Каждая локация PNG-картинка, которую мы разбиваем на клетки 30x30. И дальше на клетках нужно было вручную указывать, что это за объект: это стена, сквозь неё нельзя пройти, это стенд партнёра, вот здесь будет открываться такая-то ссылка, а это переход на другую локацию с таким-то ID. В общем, перед Joker мы с Колей Молчановым не уходили из офиса: размечали карту, выкатывали последовательно на test/dev/prod, тестировали на каждом шаге.



Наш редактор, где мы размечаем NPC-объекты


Теперь, когда сезон завершён, можно подвести итог: понравилась ли платформа зрителям? Мы вдумчиво прочитали то, что о ней писали в форме обратной связи и хвалебное, и критическое.


Хвалят за то, что атмосфера конференции получается живее, чем когда есть только сайт с расписанием и видеоплеером.


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


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


Но раз сама затея зрителям понравилась, к следующему конференционному сезону подготовим вторую итерацию, в которой учтём собранный фидбек.


Вот о следующем сезоне давайте и поговорим.




Анонс-2021: новые даты и новые цены


В апреле мы начнём новый конференционный сезон. Что можем о нём сказать?


  • Определились с датами пяти конференций (с другими продолжаем определяться, полный список будет на jugru.org):
    Heisenbug: 6-9 апреля, билеты уже в продаже
    JPoint: 13-16 апреля, билеты уже в продаже
    Mobius: 13-16 апреля
    HolyJS: 20-23 апреля, билеты уже в продаже
    DotNext: 20-23 апреля
  • Этот сезон, как и два предыдущих, пройдёт в онлайне (пандемия не спешит исчезать). Так что поучаствовать снова можно будет из любой точки планеты.
  • И поскольку он пройдёт в онлайне, мы бросим силы на то, чтобы онлайн-платформа с виртуальной площадкой стала богаче возможностями пока не назовём список новых фич, но наверняка станет интереснее.
  • Мы пересмотрели тарифную сетку. Раньше было два варианта билетов: Standard (на одну конференцию) и Full Pass (абонемент на весь сезон). Теперь появляются ещё два: бюджетный Basic (вдвое дешевле Standard, но не даёт доступ к видеозаписям дискуссионным зонам, смотреть доклады можно только в прямом эфире) и Extended (на одну конференцию, но даёт также доступ к видеозаписям остальных). Подробно все варианты можно сравнить на сайте конференции при выборе билета.
  • И, как обычно, цена билетов растёт по мере приближения конференции. Так что самый выгодный момент для приобретения сейчас.
  • Если вы участвовали в наших последних конференциях, то больше информации скоро получите (или уже получили) по почте.



Видеозаписи докладов



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


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


Heisenbug (тестирование)
Тест-кейсы как код (Артем Ерошенко)
Воркшоп: Как начать свой проект автоматизации с нуля с божьей помощью и Selenide (Андрей Солнцев): часть 1 часть 2


Mobius (мобильная разработка)
Jetpack Compose live coding declarative UI Антон Шилов)
gRPC в iOS приложениях. REST in peace? (Светослав Карасев)


DotNext (.NET)
Nullability in C# (Jared Parsons)
Как устроен JIT-компилятор в CoreCLR (Егор Богатов)


Joker (Java)
Заменят ли роботы программистов? (Тагир Валеев)
Spring Patterns для взрослых (Евгений Борисов)


HolyJS (JavaScript)
Воркшоп. Новые приключения во фронтенде, версия 2021 (Виталий Фридман): часть 1, часть 2
Революция в микрофронтендах, module federation, Webpack 5 Павел Черторогов


DevOops (DevOps)
Путь (Microsoft) DevOps (Саша Розенбаум)
Платформенная разработка и топологии команд (Михаил Бижан)


C++ Russia (C++)
Ищем баги в продакшене всем миром: GWP-ASan и что дальше (Константин Серебряный)
Дискуссия: Собеседование С++ (Павел Филонов, Илья Шишков, Роман Русяев)


Увидимся в следующем году на новых конференциях!

Подробнее..

JPoint 2021 тенденции и тренды мира Java

19.04.2021 00:19:54 | Автор: admin
В третьем онлайн-сезоне конференций, проводимых JUG Ru Group, с 13 по 17 апреля 2021 года успешно прошла Java-конференция JPoint 2021.



Что было интересного на конференции? Какой тематики были доклады? Кто из спикеров и про что рассказывал? Что изменилось в организации конференции и долго ли ждать возвращение офлайн-формата? Можно ли что-то ещё придумать оригинальное при написании обзора о конференции?

Основным инструментом, который поможет делать рассказ о конференции и её докладах в этом обзоре, будет облако тегов (англ. tag cloud). Облако тегов визуальное представление текстовых данных, в котором слова, используемые с большей частотой, выделяются размером шрифта или цветом. Облака тегов помогут

  • показать общие тенденции, тренды, направленность прошедшей конференции;
  • проиллюстрировать каждый из представленных в обзоре докладов;
  • поиграть в игру, где будут и облака тегов, и доклады, и спикеры.

Картинка в начале статьи с облаком тегов в форме логотипа JPoint была сформирована с использованием названий и описаний абсолютно всех докладов конференции. Посмотреть файл в оригинальном размере можно по следующей ссылке. Из 1685 слов в топ-3 с заметным отрывом попали: Spring (50 повторений), Java (49 повторений) и data (21 повторение). Из прочих фаворитов, но с меньшей частотой использования, можно также отметить слова session, JDK, cloud, code, Kubernetes, GraphQL и threads. Данная информация помогает понять, куда движется Java-платформа и названия каких сущностей, технологий и продуктов являются самыми актуальными сегодня.

Открытие


Долгожданное открытие конференции выполнили Алексей Фёдоров, Глеб Смирнов, Андрей Когунь и Иван Углянский. Ими были представлены спикеры, эксперты и программный комитет все те, без которых проведение конференции было бы невозможным.



Редкий случай, когда можно было видеть одновременно трёх лидеров Java-сообществ: JUG.ru (Алексей Фёдоров), JUG.MSK (Андрей Когунь) и JUGNsk (Иван Углянский).



Основные события конференции были трёх типов:

  • доклады;
  • мини-доклады партнёров;
  • воркшопы.

Доклады


Приятной неожиданностью для русскоязычных участников конференции стало то, что Себастьян Дашнер свой доклад Качественный код в тестах не просто приятный бонус делал на русском языке. Себастьян принимает участие в качестве спикера в конференциях JUG Ru Group c 2017 года, причём, не только в Java-конференциях. Текущий доклад был посвящён интеграционному тестированию, поэтому в нём присутствовали и Java, и тесты на JUnit, и Docker. В качестве приглашённого эксперта рассказ успешно дополнил Андрей Солнцев. Отличное знание русского языка и неизменно интересный доклад от Себастьяна Дашнера.





В докладе Building scalable microservices for Java using Helidon and Coherence CE от Дмитрия Александрова и Aleksandar Seovic было продемонстрировано совместное использование двух продуктов компании Oracle Helidon (в его разработке участвует Дмитрий) и Oracle Coherence (Aleks является архитектором продукта). Митя ранее делал доклады про MicroProfile (первый доклад и второй) и написал хорошую статью на Хабре про Helidon, поэтому было любопытно посмотреть дальнейшее развитие темы. Повествование сопровождалось демонстрацией кода и запуском приложения, код которого доступен на GitHub. Докладчики, каждый из которых лучше знаком со своим продуктом, отлично дополняли друг друга. Посмотреть оказалось увлекательно и полезно.





Анна Козлова работает над созданием нашего любимого инструмента IntelliJ IDEA, внеся по количеству коммитов самый большой вклад среди всех конрибьютеров в репозиторий IntelliJ IDEA Community Edition, что вызывает огромное уважение.

В своём докладе Многоступенчатые рефакторинги в IntelliJ IDEA Анна очень доходчиво и убедительно показала, как сложные типы рефакторингов могут быть получены комбинацией более простых уже существующих рефакторингов. В препарировании рефакторингов ей ассистировал коллега по компании JetBrains Тагир Валеев. Исключительно полезен как сам доклад (определённо, стоит его пересмотреть), так и озвученная Анной и Тагиром статистика применения разного типа рефакторингов пользователями.





Type inference: Friend or foe? от Venkat Subramaniam. Венкат фантастически харизматичный спикер, которого каждый раз хочется смотреть при присутствии его докладов на конференции. Мне кажется, ценность его докладов в том числе в том, что он заставляет увидеть другую сторону каких-то вещей, ранее казавшимися простыми и очевидными. В этот раз подобной темой было выведение типов (type inference). Кроме интересной информации в очень экспрессивном исполнении наконец-то узнал, в чём Венкат показывает презентации и запускает код (ломал голову над этим при просмотре его предыдущих докладов) это редактор vi.





Доклад Антона Кекса про то, Что такое Работающий Продукт и как его делать своеобразное продолжение его выступления The world needs full-stack craftsmen двухлетней давности. Если в прошлом докладе Антон говорил о недопустимости узкой специализации разработчика, то в этот раз сфокусировал своё внимание и внимание слушателей на том, почему важно и как можно сделать качественный работающий программный продукт. Докладчик подкреплял приведённые теоретические тезисы практическими примерами, поэтому просмотр стал весьма захватывающим зрелищем.





Под доклад Spring Data Рostроитель (Spark it!) в исполнении Евгения Борисова предусмотрительно был отведён отдельный пятый день конференции. Планировалось, что демонстрация написания поддержки Spark для Spring Data займёт 6 часов (в итоге вышло почти 7). Положительным моментом онлайн-конференции является то, что в случае длинных докладов можно комфортно прерывать и продолжать просмотр позднее. Много кода, новой информации и подробных пояснений. Традиционно получилось качественно, основательно и увлекательно.



Мини-доклады партнёров


+10 к безопасности кода на Java за 10 минут стал первым из 15-минутных докладов партнёров, увиденных на конференции. Алексей Бабенко сконцентрировал в небольшом времени, отведённом на доклад, внимание на вопросах безопасности при написании кода на языке Java. Формат мини-докладов, которые показываются в перерывах между большими докладами, оказался достаточно удачным и востребованным.





Ещё один мини-доклад, 1000 и 1 способ сесть на мель в Spring WebFlux при написании высоконагруженного сервиса от Анатолия Тараканова, может пригодиться в том случае, если используете Spring WebFlux и возникли какие-либо проблемы в разработке и эксплуатации приложения. Краткое перечисление проблем и способов их решений может чем-то помочь.





В кратком докладе R2DBC. Стоит ли игра свеч? от Антона Котова даётся оценка практической применимости спецификации R2DBC в текущий момент. После ранее прослушанного вот этого доклада Олега Докуки было интересно узнать сегодняшнее положение вещей. Антон в конце доклада даёт однозначный ответ на вопрос Стоит ли игра свеч?. Через некоторое время ответ должен, вероятно, измениться.





Доклад Секретный ингредиент: Как увеличить базу пользователей в 3 раза за год в исполнении Александра Белокрылова и Алисы Дрожжиновой представил следующие новости от компании BellSoft, наиболее известной своим продуктом Liberica JDK:

  • клиентская база компании увеличилась в 3 раза за последний год;
  • появился новый инструмент Liberica Administration Center (LAC) для централизованного обновления Java на компьютерах пользователей;
  • стала доступна утилита Liberica Native Image Kit на базе GraalVM CE;
  • компания ведёт работы в области серверов приложений (на сайте доступен продукт LiberCat на основе Apache Tomcat).





Кирилл Скрыган докладом Code With Me новая платформа для удаленной коллаборативной разработки представил новую возможность продуктов компании JetBrains для парного программирования и коллективной разработки в среде разработки. Была показана базовая функциональность сервиса и перечислены получаемые преимущества.



Воркшопы


На конференции было два воркшопа: Парное программирование вместе с Андреем Солнцевым и Антоном Кексом и Строим Бомбермена с RSocket вместе с Сергеем Целовальниковым и Олегом Докукой. Для просмотра во время конференции выбор пал на воркшоп Строим Бомбермена с RSocket. Олег и Сергей убедительно продемонстрировали на примере игры взаимодействие составных частей приложения по протоколу RSocket. Код приложения доступен на GitHub для изучения и повторения действий, выполненных во время воркшопа.



Конференции, Java-митапы и игра


На сайте jugspeakers.info по-прежнему доступно приложение, состоящее из двух частей:

  • поиск и просмотр информации о конференциях и Java-митапах, организуемых JUG Ru Group, JUG.MSK и JUGNsk (спикеры, доклады, презентации, видео, статистика);
  • игра Угадай спикера.

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

  1. Начальная страница отображает теперь ближайшую или идущую в данный момент конференцию (картинка слева ниже сделана во время работы JPoint 2021). Установка значений всех фильтров поиска по умолчанию на текущую конференцию также должна помочь сделать программу максимально полезной во время идущей сейчас конференции.
  2. Приложение дополнено фильтрами по организатору конференций и митапов (средняя картинка).
  3. Добавлена информация о видео всех докладов, сделанных публично доступными (в том числе видео докладов с Joker 2020).
  4. Появились данные о конференции SnowOne, второй год проводящейся новосибирским Java-сообществом JUGNsk (см. картинку внизу справа).
  5. Стало возможным видеть учётные записи Хабра у спикеров.



Во второй части приложения (в игре Угадай спикера) появилось 2 новых режима: угадывание облака тегов по спикеру и спикера по облаку тегов. Облака тегов формируются на лету, при выборе пользователем конференций или митапов. Источником для создания облаков тегов по спикерам являются наименования и описания их докладов. Для каждого спикера создаётся одно (по тексту на английском языке) или два облака тегов (второе облако возможно, если у докладов есть описание и на русском языке). Переключением языка интерфейса можно посмотреть оба облака тегов.

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



Чем больше конференций выбрано в игре для данных режимов (угадывание облака тегов по спикеру и спикера по облаку тегов), тем больше информации для формирования облака тегов и результат будет более точный. Java-разработчикам для игры можно порекомендовать следующие варианты в порядке усложнения:

  1. Выбрать только одну конференцию JPoint 2021 (Тип события JPoint, Событие JPoint 2021)
  2. Выбрать все конференции JPoint (Тип события JPoint, Событие конференции за все годы)
  3. Выбрать все Java-события (Тип события Joker, JPoint, SnowOne, JBreak, JUG.MSK, JUG.ru и JUGNsk)

Код приложения находится на GitHub, репозиторию можно ставить звёздочки.

Закрытие


Алексей Фёдоров, Глеб Смирнов и Андрей Когунь закрыли конференцию, подведя итоги и поделившись каждый своими впечатлениями от пятидневного конференционного марафона.



Несмотря на вынужденное ограничение онлайн-форматом, конференция продолжает держать высокую планку: удобная платформа для просмотра докладов и взаимодействия с другими участниками, множество докладов по Java-технологиям, горячая информация о новых продуктах (Space и Code With Me), любимые спикеры с отлично дополняющими их приглашёнными экспертами.

В весенне-летнем сезоне онлайн-конференций JUG Ru Group ещё будут конференции HolyJS, DotNext (20-23 апреля 2021 года) и Hydra (15-18 июня 2021 года). Можно посетить любую из конференций отдельно или купить единый билет на все шесть конференций сезона (три уже прошедших и три оставшихся), видео докладов становятся доступными сразу же после завершения конференций.
Подробнее..

Ах, эти строки

13.08.2020 12:11:08 | Автор: admin

Это текстовая версия моего доклада "Ах, эти строки" на конференции JPoint-2020.
Дабы не тратить время читателей зря, сразу расставим все точки над "ё".


О чём статья?


Статья об эффективном (или не очень) использовании строк.


Для кого статья?


Статья для разработчиков, занимающихся производительностью, и им сочувствующих.


Откуда всё это?


Что-то выловлено в коде проекта, что-то во фреймворках и библиотеках.


Что, чем и на чём измеряли?


  • код бенчмарков и результаты прогонов доступны на ГитХабе
  • для измерения использовался JMH 1.23
  • замеры проводились на рабочей машине с Intel Core i7-7700 (сами по себе цифры не важны, важны соотношения между ними и выявленные закономерности)
  • по умолчанию использовался JDK 11, но также 8 и 14 (явно прописаны на соответствующих страницах)
  • режим бенчмарков: среднее время выполнения + расход памяти (меньше значит лучше)

Пакет java.lang и его обитатели


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


Но есть класс неподвластный консерватизму: java.lang.String. Ниже список последних предложений по его улучшению (или улучшению работы с ним), многие из них уже реализованы:


  • JEP 192: String Deduplication in G1
  • JEP 250: Store Interned Strings in CDS Archives
  • JEP 254: Compact Strings
  • JEP 280: IndifyString Concatenation
  • JEP 326: Raw String Literals (Preview)
  • JEP 355: Text Blocks (Preview)
  • JEP 348: Compiler Intrinsics for Java SE APIs (в основном это пока про String.format())

Обратите внимание на сжатые строки они затрагивают основы основ если сравнить содержимое java.lang.String в "восьмёрке" и в "девятке", то изменилось решительно всё.


Почему именно строки


Повышенное внимание разработчиков платформы к строкам тоже понятно и подробно изложено в классическом докладе Катехизис java.lang.String. Вкратце:


  • они [строки] везде
  • они съедают весомую часть кучи
  • они [при неправильном использовании] могут ухудшить производительность
  • когда им хорошо, то хорошо всем

Подходы к прокачиванию производительности


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


  • наращивать объём исполняемого
  • уменьшать / переупорядочивать объём исполняемого

Первый подход только на первый взгляд выглядит нелогичным, на деле же это не что иное как кэширование: приложению очень часто всё равно, откуда берётся то или иное значение, важна лишь его правильность. Значит можно прикрутить к приложению кэш, хранить там значения дорогих вычислений и переиспользовать их. Когда мы говорим про строки, то тут есть сразу два похожих механизма: интернирование и дедуплицирование (JEP 192). Подчёркиваю красным: это не кэширование в привычном смысле (хотя иногда может быть похоже как две капли воды).


Поскольку большинство из нас это рядовые разработчики и влезть в плюсовый адъ внутри ВМ и сделать там что-то осмысленное могут не только лишь все (мало кто может это сделать), то для нас более перспективным является второй подход делать меньше получая больше. О нём и поговорим.


Основная проблема строк


Проистекает она из JLS 15.18.1 и означает, что [почти] любое преобразование строки порождает новую строку, поэтому зачастую для получения прироста нужно лишь избавиться от ненужных преобразований, при чём многие из них легко формализуются и обращаются в правила статического анализатора.


Отсюда плавно вытекает стратегия и тактика улучшения работы со строками:


  • стратегия: не использовать строки там, где это возможно
  • тактика: по возможности избегать преобразования строк

Ключи и словари


Строки очень часто используются в качестве ключей (внезапно). Причиной является набор уникальных свойств, доступный прямо из коробки:


  • строки неизменяемы
  • строки определяют equals() / hashCode()
  • строки кэшируют хэш-код
  • строки сериализуемы и реализуют java.lang.Comparable (их можно класть в TreeMap)
  • строки особо реализуют Object.equals()
  • любой объект можно представить в виде строки вызвав obj.toString()

Многие знают, что набор постоянных строк-ключей можно представить в виде перечисления, что даёт возможность отказаться от HashMap в пользу EnumMap:


Map<String, ?> map = new HashMap<>();class Constants {  static final String MarginLeft = "margl";  static final String MarginRight = "margr";  static final String MarginTop = "margt";  static final String MarginBottom = "margb";}

легко заменяется на


Map<String, ?> map = new EnumMap<>(Constants.class);enum Constants {  MarginLeft,  MarginRight,  MarginTop,  MarginBottom}

что даёт более легковесный и быстрый словарь:


@Benchmarkpublic Object hm() {  var map = new HashMap<>();  map.put(Constants.MarginLeft, 1);  map.put(Constants.MarginRight, 2);  map.put(Constants.MarginTop, 3);  map.put(Constants.MarginBottom, 4);  return map;}@Benchmarkpublic Object em() {  var map = new EnumMap<>(ConstantsEnum.class);  map.put(ConstantsEnum.MarginLeft, 1);  map.put(ConstantsEnum.MarginRight, 2);  map.put(ConstantsEnum.MarginTop, 3);  map.put(ConstantsEnum.MarginBottom, 4);  return map;}

Прирост ощутим:


                               Mode    Score    Error   UnitsenumMap                        avgt   23.487   0.694   ns/ophashMap                        avgt   67.480   2.395   ns/openumMap:gc.alloc.rate.norm    avgt   72.000   0.001    B/ophashMap:gc.alloc.rate.norm    avgt  256.000   0.001    B/op

Перебор также пойдёт бодрее:


@Benchmarkpublic void hashMap(Data data, Blackhole bh) {  Map<String, Integer> map = data.hashMap;  for (String key : data.hashMapKeySet) {    bh.consume(map.get(key));  }}@Benchmarkpublic void enumMap(Data data, Blackhole bh) {  Map<ConstantsEnum, Integer> map = data.enumMap;  for (ConstantsEnum key : data.enumMapKeySet) {    bh.consume(map.get(key));  }}

что даёт


                               Mode    Score    Error   UnitsenumMap                        avgt   36.397   3.080   ns/ophashMap                        avgt   55.652   4.375   ns/op

В сложных случаях так не получается:


// org.springframework.aop.framework.CglibAopProxyMap<String, Integer> map = new HashMap<>();getCallbacks(Class<?> rootClass) {  Method[] methods = rootClass.getMethods();  for (intx = 0; x < methods.length; x++) {    map.put(methods[x].toString(), x);          // <------  }}// зеркальный методaccept(Method method) {  String key = method.toString();  // key используется тут в качестве ключа}

Проблема понятна: вызов java.lang.reflect.Method.toString() порождает новую строку. Много ли теряем?


@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)public class MethodToStringBenchmark {  private Method method;  @Setup  public void setup() throws Exception {    method = getClass().getMethod("toString");  }  @Benchmark  public String methodToString() { return method.toString(); }}

Это простейший случай вызов method.toString() возвращает строку:


"public java.lang.String java.lang.Object.toString()"

а стоит это удовольствие немало:


                                       Mode  Score  Error   UnitsmethodToString                         avgt   85,4   1,3   ns/opmethodToString:gc.alloc.rate.norm     avgt  344,0   0,0    B/op

Если мы провернём это в более жизненном ключе, например:


public class MethodToStringBenchmark {  private Method method;  @Setup  public void setup() throws Exception {    method = getClass().getMethod("getInstance");  }  @Benchmark  public String methodToString() { return method.toString(); }  MethodToStringBenchmark getInstance() throws ArrayIndexOutOfBoundsException {    return null;  }}

то расценки существенно вырастут:


                                       Mode     Score    Error   UnitsmethodToString                         avgt   199.765   3.807   ns/opmethodToString:gc.alloc.rate.norm     avgt  1126.400   9.817    B/op

ведь возвращается уже более внушительная строка:


"public tsypanov.strings.reflection.MethodToStringBenchmark tsypanov.strings.reflection.MethodToStringBenchmark.getInstance() throws java.lang.ArrayIndexOutOfBoundsException"

На первый взгляд всё безнадёжно, ведь никаких enum-ов не напасёшься на все проксируемые методы. Давайте лучше присмотримся к самому классу java.lang.reflect.Method. Уже поверхностный осмотр показывает, что он вполне может быть ключом вместо строки:


  • реализует equals() / hashCode()
  • неизменяемый *

Почему неизменяемый со звёздочкой?


не торопитесь открывать, подумайте

Всё из-за него:


public final class Method extends Executable {  @Override  @CallerSensitive  public void setAccessible(boolean flag) {      AccessibleObject.checkPermission();      if (flag) checkCanSetAccessible(Reflection.getCallerClass());      setAccessible0(flag);  }}

Это тот самый случай, когда теория запрещает использовать объект этого класса в качестве ключа, ведь у него есть изменяющий состояние метод, а крестьянская смекалка говорит "Можно!". Ведь сколько бы мы ни дёргали за ручку Method.setAccessible() поведение его equals()/hashCode() не изменится.


Есть и недостатки:


  • java.lang.reflect.Method не реализует Comparable
  • хэш-код объекта Method не равен хэш-коду строки (и он не кэшируется)

В данном случае нам важно только положить пару "ключ-значение" в словарь и получить значение по ключу, следовательно меняем String на Method.


Будет ли толк от нашей заплатки в боевом приложении? Проверим на примере, который и подтолкнул меня покопаться в CglibAopProxy:


@Configurationpublic class AspectConfig {  @Bean  ServiceAspect serviceAspect() { return new ServiceAspect(); }  @Bean  @Scope(BeanDefinition.SCOPE_PROTOTYPE)  AspectedService aspectedService() { return new AspectedServiceImpl(); }  @Bean  AbstractAutoProxyCreator proxyCreator() {    var factory = new AnnotationAwareAspectJAutoProxyCreator();    factory.setProxyTargetClass(true);    factory.setFrozen(true);           // <--- обратите внимание    return factory;  }}

Небольшое пояснение: у нас есть некий компонент-прототип (это нужно, чтобы хранить состояние) с 1 методом, обёрнутым в 1 аспект. Поскольку в нашем случае мы знаем, что цепочка проброса неизменна, то её можно "заморозить", что позволяет "Спрингу" выполнить под капотом некоторые оптимизации (см. документацию).


Вычислим стоимость создания этого бина:


@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.MICROSECONDS)public class AspectPrototypeBenchmark {  private AnnotationConfigApplicationContext context;  @Setup  public void setUp() {    context = new AnnotationConfigApplicationContext(AspectConfig.class);  }  @Benchmark  public AspectedService getAdvisedBean() {    return context.getBean(AspectedService.class);  }  @TearDown  public void closeContext() { context.close(); }}

Имеем:


                                       Mode      Score     Error   UnitsbeforegetAdvisedBean                         avgt     14.024    0.164   us/opgetAdvisedBean:gc.alloc.rate.norm     avgt  10983.307   14.193    B/opaftergetAdvisedBean                         avgt      8.150    0.202   us/opgetAdvisedBean:gc.alloc.rate.norm     avgt   7133.664    5.594    B/op

Неплохо, как для такого простого изменения.


З.. Обратите внимание, что этот бенчмарк лежит в другом репозитории, где собраны бенчмарки для "Спринга".


Составные ключи


В JDK есть класс ObjectStreamClass, использующийся при сериализации, в нём вложенный класс FieldReflectorKey, а там внутри проблема.


public class ObjectStreamClass implements Serializable {  private static class Caches {    static final ConcurrentMap<FieldReflectorKey, Reference<?>> reflectors =            new ConcurrentHashMap<>();  }  private static class FieldReflectorKey extends WeakReference<Class<?>> {    private final String sigs;    private final int hash;    private final boolean nullClass;    // ...}

Фамилия имя и отчество виновного известны: JDK-6996807 FieldReflectorKey hash code computation can be improved. Уже из заголовка понятно: вычисление хэш-кода стоит неоправданно дорого. Больное место находится в конструкторе:


FieldReflectorKey(Class<?> cl, ObjectStreamField[] fields,                    ReferenceQueue<Class<?>> queue){  super(cl, queue);  nullClass = (cl == null);  StringBuilder sbuf = new StringBuilder();  // <---- !!!  for (int i = 0; i < fields.length; i++) {    ObjectStreamField f = fields[i];    sbuf.append(f.getName()).append(f.getSignature());  }  sigs = sbuf.toString();  hash = System.identityHashCode(cl) + sigs.hashCode();}

После внесения изменений получаем:


FieldReflectorKey(Class<?> cl, ObjectStreamField[] fields,                  ReferenceQueue<Class<?>> queue){  super(cl, queue);  nullClass = (cl == null);  sigs = new String[2 * fields.length];  for (int i = 0, j = 0; i < fields.length; i++) {    ObjectStreamField f = fields[i];    sigs[j++] = f.getName();    sigs[j++] = f.getSignature();  }  hash = System.identityHashCode(cl) + Arrays.hashCode(sigs);}

Теперь вместо цельной строки создаётся массив, заполняемый именами и сигнатурами классов, что даёт небольшой прирост:


SPECjvm2008:serial improves a little bit with this patch, and the allocation rate is down ~5%.

Ровно та же проблема была выловлена в "Спринге" в o.s.context.support.StaticMessageSource:


public class StaticMessageSource extends AbstractMessageSource {  private final Map<String, String> messages = new HashMap<>();  @Override  protected String resolveCodeWithoutArguments(String code, Locale locale) {    return this.messages.get(code + '_' + locale.toString());  }  public void addMessage(String code, Locale locale, String msg) {    // ...    this.messages.put(code + '_' + locale.toString(), msg);  }}

Измерить производительность можно с помощью бенчмарка:


private final String code = "code1";private final Locale locale = Locale.getDefault();@Benchmarkpublic Object concatenation(Data data) {  return data.stringObjectMap.get(data.code + '_' + data.locale);}

Что даёт


concatenation                          avgt     53.241    1.494   ns/opconcatenation:gc.alloc.rate.norm      avgt    120.000    0.001    B/op

Решение составной ключ, который может быть представлен в виде отдельного класса


@EqualsHashCode@RequiredArgsConstructorprivate static final class Key {  private final String code;  private final Locale locale;}

списка:


Arrays.asList(code, locale);// или в старших JDKList.of(code, locale)

или даже записи (если вы красавчик и у вас Java 14)


private static record KeyRec(String code, Locale locale) {}

Рассмотрим их показатели:


                                       Mode      Score     Error   UnitscompositeKey                           avgt      6.065    0.415   ns/opconcatenation                          avgt     53.241    1.494   ns/oplist                                   avgt     31.001    1.621   ns/opcompositeKey:gc.alloc.rate.norm       avgt      10              B/opconcatenation:gc.alloc.rate.norm      avgt    120.000    0.001    B/oplist:gc.alloc.rate.norm               avgt     80.000    0.001    B/op

Обратите внимание, что на создание 1 составного ключа мы выделили 0 байт, т. е. анализ области видимости отработал на отлично (чего не скажешь о списке), поэтому я предложил именно такой вариант. Однако Ёрген Холлер, занимавшийся этим изменением рассудил иначе. На первый взгляд, решение несколько странное, ведь последовательный поиск в двух словарях очевидно дороже:


                                       Mode      Score     Error   UnitscompositeKey                           avgt      6.065    0.415   ns/opmapInMap                               avgt      9.330    1.010   ns/opmapInMap:gc.alloc.rate.norm           avgt      10              B/opcompositeKey:gc.alloc.rate.norm       avgt      10              B/op

Но дороже он будет только при действенном анализе области видимости, а с этим иногда напряжёнка:


этот же бенчмарк на JDK 14                                       Mode      Score     Error   UnitscompositeKey                           avgt      7.803    0.647   ns/opmapInMap                               avgt      9.330    1.010   ns/oprecord                                 avgt     13.240    0.691   ns/oplist                                   avgt     37.316    6.355   ns/opconcatenation                          avgt     69.781    7.604   ns/opcompositeKey:gc.alloc.rate.norm       avgt     24.001    0.001    B/opmapInMap:gc.alloc.rate.norm           avgt      10              B/oprecord:gc.alloc.rate.norm             avgt     24.001    0.001    B/oplist:gc.alloc.rate.norm               avgt    105.602    9.786    B/opconcatenation:gc.alloc.rate.norm      avgt    144.004    0.001    B/op

Оп-па! А ключ-то составной теперь создаётся в куче! Мораль сей басни такова: скаляризация и анализ области видимости очень хрупкие вещи, которые не прописаны в спецификации языка и ВМ, и которые нам никто не обещает.


Вывод для разработчика простой: склейка строк для создания ключа это плохо, ибо


  • требует относительно много времени и доппамяти
  • при частом обращении может стать узким местом

Выход есть отдельный класс для составного ключа (в крайнем случае подойдёт массив или Arrays.asList() / List.of()).


Склеивание строк


Когда речь заходит о склейке мы часто задаём неправильный вопрос: какой способ самый лучший? Правильный вопрос, ПМСМ, звучит так: а нужно ли вообще их клеить? Для примера рассмотрим часть метода org.springframework.core.ResolvableType.toString():


StringBuilder result = new StringBuilder(this.resolved.getName());if (hasGenerics()) {  result.append('<');  result.append(StringUtils.arrayToDelimitedString(getGenerics(), ", "));  result.append('>');}return result.toString();

Переберём все возможные исполнения, благо их аж целых 2:
1) hasGenerics() возвращается истину и мы честно клеим строки
2) hasGenerics() возвращается ложь и мы переливаем значение this.resolved.getName() в StringBuilder, а оттуда снова в строку


Очевидно, что во втором случае (он наиболее частый, т. к. большинство бинов нетипизированы) на выходе мы получим ту же строку, что и из this.resolved.getName(), поэтому код можно упростить, повысив одновременно его производительность:


if (hasGenerics()) {  return this.resolved.getName()     + '<'    + StringUtils.arrayToDelimitedString(getGenerics(), ", ")    + '>';}return this.resolved.getName();

Обратите внимание: после внесения StringBuilder-а внутрь условного блока мы можем отказаться от него в пользу + (об этом чуть ниже).


Склейка: если всё-таки нужно


Рассмотрим задачу преобразования массива байт в шестнадцатеричный вид. Решение следующее:


private static String bytesToHexString(byte[] bytes) {  StringBuilder sb = new StringBuilder();  for (int i = 0; i < bytes.length; i++) {    sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1));  }  return sb.toString();}

Вопиющая неэффективность метода bytesToHexString очевидна даже новичку: преобразование байта в строку, взятие подстроки, добавление в StringBuilder. На этом варианте останавливаться не будем (хотя он и был выловлен в коде двух проектов). Существует похожий (и тоже неэффективный) вариант решения задачи (взят из статьи про p6spy):


public String toHexString(byte[] bytes) {  StringBuilder sb = new StringBuilder();  for (byte b : bytes) {    int temp = (int) b & 0xFF;    sb.append(HEX_CHARS[temp / 16]);    sb.append(HEX_CHARS[temp % 16]);  }  return sb.toString();}

При первом же рассмотрении разработчик наверняка обратит внимание на создание StringBuilder-а конструктором по умолчанию, хотя нам известно количество проходов по циклу, а также тот факт, что при каждом проходе добавляются два знака шестнадцатеричного алфавита. Вырисовывается очевидное улучшение:


public String toHexStringPatched(byte[] bytes) {  StringBuilder sb = new StringBuilder(bytes.length * 2);  for (byte b : bytes) {    int temp = (int) b & 0xFF;    sb.append(HEX_CHARS[temp / 16]);    sb.append(HEX_CHARS[temp % 16]);  }  return sb.toString();}

Если мы прогоним через оба метода 1 Мб данных, то обнаружим, что второй даёт существенную экономию памяти при незначительном приросте по времени:


original                          avgt        4167,950      82,704   us/oppatched                           avgt        3972,118      34,817   us/oporiginal:gc.alloc.rate.norm      avgt    13631776,184       0,005    B/oppatched:gc.alloc.rate.norm       avgt     8388664,173       0,002    B/op

Оказывается, что львиную долю занимает проверка выхода за пределы массива и доступ к самому массиву:


@Overridepublic AbstractStringBuilder append(char c) {  ensureCapacityInternal(count + 1);  value[count++] = c;  return this;}

Обратите внимание: несмотря на точно заданный размер хранилища и известное количество проходов не существует никакого механизма, который мог бы подсказать исполнению, что проверки доступа можно выбросить. Поэтому правильным решением является отказ от StringBuilder-а в пользу голого массива:


public String toHexString(byte[] bytes) {  char[] result = new char[bytes.length * 2];  int idx = 0;  for (byte b : bytes) {    int temp = (int) b & 0xFF;    result[idx++] = HEX_CHARS[temp / 16];    result[idx++] = HEX_CHARS[temp % 16];  }  return new String(result);}

И вот теперь мы получим существенный прирост:


original                          avgt        4167,950      82,704   us/oppatched                           avgt        3972,118      34,817   us/opchars                             avgt        1377,829       4,861   us/oporiginal:gc.alloc.rate.norm      avgt    13631776,184       0,005    B/oppatched:gc.alloc.rate.norm       avgt     8388664,173       0,002    B/opchars:gc.alloc.rate.norm         avgt     6291512,057       0,001    B/op

Любопытно, что если запустить этот же код на старших версиях JDK, то неожиданно возникает просадка по памяти:


original                          avgt        3813,358      75,014   us/oppatched                           avgt        3733,343      90,589   us/opchars                             avgt        1377,829       4,861   us/oporiginal:gc.alloc.rate.norm      avgt     6816056,159       0,005    B/oppatched:gc.alloc.rate.norm       avgt     4194360,157       0,006    B/opchars:gc.alloc.rate.norm         avgt     6291512,057       0,001    B/op   <----

Совершенно не очевидно для нас показатель потребления памяти для массива вернулся на прежний уровень. Причина в реализации работы со сжатыми строками:


abstract class AbstractStringBuilder implements Appendable, CharSequence {  byte[] value;  public AbstractStringBuilder append(char c) {    this.ensureCapacityInternal(this.count + 1);    if (this.isLatin1() && StringLatin1.canEncode(c)) {      this.value[this.count++] = (byte)c;                     // <-----    } else {      // ...    }    return this;  }}

Если на вход StringBuilder.append(char) пришел знак, входящий в множество ASCII (а шестнадцатеричный алфавит входит туда по умолчанию), то его старший байт усекается, а младший кладётся в хранилище. Если же используется голый массив, то в него всегда кладётся полновесный char о двух байтах. Поэтому если у вас на проекте JDK 9 и выше, то шестнадцатеричный алфавит нужно объявлять как массив байт, а char[] менять на byte[].


Вывод для разработчика: иногда склейка строк сводится к задаче о буферизации с известными узкими местами:


  • проверкой выхода за пределы хранилища
  • расширением хранилища
  • переносом данных при расширении

Универсальное решение: семь раз отмерь один раз отрежь.


Необходимо отметить, что выше описан редкий случай известно количество проходов и объём данных, записываемых на каждом проходе. Обычно же имеем дело со следующими разновидностями:


// сложение String str = s1 + s2 + s3;// склеивание цепочкойString str = new StringBuilder().append(str1).append(str2).append(str3).toString();// склеивание путём раздельного добавленияStringBuilder sb = new StringBuilder();sb.append(str1);sb.append(str2);sb.append(str3);String str = sb.toString();

Измерим производительность с помощью бенчмарка:


private final String str1 = "1".repeat(10);private final String str2 = "2".repeat(10);private final String str3 = "3".repeat(10);private final String str4 = "4".repeat(10);private final String str5 = "5".repeat(10);@Benchmark public String concatenation() { /*...*/ }@Benchmark public String chainedAppend() { /*...*/ }@Benchmark public String newLineAppend() { /*...*/ }

Ожидаемо побеждает сложение, в затылок ему дышит склеивание цепочкой:


                                    Mode     Score     Error   UnitschainedAppend                       avgt    33,973    0,974   ns/opconcatenation                       avgt    36,189    1,260   ns/opnewLineAppend                       avgt    71,083    5,180   ns/opchainedAppend:gc.alloc.rate.norm   avgt    96,000    0,001    B/opconcatenation:gc.alloc.rate.norm   avgt    96,000    0,001    B/opnewLineAppend:gc.alloc.rate.norm   avgt   272,000    0,001    B/op

Из этого давно уже сделан простой и очевидный вывод: в большинстве случаев скрипач StringBuilder не нужен, сложение строк будет и проще, и производительнее. Дело в интринзификации: исполнение распознаёт подобные цепочки и обнаружив, что размер складываемых строк известен, прямо выделяет нужный объём памяти и переносит данные без использования StringBuilder-а. Логичное и несложное улучшение.
Теперь поставим вопрос иначе. Предположим, у нас есть цепочка сложения / StringBuilder.append() и логика приложения заставляет разорвать её:


StringBuilder sb = new StringBuilder()        .append(str1)        .append(str2)        .append(str3);if (smth) sb.append(str4);return sb.append(str5).toString();

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


                                    Mode     Score     Error   UnitschainedAppend                       avgt    33,973    0,974   ns/opconcatenation                       avgt    36,189    1,260   ns/opnewLineAppend                       avgt    71,083    5,180   ns/optornAppend                          avgt    66,261    2,095   ns/opchainedAppend:gc.alloc.rate.norm   avgt    96,000    0,001    B/opconcatenation:gc.alloc.rate.norm   avgt    96,000    0,001    B/opnewLineAppend:gc.alloc.rate.norm   avgt   272,000    0,001    B/optornAppend:gc.alloc.rate.norm      avgt   272,000    0,001    B/op

Это подводит нас уже к менее очевидному выводу: сшивание цепочки даёт конские приросты и позволяет упростить код (вспомните пример с ResolvableType.toString()). Рассмотрим часть встроенного в "Спринг" профилировщика:


// o.s.a.interceptor.AbstractMonitoringInterceptorString createInvocationTraceName(MethodInvocation invocation) {  StringBuilder sb = new StringBuilder(getPrefix());                    // < ----  Method method = invocation.getMethod();  Class<?> clazz = method.getDeclaringClass();  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {    clazz = invocation.getThis().getClass();  }  sb.append(clazz.getName());  sb.append('.').append(method.getName());  sb.append(getSuffix());  return sb.toString();}

Обратите внимание: между объявлением переменной sb и её использованием находится другой код, а значит объявление можно сместить:


String createInvocationTraceName(MethodInvocation invocation) {  Method method = invocation.getMethod();  Class<?> clazz = method.getDeclaringClass();  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {    clazz = invocation.getThis().getClass();  }  StringBuilder sb = new StringBuilder(getPrefix());                    // < ----  sb.append(clazz.getName());  sb.append('.').append(method.getName());  sb.append(getSuffix());  return sb.toString();}

Тут же "Идея" поможет нам всё это ужать и сделать совсем красиво:


protected String createInvocationTraceName(MethodInvocation invocation) {  Method method = invocation.getMethod();  Class<?> clazz = method.getDeclaringClass();  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {    clazz = invocation.getThis().getClass();  }  return getPrefix() + clazz.getName() + '.' + method.getName() + getSuffix();}

Этот код не только проще и выразительнее, но и должен быть производительнее. Проверим:


Гладко вписано в бумаги, да забыли про овраги...
                                Mode      Score     Error   Unitsbefore                          avgt     97,273    0,974   ns/opafter                           avgt     89,089    1,260   ns/opbefore:gc.alloc.rate.norm      avgt    728,000    0,001    B/opafter:gc.alloc.rate.norm       avgt    728,000    0,001    B/op

Ёлки-палки, нормально же общались! Буквально пол-страницы назад в похожем как две капли воды коде ровно такой же финт ушами давал хороший прирост, а тут что-то пошло нет так. Чтобы разобраться давайте выведем наименьший пример, на котором проблема воспроизведётся:


@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g", "-XX:+UseParallelGC"})public class BrokenConcatenationBenchmark {  @Benchmark  public String slow(Data data) {    Class<? extends Data> clazz = data.clazz;    return "class " + clazz.getName();  }  @Benchmark  public String fast(Data data) {    Class<? extends Data> clazz = data.clazz;    String clazzName = clazz.getName();    return "class " + clazzName;  }  @State(Scope.Thread)  public static class Data {    Class<? extends Data> clazz = getClass();    @Setup    // explicitly load name via Class.getName0()    public void setup() { clazz.getName(); }          <---- обратите внимание  }}

Внешне этот пример очень похож на JDK-8043677. Теперь метод Class.getName():


public String getName() {  String name = this.name;  if (name == null) {    this.name = name = this.getName0();  }  return name;}private native String getName0();

Это обычный ленивый геттер: при первом обращении полю присваивается значение, дальше оно только возвращается. Теперь вспомним, что мы явно вызываем этот метод в setup(), иными словами во время прогона никаких сторонних эффектов уже быть не может. Тем не менее, просадка по производительности стабильно воспроизводится.
Признаюсь, я не нашел объяснения самостоятельно, поэтому задал вопрос на StackOverflow. На выручку пришел apangin, за что ему огромная благодарность. Дело тут вот в чём:


Виртуальная машина собирает статистику исполнения байт-кода. Если один и тот же код исполняется в разных контекстах, то итоговый профиль объединяет в себе статистику по каждому из них. Это называется отравление профиля.
Очевидно, что Class.getName() вызывается не только из кода бенчмарка. И ещё до того, как JIT начинает компиляцию бенчмарка, он уже знает, что условие
if (name == null) {this.name = name = getName0();}


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

По ссылке есть пример достижения ровно такого же эффекта без использования нативного метода. В нашем случае нужно извлечь выражение Class.getName() в отдельную переменную.


И вот теперь у нас есть желаемый прирост:


                                Mode      Score     Error   Unitsbefore                          avgt     97,273    0,974   ns/opafter                           avgt     13,301    0,411   ns/opbefore:gc.alloc.rate.norm      avgt    728,000    0,001    B/opafter:gc.alloc.rate.norm       avgt    280,000    0,001    B/op

Выводы для разработчика:


  • сшивание цепочки = упрощение кода + производительность
  • с старых изданиях JDK (<9) держи в уме угловые случаи

Склейка: среди if-ов как среди рифов


Наш следующий гость библиотека ASM, использующаяся для работы с байт-кодом. Рассмотрим один из методов класса org.objectweb.asm.Type:


void appendDescriptor(final Class<?> clazz, final StringBuilder sb) {  String name = clazz.getName();  for (int i = 0; i < name.length(); ++i) {    char car = name.charAt(i);    sb.append(car == '.' ? '/' : car);  }  sb.append(';');}

Имеем уже описанную выше проблему: данные складываются в хранилище познаково, что медленно, т. к. каждый StringBuilder.append(char) означает отдельную проверку выхода за пределы массива и доступ к нему. Чтобы обратить это в массовое добавление, нужно выразить алгоритм одним словом. И это слово замена, ведь точка заменяется косой чертой, а все прочие знаки остаются без изменений. Значит мы можем переписать код так:


void appendDescriptor(final Class<?> clazz, final StringBuilder sb) {  sb.append(clazz.getName().replace('.', '/'));}

Теперь нужно проверить: выиграем или нет. Ведь у изменённого варианта есть недостаток: при одном единственном вхождении искомого знака String.replace(char, char) создаёт новую строку, что требует времени и доппамяти (чего не наблюдалось в прежнем издании).
Прогоним бенчмарк для класса java.lang.String:


@State(Scope.Thread)@OutputTimeUnit(TimeUnit.NANOSECONDS)@BenchmarkMode(value = Mode.AverageTime)@Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g"})public class CharacterReplaceBenchmark {  private final Class<?> klass = String.class;  @Benchmark  public StringBuilder manualReplace() {    return ineffective(klass, new StringBuilder());  }  @Benchmark  public StringBuilder stringReplace() {    return effective(klass, new StringBuilder());  }}

Итог неоднозначен:


                                     Mode     Score     Error   UnitsmanualReplace                        avgt    43,312    1,767   ns/opstringReplace                        avgt    30,741    3,247   ns/opmanualReplace:gc.alloc.rate.norm    avgt    56,000    0,001    B/opstringReplace:gc.alloc.rate.norm    avgt   112,000    0,001    B/op

С одной стороны имеем существенный выигрыш по времени, с другой двукратную просадку по памяти. Если вместо java.lang.String полю klass присвоить значение


private final Class<?> klass = CharacterReplaceBenchmark.class;

то результат снова удивит:


                                     Mode     Score     Error   UnitsmanualReplace                        avgt   160,336    2,628   ns/opstringReplace                        avgt    67,258    1,535   ns/opmanualReplace:gc.alloc.rate.norm    avgt   200,000    0,001    B/opstringReplace:gc.alloc.rate.norm    avgt   240,000    0,001    B/op

Время выполнения сократится более чем в 2,5 раза, при этом разница в потреблении памяти составит всего 20%. Если же имя класса будет ещё длиннее


private final Class<?> klass = org.springframework.objenesis.instantiator.perc.PercSerializationInstantiator.class;

то String.replace(char, char) будет выигрывать как по времени, так и по памяти:


                                     Mode     Score     Error   UnitsmanualReplace                        avgt   212,368    3,370   ns/opstringReplace                        avgt    75,503    1,028   ns/opmanualReplace:gc.alloc.rate.norm    avgt   360,000    0,001    B/opstringReplace:gc.alloc.rate.norm    avgt   272,000    0,001    B/op

Причина в том, что StringBuilder выделяет место с запасом, а из-за отсутствия механизма предсказания совокупного объёма расширение массива всегда происходит по одной и той же формуле независимо от того, сколько данных осталось записать:


// java.lang.AbstractStringBuilderprivate int newCapacity(int minCapacity) {  // overflow-conscious code  int newCapacity = (value.length << 1) + 2;  if (newCapacity - minCapacity < 0) {    newCapacity = minCapacity;  }  return newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0          ? hugeCapacity(minCapacity)          : newCapacity;}

Поэтому в примере выше потребление памяти выглядит следующим образом:


java.lang.String                        16 знаков  16 ячеекt.s.b.s.CharacterReplaceBenchmark       58 знаков  70 ячеекo.s.o.i.p.PercSerializationInstantiator 77 знаков  142 ячейки

В худшем случае только около половины массива заполнено живыми данными, а всё остальное пустые ячейки.
Несмотря на всё сказанное выше, существуют случаи, когда познаковый перебор и замена оказываются более выгодными:


// com.intellij.internal.statistic.beans.ConvertUsagesUtilchar c = text.charAt(i);switch (c) {  case GROUP_SEPARATOR:  case GROUPS_SEPARATOR:  case GROUP_VALUE_SEPARATOR:  case '\'':  case '\"':  case '=' :    escaped.append(' ');    break;  default:    escaped.append(c);    break;}

Если переписать это с использованием String.replace(char, char), то получится следующая цепочка:


return text  .replace(GROUP_SEPARATOR, ' ')  .replace(GROUPS_SEPARATOR, ' ')  .replace(GROUP_VALUE_SEPARATOR, ' ')  .replace('\'', ' ')  .replace('\"', ' ')  .replace('=' , ' ');

Здесь в худшем случае (есть хотя бы 1 вхождение каждого искомого знака) мы получим 6 новых строк и 6 полных переборов. Множественные поиск/замена относительно редки, но иногда встречаются:



Выводы для разработчика:


  • неразрывное действие лучше, чем цикл
  • разовое выделение памяти быстрее, чем многократное
  • массовые операции в 99 случаях из 100 выигрывают у одиночных
  • из любого правила бывают исключения в 1 случае из 100

StringJoiner: склеивание через разделитель


Смотревшие доклад lany Java 9-14: Маленькие оптимизации знают про JDK-8054221, а именно улучшенную реализацию StringJoiner-а:


// былоpublic final class StringJoiner {  private final String prefix;  private final String delimiter;  private final String suffix;  private StringBuilder value;}// сталоpublic final class StringJoiner {  private final String prefix;  private final String delimiter;  private final String suffix;  private String[] elts;  private int size;  private int len;}

Самое праздничное во всём этом: StringBuilder.toString():


char[] chars = new char[len + addLen];int k = getChars(prefix, chars, 0);if (size > 0) {  k += getChars(elts[0], chars, k);  for (int i = 1; i < size; i++) {    k += getChars(delimiter, chars, k);    k += getChars(elts[i], chars, k);  }}k += getChars(suffix, chars, k);return new String(chars);

Зная подробности реализации можно использовать StringJoiner в задаче склеивания множества строк без разделителя:


StringBuilder pathBuilder = new StringBuilder();for (PathComponent pathComponent : pathComponents) {  pathBuilder.append(pathComponent.getPath());}return pathBuilder.toString();

лёгким движением руки превращается в


StringJoiner pathBuilder = new StringJoiner("");for (PathComponent pathComponent : pathComponents) {    pathBuilder.add(pathComponent.getPath());}return pathBuilder.toString();

что даёт прирост на больших объёмах данных, особенно для нелатинских строк:


                         latin  length    Mode     Score    Error   Unitssb                        true      10    avgt     122,2     5,0   ns/opsb                        true     100    avgt     463,5    42,6   ns/opsb                        true    1000    avgt    3446,6   109,1   ns/opsj                        true      10    avgt     141,1     5,3   ns/opsj                        true     100    avgt     356,0     6,9   ns/opsj                        true    1000    avgt    2522,1   287,7   ns/opsb                       false      10    avgt     229,8    14,7   ns/opsb                       false     100    avgt     932,4     8,7   ns/opsb                       false    1000    avgt    7456,4   527,2   ns/opsj                       false      10    avgt     192,6    70,8   ns/opsj                       false     100    avgt     577,7    60,3   ns/opsj                       false    1000    avgt    3541,9   135,0   ns/opsb:gc.alloc.rate.norm    true      10    avgt     512,0     0,0    B/opsb:gc.alloc.rate.norm    true     100    avgt    4376,0     0,0    B/opsb:gc.alloc.rate.norm    true    1000    avgt   41280,0     0,0    B/opsj:gc.alloc.rate.norm    true      10    avgt     536,0    14,9    B/opsj:gc.alloc.rate.norm    true     100    avgt    3232,0    12,2    B/opsj:gc.alloc.rate.norm    true    1000    avgt   30232,0    12,2    B/opsb:gc.alloc.rate.norm   false      10    avgt    1083,2     7,3    B/opsb:gc.alloc.rate.norm   false     100    avgt    9744,0     0,0    B/opsb:gc.alloc.rate.norm   false    1000    avgt   93448,0     0,0    B/opsj:gc.alloc.rate.norm   false      10    avgt     768,0    12,2    B/opsj:gc.alloc.rate.norm   false     100    avgt    5264,0     0,0    B/opsj:gc.alloc.rate.norm   false    1000    avgt   50264,0     0,0    B/op

Теперь посмотрите на код и постарайтесь найти в нём недоработку:


char[] chars = new char[len + addLen];int k = getChars(prefix, chars, 0);if (size > 0) {  k += getChars(elts[0], chars, k);  for (int i = 1; i < size; i++) {    k += getChars(delimiter, chars, k);    k += getChars(elts[i], chars, k);  }}k += getChars(suffix, chars, k);return new String(chars);

Ответ
char[] chars = new char[len + addLen];     // почему char[], а не byte[] ?!!int k = getChars(prefix, chars, 0);if (size > 0) {  k += getChars(elts[0], chars, k);  for (int i = 1; i < size; i++) {    k += getChars(delimiter, chars, k);    k += getChars(elts[i], chars, k);  }}k += getChars(suffix, chars, k);return new String(chars);

А вот это уже совсем другая история. Если совсем коротко, то причина в пакете: StringJoiner лежит в java.util, а весь функционал связанный со сжатыми строками в java.lang. Поэтому внутри StringBuider-а массив байтов, а в StringJoiner всё ещё char[]. О попытках это исправить я подробно писал в прошлой статье.


Выводы:


  • старайтесь избегать выражений вроде map.get(/* new String */) / map.put(/* new String */)
  • составной ключ вида "_" + smth почти всегда можно заменить
  • при буферизации обращайте внимание на объём данных и размер буфера
  • клейте строки через +, зачастую StringBuilder не нужен
  • одиночные преобразования почти всегда проигрывают массовым
  • помните о StringJoiner-e и используйте его для типовых задач

Пишите свои примеры в комментариях, будем разбирать.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru