- Java 100%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| gradle/wrapper | ||
| src/main/java/fr/ekaii/dmagent | ||
| .gitignore | ||
| build.gradle | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle | ||
jvm-direct-memory-agent
Tiny Java agent that polls the JVM direct buffer pool every N seconds and
appends a single TSV line per sample to a file. Designed for live monitoring
of Netty / JDK direct-memory pressure on Minecraft (Paper/Folia) servers so
an external alerting pipeline can fire before -XX:MaxDirectMemorySize
is exhausted and players get kicked.
Why it exists (incident 2026-05-05)
ExtendedHorizons leaked DirectByteBuffer allocations until the configured
-XX:MaxDirectMemorySize=6G was saturated. The JVM has no built-in alert for
direct-memory pressure (it's not part of the regular GC heap, so heap-usage
gauges miss it entirely). When it ran out, players were kicked. This agent
gives us a continuously-sampled gauge — used / max — that we can scrape and
alert on long before saturation.
Usage
Add to your JVM start command:
-javaagent:/path/to/jvm-direct-memory-agent-1.0.0.jar=outFile=/var/log/dmagent.tsv,intervalSec=1
Both args are optional:
| key | default | meaning |
|---|---|---|
outFile |
/tmp/dmagent.tsv |
path to append samples to |
intervalSec |
1 |
sampling interval in seconds (min 1) |
The agent runs on a daemon thread, so it never holds the JVM alive on its own.
The output file is opened/closed per sample (append + flush), so an external
log rotator can rotate or tail -F it without coordination.
Output format
One TSV row per sample, no header:
ts_iso \t count \t used_bytes \t max_bytes \t ratio
ts_iso— UTC ISO-8601, e.g.2026-05-05T17:58:22.655Zcount— number of liveDirectByteBufferinstances (fromBufferPoolMXBean#getCount)used_bytes— direct memory currently allocated (BufferPoolMXBean#getMemoryUsed)max_bytes— effectiveMaxDirectMemorySize. Resolved by parsing-XX:MaxDirectMemorySize=...out ofRuntimeMXBean.getInputArguments(); if absent, falls back toRuntime.getRuntime().maxMemory()(the documented JVM default when the flag is unset). We deliberately avoidsun.misc.VM.maxDirectMemory()so the agent needs no--add-opens.ratio—used_bytes / max_bytes, formatted with 6 decimals.
Example:
2026-05-05T17:58:22.655206692Z 0 0 31675383808 0.000000
2026-05-05T17:58:23.668336892Z 2 50000056 31675383808 0.001579
2026-05-05T17:58:26.670200220Z 3 150000063 31675383808 0.004736
Build
Requires JDK 21.
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew jar
Output: build/libs/jvm-direct-memory-agent-1.0.0.jar
The jar has no runtime dependencies (everything is in java.lang.management
and java.nio), so it works as a flat jar.
Smoke test
cat > /tmp/SmokeTest.java <<'EOF'
import java.nio.ByteBuffer;
public class SmokeTest {
public static void main(String[] a) throws Exception {
ByteBuffer b1 = ByteBuffer.allocateDirect(50_000_000);
Thread.sleep(3500);
ByteBuffer b2 = ByteBuffer.allocateDirect(100_000_000);
Thread.sleep(3500);
}
}
EOF
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
$JAVA_HOME/bin/javac /tmp/SmokeTest.java -d /tmp
$JAVA_HOME/bin/java \
-javaagent:build/libs/jvm-direct-memory-agent-1.0.0.jar=outFile=/tmp/dmagent-smoke.tsv,intervalSec=1 \
-cp /tmp SmokeTest
cat /tmp/dmagent-smoke.tsv
You should see ~7-8 rows, with used_bytes stepping 0 → ~50M → ~150M.