Вдохновение для этого поста пришло после того, как я наткнулся на определение « Свинья в Питоне » в глоссарии управления памятью . По-видимому, этот термин используется для объяснения ситуации, когда 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.