Prioritize Optimization Efforts Using Profiling
Every Java developer has been told “profile before optimizing.” The hard part isn’t hearing the advice — it’s knowing what to look for once you have a profile. This post walks through one workload that demonstrates why focusing on frequently-executed code paths, not individually-expensive ones, delivers the real performance gains.
The Code
The program simulates a service request pipeline with two stages per request:
- Hot path (
computeScore): called every request iteration — a CPU-heavy numeric calculation. In this example that means 1 million invocations. - Cold path (
serializeOriginal/serializeOptimized): called once every 500 requests to produce output (simulating JSON serialization). Each individual call is expensive, but the total number of calls across one run is only 2,000.
The code measures each stage in isolation for three scenarios:
| Scenario | Hot path | Cold path |
|---|---|---|
| Baseline | original (100 iterations) | original (50 fields x 128 chars) |
| Strategy A | optimized (~8 iterations) | original |
| Strategy B | original | optimized (3 fields x 8 chars) |
Here is the full source:
class OptimizationComparison {
static final int WARMUP = 10_000;
static final int MEASURE = 1_000_000;
static final int COLD_INTERVAL = 500;
// Hot path: ORIGINAL (100 iterations).
static double computeScoreOriginal(double x) {
double acc = 0;
for (int i = 0; i < 100; i++) {
acc += Math.sin(x * i + 7.0) / (i + 1);
}
return acc;
}
// Hot path: OPTIMIZED (~8 iterations).
static double computeScoreOptimized(double x) {
double acc = 0;
for (int i = 0; i < 8; i++) {
acc += Math.sin(x * i + 7.0) / (i + 1);
}
return acc;
}
// Cold path: ORIGINAL.
static String serializeOriginal(int id, double score) {
StringBuilder sb = new StringBuilder(8192);
sb.append("{\"requestId\":").append(id);
for (int i = 0; i < 50; i++) {
sb.append(",\"field\").append(i).append("":");
for (int j = 0; j < 128; j++) {
sb.append((char) ('A' + (int) (score * j) % 26));
}
}
sink += id;
return sb.toString();
}
// Cold path: OPTIMIZED.
static String serializeOptimized(int id, double score) {
StringBuilder sb = new StringBuilder(512);
sb.append("{\"requestId\":").append(id);
for (int i = 0; i < 3; i++) {
sb.append(",\"field\").append(i).append("":");
for (int j = 0; j < 8; j++) {
sb.append((char) ('A' + (int) (score * j) % 26));
}
}
sink += id;
return sb.toString();
}
static volatile long sink;
static double[] measure(String name,
java.util.function.DoubleFunction<Double> hotFn,
java.util.function.BiFunction<Integer, Double, String> coldFn) {
System.out.println("--- " + name + " ---");
for (int i = 0; i < WARMUP; i++) { hotFn.apply(1.0); }
sink = 0;
long t0 = System.nanoTime();
double dummy = 0;
for (int i = 0; i < MEASURE; i++) { dummy += hotFn.apply(i); }
sink += (long) dummy;
long hotMs = (System.nanoTime() - t0) / 1_000_000L;
long t1 = System.nanoTime();
for (int b = 0; b < MEASURE / COLD_INTERVAL; b++) {
coldFn.apply(b, dummy);
}
sink += (long)(MEASURE / COLD_INTERVAL);
long coldMs = (System.nanoTime() - t1) / 1_000_000L;
System.out.printf(" hot: %6d ms cold: %4d ms total: %6d ms%n", hotMs, coldMs, hotMs + coldMs);
return new double[]{hotMs, coldMs};
}
public static void main(String[] args) {
System.out.println("=== Prioritize Optimization via Profiling ===\n");
System.out.println("Hot path: computeScore() (called " + MEASURE + " times -- EVERY iteration)");
System.out.println("Cold path: serialization (called once per " + COLD_INTERVAL
+ " requests -- only " + (MEASURE / COLD_INTERVAL) + " total)\n");
double[] baseline = measure("Baseline",
OptimizationComparison::computeScoreOriginal,
OptimizationComparison::serializeOriginal);
System.out.println();
double hotBase = baseline[0], coldBase = baseline[1];
double grandBase = hotBase + coldBase;
System.out.printf(" Hot dominates: %.1f%%%n", hotBase / grandBase * 100);
System.out.println();
double[] stratA = measure("Strategy A -- optimize HOT path (fast algorithm)",
OptimizationComparison::computeScoreOptimized,
OptimizationComparison::serializeOriginal);
System.out.println();
double[] stratB = measure("Strategy B -- optimize COLD path (trim output)",
OptimizationComparison::computeScoreOriginal,
OptimizationComparison::serializeOptimized);
System.out.println();
double grandA = stratA[0] + stratA[1];
double grandB = stratB[0] + stratB[1];
double hotReduction = (hotBase - stratA[0]) / hotBase * 100;
double coldReduction = (coldBase - stratB[1]) / coldBase * 100;
double improvementA = (grandBase - grandA) / grandBase * 100;
double improvementB = (grandBase - grandB) / grandBase * 100;
System.out.println("=== Results ===");
System.out.printf("Baseline total: %6.0f ms%n", grandBase);
System.out.printf("Strategy A total: %6.0f ms (%5.1f%% improvement)%n", grandA, improvementA);
System.out.printf("Strategy B total: %6.0f ms (%5.1f%% improvement)%n", grandB, improvementB);
System.out.println();
System.out.printf("Hot path reduced by: %5.1f%%%n", hotReduction);
System.out.printf("Cold path reduced by:%5.1f%%%n", coldReduction);
System.out.println();
if (improvementA > improvementB * 0.5) {
System.out.println("Profiling correctly pointed at the hot path as the priority.");
} else {
System.out.println("Results vary per JVM/OS -- always check profiling data for your workload.");
}
if (sink == 0) throw new RuntimeException("sink is " + sink);
}
}
Note the warmup phase: before each measurement, 10,000 iterations of the hot-path method run so that the JIT has compiled and tuned the methods to steady-state. Without this, you’d be measuring interpretation overhead rather than real cost.
Running it
The baseline row is what profiling tells you: the hot path accounts for 98.7% of total execution time (3,624 ms out of 3,670 ms). The cold path — each call individually takes ~23 ms — contributes only 46 ms across the entire run because it fires just 2,000 times.
Strategy A optimizes the hot path (reducing its inner loop from 100 iterations to 8, simulating the discovery of a faster algorithm). Result: 95.6% total improvement — the workload drops from 3,670 ms down to 162 ms.
Strategy B optimizes the cold path (trimming output payload from 50 fields to 3, each with only 8 characters). Even though the cold path was reduced by 93.5%, the overall improvement is -0.5% — essentially no change at all. The hot path variance alone (the baseline hot time and Strategy B’s hot time differ by ~63 ms despite using identical code) dwarfs whatever gain came from the cold-path optimization.
The takeaway isn’t that cold-path work doesn’t matter — it does, when it matters. But you decide which is “which” by looking at total cost (per-call cost x call frequency), not individual call expense. A profiler giving you per-method breakdowns lets you make that calculation instead of guessing.
Diagnosing before tuning
tells you where to look, but it doesn’t answer why. When a hot path suddenly gets slower (or memory usage spikes), the fastest first step is often checking recent code changes, not digging into JVM flags or configuration. A single method call added in an inner loop, a new String.format instead of StringBuilder, or even a library upgrade that changed a return type can introduce regressions that look like platform problems until you trace them back to a diff.
JVM bugs and configuration issues are real, but they typically manifest as system-wide symptoms — GC pauses across all threads, unexpected compiler behavior, or performance that changes with JVM version rather than code version. If profiling pinpoints a single method or path that jumped in cost, the regression almost certainly lives in recent commits or dependency updates, not in the runtime environment.
Takeaway
Profile first: the code path with the highest total cost (not highest per-call cost) is where optimization effort pays off. Optimize there, and you’ll see the difference in your numbers — optimizing the “wrong” expensive method won’t move the needle at all.