Статьи

Обнаружение утечек памяти из дампа кучи JVM

Одной из основных причин популярности Java является сборщик мусора. Во время выполнения любой объект, который недоступен из корня GC, будет автоматически уничтожен и его память будет использована повторно (рано или поздно). Наиболее распространенными корнями GC являются локальные переменные в методах и полях статических данных; объект достижим, когда корень GC указывает на него напрямую или через цепочку других объектов. Таким образом, ни один объект в Java не может действительно «утечь», то есть стать недоступным для работающей программы, но при этом израсходовать память — во всяком случае, ненадолго. Итак, почему «утечка памяти» остается одной из распространенных проблем с памятью в приложениях Java? Если вы задаетесь вопросом об этом или когда-либо пытались понять, почему используемая память вашего приложения продолжает расти, читайте дальше.

Вкратце, утечки памяти в Java не являются настоящими утечками — это просто структуры данных, которые становятся неконтролируемыми из-за ошибок или неоптимального кода. Можно утверждать, что они являются побочным эффектом силы языка и его экосистемы. В прежние времена, предшествовавшие Java, написание большой программы на C ++ было медленным, иногда болезненным процессом, который требовал серьезных усилий слаженной группы разработчиков. В настоящее время, с богатым набором классов библиотек JDK и еще более богатым выбором стороннего кода с открытым исходным кодом, с мощными IDE и, прежде всего, мощным, но все же «прощающим» языком, которым стала Java, ситуация изменилась. Свободная группа или даже один разработчик могут быстро собрать действительно большое приложение, которое работает. Подвох? Они, вероятно, будут иметь ограниченное понимание того, как именно это приложение, или, по крайней мере, некоторые его части, работают.

К счастью, большая часть таких проблем может быть решена с помощью модульных тестов. Хорошо протестированные библиотеки и код приложения обеспечивают хорошее приложение. Однако есть одно предупреждение: модульные тесты редко пишутся для моделирования реальных объемов данных и / или реальной продолжительности выполнения. Это основная причина, по которой может проникнуть неоптимальный код, вызывающий утечку памяти.

Одним из распространенных источников утечек памяти являются кэши памяти, которые используются, чтобы избежать медленных операций, таких как повторное извлечение одних и тех же объектов из базы данных. В качестве альтернативы, некоторые данные, сгенерированные во время выполнения, такие как история операций, могут периодически выгружаться на диск, но также сохраняться в памяти, снова для ускорения доступа к нему. Повышение производительности за счет предотвращения повторных медленных чтений из внешнего хранилища за счет некоторой дополнительной памяти, как правило, является разумным выбором. Но если ваш кеш не имеет ограничений по размеру или других механизмов очистки; объем внешних данных высок по сравнению с кучей JVM; и если приложение работает достаточно долго — у вас могут возникнуть проблемы. Во-первых, паузы GC станут более частыми и займут больше времени. Затем, в зависимости от некоторых вторичных факторов, существует два сценария. В первом случае приложение скоро зависнет с OutOfMemoryError, Во втором случае, который обычно возникает, когда при каждом вызове GC может быть восстановлено небольшое количество памяти, приложение может столкнуться с другой проблемой: оно не будет аварийно завершаться, но оно будет проводить почти все свое время в GC, создавая впечатление зависания. Второй сценарий может быть трудно диагностировать, если не используется размер используемой кучи. Но даже если разворачивается первый, как вы узнаете, какие структуры данных отвечают за это?

К счастью, поскольку эта проблема не нова, с течением времени были разработаны различные инструменты для диагностики утечек памяти. Они подразделяются на две большие категории: инструменты, которые непрерывно собирают информацию во время выполнения, и инструменты, которые анализируют один снимок памяти JVM, называемый дампом кучи.

Инструменты, которые собирают информацию, непрерывно работают через инструменты (изменение) кода приложения и / или активацию внутренних механизмов сбора данных в JVM. Примерами таких инструментов являются Visual VM или Mission Control , оба из которых поставляются с JDK. В принципе, такие инструменты более точны в выявлении утечек памяти, поскольку они могут различать большие, неизменные структуры данных и структуры, которые продолжают расти со временем. Однако сбор этой информации на достаточно подробном уровне часто приводит к высоким накладным расходам времени выполнения, что делает этот метод непригодным для использования в производственном или даже предпроизводственном тестировании.

Таким образом, второй вариант: анализ одного дампа кучи и выявление структур данных, которые являются «кандидатами на утечку», действительно привлекателен. Взятие дампа кучи приостанавливает работающую JVM на относительно короткий период. Создание дампа занимает около 2 секунд на 1 ГБ используемой кучи. Так, если, например, ваше приложение использует 4 ГБ, оно будет остановлено на 8 секунд. Дамп может быть jmap  получен по требованию (с помощью  утилиты JDK) или в случае сбоя приложения с помощью  OutOfMemoryError  (если JVM была запущена с параметром   -XX:+HeapDumpOnOutOfMemoryError командной строки).

Дамп кучи — это двоичный файл размером с кучу вашей JVM, поэтому его можно читать и анализировать только с помощью специальных инструментов. Существует целый ряд таких инструментов, как с открытым исходным кодом, так и коммерческих. Наиболее популярным инструментом с открытым исходным кодом является Eclipse MAT; есть также VisualVM и некоторые менее мощные и менее известные инструменты. Коммерческие инструменты включают профилировщики Java общего назначения: JProfiler и YourKit, а также один инструмент, специально созданный для анализа дампа кучи, под названием JXRay.

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

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

