Attribution: This article was based on content by @bluestreak on hackernews.
Original: https://questdb.com/blog/jvm-current-thread-user-time/

Introduction

A tiny change can have outsized impact when it removes work from a hot path. QuestDB recently documented exactly that: a 40-line fix eliminated a huge performance gap in a JVM-based workload (QuestDB, 2024), and the thread sparked conversation on Hacker News (bluestreak, 2024). This tutorial walks you through the reasoning, tools, and a small practical refactor pattern so you can reproduce similar wins safely in your codebase.

Key Takeaways

  • Small changes that avoid expensive operations on a hot path can yield dramatic speedups when they remove system calls, allocations, or synchronization.
  • Use sampling profilers (flame graphs) and rigorous benchmarking to find true bottlenecks and avoid measurement artifacts (Gregg, 2013; Georges et al., 2007).
  • Implement lightweight caching or sampling strategies to replace costly per-call instrumentation—then validate across workloads and concurrency levels.
  • Always account for JVM warm-up, GC, CPU frequency scaling, and reproducibility when measuring performance.

Background: a “hot path” is the code that the program executes most often or that consumes most CPU time.

Credit: This tutorial is inspired by a QuestDB investigation and community discussion (QuestDB, 2024; bluestreak, 2024).

Prerequisites (time: 15–30 minutes)

You will need:

  • Basic Java and Maven/Gradle knowledge.
  • JDK 11+ installed.
  • Familiarity with the JVM concepts JIT (Just-In-Time compilation), JNI (Java Native Interface), and GC (Garbage Collection).
  • A sampling profiler (async-profiler or Java Flight Recorder) or perf on Linux.
  • git and a terminal.

Acronyms explained on first use: JIT = Just-In-Time (compiler), JVM = Java Virtual Machine, JNI = Java Native Interface, GC = Garbage Collection.

Setup / Installation (time: 10–20 minutes)

  1. Clone a small example repository or create a new Maven/Gradle project.
  2. Add a simple benchmark harness (see code example 1).
  3. Install async-profiler (optional) or enable Flight Recorder.

Expected output: a small app that runs a tight loop invoking a method that reads thread CPU time. You will be able to run the baseline and collect profiles.

Step-by-step Instructions (total time: 60–120 minutes)

  1. Reproduce the baseline (time: 10–20 minutes)
  • Create a minimal workload that calls a JVM API in a hot loop. This simulates the situation where a per-iteration call becomes the bottleneck.

Code example 1 — baseline.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// baseline.java - heavy loop calling ThreadMXBean.getCurrentThreadUserTime()
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class Baseline {
    public static void main(String[] args) {
        ThreadMXBean tm = ManagementFactory.getThreadMXBean();
        long iterations = 10_000_000L;
        long sum = 0;
        for (long i = 0; i < iterations; i++) {
            // expensive call on hot path (native/OS work)
            long t = tm.getCurrentThreadUserTime(); // potential hotspot
            sum += (t & 1); // keep value used to avoid optimization away
        }
        System.out.println("done sum=" + sum);
    }
}

Expected output: program completes; runtime might be dominated by the getCurrentThreadUserTime() calls.

  1. Profile to find the hot path (time: 20–30 minutes)
  • Run the program under a sampling profiler to generate a flame graph. Look for the method that dominates CPU time.

Instruction:

  • Use async-profiler: ./profiler.sh -e cpu -d 30 -o flamegraph <pid> (Gregg, 2013). Expected result: the profiler points to getCurrentThreadUserTime() or wrapper methods as a major consumer of CPU.
  1. Reason about why it is slow (time: 10–20 minutes)
  • Calls like getCurrentThreadUserTime() can invoke JNI into libc and OS syscalls such as clock_gettime, getrusage(RUSAGE_THREAD), or per-thread kernel harvesting depending on JVM and OS. System calls are expensive when invoked millions of times.
  • The fix approach: avoid calling the expensive API on every iteration. Replace it with a cheaper timestamp or sample/accumulate periodically.

Background: CPU-bound means the program is limited by processor cycles, not I/O or memory.

  1. Implement a small sampling/cache fix (time: 15–30 minutes)
  • Replace per-iteration expensive call with a lightweight sampler that updates intermittently or on a background thread. This is often a 20–60 line change.

Code example 2 — sampling wrapper

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ThreadCpuSampler.java - lightweight sampling of thread CPU time
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.atomic.AtomicLong;

public class ThreadCpuSampler {
    private static final ThreadMXBean tm = ManagementFactory.getThreadMXBean();
    private static final ThreadLocal<Long> lastSample = ThreadLocal.withInitial(() -> 0L);
    private static final int SAMPLE_RATE = 128; // sample every N calls

    public static long cheapCurrentThreadUserTime(int callIndex) {
        // only call the JVM API every SAMPLE_RATE calls
        if ((callIndex & (SAMPLE_RATE - 1)) == 0) {
            lastSample.set(tm.getCurrentThreadUserTime()); // occasional expensive call
        }
        return lastSample.get();
    }
}

