Диалоги о Java Performance
Каждый год на JPoint эксперты выступают с хардкорными докладами о производительности Java. И ни разу не было скучно — вопрос сохраняет актуальность на протяжении многих лет. О том, откуда растут ноги у мифов, что делает JVM, как измерять производительность, при чём тут бизнес-требования заказчика и как обойти часть граблей мы поговорили с экспертами, для которых Java performance — не проблема, а работа.
Java Performance и всемогущая JVM
— Почему говорят, что Java медленная и проблемы производительности критичны?
— Я думаю, что восприятие Java как чего-то медлительного осталось в прошлом. В настоящее время виртуальные машины Java имеют реально навороченные JIT-компиляторы и алгоритмы сборки мусора, которые обеспечивают очень хорошую производительность для обычных приложений. Конечно, вы всегда можете найти примеры, где Java медленнее, чем нативные C/C++ приложения. Но такие системы, как Hadoop, Neo4j или H20 — примеры того, что даже большие, сложные высоконагруженные приложения могут быть написаны на Java. То есть производительность Java очень высока и как технология Java — весьма конкурентоспособна.
По моему опыту люди сегодня больше жалуются на производительность «альтернативных» языков для JVM, таких как Ruby (JRuby), Clojure или Scala. Я думаю, что JVM сможет оптимизировать их так же хорошо, как и чистый Java-код. Это лишь вопрос времени.
— Как оценивать и измерять производительность Java?
— Измерение производительности Java — это настоящая наука. Нет ничего удивительного в том, что такие компании, как Oracle, Google, Twitter или Одноклассники имеют выделенные группы по работе с производительностью, состоящие из очень опытных и квалифицированных сотрудников.
Привычные подходы профилирования производительности Java-приложений, скорее всего, всегда будут терпеть крах.
В настоящее время Java Microbenchmark Harness или, для краткости, JMH Алексея Шипилёва (обратите внимание, именно через «ё», он настаивает на этом, по крайней мере, в русскоязычных публикациях) который является частью OpenJDK, зарекомендовал себя как стандарт измерения и анализа производительности небольших и средних Java-приложений. Сложность и изобретательность реализации JMH даст вам представление о том, что JMH делает для того, чтобы точно измерить производительность приложения на Java. Его блог на http://shipilev.net/ — незаменимый источник информации по вопросам производительности и оптимизации Java.
И пока JMH, в основном, отвечает за измерение и оптимизацию производительности Java-кода, продолжает существовать проблема улучшения производительности GC. И это уже другая история, которая требует свой, другой набор инструментов и экспертизу.
— Где чаще всего бывают проблемы с производительностью?
— Это, конечно, в значительной степени зависит от типа вашего Java-приложения. Если речь о приложениях, привязанным к данным (то есть, если исполняется простой код на огромных данных), вероятно, более важно правильно выбрать и правильно настроить ваш GC-алгоритм. И даже в этом случае вам сперва придётся выбрать, хотите вы оптимизировать приложение для максимальной пропускной способности или же вы считаете более важным latency приложения.
С другой стороны, если ваше приложение зависит от вычислений, вы можете использовать инструменты типа JMH и/или нативных профайлеров, чтобы проверить, что виртуальная машина действительно полностью встраивает и оптимизирует все нагруженные исполняемые ветви.
Для жёстко распараллеленных приложений лучше выбрать правильную политику синхронизации/блокировки (которая может варьироваться в зависимости от железа/платформы, на которых работает ваше приложение) и для ввода/вывода связанных приложений важно выбрать правильную абстракцию (т.е. блокирующий vs неблокирующий) и API.
Но главное золотое правило: большая часть усилий должна быть направлена на правильность ваших алгоритмов, потому что ни одна виртуальная машина никогда не сможет превратить плохо написанный код в высокопроизводительную программу. Тонкая и точная настройка на уровне JVM, как объяснено выше, всегда должна быть только последним шагом цикла разработки.
— Вы — эксперт по JVM. Как часто JVM становится причиной низкой производительности? В чём основные проблемы и как с ними бороться?
— В идеале, после определённого количества итераций Java-приложение должно достичь состояния, при котором по меньшей мере 95% времени работает скомпилированный JIT-ом код. При этом происходят регулярные, но довольно короткие GC-паузы. Это можно легко проверить с помощью таких инструментов, как JConsole, JITwatch или Flight Recorder. Если это не так, то нужно выяснить, какая часть виртуальной машины вызывает проблему и затюнить (или заоптимизировать) соответствующий компонент VM.
Вы, может быть, знаете, что продуктовая версия HotSpot JVM предлагает около 600 так называемых расширенных опций (это те опции, которые начинаются с -ХХ), а debug-сборка включает более 1200 таких опций (чтобы получить полный список, вы можете использовать ключ -XX:+PrintFlagsFinal). С помощью этих опций вы можете получить детальную информацию о том, что происходит внутри виртуальной машины. Но самое главное, то что эти опции могут использоваться для тонкой настройки практически любой подсистемы виртуальной машины. Плохая новость заключается в том, что эти опции не очень хорошо документированы, так что, вероятно, вам придётся заглянуть в исходники, чтобы понять, что происходит на самом деле.
Некоторые проблемы со сборкой мусора могут решиться путём выбора правильного Garbage Collector (в текущей версии HotSpot их там полно) и правильной конфигурации heap-а. Проблемы производительности в сгенерированном коде часто вызываются неправильным выбором того, что нужно инлайнить. Опять-таки, это влияет в определённой степени, но, увы, это очень сложная тема и трудно добиться улучшения в одной части кода без снижения производительности других частей.
Блокировки, ожидания потоков и различные эффекты с кэшами – ещё один источник частых проблем. А вот для их решения требуется глубокое знание виртуальной машины, так же как и знание внутренностей аппаратной платформы и операционной системы.
Наконец, я хочу отметить, что в последнее время мы всё чаще видим проблемы с работой Java в виртуальных средах. JVM совершает большое количество «самоконфигураций» при запуске, чтобы оптимально адаптироваться к окружающей среде. Но вот если информация о реальных ресурсах (таких как количество CPU или объём доступной памяти), не соответствуют тому, что получила JVM, это может привести к очень плохой производительности.
Например, если операционная система рапортует о большом количестве доступных логических CPU, виртуальная машина создаст множество потоков для сборки мусора и для JIT-а. Но если хостовая операционная система будет распределять эти потоки на совсем небольшое количество физических CPU, то пользовательские потоки Java-приложения могут быть полностью вытеснены системными потоками виртуальной машины. Схожая проблема возникает, если гостевая операционная система выделяет для какой-то JVM большой объем памяти, но эта память шарится (делится) между другими гостевыми ОС.
— Как сейчас реализуются алгоритмы управления памятью в JVM, что делается для того, чтобы разработчики получали меньше проблем с производительностью?
— На самом деле, виртуальная машина HotSpot использует различные системы управления памятью и стратегии. Самая большая и самая, наверное, известная для обычного программиста на Java — это, конечно, heap плюс работающие на этом heap-е алгоритмы сборки мусора.
Однако JVM работает еще со множеством других пулов памяти. Существует metaspace, который хранит загруженные классы и кеш для хранения сгенерированного виртуальной машиной кода (самый, наверное, известный вид кода – это скомпилированные JIT-ом методы, а также сгенерированные куски интерпретатора и различные заглушки кода).
Ну и наконец, различные подсистемы виртуальной машины, такие как GC или JIT-компилятор, могут на некоторое время потребовать существенную часть нативной памяти для того, чтобы выполнить свою работу. Эта память обычно выделяется на лету и поддерживается в различных сегментах, ресурсных зонах или кэше подсистемы. Например, JIT-компиляция большого метода может временно потребовать больше гигабайта нативной памяти. Все эти пулы памяти могут быть настроены под требования конкретного приложения.
— Расскажите, пожалуйста, интересную историю из вашей практики, когда была проблема производительности Java. Как она была решена?
— Наша SAP JVM работает на множестве различных платформ, среди которых есть HP-UX на PARISC и Itanium CPU. HP-UX на Itanium обеспечивает программную эмуляцию для бинарных файлов PARISC, что делает возможным легко и прозрачно запускать приложения PARISC. Правда, на порядок медленнее, чем нативные приложения.
После того, как мы получили слишком много жалоб клиентов на очень плохую производительность нашей JVM на HP-UX/Itanium, мы стали разбираться, в чем дело. Оказалось, что клиенты использовали бинарные файлы PARISC для запуске на HP-UX/Itanium. Поскольку объяснять каждому клиенту сложности эмуляции мы не могли, мы просто добавили в очередную версию нашей JVM проверку, которая запрещает исполнение кода для PARISC в режиме программной эмуляции.
Java Performance для Enterprise
«О возможных проблемах производительности Java приходится слышать как в учебной, так и профессиональной среде. В каждой шутке есть доля шутки. Отчасти медлительность Java — это миф, тем более, что производительность платформы меняется со временем и то, что вызывало критику лет 10 назад, сейчас работает совершенно по-другому. Рассуждать о Java performance на уровне «тормозит — не тормозит» опрометчиво.
На практике, основные проблемы в промышленных приложениях связаны с наличием лишнего кода, когда действий выполняется больше, чем нужно для решения задачи. Где-то разработчик перемудрил, а где-то бизнес-требования так запутаны, что с первого раза быстро не напишешь. Иногда производительности железа хватает, а иногда решение работает медленнее, чем ожидалось. Основная рекомендация здесь одна — не выполнять ненужный код или хотя бы его часть.
Ещё одна причина снижения производительности кроется в алгоритмах; когда, например, используют полный перебор вместо того, чтобы найти запись по алгоритмическому признаку. Но на практике хитроумные (более умные, чем HashMap) структуры данных редко используются для повышения производительности.
Могут оказывать влияние на производительность и сторонние инструменты разработки, например, библиотеки и фреймворки. На практике проблемы могут быть на всех уровнях — и они случаются. Разработчики обычно выявляют их на тестах, проводится анализ с помощью разных техник профилирования (на уровне браузера, сервера приложений, базы данных). Проблемы со сторонним кодом возникают часто, и единственный способ отловить их — делать замеры и тщательно следить за развитием этого самого стороннего кода. В моей практике, что ни месяц, то находка. Бывает, замена одной строки кода ускоряет вставку данных в базу в 10 раз.
Вообще, главное в измерении и работе с производительностью Java — не забывать о нагрузочном тестировании. Соответственно, сколь велики и разлаписты бизнес-требования к enterprise-разработке, столь огромны тесты, которые их проверяют и помогают заметить неприятные моменты, связанные с performance. Например, нужно учитывать, что нагрузка в корпоративной среде неравномерная: утром все пришли, вошли в систему, пик нагрузки, затем к обеду идёт спад, в обед нагрузка наименьшая, а после обеда пользователи с новой силой повышают нагрузку до высоких значений. Конечно, при первом анализе такие нюансы могут быть упущены. А по факту они могут очень сильно сказываться на том, где ожидать массовой нагрузки и как писать код, чтобы избежать неприятностей с производительностью.
Вы вот меня спрашиваете про самые типичные ошибки и грабли. У каждого они свои. Например, выбор конкретной модели процессора или памяти редко что меняет. Конечно, если сравниваются образцы 2006 и 2016 годов, то модель имеет значение. Много вопросов лежит в плоскости масштабируемости. Многие находятся в поисках священного Грааля enterprise-разработки: все хотят, сделав единственный замер, предсказать, сколько нужно серверов и какой мощности для разворачивания приложения. Тут единственный выход — тестировать на живом сервере, учитывать запасы ресурсов для отказоустойчивости.
Вообще, наверное, есть универсальный инструмент оптимизации — неотвратимость тестирования. У нас в компании есть специалисты по нагрузочному тестированию и анализу результатов, но основной эффект достигается тогда, когда разработчик сам отвечает за скорость работы кода. Производительность — это не то, что можно взять и добавить постфактум. Предугадать, где будет проблема, тяжело, — подозревать каждую строку исходного кода не хватит никакого времени. Общая проблема в том, что тестирование было проведено либо не так, либо не на том объёме данных, либо не на той нагрузке и конфигурации. Например, при тестировании библиотеки не обновили, а заказчик взял и обновил — считайте, проблема пропущена.
На моей памяти были проекты, которые пробовали обойти вопрос производительности стороной, но либо это были маленькие демо-проекты, либо те, которые очень быстро возвращались с жалобами от клиентов на медленную работу приложения.
Часто к снижению производительности или падению всего приложения приводит не какая-то одна глобальная ошибка, а комбинация двух-трёх проблем, каждая из которых, на первый взгляд, не представляет угрозы. И самое страшное, что разработчик может и не узнать о проблемах, возникших в его коде. Если проблему удаётся решить силами инженеров технической поддержки, то редко ищут автора кода, чтобы «наградить» его.
Кстати, о жалобах. Если клиент негодует, чтобы понять, где искать проблему, есть простой алгоритм. Нужно спросить представителя заказчика, а как должно быть. Если всё отрабатывает медленно и вам кажется, что дело в субъективных ощущениях, уточните, за какое время должно работать. Такие требования, конечно же, лучше собирать тоже заранее (во время разработки, или до её начала). Мы требования разбиваем по компонентам: например, если пользователь хочет, чтобы страница открывалась за 5 с, мы распределяем бюджет на браузер — 1 с, на серверную часть — 3,5 с, сеть — 0,5 с и так далее. Зачастую разбивка идёт вплоть до компонентов самой системы, которые, кстати, логируют время работы, и разработчики, открыв профайлер, могут проверить укладывается ли их код в допустимые рамки.
Итак, проблемы есть. Куда смотреть, откуда ноги растут?
- Посмотреть на загруженность CPU и содержимое GC логов. Если система загружена, и при этом нагрузка вызвана работой GC, то либо памяти мало (нужно повышать -Xmx), либо «кто-то слишком много ест».
- Сама по себе 50-100% загруженность уже говорит о том, что времена отклика могут получаться самыми невообразимыми.
- Бывает, вина лежит на сторонних приложениях. Безобидное обновление может привезти пачку тормозов «в подарок». Канонический пример — история с RedHat Linux: механизм transparent huge pages вызывает проблемы с производительностью. Это «фича» Red Hat Linux, из-за которой «внезапно» начинают тормозить как java-приложения, так и базы данных. Проблеме подвержены Red Hat Linux 6+ (CentOS 6+). По ссылке можно почитать подробнее. THP режим нужно обязательно отключать. Если подобная настройка железа и программ выполняется вручную, то не исключён человеческий фактор, и в итоге — тормоза.
- Задавать классические вопросы «работало ли оно раньше» и «что меняли». Спрашивать, что делали на стороне клиента, накатывали ли обновления на компоненты системы. Если накатывали — вектор поиска становится понятнее. Если ясности нет, память свободная, тогда смотрим в профайлер, какая активность занимает ресурсы. Ну а дальше у каждого своя история со своим кодом.
Теперь посмотрим на проблему производительности поближе к реальным условиям, к клиенту. Нередко приложение совершенно неожиданно ведёт себя в продакшене, хотя на тестах всё было идеально. И этому есть объяснение. Если ситуация воспроизводится только у клиента, то это может говорить о проблеме с исходными данными. Например, мы знаем, что приложению предстоит совершать операции с 1’000’000 клиентами. Мы генерируем 1’000’000 случайных имён и фамилий, тесты проходят хорошо, поиск клиента по ФИО летает. Выходим в продакшн — пользователи негодуют. Смотрим внимательнее на проблемный поиск — и оказывается, что Ивановых Иванов Ивановичей тысяча. Если в нагрузочных тестах не учесть такое количество повторений, то немудрено, что на тестах пара найденных строк отображаются мгновенно, а в эксплуатации поиск работает через раз.
Сгенерировать корректный набор данных для тестов — целая наука. Нужно создать не просто осмысленные, допустим, 100 ГБ данных, но и учесть взаимодействия между данными — должно быть максимально похоже на то, что будет в эксплуатации.
- Идеальный случай — наличие дампа базы клиента. Его можно использовать напрямую или предварительно зашифровать. Причём шифрование должно учитывать существующие соотношения и ссылки между объектами и обеспечивать безопасность данных одновременно. Например, простая замена всех значений на MD5, не является достаточно безопасной. Даже поиск в Google по MD5 может найти «исходное» значение. Правильнее использовать HMAC, чтобы с одной стороны одинаковые данные хешировались в одинаковые хэши, но при этом дешифрация будет невозможна.
- Бывает и так, что дампа нет. В таком случае изучаются параметры будущей системы и пишутся скрипты, которые генерируют тестовые данные на основе этих параметров. При этом стоит избегать равномерных распределений, ведь, в реальности редко когда распределения равномерны.
Что ещё можно сказать о Java performance для enterprise? Собирайте нефункциональные требования заказчика: разбивку по сценариям работы, частоту взаимодействий пользователя с тем или иным модулем приложения, количество пользователей и ожидаемое время работы. Исправление проблем производительности на поздних этапах может стоить очень дорого, а лишняя секунда пока ждёт оператор колл-центра приводит к серьёзным потерям».
Если у вас возникли вопросы по высказываниям экспертов или вы хотите рассказать о своей позиции по вопросам производительности Java, добро пожаловать