import java.util.ArrayList;
import java.util.List;

public class MemLeakTest extends Thread {

  private static int NUM_ARRS = 300;
  private static byte[][] STATIC_DATA = new byte[NUM_ARRS][];
  static {  // Initialize unchanging static data
    for (int i = 0; i < NUM_ARRS; i++) STATIC_DATA[i] = new byte[2000000];
  }

  private static final int NUM_THREADS = 20;

  public static void main(String args[]) {
    MemLeakTest[] threads = new MemLeakTest[NUM_THREADS];
    for (int i = 0; i < NUM_THREADS; i++) {
      threads[i] = new MemLeakTest();
      threads[i].start();
    }
  }

  @Override
  public void run() {  // Each thread maintains its own "history" list
    List<HistoryRecord> history = new ArrayList<>();
    for (int count = 0; ; count++) {
      history.add(new HistoryRecord(count));
      if (count % 10000 == 0) System.out.println("Thread " + this + ": count = " + count);
    }
  }

  static class HistoryRecord {
    int id;
    String eventName;

    HistoryRecord(int id) {
      this.id = id;
      this.eventName = "Foo xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + id;
    }
  }
}

This application would inevitably crash with an OutOfMemoryError. To achieve this quickly and generate a heap dump of a reasonable size, compile this code and run it as follows:

> java -Xms1g -Xmx1g -verbose:gc -XX:+UseConcMarkSweepGC -XX:+UseParNewGC \
 -XX:OnOutOfMemoryError="kill -9 %p" -XX:+HeapDumpOnOutOfMemoryError \
 -XX:HeapDumpPath="./memleaktest.hprof" MemLeakTest

Several of the above command line flags deserve some explanation:

  1. It is suggested to use the Concurrent Mark-Sweep GC, at least if you use JDK 8 or older since the default (parallel) garbage collector in these JDK versions is really suboptimal in many aspects. In this particular case, it will cause the app to struggle for a really long time until it finally runs out of memory.

  2. The -XX:OnOutOfMemoryError="kill -9 %p"  flag tells the JVM to kill itself as soon as any thread throws an OutOfMemoryError. This is a very important flag that every multi-threaded app should use in production, unless it’s specially designed to handle OutOfMemoryError  and continue to run after that (which is hard to implement correctly). 

  3. The -XX:HeapDumpPath can specify either a heap dump file name or a directory where to write the dump. In the former case, if a file with the same name already exists, it will be overwritten. In the latter, each dump will have a name that follows the java_pid<pid>.hprof  pattern.

When you start the above program, it will run for 30 seconds or so, logging more and more GC pauses until one of its threads throws an  OutOfMemoryError. At that point, the JVM will generate a heap dump and stop.

To analyze the dump with JXRay, download the jxray.zip file from www.jxray.com, unzip it, and run

 > jxray.sh memleaks.hprof memleaks.html

In 10-15 seconds, JXRay will generate the HTML file. Open it, and on the top, you will immediately see the list of the most important problems that the tool has detected:

As you can see, in addition to the memory leak, the tool found several other problems that cause a large memory waste. In our benchmark, these issues — duplicate strings, empty (all zero) byte arrays, and array duplication — are a side effect of the simplistic code. However, in real life, many unoptimized programs demonstrate exactly the same problems, sometimes causing similarly large memory waste!

Now, let’s look at the main problem that we want to illustrate here — the memory leak. For that, we jump to section 4 of the report and click to expand it:

In this table, each expandable line contains a GC root with the amount of memory retained by it. The top GC root is our STATIC_DATA array, but as you can see, JXRay doesn’t think that this is a source of a memory leak. However, the second GC root, which is an aggregation of all of the identical  ArrayList s referenced by our worker threads, is marked as a leak candidate. If you click on this line, you will immediately see a reference chain (or, more precisely, multiple identical reference chains lumped together) leading to the problematic objects:

That’s it. We are done! We can clearly see the data structures that manage the leaking objects. Note that each individual ArrayList only retains about 2 percent of memory, so if we looked at these lists separately, it would be hard to realize that they are leaking memory. However, JXRay treats these objects as identical, because they come from threads with identical stack traces and have identical reference chains. Once these objects and the memory that they retain are aggregated, it’s much easier to see what’s really going on.

How does JXRay decide which objects (usually collections or arrays) are the potential memory leak sources? In a nutshell, it first scans the heap dump starting from the GC roots and generates an object tree internally, aggregating identical reference chains whenever possible. Then, it checks this tree for objects that reference other objects so that (a) the number of referenced objects per each collection/array is high enough and (b) together, the referenced objects retain a sufficiently high amount of memory. It turns out that in practice, surprisingly, not many objects fit these criteria. Thus, the tool finds a real leak if it’s serious enough and doesn’t bother you with many false positives.

Of course, this method has its limitations. It cannot tell you that the given data structure is really a leak, i.e. it keeps growing over time. However, even if that’s not the case, the above memory pattern is worth looking for when simpler things have already been optimized. In some situations, you may find that so many objects are not worth keeping in memory, or more compact data structures can be used, etc.

In summary, Java applications may have a problem with the unbounded growth of some data structures, and that’s what is called a «Java memory leak.» There are multiple ways to detect leaks. One way that incurs minimum overhead and results in not many false positives is heap dump analysis. There are tools that analyze JVM heap dumps for memory leaks, and JXRay is the one that requires a minimum effort from the user.