Статьи

Сборка мусора: увеличение пропускной способности

Вдохновение для этого поста пришло после того, как я наткнулся на определение « Свинья в Питоне » в  глоссарии управления памятью По-видимому, этот термин используется для объяснения ситуации, когда GC неоднократно продвигает крупные объекты из поколения в поколение. Эффект от этого, по-видимому, аналогичен эффекту питона, который проглатывает свою добычу целиком только для того, чтобы обездвижиться во время пищеварения.

В течение следующих 24 часов я просто не мог получить картину удушья питонов из моей головы. Как говорят психиатры, лучший способ избавиться от своих страхов — это рассказать о них. Итак, поехали. Но вместо питонов остальная часть истории будет о настройке сборки мусора. Я обещаю.

Остановки сбора мусора хорошо известны тем, что они могут стать узким местом в производительности. Современные JVM поставляются с современными сборщиками мусора, но, как я понял, найти оптимальную конфигурацию для конкретного приложения по-прежнему сложно. Чтобы даже иметь шанс вручную подойти к проблеме, нужно было бы понять точную механику алгоритмов сбора мусора. Этот пост может помочь вам в этом, поскольку я собираюсь использовать пример, чтобы продемонстрировать, как небольшие изменения в конфигурации JVM могут повлиять на пропускную способность вашего приложения.

пример

Приложение, которое мы используем для демонстрации влияния ГХ на пропускную способность, является простым. Он состоит всего из двух потоков:

  • PigEater — имитирует ситуацию, когда питон продолжает есть одну свинью за другой. Код достигает этого путем добавления 32 МБ байтов в  java.util.List  и ожидания 100 мс после каждой попытки.
  • PigDigester — имитирует процесс асинхронного переваривания. Код реализует пищеварение, просто обнуляя этот список свиней. Поскольку это довольно утомительный процесс, после каждой эталонной очистки эта нить спит в течение 2000 мс.

Оба потока будут работать в цикле while, продолжая есть и переваривать, пока змея не наполнится. Это происходит примерно при 5000 съеденных свиней.

package eu.plumbr.demo;

public class PigInThePython {
  static volatile List pigs = new ArrayList();
  static volatile int pigsEaten = 0;
  static final int ENOUGH_PIGS = 5000;

  public static void main(String[] args) throws InterruptedException {
    new PigEater().start();
    new PigDigester().start();
  }

  static class PigEater extends Thread {

    @Override
    public void run() {
      while (true) {
        pigs.add(new byte[32 * 1024 * 1024]); //32MB per pig
        if (pigsEaten > ENOUGH_PIGS) return;
        takeANap(100);
      }
    }
  }

  static class PigDigester extends Thread {
    @Override
    public void run() {
      long start = System.currentTimeMillis();

      while (true) {
        takeANap(2000);
        pigsEaten+=pigs.size();
        pigs = new ArrayList();
        if (pigsEaten > ENOUGH_PIGS)  {
          System.out.format("Digested %d pigs in %d ms.%n",pigsEaten, System.currentTimeMillis()-start);
          return;
        }
      }
    }
  }

  static void takeANap(int ms) {
    try {
      Thread.sleep(ms);
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

Now lets define the throughput of this system as the “number of pigs digested per second”. Taking into account that the pigs are stuffed into the python after each 100ms, we see that theoretical maximal throughput this system can thus reach up to 10 pigs / second.

Configuring the GC example

Lets see how the system behaves using two different configuration. In all situations, the application was run using a dual-core Mac (OS X 10.9.3) with 8G of physical memory.

First configuration:

  • 4G of heap (-Xms4g –Xmx4g)
  • Using CMS to clean old (-XX:+UseConcMarkSweepGC) and Parallel to clean young generation -XX:+UseParNewGC)
  • Has allocated 12,5% of the heap (-Xmn512m) to young generation, further restricting the sizes of Eden and Survivor spaces to equally sized.

Second configuration is a bit different:

  • 2G of heap (-Xms2g –Xmx2g)
  • Using Parallel GC to conduct garbage collection both in young and tenured generations(-XX:+UseParallelGC)
  • Has allocated 75% of the heap to young generation (-Xmn1536m)

Now it is time to make bets, which of the configurations performed better in terms of throughput (pigs eaten per second, remember?).  Those of you laying your money on the first configuration, I must disappoint you. The results are exactly reversed:

  • First configuration (large heap, large old space, CMS GC) is capable of eating 8.2 pigs/second
  • Second configuration (2x smaller heap, large young space, Parallel GC) is capable of eating 9.2 pigs/second

Now, let me put the results in perspective. Allocating 2x less resources (memory-wise) weachieved 12% better throughput. This is something so contrary to common knowledge that it might require some further clarification on what was actually happening.

Interpreting the GC results

The reason in what you face is not too complex and the answer is staring right at you when you take a more closer look to what GC is doing during the test run. For this, you can use the tool of your choice, I peeked under the hood with the help of jstat, similar to the following:

jstat -gc -t -h20 PID 1s

Looking at the data, I noticed that the first configuration went through 1,129 garbage collection cycles (YGCT+FGCT) which in total took 63.723 seconds:

Timestamp        S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
594.0 174720.0 174720.0 163844.1  0.0   174848.0 131074.1 3670016.0  2621693.5  21248.0 2580.9   1006   63.182  116 	0.236   63.419
595.0 174720.0 174720.0 163842.1  0.0   174848.0 65538.0  3670016.0  3047677.9  21248.0 2580.9   1008   63.310  117 	0.236   63.546
596.1 174720.0 174720.0 98308.0 163842.1 174848.0 163844.2 3670016.0   491772.9  21248.0 2580.9   1010   63.354  118 	0.240   63.595
597.0 174720.0 174720.0  0.0   163840.1 174848.0 131074.1 3670016.0   688380.1  21248.0 2580.9   1011   63.482  118 	0.240   63.723

The second configuration paused in total of 168 times (YGCT+FGCT) for just 11.409 seconds.

Timestamp        S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
539.3 164352.0 164352.0  0.0    0.0   1211904.0 98306.0   524288.0   164352.2  21504.0 2579.2 	27    2.969  141 	8.441   11.409
540.3 164352.0 164352.0  0.0    0.0   1211904.0 425986.2  524288.0   164352.2  21504.0 2579.2 	27    2.969  141 	8.441   11.409
541.4 164352.0 164352.0  0.0    0.0   1211904.0 720900.4  524288.0   164352.2  21504.0 2579.2 	27    2.969  141 	8.441   11.409
542.3 164352.0 164352.0  0.0	0.0   1211904.0 1015812.6  524288.0   164352.2  21504.0 2579.2 	27	2.969  141 	8.441   11.409

Considering that the work needed to carry out in both cases was equivalent in regard that – with no long-living objects in sight the duty of the GC in this pig-eating exercise is just to get rid of everything as fast as possible. And using the first configuration, the GC is just forced to run ~6.7x more often resulting in ~5,6x longer total pause times.

So the story fulfilled two purposes. First and foremost, I hope I got the picture of a choking python out of my head. Another and more significant take-away from this is – that tuning GC is a tricky exercise at best, requiring deep understanding of several underlying concepts. Even with the truly trivial application used in this blog post, the results you will be facing can have significant impact on your throughput and capacity planning. In real-world applications the differences are even more staggering. So the choice is yours, you can either master the concepts, or, focus on your day-to-day work and let Plumbr to find out suitable GC configuration according to your needs.

Anyhow, hopefully I managed to simplify some complex concepts via the example. If you wish to stay tuned on future posts, subscribe to our Twitter feed and get our content about Java performance tuning.