Сравнение библиотек логирования Log4j и Gflog. Управление памятью

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

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

Сложные системы логируют большой объём информации. Хорошо продуманные и детализированные записи могут помочь в поиске и устранении серьёзных проблем. У большинства систем нет ограничений, какое логирование использовать ― синхронное или асинхронное. И всё же многие высокопроизводительные системы обычно предпочитают использовать асинхронное логирование без мусора (garbage-free), чтобы избежать задержек при сборке мусора (GC events) и операциях ввода-вывода (IO operations). В этой статье мы заглянем под капот библиотек логирования Java Log4j и Gflog и посмотрим, чем они отличаются с точки зрения управления памятью. Мы узнаем, как они реализованы, чтобы максимально сократить создание короткоживущих объектов.

Введение

Библиотеки асинхронного логирования обычно используют очередь для передачи записей из потоков приложения в фоновый поток. Фоновый поток форматирует записи в указанном формате и пишет или отправляет их в настроенное хранилище или сервис. Обычно логи пишут в файл или отправляют через TCP или UDP соединение в нижестоящий сервис (ELK Stack, Graylog, Splunk, и т. д.). 

Длина логируемых сообщений зависит от приложения и может составлять десятки или тысячи символов. Garbage-free логгеры повторно используют одни и те же объекты и структуры, чтобы не создавать короткоживущие объекты на каждую операцию логирования. Длинные записи могут раздувать объекты и структуры и влиять на производительность, особенно на размер занимаемой памяти. Например, запись большой структуры данных в текстовом виде может быть длинной, а некоторые могут состоять всего из нескольких слов. Компромисс между занимаемой и действительно используемой памятью ― это одна из основных проблем при разработке garbage-free логгера. Кроме того, чрезмерное использование пулов объектов отрицательно сказывается на эффективности сборки мусора. Это может проявляться по-разному в зависимости от реализации сборщика и типа цикла сборки мусора. Как правило, увеличивается время выполнения блокирующих (stop-the-world) циклов сборки мусора, а также использование процессора при параллельных (concurrent) циклах.

Давайте пройдёмся по библиотекам Log4j 2.0.14 и Gflog 3.0.1 и посмотрим, чем они отличаются.

Log4j

Log4j ― хорошо известная библиотека логирования. Она поддерживает множество функций, включая garbage-free режим, который нас интересует. Вся магия происходит внутри одного вызова.

Log4j API

log.info("Hello there: {}", message);

Log4j использует Disruptor для передачи записей журнала в фоновый поток. Disruptor основан на объектно-ориентированном кольцевом буфере фиксированной длины и поддерживает топологию потребителей. В Log4j кольцевой буфер содержит многократно используемые объекты событий журнала (log events), а потребителем является фоновый поток, который их обрабатывает.

Log4j Structure

Log4j создаёт Disruptor на 256К слотов, исходя из конфигурации, которую использует команда Log4j при тестировании производительности. Каждый слот представляет повторно используемое событие журнала и содержит имя логгера, имя потока, уровень логирования, сообщение и другую информацию. Объект messageText хранит отформатированное сообщение. Он выделяется лениво при первом использовании слота с начальной длиной 128, которая устанавливается системным свойством log4j.initialReusableMsgSize.

Когда вы логируете сообщение, Log4j берёт thread-local транслятор, заполняет параметры записи журнала и вызывает метод Disruptor#publish. Там он запрашивает слот, копирует все ссылки в него и форматирует сообщение в StringBuilder messageText объект слота. После чего Log4j публикует слот и он может быть обработан фоновым потоком.

Таким образом, одно повторяющееся длинное сообщение может раздуть StringBuilder объект во всех слотах, что приведёт к огромной трате памяти. К счастью, Log4j имеет системное свойство log4j.maxReusableMsgSize, которое по умолчанию равно 518. Фоновый поток подрезает StringBuilder объекты, если они длиннее 518, до этой же самой длины. Таким образом, в худшем случае все они могут быть длиной 518, а более длинные сообщения будут приводить к созданию короткоживущих объектов размером более 518 байт.

Gflog

