The demo-java-leak module is a self-contained demo web application with a steadily
growing memory leak. It is the Java counterpart of the prof-dotnet-leak demo service:
a continuously running, always-leaking workload that gives the Datadog Profiling UI a
steady, easy-to-find source of Java live-heap and reference-chain profile data in staging
and production. The deployed service (prof-java-leak, owned by profiling-backend) runs
this app under the released Datadog Java tracer.
The module has zero third-party dependencies and no dependency on the profiler itself:
no :ddprof-lib project or com.datadoghq.profiler dependency, no JavaProfiler API
calls, no -agentpath. The app is plain JDK Java (java.base, jdk.httpserver,
java.management) and is oblivious to profiling. In the deployed service, profiling
comes solely from dd-trace-java attached with -javaagent. The module is not published
to any Maven repository; it exists only as a deployable demo artifact.
The app is built from a small set of plain-JDK components:
| Component | Role |
|---|---|
com.datadoghq.demo.leak.Main |
Entry point: starts the HTTP server on an ephemeral port and runs the self-traffic loop; never exits on its own |
HttpLeakHandler |
Serves each request by creating a request-scoped state object and handing it to the cache; always returns a small fixed response |
RequestCache |
The leak: an unbounded cache that strongly retains every recorded request state two ways - the entries map and its own removal-listener registry whose closures capture the states; retention is removed only by flush() |
RequestState |
Request-scoped object: a key string plus long fields sized to clear the allocation sampler's floor |
HeapWatermark |
Samples post-GC heap usage (via MemoryPoolMXBean, falling back to Runtime.maxMemory() when the max is undefined) and reports when occupancy crosses the flush threshold |
Every handled HTTP request creates one request-scoped state object. The cache retains each object two ways: the direct strong reference in the entries map, and a closure in the cache-owned removal-listener registry that captures the instance — a Java analog of a cache misconfiguration where per-entry callbacks outlive the request and pin the entries they observe. There is no eviction and no size cap: the retained population only grows, so the per-klass population trend the reference-chain tracker relies on keeps rising across GC epochs.
The app generates its own request traffic over the real HTTP handler path: Main runs
a loop that issues requests to its own server using the JDK HTTP client. No external
client or load generator is needed. Each round forces a System.gc() so GC epochs
advance on a predictable schedule even though a quiet 512 MiB heap may not collect on
its own often enough.
The JVM runs with -Xmx512m. An unbounded cache under a bounded heap would eventually
OOM, and a throttled-growth plateau would flatten the population trend the leak
detectors need. Instead the app paces growth and flushes on a high watermark:
- The cache grows at a rate sized so one growth phase fills most of the heap over a long window (far more than the ~10 GC epochs the leak-trend gates need to qualify).
- Each round,
HeapWatermarkchecks post-GC occupancy. Below the high watermark (75% of max heap), growth continues. - When occupancy crosses the watermark,
RequestCache.flush()drops the entire cache in one step (logging oneflushed N retained entriesline), and growth resumes from an empty cache.
The result is a sawtooth occupancy: long monotonic growth phases — each long enough for
the reference-chain trend gates to qualify and emit chains — followed by a single-step
drop and regrowth, with no OutOfMemoryError and no container restart.
The module compiles with --release 21 (configured in demo-java-leak/build.gradle.kts),
not the repo-wide --release 8: it builds under the repo's JDK 21 Gradle and runs on the
JDK 27 container image. Build with the repo wrapper:
./.claude/commands/build-and-summarize :demo-java-leak:buildThe Gradle application plugin writes Main-Class: com.datadoghq.demo.leak.Main into the
jar manifest, so the jar is directly runnable (java -jar). The module's test JVM runs
with the same -Xmx512m heap as the deployed app, so tests observe the same watermark
behavior as production.
The prof-java-leak service image (in the profiling-backend repository) does not use
Gradle. Its Dockerfile clones the public java-profiler repository at a pinned,
immutable commit, compiles the module's sources with plain
javac --release 21, and packages them with jar --main-class. The image records the
source commit (APP_REPOSITORY_URL / APP_COMMIT_SHA → DD_GIT_REPOSITORY_URL /
DD_GIT_COMMIT_SHA). Because the module has no third-party dependencies, the two build
paths (this Gradle module and the image's javac) compile the same plain-JDK sources and
cannot drift apart in dependencies. At runtime the image starts the app with
-javaagent:dd-java-agent and -Xmx512m, with heap, live-heap and reference-chain
profiling enabled through the tracer's DD_PROFILING_* configuration.