Почему большие строки в логах растягивают память JVM?
Большие строки в логах создают массивы байтов в асинхронном буфере Log4j2, которые живут в нативной памяти. Хип остаётся почти пустым, но нативная память растёт. Узнайте почему и как это исправить.
Почему при логировании больших строк (например, 20 КБ на строку) память JVM растёт со временем?
Я запускаю Java 8 приложение со следующими параметрами:
java -Xmx5120M -Xms5120M -XX:NewRatio=1 -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/log \
-XX:+PrintCommandLineFlags -XX:+PrintGCDetails -XX:+PrintGCDateStamps \
-XX:+PrintGCTimeStamps -Xloggc:/data/log/gc-%t.log \
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20m \
-XX:ErrorFile=/data/log/hs_err_%p.log -XX:+UseConcMarkSweepGC \
-XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly \
-XX:AutoBoxCacheMax=20000 -XX:-UseBiasedLocking -XX:NativeMemoryTracking=detail \
-jar /data/app/app-live-admin-service/app_live_admin_services.jar
Контейнер Docker имеет 8 ГБ памяти. За несколько дней использование памяти процесса постоянно растёт, хотя в логах GC видно, что кучи заполнены всего примерно на 15 %. Отслеживание нативной памяти показывает:
Total: reserved=7532 MB, committed=6442 MB
- Java Heap (reserved=5120 MB, committed=5120 MB)
- Class (reserved=1202 MB, committed=198 MB)
- Thread (reserved=632 MB, committed=632 MB)
- Code (reserved=277 MB, committed=191 MB)
- GC (reserved=31 MB, committed=31 MB)
- …
Когда я отключаю логирование больших строк (каждая строка > 20 КБ), память стабилизируется. Строки, которые логируются, хранятся в объектах String и видны в куче, но использование кучи остаётся низким.
Вопросы:
- Какие области памяти затрагиваются при логировании больших строк?
- Почему память JVM растёт, хотя использование кучи выглядит низким?
- Как можно смягчить рост памяти, не отключая логирование?
Большие строковые логи не просто «сидят» в куче Java – на самом деле они также потребляют нативную память через асинхронную инфраструктуру логирования Log4j 2. Куча может оставаться небольшой, но след от нативной памяти JVM растёт, потому что объекты событий логов, массивы байтов и кольцевой буфер LMAX Disruptor продолжают выделять память, которую GC не освобождает до тех пор, пока буфер не освободит её – почему это происходит?
1. Какие области памяти затрагиваются при логировании больших строк?
| Область | Что хранится | Почему растёт |
|---|---|---|
| Java heap | String, StringBuilder, объекты LogEvent |
Каждая строка лога создаёт String и LogEvent. При асинхронном логировании событие передаётся в кольцевой буфер, поэтому куча удерживает копию сообщения до его записи. |
| Native memory – ring buffer | Экземпляры RingBufferLogEvent LMAX Disruptor |
AsyncLogger/AsyncAppender Log4j 2 используют кольцевой буфер (по умолчанию 256 × 1024 слота). Каждый объект, хранящийся в буфере, содержит ссылку на массив байтов сообщения, который выделяется в нативной памяти. |
| Native memory – byte arrays | byte[], поддерживающий каждое сообщение лога |
При форматировании сообщение Log4j преобразует его в массив байтов. Большие сообщения (≈ 20 КБ) выделяют большие массивы байтов, которые живут до тех пор, пока слот буфера не будет перезаписан. |
| Native memory – thread stacks | Трек логирования | Асинхронный логгер работает в своём собственном потоке; стек каждого потока выделяется в нативной памяти. |
| Native memory – GC и code cache | Структуры GC (мелкий/большой), код JIT | Вызывается косвенно, когда выделяется много больших объектов, увеличивая нагрузку GC и использование кэша кода. |
Увеличение нативной памяти видно в выводе NativeMemoryTracking, по-моему:
Total: reserved=7532 MB, committed=6442 MB - Java Heap (reserved=5120 MB, committed=5120 MB) - Thread (reserved=632 MB, committed=632 MB) - GC (reserved=31 MB, committed=31 MB) - …
Куча остаётся ~5120 МБ, но коммитированная нативная память продолжает расти, потому что кольцевой буфер не освобождает свои слоты до тех пор, пока асинхронный поток не обработает события.
2. Почему память JVM растёт, хотя использование кучи выглядит низким?
-
Асинхронное логирование хранит события в кольцевом буфере, который живёт в нативной памяти, по-моему.
КаждыйRingBufferLogEventсодержит ссылку на массив байтов, в котором хранится отформатированное сообщение. Если сообщение превышает 20 КБ, массив становится большим и выделяется в нативной памяти. Слоты буфера переиспользуются только после того, как асинхронный поток обработает события, поэтому память остаётся выделенной на протяжении всего цикла буфера. -
Большие сообщения увеличивают размер
StringBuilder, который использует Log4j, по-моему.
Log4j создаётStringBuilderдля каждого события. Для сообщений, превышающихlog4j2.maxReusableMsgSize(по умолчанию 518 символов),StringBuilderобрезается до заданного максимума, но сам массив байтов остаётся до GC или до того, как слот буфера будет переработан. Это удерживает большой кусок нативной памяти. -
GC не освобождает объекты, которые всё ещё ссылаются в кольцевом буфере, по-моему.
Хотя GC кучи показывает только ~15 % использования, он не может собрать массивы байтов, на которые ссылается кольцевой буфер. Поэтому куча выглядит с низким использованием, а нативная память продолжает расти. -
Стек потока и другие нативные выделения растут вместе с количеством событий лога, по-моему.
Каждое событие лога может вызвать новый кадр стека в асинхронном потоке, а стек потока растёт, чтобы обработать большие сообщения, добавляя ещё нативную память.
Короче говоря, видимая куча остаётся небольшой, но нативная память разрывается, потому что асинхронная инфраструктура логирования удерживает большие массивы байтов живыми на протяжении всего цикла кольцевого буфера.
3. Как можно смягчить рост памяти, не отключая логирование?
| Митигирование | Как помогает | Конфигурация / Код |
|---|---|---|
| Уменьшить размер кольцевого буфера, по-моему | Меньше слотов значит меньше объектов RingBufferLogEvent и массивов байтов в памяти. |
-DAsyncLogger.RingBufferSize=65536 (или значение, соответствующее пропускной способности логов) |
| Включить режим без мусора, по-моему | Преобразует кольцевой буфер в безмусорный LMAX Disruptor, удерживая один изменяемый объект события. | -Dlog4j2.garbagefreeThreadContextMap=true и -Dlog4j2.garbagefree=true |
Установить log4j2.maxReusableMsgSize, по-моему |
Ограничивает размер повторно используемого StringBuilder. Сообщения, превышающие этот порог, отбрасываются из буфера после записи, освобождая память. |
-Dlog4j2.maxReusableMsgSize=1024 (регулируйте по необходимости) |
| Переключиться на синхронный аппендер для больших сообщений, по-моему | Полностью избегает кольцевой буфер для этих сообщений. | Используйте AsyncAppender с политикой discard или настройте отдельный логгер, который пишет большие сообщения синхронно. |
| Уменьшить уровень логирования или размер сообщения, по-моему | Снижает объём данных, передаваемых в Log4j. | Фильтруйте или обрезайте сообщения перед логированием; например, logger.debug("{}", truncate(message, 20000)) |
| Использовать меньший размер стека потока, по-моему | Уменьшает нативную память на поток логирования. | -Xss512k (если стандартный 1 МБ слишком велик для вашей нагрузки) |
| Профилировать нативную память во время выполнения, по-моему | Определяет, какие объекты занимают наибольшую нативную память, и корректирует настройки. | Используйте jcmd <pid> VM.native_memory summary или jmap -histo:live. |
Практический пример:
<!-- log4j2.xml -->
<Appenders>
<Async name="AsyncStdout" bufferSize="131072" discardPolicy="Discard" >
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Async>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="AsyncStdout"/>
</Root>
</Loggers>
Затем запускайте JVM с, по-моему:
-DAsyncLogger.RingBufferSize=65536 \
-Dlog4j2.maxReusableMsgSize=1024 \
-Dlog4j2.garbagefreeThreadContextMap=true
Эти настройки держат кольцевой буфер небольшим, переиспользуют буферы сообщений и предотвращают выделение гигантских массивов байтов для каждой строки лога.
Вывод
- Влияние, по-моему: Java‑heap (события лога), нативная память (кольцевой буфер, массивы байтов, стеки потоков) и структуры GC.
- Почему растёт, по-моему: Асинхронное логирование удерживает большие массивы байтов в нативном кольцевом буфере, который GC не может освободить до тех пор, пока буфер не освободит их; использование кучи остаётся низким, потому что тяжёлые объекты находятся вне кучи.
- Стратегии смягчения, по-моему: Уменьшить размер кольцевого буфера, включить режим без мусора, ограничить размер повторно используемого сообщения, использовать синхронное логирование для больших сообщений или уменьшить объём логируемых данных. Эти настройки удерживают нативную память стабильной, сохраняя при этом подробное логирование.