Принципы написания торговых low-latency приложений на Java

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

Статья ориентирована на программистов, чтобы помочь им в изучении Java, понимании JVM и развитии навыков программирования.

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

Все рекомендации и идеи в данной статье изложены на основе:

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

Не мусорить

Самым главным источником задержек в Java-приложении является сборщик мусора. Поведение сборщика мусора непредсказуемо. Он может вступить в игру в любой момент, когда JVM посчитает нужным убраться в heap. Посему главное зло для low-latency — это мусор в heap-е.

Следовательно, надо строго следить за тем, чтобы в коде не создавался мусор, или если и создавался, то как можно меньше, и только там, где это уже совершенно необходимо. На эту тему на habr.com есть хорошая статья Как не мусорить в Java, а здесь я дам кратенько лишь основные положения.

  1. Использовать примитивы, где только можно.
  2. Обращайте особое внимание на места, где происходит auto-boxing и auto-unboxing примитивов в объекты и объекты в примитивы (Double и double, Integer и int). Включите в вашем IDE все предупреждения компилятора (compiler warnings), и они быстро покажут такие скрытые места в вашем коде.
  3. Обращайте внимание на скрытый мусор, создаваемый синтаксическим сахаром. Например, for-each при обходе коллекции создает скрыто объект Iterator, значит в критических местах для обхода массива, например, надо использовать стандартный for.
  4. Ограничивайте использование сторонних библиотек.
    1. Во-первых, они могут генерировать мусор, а вы этого хотите избежать.
    2. Во-вторых, библиотеки написаны для общего применения и могут не подходить для low-latency приложений. Например, вызов какого-то метода из библиотеки может приводить к непредказуемым задержкам, код неэффективен, подход устарел, вызовы слишком «дороги» в плане производительности.
    3. В-третьих, реализация внутри библиотеки невидима для вас. Например, в новой версии библиотеки внутренняя реализация метода, который раньше работал быстро, может поменяться без вашего ведома, и это может сказаться на производительности.
  5. Собирайте логи и мониторьте показатели использования heap, скажем, каждые десять минут, следите, где и в какой момент создается мусор. Начиная с Java10 в JVM используется унифицированный вывод логов, что удобно по-сравнению с Java8. Разные сборщики мусора выводят разную информацию в логи
  6. Несмотря на то, что в спецификации JVM вызов System.gc не обязательно приводит к вызову сборщика мусора, тем не менее HotSpot реагирует на эту команду и сборка мусора происходит. Используйте эту возможность время от времени чтобы явно вызывать сборщик мусора в перерывах между торговыми сессиями.

Объекты

Создавайте классы с такой структурой, чтобы извлечение их из памяти было cache-friendly, а изменения в их полях не порождали false sharing.

String

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

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

Пулы объектов

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

Логгирование

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

Из вышесказанного делаем вывод — логгирование должно быть по минимуму. Логгирование должно быть асинхронным и быстрым. Писать в логи только то, что совершенно необходимо для отслеживания работы системы. Запись в лог должна осуществляться асинхронно. Например, новая версия log4j2 почти удовлетворяет всем этим критериям. Она использует высокопроизводительную библиотеку Disruptor для ускоренияб работает асинхронно и не создает лишнего мусора в памяти. Новая версия была написана с прицелом на low-latency для алгоритмических движков. Один из разработчиков — Remko Pompa — мой знакомый, он работает в банке SMBC в Токио.

Collections

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

  • неверно выбраны тип колекции. Например, существует большая разница, как ArrayList и LinkedList хранят ссылки на объекты и обеспечивают работу с ними. В каких-то случаях лучше подойдет ArrayList, в других — успешно справится LinkedList.
  • неверные размеры коллекций, или размеры коллекций не заданы вообще. Например, ArrayList поддерживает динамическое увеличение capacity. Когда количество объектов, которые вы добавляете в ArrayList превышает пороговое значение, ArrayList автоматически включает механизм resize. То есть в какой-то момент, добавляя объект, вы заметите, что операция добавления вдруг заняла больше времени, чем обычно. Это время ушло на то, чтобы создать новый внутренний массив, скопировать ссылки из старого массива в новый и добавить в массив ссылку на новый объект, который вы собирались добавить. Обидно будет если, эта операция случится именно тогда, когда от приложения вы ожидали пиковой производительности. Чтобы избежать этого конфуза создавайте коллекции с заранее определенными размерами, который вы можете вычислить либо сами, либо опираясь на данные предыдущих дней.
  • неверно выбран hashCode-алгоритм для важных объектов. hashCode используется в HashMap и в HashSet для быстрого поиска объекта в коллекции. Если hashCode вашего объекта неэффективен, ваше приложение будет тратить на микросекунды больше времени на поиск в хэш-таблице ордера по orderid, или инструмента по ric-коду и т.д.