Inline comment: the SAMPLE_RATE trades freshness for cost. You can tune it.

Code example 3 — harness using sampler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// use_sampler.java - use sampler in hot loop
public class UseSampler {
    public static void main(String[] args) {
        long iterations = 10_000_000L;
        long sum = 0;
        for (int i = 0; i < iterations; i++) {
            long t = ThreadCpuSampler.cheapCurrentThreadUserTime(i); // cheap on hot path
            sum += (t & 1);
        }
        System.out.println("done sum=" + sum);
    }
}

Expected output: same correctness result, much lower runtime if the expensive call was the bottleneck.

  1. Measure and validate (time: 20–30 minutes)
  • Apply rigorous benchmarking: run several iterations, include warm-up, pin CPU governor to performance, disable background noise.
  • Use statistical methods or toolkits (e.g., JMH for Java) (Georges et al., 2007).

Example measurement pattern:

  • Warm up the JVM with multiple short runs.
  • Measure 10 independent runs and report median and confidence intervals. Expected result: large reduction in CPU time spent in the previously hot API. In the QuestDB case study, replacing frequent JVM thread-time calls with a cheaper approach yielded an order-of-magnitude improvement, up to ~400× for that specific metric (QuestDB, 2024).
  1. Hardening and defensive checks (time: 15–30 minutes)
  • Ensure correctness across distributions, concurrency, and long runs. Add tests that compare sampled value against the exact API every so often.
  • If you care about absolute accuracy, annotate trade-offs in code and documentation.

Code example 4 — periodic correctness check

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// sanity_check.java - occasionally validate sampled against exact value
public class SanityCheck {
    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            long cheap = ThreadCpuSampler.cheapCurrentThreadUserTime(i);
            if ((i & 1023) == 0) {
                // every 1024 calls, spot-check for divergence
                long exact = ManagementFactory.getThreadMXBean().getCurrentThreadUserTime();
                if (Math.abs(exact - cheap) > 10_000_000L) {
                    System.err.println("Divergence observed: cheap=" + cheap + " exact=" + exact);
                }
            }
        }
    }
}

Expected output: no unexpected divergence for reasonable SAMPLE_RATE or a logged divergence if sample rate is too coarse.

Common Issues & Solutions (time: ongoing)

  • Warm-up effects: JIT can make hotspots appear or disappear during warm-up. Solution: include warm-up runs; prefer tools like JMH (Georges et al., 2007).
  • Measurement noise: other processes, CPU frequency scaling, or thermal throttling distort results. Solution: isolate CPU, set governor to performance, run multiple iterations.
  • Incorrect caching: caching stale values can break correctness. Solution: choose sampling frequency based on acceptable error and add sanity checks.
  • GC pauses: frequent allocations in profiling/instrumentation can change performance. Solution: minimize allocations in hot paths and run with suitable heap settings.
  • Overfitting to a microbenchmark: an optimization that wins on a synthetic test may regress in real workloads. Solution: validate on production-like traces and end-to-end benchmarks.

Next Steps / Further Learning (time: varies)

  • Integrate the fix into your codebase, add unit and performance tests, and deploy to a canary environment.
  • Learn more about profiling and flame graphs from Gregg (2013).
  • Read Hennessy and Patterson (2017) for deeper understanding of CPU caches and memory hierarchy effects.
  • Use JMH for rigorous Java microbenchmarks and to avoid common benchmarking pitfalls (Georges et al., 2007).

Recommended readings and citations

  • Gregg (2013) — flame graphs and sampling profiling techniques.
  • Georges, Buytaert, and Eeckhout (2007) — statistically rigorous JVM benchmarking methods.
  • Hennessy and Patterson (2017) — computer architecture (memory hierarchy implications).

Background: flame graphs are visualization of sampled stack traces showing hot call paths, popularized by Brendan Gregg.

Troubleshooting / Common Pitfalls (recap)

  • If you see little or no improvement: profile again — maybe the bottleneck was elsewhere (memory, I/O, or synchronization).
  • If correctness fails under load: reduce sampling interval or add stronger validation checks.
  • If improvement disappears under concurrency: consider thread-safety of your sampler, or a per-thread ThreadLocal sampler.

Final note: small textual changes matter when they eliminate tens of millions of expensive operations, but measurement and validation are the guardrails that separate anecdote from robust optimization. The QuestDB example (QuestDB, 2024) is a reminder: always profile before guessing, and validate across realistic workloads.

References

  • QuestDB (2024). “JVM current thread user time” blog post. https://questdb.com/blog/jvm-current-thread-user-time/ (inspired example).
  • bluestreak (2024). Hacker News discussion highlighting the 40-line fix.
  • Gregg, B. (2013). Flame graphs and performance analysis.
  • Georges, A., Buytaert, D., & Eeckhout, L. (2007). “Statistically Rigorous Java Performance Evaluation”.
  • Hennessy, J., & Patterson, D. (2017). Computer Architecture: A Quantitative Approach.

References