Я уже сбился со счёта, сколько раз мне говорили, что Java — неподходящий язык для разработки приложений, основным требованием к которым является высокая производительность. Обычно первым делом я прошу уточнить, что подразумевается под словом «производительность», поскольку две самые популярные метрики — пропускная способность и задержка — иногда конфликтуют друг с другом, а способы оптимизации одной из них существенно ухудшают вторую.
Существуют методики разработки Java-приложений, которые соответствуют требованиям к производительности (или даже превосходят их) приложений, созданных на языках, традиционно применяемых для этой цели. Однако даже этого может быть недостаточно, чтобы обеспечить наилучшую производительность с точки зрения задержек. Java-приложениям по-прежнему приходится полагаться на операционную систему в вопросе предоставления доступа к оборудованию. Обычно чувствительные к задержке приложения (часто называемые «приложениями реального времени») лучше всего работают, когда имеют практически прямой доступ к оборудованию, то же самое относится и к Java. В этой статье я познакомлю вас с методиками, которые можно применять, когда мы хотим, чтобы приложения максимально эффективно задействовали системные ресурсы.
Язык Java проектировался с целью возможности портирования программ на двоичном уровне для широкого диапазона оборудования и системных архитектур. Это было реализовано при помощи виртуальной машины — абстрактной модели платформы выполнения — и выполнения результата работы компилятора исходного кода Java. Смысл заключался в том, что для перехода на другой тип аппаратной платформы достаточно будет только портировать виртуальную машину. Приложения и библиотеки должны работать без изменений (в соответствии с девизом «write once run everywhere»).
Однако приложения, имеющие строгие требования к задержкам и производительности, во время выполнения обычно должны быть как можно ближе к оборудованию — они стремятся выжать из «железа» всю производительность и им не должен в этом мешать промежуточный код, предназначенный исключительно для портируемости или абстрактных программных концепций наподобие динамического управления памятью.
За прошедшие годы виртуальная машина Java эволюционировала в чрезвычайно сложную платформу, способную генерировать машинный код в среде выполнения из байт-кода Java и оптимизировать этот код на основе динамически собираемых метрик. На это не способны статически компилируемые языки наподобие C++, поскольку у них отсутствует необходимая информация среды выполнения. Аккуратный подход к выбору структур данных и алгоритмов может минимизировать или даже устранить потребность в сборке мусора — одного из самых очевидных аспектов окружения среды выполнения Java, мешающего обеспечивать стабильные показатели задержки.
Но в конечном итоге, виртуальная машина Java является именно виртуальной, то есть она должна выполняться поверх операционной системы, управляющей её доступом к аппаратной платформе. Какой бы ни была операционная система: Linux (наверно, самая широко используемая ОС в серверных окружениях), Windows или какая-то другая, проблема всё равно остаётся.
«Проблема» Linux
В течение многих лет Linux эволюционировала как член семейства операционных систем Unix. Первая версия Unix была разработана в конце 1960-х; поначалу она развивалась и завоёвывала популярность в научных и исследовательских кругах, а затем под разными обличьями и в мире коммерции. Linux стал доминирующим вариантом Unix, хотя по-прежнему сохраняет в себе многие особенности своего предка. Сегодня с появлением сред выполнения на основе контейнеров и облачных технологий его доминирование стало почти абсолютным.
Однако с точки зрения приложений реального времени (то есть чувствительных к задержке) Linux/Unix имеет проблемы. В основном они проистекают из того, что Unix проектировался как система с разделением времени. Исходной аппаратной платформой для него были миникомпьютеры, на которых одновременно работало множество пользователей. У каждого из пользователей были собственные задачи, и Unix гарантировал, что все они получат «справедливую долю» ресурсов компьютера.
Операционная система отдавала предпочтение пользователям, выполняющим большой объём ввода-вывода, в том числе взаимодействующим с системой через терминал; это делалось за счёт задач, в основном связанных с вычислениями (так называемых задач, ограниченных возможностями процессора). Учитывая, что почти все компьютеры того времени имели один процессор (одноядерный), это было логично.
Однако в процессе эволюции компьютеров с несколькими процессорами потребовались серьёзные инженерные изменения в сердце операционной системы Unix, которые позволили бы использовать эти ядра эффективным образом. Впрочем, подход оставался тем же — интерактивным задачам всегда отдавался больший приоритет, чем задачам с интенсивными вычислениями. Благодаря наличию нескольких ядер, в конечном итоге это всё повышало общую производительность.
Сегодня почти каждый компьютер, даже мобильные устройства наподобие телефонов, имеют несколько ядер, не говоря уже о рабочих станциях и машинах серверного класса. Кажется логичным, что нужно изучить эти окружения и посмотреть, можно ли использовать другой подход для более эффективной поддержки платформой приложений реального времени, чувствительных к задержкам.
Как можно решать эти проблемы?
В моей компании разработано множество опенсорсных библиотек для поддержки сборки приложений, оптимизированных с целью обеспечения низких задержек. Разработка велась с учётом большого опыта работы в этой области. Ниже я расскажу о том, чему мы научились и как это помогло нам достичь таких результатов.
▍ Среда выполнения Java
Основные проблемы, способные повлиять на задержки в Java-приложениях, связаны с управлением кучей, имеющей сборку мусора, и синхронизацией доступа к общим ресурсам при помощи блокировок. Существуют методики для решения обеих этих проблем, однако они требуют, чтобы разработчики отошли от идиоматического стиля программирования на Java. В идеале следует использовать библиотеки, инкапсулирующие низкоуровневые подробности и специализированные методики, однако нам нужно разобраться, что же происходит внутри.
Один из подходов, которому отдают предпочтение разработчики фреймворков и библиотек, предназначенных для приложений с низкими задержками — обход сборщика мусора Java благодаря использованию памяти, не являющейся обычной кучей Java (называемой памятью вне кучи). Память отображается в постоянное хранилище при помощи обычных механизмов операционной системы или же воссоздаётся через сетевые подключения к другим системам.
Очевидное преимущество такого подхода в том, что доступ к памяти не подвержен недетерминированным вмешательствам сборщика мусора. Его недостаток в том, что управление жизненным циклом объектов, созданных в этих областях, становится задачей приложения или библиотеки.
В распространённых архитектурах современных приложений содержатся способы связи между компонентами, обычно реализуемые при помощи сообщений. При обмене данными сообщения сериализуются и десериализуются из стандартных форматов наподобие JSON или YAML, а предоставляющие эту функциональность библиотеки часто предоставляют возможность высокоуровневого управления распределением объектов. При аккуратном проектировании можно выбрать библиотеки, нацеленные на минимизацию создания новых объектов Java, а следовательно, положительно влияющие на производительность.
Конкурентный доступ к общим изменяемым данным с самых первых дней развития Java синхронизировался при помощи взаимоисключающих блокировок. Если поток пытается получить доступ к блокировке, созданной другим потоком, то он прерывается до снятия блокировки. В многоядерном окружении можно реализовать синхронизацию при помощи альтернативных методик, не требующих прерывания потока, и в большинстве случаев это положительно влияет на снижение задержек.
Написание такого кода не является простой задачей, однако эту функциональность можно инкапсулировать за интерфейсами Lock в стандартных библиотеках Java или даже определить структуры данных, обеспечивающих безопасный конкурентный доступ без блокировок при помощи стандартных API. В некоторых стандартных библиотеках Java Collections используется такой подход, хотя он и непрозрачен для пользователей.
▍ Linux
Справедливо будет заметить, что существовали и варианты Unix «реального времени», предоставлявшие специализированным приложениям другие среды исполнения. Хотя в общем случае они были нишевыми продуктами, сегодня многие из подобных подходов и функций доступны в популярных дистрибутивах Unix и Linux.
Функции для минимизации задержек в общем случае разделяются на две категории: управление памятью и планирование потоков.
Вся память в процессе Linux, в том числе куча Java со сборкой мусора, подвергается временной «откачке» на диск, чтобы другие процессы могли использовать ОЗУ для своих целей, прежде чем возникнет потребность возврата памяти. Это происходит абсолютно прозрачно для процесса, а разница во времени доступа к данным в памяти и к данным на накопителе может различаться на несколько порядков величин. Разумеется, память вне кучи подвержена такому же поведению.
Однако современные системы Unix и Linux позволяют помечать области памяти, чтобы их игнорировала операционная система в процессе поиска областей, которые можно забрать у процесса. Это означает, что для таких областей памяти в этом процессе время доступа к памяти будет предсказуемым. Стоит также сказать, что у активно выполняемого Java-приложения частота доступа к памяти процесса снижает вероятность откачки этой памяти, но риск всё равно присутствует.
Такое резервирование памяти процесса будет означать, что другим процессам останется меньше памяти, от чего они могут страдать, однако в мире приложений «реального времени» всегда нужно немного эгоизма!
Структуры данных, предназначенные для обеспечения низких задержек по умолчанию или при помощи опций, обычно предоставляют возможность блокировки или резервирования своей памяти в ОЗУ.
Потоки в Java-программе, как и их аналоги в других приложениях и даже в задачах операционной системы, имеют доступ к процессору, управление которым выполняется компонентом операционной системы под названием «планировщик». Планировщик имеет набор политик, который он использует, чтобы решать, какие нужно выбирать потоки, требующие доступа к процессору (они называются Runnable thread) — обычно количество Runnable thread больше, чем количество процессоров.
Как говорилось выше, традиционные политики планировщиков Unix/Linux отдают предпочтение интерактивным, а не вычислительноёмким потокам. Если мы стремимся выполнять чувствительные к задержкам приложения, то это не идёт нам на пользу — нужно, чтобы наши потоки каким-то образом получили больший приоритет, чем потоки, нечувствительные к задержкам.
Современные системы Unix/Linux предоставляют альтернативные политики планирования, способные обеспечить такие возможности, позволяя зафиксировать приоритеты планирования потоков на высоких уровнях, чтобы они всегда забирали ресурсы процессора у других потоков, когда они являются Runnable, благодаря чему они могут быстрее реагировать на события.
Но на поведение планировщика можно влиять ещё сильнее. В обычной ситуации при управлении потоками используются все доступные ресурсы процессоров. В настоящее время появилась возможность изменять список используемых планировщиком процессоров. Мы можем полностью убрать процессоры из списка доступных планировщику и использовать их исключительно под наши специализированные потоки.
Или же можно разбить процессоры на группы и связать группу процессоров с конкретной группой потоков. Эта функция является частью более общего компонента Linux для управления ресурсами, называющегося группами. Он является частью системы поддержки виртуализации Linux и крайне важен для реализации контейнеров, например, генерируемых Docker в современных окружениях. Однако он доступен приложениям общего назначения через специальные системные вызовы.
Как и в описанном выше случае с резервированием памяти, мы поступаем эгоистично, ведь это очевидно отрицательно повлияет на другие части системы. Для получения оптимальных результатов нужно очень тщательное конфигурирование, потому что высок потенциал ошибок, а последствия просчётов могут быть очень серьёзными.
▍ Заключение
Написание и развёртывание приложений с низкими задержками требует больших навыков, знания не только используемого языка, но и окружения, в котором будут выполняться приложения.
❒ Ресурсы
Подробнее о некоторых из тем, рассмотренных в статье, можно прочитать в этой книге.
RUVDS | Community в telegram и уютный чат
Комментарии (25)
singalen
12.10.2022 20:27+1Я уперся в задачу, где за минуту нужно создавать и выбрасывать десятки миллионов объектов, и рантайм Джавы, при всём уважении, справляется ужасно. В каждом объекте есть несколько String-ов, так что каждый - это несколько аллокаций и оверхед процессора и памяти на gc. А процесс потребляет более 75% доступной памяти.
В языке без gc, где такой объект можно обработать через memcpy, производительность была бы на порядок выше.
Так что методики, конечно, существуют, но не на всякую задачу налазят.
GerrAlt
12.10.2022 23:06+5То что вы описали это не задача, а ваше ее решение. Возможно у той исходной задачи, которую вы пытались решить генерированием миллионов объектов с несколькими стрингами внутри, есть лучшие решения на java.
singalen
13.10.2022 09:22Это правда. Но, увы, исходная задача не слишком сильно отличается и оставляет мало места для манёвра: читать файл с миллионами записей раз в минуту; возможно, его содержимое будет почти таким же, как у предыдущего, а возможно, и другим; группировать по id и выдавать его в другом формате.
Производительность критична настолько, что каждый лишний лукап по id заметно её ухудшает.
iboltaev
13.10.2022 11:36+6эту задачу можно решить, не генерируя миллионы стрингов. Можно файл зачитать в память в виде массива char-ов (можно еще mmap заюзать), а строки представлять просто интами - смещениями начала и конца, написать собственные equals/hashCode/lexicographicalCompare. Такие строки, с несколькими интами, вполне можно аллоцировать/деаллоцировать, используя все тот же объектный пул. Записи, состоящие из строк, тоже можно через пул аллоцировать.
singalen
13.10.2022 19:16Вы не поверите, я пробовал что-то подобное. Но обратите внимание, как далеко мы зашли: мы переписываем часть джавовского рантайма: пишем свои собственные классы без классов, свой собственный аллокатор и сборщик мусора, и не факт, что эта коллекция велосипедов будет работать лучше.
Мы потеряли читабельность Джавы, не решили полностью проблему миллиона аллокаций, и что приобрели?
Вы не думаете, что этот доказывает мой тезис - что методы существуют, но зайти надо настолько далеко, что Джава уже перестаёт быть Джавой?
iboltaev
13.10.2022 20:13ну так в каждом языке можно накопать примеров, когда язык перестает быть языком. На C, если пишем какую-нибудь научную числодробилку, придется в стиле фортрана писать с громадными отдельными массивами double-ов, в Python иногда приходится на C часть кода переписывать, да полно всякого. Конкретно в java конкретно ваш пример ложится плохо, неидиоматично, но это не значит, что его нельзя сделать.
singalen
13.10.2022 22:28Я рад, что мы согласны :) Я не говорил, что нельзя, я тоже сказал, что плохо ложится (употребил слово «ужасно», потому что выходит уродливо).
iboltaev
14.10.2022 13:02я думаю, что если спросить мое начальство, что бы оно предпочло - сделать, чтоб работало, но некрасиво, или сделать красиво, но чтоб работало хреново, то однозначно вариант №1
mortadella372
12.10.2022 23:24А какую джаву использовали? Из новых, с Compact Strings, или старую 8?
singalen
13.10.2022 09:43Вообще 7ю. Но не то чтобы Compact Strings сильно помогли, у нас все String-и длиной до 16 символов. Не думаю, что аллокации делаются меньше 32 байт. Хотя померять, конечно, стоит, может, и перейдём на 9+.
vassabi
13.10.2022 13:17ух какой кровавый энтерпрайз!
если строки такие короткие - может пул строк делать?
singalen
13.10.2022 19:55Можно, но это полумеры. Как на меня, лучше бы они были в теле объекта, поддавались memcpy() и не требовали сборки мусора. Да и разные они, это всё ID из разных систем. (Идею представлять их long-ами я тоже рассматривал - не налазит).
Дальше идёт идея разметить вручную пул байтов, вон и комментаторы её независимо предложили, но это уже лютый overkill.
mortadella372
14.10.2022 00:59Compact Strings не на компрессии, у них внутренняя структура основана на byte[] вместо char[], так что на строках любой длины будет разница. Рассчет на то, что строки в основном "LATIN-1". Если будет много "настоящего" юникода, то может быть и проигрыш.
Foror
13.10.2022 15:38+1Так не создавайте миллионы объектов. Это изначально глупо, на любом ЯП. Выделите заранее массив байтов под нужный объем данных, напишите для него обёртку и работайте со структурами через эту обёртку. Вам нужно на самом деле кастомный фреймворк под эту задачу, уровня СУБД, а вы пытаетесь подобную задачи наивными аллокациями решить, не понимая тонкостей работы с JVM. Ну и на 7 джаве это конечно совсем...
singalen
13.10.2022 19:49+2Вот про «не понимая», мне кажется, было необоснованно.
Давайте и я расскажу вам несколько прописных истин. Во-первых, проектам свойственно расти естественным путем. Что хорошо работало для сотен тысяч, работает неважно (хотя всё ещё работает) для десятков миллионов. Во-вторых, бывает так, что сама постановка задачи прямо требует делать то, что вам кажется «глупо» (а на самом деле - это рационализация подсознательного понимания того, что таки да, ваш любимый молоток здесь не очень хорош). В-третьих, выбор языка диктуется многими факторами; в нашем случае - поддержкой фреймворков, с которыми нужно интегрироваться.
Да, если бы проект писали или переписывали с нуля с сегодняшними требованиями, то, скорее всего, взяли бы что-то другое.
По сути.
На идею с ручным нарезанием буфера на объекты: а) мы превращаем Джаву в язык уровня выше Ассемблера, но ниже, чем С - в С хотя бы можно сделать этот буфер массивом struct-ов. б) это уменьшает в разы, но не решает полностью проблему миллионов аллокаций и gc.
Фреймворк или БД: а) вот вы и сами начинаете понимать, что тут не Джава нужна. б) Я подозреваю, что самая лучшая специализированная in-memory БД с минимальным оверхедом будет делать ровно то же самое: строить HashTable и сканировать её. Не уверен, что вы подразумеваете под фреймворком - JNI-библиотеку, которая делает то же самое на С?
Интересный разговор у нас получился. Я: «для некоторых задач неверно, что Джаву можно оптимизировать настолько же, как языки без gc». Вы: «Вы всё неправильно делаете и не понимаете. Нужно только переделать Джаву, чтобы она перестала быть Джавой, или заменить её на другой язык, и вообще у вас задача неправильная».
Foror
14.10.2022 08:27Последнюю версию джавы можно оптимизировать как языки без gc, есть возможность ручного управления памятью и реализации части логики в си.
Что касается вашего случая, не нужно переделывать джаву, а нужен специализированный фреймворк под вашу задачу. В тех же упомянутых вами си для узких мест используют ассемблерные вставки и никто не говорит в этом случае о переделывании си.
В джаве вы можете вынести работу с памятью из GC, даже в 7 джаве, просто это более геморойно будет, чем в новых версиях джавы.
Foror
14.10.2022 08:33>будет делать ровно то же самое: строить HashTable и сканировать её
Хеш таблицы имею различные реализации. Стандартная в джаве не самая оптимальная, есть более качественные реализации, в том числе и для джавы, в том числе работающие вне стандартного heap. Я уже не говорю, что и хеши могут вычисляться по разному и иметь свои тонкости. Вашу проблему легко можно решить на джаве, вопрос лишь во времени и насколько оптимальный результат вы хотите получить.
Maccimo
14.10.2022 08:54мы превращаем Джаву в язык уровня выше Ассемблера, но ниже, чем С
Когда в программе на Си приходится использовать вставки на ассемблере или интринсики, то никто не сокрушается про уровень и не говорит, что это больше не Си. И тут тоже никаких поводов для заламывания рук нет. Как нет и никаких «превращений», это обыкновеная оптимизация.
Люди Lombok не моргнув глазом используют, а вы из-за буферов жабу жабой считать отказываетесь.
Нужно только переделать Джаву, чтобы она перестала быть Джавой
Если некий исходный текст успешно компилируется
javac
, то это исходный текст на Java. Или вы нашли баг вjavac
и после его исправления оный текст компилироваться перестанет.
iboltaev
ну кстати с синхронизацией доступа без блокировок в Java действительно проще, всякие lock-free за счет гарантий ссылочной целостности и имея java.util.concurrent.atomic сильно проще писать. Я в 2012м, помнится, на C++ делал свою либу lock-free примитивов, с этим были проблемы, даже простой RCU (read-copy-update) без собственных пулов, меченых указателей и прочих танцев с бубном не получался.
С другой стороны, если приходится в Java использовать память вне кучи - тогда нет большого смысла писать на Java. Ну и вроде как можно использовать те же объектные пулы, тогда не нужно будет часто насиловать кучу и gc будет поспокойнее. Разве что тут начинается ручное управление памятью)
Maccimo
А если в программе на Си встретится несколько интринсиков то, видимо, надо сразу переходить на ассемблер? Помимо автоматического управления памятью в жабе есть и другие удобства.
iboltaev
на ассемблер переходить не нужно, все же другая парадигма программирования