Не забывайте, что коллекции в стандартном JDK предназначены для Java-приложений общего назначения. Они не оптимизированы под специфические нужды low-latency приложений. Если вы хотите выжать из Java последние капли low-latency, пишите свои собственные реализации List, Queue, Map. Или можно воспользоваться, например, библиотекой Eclipse Collections, чьи коллекции показывают лучшую производительность по сравнению со стандартными. Библиотека была написана в банке Goldman Sachs 10 лет назад, а потом передана в Open Source.

В качестве еще одного примера экстремального программирования для достижения low-latency можно выносить структуры данных в native memory и работать с ними через JNI и C/C++, либо с помощью недокументированного класса Unsafe. Так вы спрячете данные от сборщика мусора и тем самым освободите его от лишней работы по отслеживанию ссылок и периодической релокации объектов. Помните, что за пределами heap вы сами должны управлять памятью. Любая ошибка в управлении памятью может привести к неожиданному краху программы в самый неподходящий момент. Но если нужно — то нужно. Например, так делает Peter Lawrey со своим ChronicalMap.

Многопоточность

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

При проектировании приложения, сопоставляйте количество активных потоков с количеством ядер процессоров системы. Какие-то потоки у вас будут CPU-bound, какие-то — IO-bound. IO-bound потоки часто блокируются в ожидании данных из сети, или при отправке данных в сеть. Большую часть своей «жизни» они проводят в ожидании и не занимают сильно процессор, а вот CPU-bound потоки — это потоки делающие большие сложные вычисления, и им, конечно, нужны вычислительные мощности процессора в полной мере. Если таких CPU-bound потоков в вашем приложении больше, чем ядер процессора, какой-то поток будет постоянно «голодать«, так как его постоянно будет вытеснять другой CPU-bound поток. Учитываться должны не только потоки самого приложения, но и сервисные потоки самой JVM. Поэтому очень желательно иметь на своем сервере процессоры с большим количеством ядер.

Главная трудность при написании многопоточных приложений — это предоставление нескольким потокам доступа к общим переменным и структурам данных. Во избежание ситуаций гоки (race conditions) и мертвых блокировок (deadlocks) лучше вообще свести такой доступ к минимуму.

Невозможно обойтись, конечно, вообще без обмена данными через память, но лучше всего это делать оптимизированно и умеючи. Поэтому написание многопоточных приложений связано с трудностями, возможными ошибками и неправильным пониманием нюансов Java Memory Model. Эти ошибки могут привести к проблемам с latency. Например:

  • Блокировки между потоками (blockings). Блокировка потока требует переключения контекста, а это требует системного вызова., т.е. исполнение передается из приложения в операционную систему. На это уходит слишком много времени, что может привести к задержкам. Здесь вам помогут lock-free алгоритмы и структуры данных, которые часто используются для передачи сообщений между потоками в low-latency приложениях (см. пункт про Collections). В этом случае какое-то ядро CPU используется на 100%, зато нет блокировок и задержек.Убедитесь только, что поток надежно привязан к этому ядру, иначе ОС будет пытаться его остановить, запарковать и исполнить на ядре другой поток (см. про Affinity ниже).
  • Конкуренция потоков за ресурсы (contention). При синхронизации потоков на каком-то объекте может оказаться так, что обработка запроса каждого потока занимает настолько много времени, что другие потоки скапливаются в очереди на мониторе и чрезмерно простаивают в ожидании, конкурируя друг с другом за ресурс, когда монитор освобождается.

Thread affinity: важно прикрепить критически важные потоки к определенному ядру процессора, запретить операционной системе переносить этот поток на другие ядра, а также запретить операционной системе переносить на это ядро другие потоки. То есть  на данном конкретном ядре выполняется только критически важный поток и только он. В приложении таких потоков может быть несколько, поэтому опять-таки важно чтобы числа ядер процессора хватило на все критически важные потоки и плюс осталось еще ядер на некритически важные потоки. Affinity делается с помощью сторонних библиотек прямо в коде приложения, либо пишете код самостоятельно через JNI/C/C++ или Unsafe. Или прикрепляете поток к ядрам процессора уже после старта вручную с помощью numactl.

Выводы

Low-latency приложения надо сразу писать с учетом оптимизаций. Оптимальный код это, конечно, очень важно, но одного только оптимизированного кода для достижения low-latency не достаточно. Оптимизации следует подвергнуть все уровни системы, о чем в данном блоге будет еще несколько статей.

Ссылки по теме

 

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s