Now that we have covered the three dimensions of performance tuning, we can start investigating setting and hitting GC performance goals in practice.
For this purpose, let us take a look at an example code:
//imports skipped for brevity
public class Producer implements Runnable {
private static ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);
private Deque<byte[]> deque;
private int objectSize;
private int queueSize;
public Producer(int objectSize, int ttl) {
this.deque = new ArrayDeque<byte[]>();
this.objectSize = objectSize;
this.queueSize = ttl * 1000;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) { deque.add(new byte[objectSize]); if (deque.size() > queueSize) {
deque.poll();
}
}
}
public static void main(String[] args) throws InterruptedException {
executorService.scheduleAtFixedRate(new Producer(200 * 1024 * 1024 / 1000, 5), 0, 100, TimeUnit.MILLISECONDS);
executorService.scheduleAtFixedRate(new Producer(50 * 1024 * 1024 / 1000, 120), 0, 100, TimeUnit.MILLISECONDS);
TimeUnit.MINUTES.sleep(10);
executorService.shutdownNow();
}
}
The code submits two jobs to run every 100 ms. Each job emulates objects with a specific lifespan: it creates objects, lets them leave for a predetermined amount of time and then forgets about them, allowing GC to reclaim the memory.
When running the example with GC logging turned on with the following parameters:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
we immediately see the impact of GC in the log files, similarly to the following:
2015-06-04T13:34:16.119-0200: 1.723: [GC (Allocation Failure) [PSYoungGen: 114016K->73191K(234496K)] 421540K->421269K(745984K), 0.0858176 secs] [Times: user=0.04 sys=0.06, real=0.09 secs] 2015-06-04T13:34:16.738-0200: 2.342: [GC (Allocation Failure) [PSYoungGen: 234462K->93677K(254976K)] 582540K->593275K(766464K), 0.2357086 secs] [Times: user=0.11 sys=0.14, real=0.24 secs] 2015-06-04T13:34:16.974-0200: 2.578: [Full GC (Ergonomics) [PSYoungGen: 93677K->70109K(254976K)] [ParOldGen: 499597K->511230K(761856K)] 593275K->581339K(1016832K), [Metaspace: 2936K->2936K(1056768K)], 0.0713174 secs] [Times: user=0.21 sys=0.02, real=0.07 secs]
Based on the information in the log we can start improving the situation with three different goals in mind:
- Making sure the worst-case GC pause does not exceed a predetermined threshold
- Making sure the total time during which application threads are stopped does not exceed a predetermined threshold
- Reducing infrastructure costs while making sure we can still achieve reasonable latency and/or throughput targets
For this, the code above was run for 10 minutes on three different configurations providing three very different results summarized in the following table:
Heap | GC Algorithm | Useful work | Longest pause |
---|---|---|---|
-Xmx12g | -XX:+UseConcMarkSweepGC | 89.8% | 560 ms |
-Xmx12g | -XX:+UseParallelGC | 91.5% | 1,104 ms |
-Xmx8g | -XX:+UseConcMarkSweepGC | 66.3% | 1,610 ms |
The experiment ran the same code with different GC algorithms and different heap sizes to measure the duration of Garbage Collection pauses with regards to latency and throughput. Details of the experiments and an interpretation of the results are given in the following chapters.
Note that in order to keep the example as simple as possible only a limited amount of input parameters were changed, for example the experiments do not test on different number of cores or with a different heap layout.
Tuning for Latency
Let us assume we have a requirement stating that all jobs must be processed in under 1,000 ms. Knowing that the actual job processing takes just 100 ms we can simplify and deduct the latency requirement for individual GC pauses. Our requirement now states that no GC pause can stop the application threads for longer than 900 ms. Answering this question is easy, one would just need to parse the GC log files and find the maximum pause time for an individual GC pause.
Looking again at the three configuration options used in the test:
Heap | GC Algorithm | Useful work | Longest pause |
---|---|---|---|
-Xmx12g | -XX:+UseConcMarkSweepGC | 89.8% | 560 ms |
-Xmx12g | -XX:+UseParallelGC | 91.5% | 1,104 ms |
-Xmx8g | -XX:+UseConcMarkSweepGC | 66.3% | 1,610 ms |
we can see that there is one configuration that already matches this requirement. Running the code with:
java -Xmx12g -XX:+UseConcMarkSweepGC Producer
results in a maximum GC pause of 560 ms, which nicely passes the 900 ms threshold set for satisfying the latency requirement. If neither the throughput nor the capacity requirements are violated, we can conclude that we have fulfilled our GC tuning task and can finish the tuning exercise.
Tuning for Throughput
Let us assume that we have a throughput goal to process 13,000,000 jobs/hour. The example configurations used again give us a configuration where the requirement is fulfilled:
Heap | GC Algorithm | Useful work | Longest pause |
---|---|---|---|
-Xmx12g | -XX:+UseConcMarkSweepGC | 89.8% | 560 ms |
-Xmx12g | -XX:+UseParallelGC | 91.5% | 1,104 ms |
-Xmx8g | -XX:+UseConcMarkSweepGC | 66.3% | 1,610 ms |
Running this configuration as:
java -Xmx12g -XX:+UseParallelGC Producer
we can see that the CPUs are blocked by GC for 8.5% of the time, leaving 91.5% of the computing power for useful work. For simplicity’s sake we will ignore other safe points in the example. Now we have to take into account that:
- One job is processed in 100 ms by a single core
- Thus, in one minute, 60,000 jobs could be processed by one core
- In one hour, a single core could thus process 3.6 M jobs
- We have four cores available, which could thus process 4 x 3.6 M = 14.4 M jobs in an hour
With this amount of theoretical processing power we can make a simple calculation and conclude that during one hour we can in reality process 91.5% of the 14.4 M theoretical maximum resulting in 13,176,000 processed jobs/hour, fulfilling our requirement.
It is important to note that if we simultaneously needed to fulfill the latency requirements set in the previous section, we would be in trouble, as the worst-case latency for this case is close to two times of the previous configuration. This time the longest GC pause on record was blocking the application threads for 1,104 ms.
Tuning for Capacity
Let us assume we have to deploy our solution to the commodity-class hardware with up to four cores and 10 G RAM available. From this we can derive our capacity requirement that the maximum heap space for the application cannot exceed 8 GB. Having this requirement in place, we would need to turn to the third configuration on which the test was run:
Heap | GC Algorithm | Useful work | Longest pause |
---|---|---|---|
-Xmx12g | -XX:+UseConcMarkSweepGC | 89.8% | 560 ms |
-Xmx12g | -XX:+UseParallelGC | 91.5% | 1,104 ms |
-Xmx8g | -XX:+UseConcMarkSweepGC | 66.3% | 1,610 ms |
The application is able to run on this configuration as
java -Xmx8g -XX:+UseConcMarkSweepGC Producer
but both the latency and especially throughput numbers fall drastically:
- GC now blocks CPUs from doing useful work a lot more, as this configuration only leaves 66.3% of the CPUs for useful work. As a result, this configuration would drop the throughput from the best-case-scenario of 13,176,000 jobs/hour to a meager 9,547,200 jobs/hour
- Instead of 560 ms we are now facing 1,610 ms of added latency in the worst case
Walking through the three dimensions it is indeed obvious that you cannot just optimize for “performance” but instead need to think in three different dimensions, measuring and tuning both latency and throughput, and taking the capacity constraints into account.