Understanding Region Pinning in G1 GC: Enhancing Performance with JDK 22
Table of Contents
Hey π this is Kosma! Welcome to my personal blog where I share insights on JVM internals, performance optimization, and more.
π Introduction #
Recently, I dived into the latest enhancements in JDK 22 and stumbled upon a significant update for the G1 garbage collector: Region Pinning (JEP 423). As someone who frequently deals with JNI critical regions, this update caught my eye immediately. This feature promises to reduce latency by enabling garbage collection (GC) to continue even during Java Native Interface (JNI) critical regions.
In this post, Iβll explore what region pinning is, how it works, and its impact on performance. Iβll also include a practical example with C code and a benchmark comparison to show the real-world benefits of this change.
π What is Region Pinning? #
Region Pinning in G1 GC allows the JVM to pin regions of memory, preventing them from being moved during garbage collection. This is crucial for JNI critical regions, where native code holds references to Java objects. Previously, G1 would disable GC during these critical regions, leading to significant latency issues.
With region pinning:
- Java threads using native code won’t stall GC.
- GC can proceed normally, collecting garbage in unpinned regions.
π How Does It Work? #
G1 partitions the heap into fixed-size memory regions. During a collection, objects are moved from one region to another. Region pinning prevents the movement of regions containing critical objects, allowing GC to continue without delay.
Goals of Region Pinning #
- No stalling of threads due to JNI critical regions.
- No additional latency to start GC.
- Minimal regressions in GC pause times.
π Practical Example with Code #
Letβs look at a practical example. I created a scenario with JNI critical regions and benchmarked the performance with and without region pinning.
Java Code #
package org.kosmadunikowski.jnibenchmark;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
public class JNIBenchmark {
static {
System.loadLibrary("native");
}
private Thread[] jniThreads;
public native void processArray(int[] array);
@Setup(Level.Trial)
public void setUp() {
// Start threads doing jni work
int numThreads = Runtime.getRuntime().availableProcessors();
jniThreads = new Thread[numThreads];
for (int i = 0; i < jniThreads.length; i++) {
jniThreads[i] = new Thread(() -> {
int[] array = new int[1000000];
while (!Thread.currentThread().isInterrupted()) {
processArray(array);
}
});
jniThreads[i].setDaemon(true);
jniThreads[i].start();
}
}
@TearDown(Level.Trial)
public void tearDown() {
// Stop the jni threads
for (Thread thread : jniThreads) {
thread.interrupt();
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private void gcHeavyWorkload() {
int[] tempArray = new int[1000000];
for (int i = 0; i < tempArray.length; i++) {
tempArray[i] = i;
}
}
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public void benchmarkGcHeavyWorkload() {
gcHeavyWorkload();
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
C Code #
#include <jni.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
JNIEXPORT void JNICALL Java_org_kosmadunikowski_jnibenchmark_JNIBenchmark_processArray(JNIEnv *env, jobject obj, jintArray array) {
jint *arr = (*env)->GetPrimitiveArrayCritical(env, array, 0);
if (arr == NULL) {
return; // Array couldn't be pinned
}
// Simulate processing with sleep
usleep(5000); // Sleep for 5000 microseconds
// Simulate processing
for (int i = 0; i < 1000000; i++) {
arr[i] = arr[i] * 2;
}
(*env)->ReleasePrimitiveArrayCritical(env, array, arr, 0);
}
Why This Code? #
I wrote this code to demonstrate how region pinning impacts performance when JNI is heavily used. The Java code sets up multiple threads that continuously call a native method, simulating a scenario where JNI critical regions are frequently accessed. The C code processes the array, ensuring itβs pinned during the operation to prevent GC from moving it.
This setup allows me to benchmark the performance impact of region pinning versus traditional GC behavior. By comparing the execution times, we can see how JDK 22 improves performance.
Benchmark Results #
Here are the results from running the code on JDK 21 and JDK 22 with region pinning:
JDK Version | Mean Time (Β΅s) | 99.9% Confidence Interval (Β΅s) |
---|---|---|
JDK 21 | 4466.150 | Β± 325.280 |
JDK 22 | 3749.375 | Β± 189.150 |
As you can see, JDK 22 with region pinning shows a significant reduction in execution time, demonstrating the benefit of allowing GC to continue during JNI critical regions.
For comparison, here are the results of running a similar program without any critical regions:
JDK Version | Mean Time (Β΅s) | 99.9% Confidence Interval (Β΅s) |
---|---|---|
JDK 21 | 1218.418 | Β± 84.999 |
JDK 22 | 1239.446 | Β± 101.221 |
In this case, there’s no significant difference in performance between JDK 21 and JDK 22. This highlights that JDK 22 isn’t inherently faster than JDK 21 without using the new region pinning feature. The performance improvement is specifically due to the ability to continue GC during JNI critical regions.
π Conclusion #
Region Pinning in G1 GC is a game-changer for Java applications that heavily rely on JNI. By reducing latency and allowing GC to continue during critical regions, it enhances overall performance and application responsiveness.
If you work with JNI and experience latency issues, upgrading to JDK 22 and leveraging region pinning could bring significant improvements.
For more details, you can check out the release notes and the JEP 423.
Sincerely,
Kosma