Gflog ― это Java библиотека для асинхронного логирования без мусора. Изначально Gflog был ответвлением библиотеки Gflogger Владимира Долженко. Но дальнейшие улучшения и новый API сделали его отдельным проектом. Gflog API похож на Gflogger API, но реализация отличается.

Gflog Template API

log.info("Hello there: %s").with(message);

Gflog использует байтовый off-heap кольцевой буфер (адаптация ManyToOneRingBuffer от Agrona) для передачи записей журнала в фоновый поток. Буфер позволяет записывать байтовые сообщения переменной длины, добавляя небольшие накладные расходы 0-7 байтов на выравнивание записей. К сожалению, вы не можете передавать ссылки. Но в этом нет ничего страшного. Gflog не поддерживает логирования местоположения, откуда вызывается логирующий код, поэтому единственное, что требует исключительного пути ― это исключения. Вся необходимая информация, такая как имя логгера, имя потока, отметка времени, может быть эффективно сериализована.

Gflog Structure

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

При первой операции логирования поток создает thread-local запись журнала. Имя потока фиксируется и сохраняется в красной области и не обновляется в будущем. Один и тот же объект записи журнала повторно используется в последующих операция логирования в этом потоке.

При каждой операции логирования поток обновляет синюю и зелёную области. Синяя область имеет фиксированную структуру. Все поля являются примитивами и могут обновляться в любом порядке, поскольку они имеют постоянную длину и смещение. Обратите внимание, что имя логгера хранится в индексе, поэтому необходимо передать только 4-байтовое значение индекса и использовать его для получения строки в фоновом потоке. Зелёная зона имеет переменную длину. Поток заполняет её в режиме добавления (append) при построении сообщения.

Когда запись готова поток резервирует место в кольцевом буфере, копирует содержимое и делает запись доступной для обработки в фоновом потоком. Thread-local запись журнала инициализируется размером 4 КБ и может увеличиваться до 64 КБ, в зависимости от конфигурации. Если сообщение больше и не влазит, оно обрезается. Gflog не уменьшает размер записи журнала. Таким образом, в худшем случае у вас может быть 64 КБ на поток приложения, если вы логировали хотя бы одно длинное сообщение в каждом из них. Мусор создается только при расширении записи журнала.

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

Время эксперимента

Теперь давайте проведём эксперимент и сравним объём памяти, который используют эти библиотеки. 

Мы будем использовать библиотеку JOL (Java Object Layout) для оценки объёма памяти. Обойдём граф живых объектов начиная с объекта логгер после логирования 1/1M/10M/100M сообщений со следующим случайным распределением:

  • 50% ― сообщение длиной от 0 до 100

  • 20% ― сообщение длиной от 0 до 200

  • 10% ― сообщение длиной от 0 до 300

  • 10% ― сообщение длиной от 0 до 400

  • 9% ― сообщение длиной от 0 до 500

  • 1% ― сообщение длиной от 0 до 1000

Мы будем использовать Java 11/8 со следующими параметрами JVM:

  • -Xms1g ― начальный размер кучи 

  • -Xmx1g ― максимальный размер кучи

Посмотреть код можно здесь:

  • Log4j

  • Gflog

Обратите внимание, что JOL не учитывает off-heap память, поэтому нужно добавить к результату Gflog размер, который занимает кольцевой off-heap буфер.

Давайте залогируем одно сообщение в Gflog.

Gflog Top 5 (Java 11, 1 message)

Instances

Average (bytes)

Footprint (bytes)

Type

3512

200

702416

byte[]

2443

36

88256

java.lang.Object[]

3390

24

81360

java.lang.String

2338

32

74816

java.lang.invoke.LambdaForm$Name

148

504

74624

int[]

24960

-

1654232

(total)

Мы видим только 1.58 Мб. Добавим 8 МБ, которые занимает кольцевой буфер, и 2 МБ с запасом. Итак, результат будет 11.58 МБ. Самая тяжелая структура ― это буфер.

Теперь залогируем одно сообщение в Log4j.

Log4j Top 7 (Java 11, 1 message)

Instances

Average (bytes)

Footprint (bytes)

Type

262144

104

27262976

...log4j.core.async.RingBufferLogEvent

262143

32

8388576

