Пулы объектов для достижения высокой производительности в Java

В торговых приложениях создается множество объектов в реальном времени. Скажем, каждый новый ордер от клиента создает объект Order. Исполнение ордера на рынке порождает новый объект Fill или Partial Fill. И если поток ордеров очень высок во время активного торгового дня, это создание новых объектов оказывает:

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

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

История

Каждому Java-программисту известно использование пула соединений для подключения к базам данных (JDBC connection pools). Свободная реализация Apache DBCP основана на библиотеке Apache Commons Pool.

Идея использования пулов для объектов с целью улучшения производительности Java-приложения знакома мне, например, аж с 2002 года, когда мне пришлось работать с фреймворком Apache Avalon.

Плюсы

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

Плюсы очевидны: объекты уже заранее созданы и размещены в памяти. При выделении объекта из пула потоку передается просто ссылка на объект, что гораздо проще и быстрее, чем создание объекта с нуля. Каждый объект постоянно связан с пулом, и значит сборщик мусора его никогда не признает «мусором» и не уберет.

Минусы

Сейчас эта идея подвергается критике, потому что в идее есть минусы:

  • очень трудно написать механизм пула объектов, чтобы он работал правильно, без ошибок, утечек памяти и эффективно, чтобы в многопоточном приложении он не стал узким местом и не тормозил;
  • с развитием JVM в каждой новой версии аллокация объектов стала быстрее и намного эффективнее, а сборщики мусора стали работать лучше и производительнее;
  • память стала дешевле, ее на серверах стало больше, память стала быстрее. Это значит, что использование пулов объектов оправдано уже только там, где производительность приложения является самым высоким приоритетом;
  • в JVM добавили еще несколько экспериментальных высокопроизводительных сборщиков мусора, которые скоро можно будет использовать в PROD, что еще больше ставит под сомнение ценность идеи пула объектов.

Поэтому с точки зрения критиков проще создавать новые объекты по мере надобности как встарь и поручать размещение этих объектов в памяти и уборку их из памяти сборщику мусора.

Принципы работы

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

Эффективно работать в многопоточном приложении

Так как потоков много, а пул один, это создает опасность «узкого места» (contention), когда несколько потоков в оно время обращаются к пулу за объектом, а пул может эти запросы обработать только последовательно, а не одновременно. Это значит, что какому-то потоку объект из пула будет выделен не сразу, а с задержкой. И эта задержка может оказаться непредсказуемой.

Изменяемый размер

При старте системы пул заполняется определенным количеством заранее созданных объектов. Но сколько этих объектов должно быть создано? Очевидное решение, это сделать количество создаваемых объектов конфигурируемым, скажем, через специальный конфигурационный файл. По мере работы приложения на QA в нагрузочном тестировании мы отслеживаем, сколько каких объектов приложению понадобилось для обработки стандартной нагрузки обычного торгового дня, и это количество объектов и должно быть создано при старте приложения на PROD плюс, скажем, 20% про запас.

Выделение новых объектов блоками

Что делать, если все объекты из пула выделены, но не возвращены в пул, а потоки требуют от пула еще объектов. Пул, что называется, «истощился», «иссяк» (exhausted). В этом случае, поток придется заблокировать и заставить его ждать, пока какой-нибудь другой поток не вернет объект в пул. Так легко можно получить dead-lock, а это в торговый день может грозить серьезными неприятностями, как финансовыми так и репутационными.

Поэтому в механизме пула объектов должна быть предусмотрена процедура создания новых дополнительных экземпляров объектов, если существующего количества не хватило на всех. Пул, что называется, должен самостоятельно уметь «расширяться» (expanding). Если пул должен создавать объекты сразу блоками. Т.е. пул должен расширяться не на 1 объект по мере надобности, а сразу, скажем, на 100 объектов. Тогда, если через миллисекунду другому потоку понадобится еще один такой же объект, ему не придется ждать, ему будет сразу же выдан объект из нового созданного блока. Размер блока тоже должен быть конфигурируемым. Зачем создавать по 100 экземпляров каждого объекта и занимать ими память, если вот именно этот конкретный класс требуется реже, чем другой? Размеры блоков определяются тоже эмпирически путем нагрузочного тестирования на QA.

Сегодня как вчера

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

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

Идеальная картина

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

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

pexels-photo-769525.jpeg

Photo by Rakicevic Nenad on Pexels.com

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

Выводы

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

Пример конкреной реализации можно посмотреть у разработчика Richard Rose, о котором я уже писал, в его блоге Ultra Low Latency Trading Systems, в пакете pool его open source проекте SubMicroTrading.

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

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

Логотип WordPress.com

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

Фотография Twitter

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

Фотография Facebook

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

Connecting to %s