...log4j.util.SortedArrayStringMap

262144

24

6291456

...log4j.core.time.MutableInstant

437

2583

1128808

int[]

601

1812

1089336

java.lang.Object[]

5486

148

816392

byte[]

5331

24

127944

java.lang.String

815846

-

45866000

(total)

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

Хорошо, залогируем миллион сообщений в Gflog.

Gflog Top 5 (Java 11, 1M messages)

Instances

Average (bytes)

Footprint (bytes)

Type

3738

190

710792

byte[]

1235

72

88920

java.lang.reflect.Field

2443

36

88256

java.lang.Object[]

3602

24

86448

java.lang.String

632

120

75840

java.lang.Class

25984

-

1702872

(total)

Мы видим, что объём памяти практически не изменился. Thread-local запись main потока не расширяется, потому что сообщения слишком короткие для Gflog.

А теперь ― миллион сообщений в Log4j.

Log4j Top 7 (Java 11, 1M messages)

Instances

Average (bytes)

Footprint (bytes)

Type

268263

283

76023096

byte[]

262144

104

27262976

...log4j.core.async.RingBufferLogEvent

262745

60

15769368

java.lang.Object[]

262144

24

6291456

java.lang.StringBuilder

262144

24

6291456

...log4j.core.time.MutableInstant

452

2497

1129048

int[]

5958

24

142992

java.lang.String

1342827

-

133755584

(total)

Теперь он занимает 127.56 МБ, и мы видим 256К объектов StringBuilder. Средний размер составляет около 283 байт, что означает, что некоторые объекты всё ещё могут расширяться.

Продолжим логировать до 10/100 миллионов сообщений.

Мы видим, что объём памяти, который занимает Gflog, устойчив и не изменяется. С другой стороны, Log4j занимает всё больше и больше места, потому что объекты StringBuilder расширяются. В конце концов, все они достигнут предела.

Давайте попробуем Java 8. Может что-то изменится.

Действительно, Log4j занимает в Java 8 почти в 1,7 раза больше места. Почему в Java 11 объём памяти меньше? Благодаря Compact Strings фиче, появившейся в Java 9. Объекты StringBuilder занимают почти в 2 раза меньше места, если вы логируете только Latin-1 сообщения. Но они всё равно могут быть раздуты, если иногда вы логируете не Latin-1 сообщения, и останутся такими. 

Хорошо, а как насчёт количества живых объектов? После одного миллиона сообщений:

  • Log4j – 1343 K

  • Gflog – 26 K

В Log4j много живых объектов. Но как это влияет на сборку мусора? Давайте использовать метод System :: gc для имитации событий полной сборки мусора. Мы вызовем 5 сборок, а затем ещё 10 в каждой из точек:

  • Clean ― перед инициализацией логгера. 

  • Initialized ― после инициализации логгера, но до логирования сообщений. 

  • Measured ― после логирования 100 миллионов сообщений, но до остановки логгера. 

  • Destroyed ― после остановки логгера.

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

G1 GC ― это сборщик мусора по умолчанию в Java 11.

Parallel GC ― это сборщик мусора по умолчанию в Java 8.

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

Заключение

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

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

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

Источник: https://habr.com/ru/company/epam_systems/blog/564640/


Интересные статьи

Интересные статьи

Мне никогда раньше не приходилось иметь дело с парсингом данных из интернета. Обычно все данные для работы (аналитик данных) приходят из выгрузок компании с использование...
Библиотеки и сервисы AutoML вошли в мир машинного обучения. Для дата-сайентиста это очень полезные инструменты, но иногда они должны быть адаптированы к потребностям бизнес-контекста, в к...
Попробуем визуализировать данные по рекламным кампаниям, которые храняться в DataFrame. Дано: DataFrame, в котором хранится статистика по рекламным кампаниям по следующим показателям: Ca...
Хочу сразу оговориться: эта статья не дает готового к использованию рецепта. Это скорее моя история путешествия в мир Typescript и NodeJS, а также результаты моих экспериментов. Тем не менее, в к...
Согласно многочисленным исследованиям поведения пользователей на сайте, порядка 25% посетителей покидают ресурс, если страница грузится более 4 секунд.