← Back to DevBytes

Java Application Performance: Profiling and Optimization

Understanding Java Application Performance

Java application performance encompasses how efficiently a Java program utilizes system resources—CPU, memory, I/O, and network—to accomplish its tasks. Profiling and optimization are the two complementary disciplines that allow developers to systematically measure, analyze, and improve application behavior. Profiling reveals where time is spent and memory is consumed; optimization applies targeted changes to eliminate bottlenecks without introducing regressions.

Performance problems rarely announce themselves with a single obvious cause. Instead, they manifest as high latency, degraded throughput, excessive garbage collection pauses, or steadily climbing memory footprints. A disciplined profiling-first approach transforms guesswork into data-driven decision making, ensuring that every optimization effort delivers measurable value.

What Is Profiling?

Profiling is the process of collecting runtime metrics about an application's execution. A profiler instruments the JVM (or samples it at regular intervals) to record information such as:

Modern profilers fall into two broad categories: sampling profilers and instrumenting profilers. Sampling profilers periodically snapshot the call stack with minimal overhead, making them suitable for production environments. Instrumenting profilers modify bytecode to record every method entry and exit, providing exact invocation counts but with higher overhead, making them more appropriate for development and testing.

Why Profiling and Optimization Matter

Unoptimized Java applications cost organizations in multiple dimensions:

Beyond cost, performance optimization is a competitive differentiator. Fast applications retain users, process more data in less time, and enable leaner infrastructure. The discipline of profiling also deepens a developer's understanding of the JVM internals—garbage collection algorithms, JIT compilation thresholds, and concurrency primitives—making them a more effective engineer overall.

Profiling Tools and How to Use Them

Java Flight Recorder (JFR) and JDK Mission Control (JMC)

Java Flight Recorder is built into the JDK (starting from JDK 8u262+ with the jdk.FlightRecorder module) and provides low-overhead, always-on profiling suitable for production. It records hundreds of event types: allocation pressure, lock contention, I/O latency, and CPU usage, all with negligible overhead (typically under 1%). JDK Mission Control is the graphical analysis tool that reads JFR recordings.

To start a recording programmatically:

import jdk.jfr.Recording;
import jdk.jfr.Configuration;

public class JFRExample {
    public static void main(String[] args) throws Exception {
        // Create a recording using the "profile" configuration
        Recording recording = new Recording(
            Configuration.getConfiguration("profile")
        );
        
        recording.start();
        
        // Application workload here
        performWork();
        
        recording.stop();
        recording.dump(java.nio.file.Path.of("recording.jfr"));
        recording.close();
    }
    
    private static void performWork() {
        // Simulate application logic
        for (int i = 0; i < 1_000_000; i++) {
            String temp = "processing-" + i;
            temp.toUpperCase();
        }
    }
}

You can also start JFR from the command line:

# Start a 60-second recording with the profile configuration
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr \
     -XX:FlightRecorderOptions=settings=profile \
     -jar myapp.jar

Once the recording is collected, open it in JDK Mission Control to inspect the "Hot Methods" view, allocation profiles, and GC timelines. The "Lock Instances" view reveals contended locks; the "Threads" view shows where threads spend their time blocked or waiting.

Async Profiler for CPU and Allocation Profiling

Async Profiler is an open-source, low-overhead sampling profiler that uses perf_events on Linux to collect CPU samples and allocation events. It can generate flame graphs that visually represent the entire call stack proportionally to time spent. Unlike JFR, Async Profiler can profile native (C++) code paths, making it invaluable for investigating JVM internals or native libraries.

To run Async Profiler against a running Java process:

# Attach to process with PID 12345, sample CPU for 60 seconds
./profiler.sh -d 60 -f cpu_flame.svg 12345

# Profile allocations (requires -XX:+PreserveFramePointer in some JDK builds)
./profiler.sh -e alloc -d 60 -f alloc_flame.svg 12345

# Profile lock contention
./profiler.sh -e lock -d 60 -f lock_flame.svg 12345

The resulting SVG flame graphs are interactive: you can zoom into specific call chains and immediately identify the methods responsible for the largest share of CPU consumption or allocation pressure.

Heap Dump Analysis for Memory Issues

When an application exhibits a steadily growing heap or frequent Full GC events, a heap dump reveals the live object graph. Use jcmd to capture a heap dump from a running process:

# Capture a heap dump from process 12345
jcmd 12345 GC.heap_dump /tmp/heapdump.hprof

Alternatively, configure the JVM to automatically generate a heap dump on OutOfMemoryError:

java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/tmp/oom_dumps \
     -XX:+ExitOnOutOfMemoryError \
     -jar myapp.jar

Analyze heap dumps with tools like Eclipse Memory Analyzer (MAT) or the built-in jhat. MAT can automatically identify memory leak suspects by computing retained sizes and finding the dominator tree—the objects that, if garbage collected, would free the largest transitive closures.

Here is a common memory leak pattern: an unbounded cache implemented as a HashMap:

// BEFORE: Unbounded cache — memory leak
public class UserSessionCache {
    private final Map<String, UserSession> sessions = new HashMap<>();
    
    public void addSession(String token, UserSession session) {
        sessions.put(token, session);  // Never removed
    }
    
    public UserSession getSession(String token) {
        return sessions.get(token);
    }
    // sessions.size() grows indefinitely — OutOfMemoryError inevitable
}

The optimized version uses ConcurrentHashMap with expungeStaleEntries logic or a Guava cache with time-based eviction:

// AFTER: Size-bounded cache with eviction
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;

public class UserSessionCache {
    private final Cache<String, UserSession> sessions = CacheBuilder.newBuilder()
        .maximumSize(10_000)
        .expireAfterAccess(30, TimeUnit.MINUTES)
        .removalListener(notification -> {
            UserSession removed = notification.getValue();
            if (removed != null) removed.cleanup();
        })
        .build();
    
    public void addSession(String token, UserSession session) {
        sessions.put(token, session);
    }
    
    public UserSession getSession(String token) {
        return sessions.getIfPresent(token);
    }
}

Optimization Techniques with Practical Examples

1. String Handling Optimization

String concatenation in loops is a classic performance trap. Each concatenation creates a new StringBuilder and copies all previous content, yielding O(n²) complexity. The fix uses a single StringBuilder explicitly:

// BEFORE: Inefficient string concatenation in a loop
public String buildReport(List<String> lines) {
    String result = "";
    for (String line : lines) {
        result += line + "\n";  // New String object each iteration
    }
    return result;
}
// 10,000 lines: ~50ms, creates ~10,000 intermediate String objects

// AFTER: Explicit StringBuilder
public String buildReport(List<String> lines) {
    StringBuilder sb = new StringBuilder(8192);  // Estimate capacity
    for (String line : lines) {
        sb.append(line).append('\n');
    }
    return sb.toString();
}
// 10,000 lines: ~0.5ms, single allocation

For repeated string patterns, use String.intern() sparingly or rely on the JVM's string deduplication (-XX:+UseStringDeduplication with G1GC) to automatically share underlying byte[] arrays among equal strings.

2. Collection Selection and Initial Capacity

Choosing the right collection and sizing it correctly avoids expensive resizing operations. ArrayList and HashMap rehash or copy their internal arrays when they grow, which can be costly for large collections.

// BEFORE: Default-sized HashMap with many rehashes
Map<String, Product> catalog = new HashMap<>();  // Default: 16 buckets
for (Product p : productList) {  // 100,000 products
    catalog.put(p.getSku(), p);
}
// HashMap rehashes ~6-8 times, copying all entries each time

// AFTER: Pre-sized collections
Map<String, Product> catalog = new HashMap<>(
    productList.size() + (productList.size() / 3)  // ~133% capacity for load factor
);
for (Product p : productList) {
    catalog.put(p.getSku(), p);
}
// Single backing array allocation, no rehashing

Also consider the access pattern: use LinkedList for frequent insertions at the head; use ArrayDeque instead of Stack or LinkedList for LIFO/FIFO queues; prefer EnumSet and EnumMap for enum-based keys—they use bit vectors internally and are orders of magnitude faster.

3. Avoiding Unnecessary Object Creation

Object allocation is cheap in Java thanks to efficient TLAB (Thread-Local Allocation Buffer) allocation, but the hidden cost is garbage collection pressure. Reducing allocation rates keeps GC pauses short and infrequent.

// BEFORE: Boxing in a hot loop
public long sum(List<Integer> values) {
    long total = 0;
    for (Integer val : values) {
        total += val;  // Unboxing on every iteration
    }
    return total;
}
// Additionally, if values contains int literals, each is boxed to Integer

// AFTER: Use primitive collections or streams
public long sum(List<Integer> values) {
    return values.stream()
        .mapToInt(Integer::intValue)  // Explicit unboxing, but still allocates stream objects
        .sum();
}

// BETTER: Store as primitive array or use IntList from a primitive collection library
// Using Eclipse Collections or similar:
import org.eclipse.collections.api.list.primitive.IntList;

public long sum(IntList values) {
    long total = 0;
    for (int i = 0; i < values.size(); i++) {
        total += values.get(i);  // No boxing
    }
    return total;
}

Another common source of allocation waste is using new Boolean(true) instead of Boolean.TRUE or Boolean.valueOf(true)—the latter reuses cached instances. Similarly, Integer.valueOf(int) caches values from -128 to 127 by default (configurable with -XX:AutoBoxCacheMax).

4. Efficient I/O and Resource Management

I/O operations are often the dominant latency source. Buffering, using NIO channels, and avoiding one-byte-at-a-time reads dramatically improve throughput:

// BEFORE: Unbuffered byte-at-a-time reading
public byte[] readFile(File f) throws IOException {
    FileInputStream fis = new FileInputStream(f);
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    int b;
    while ((b = fis.read()) != -1) {  // One system call per byte
        baos.write(b);
    }
    fis.close();
    return baos.toByteArray();
}
// 100MB file: ~12 seconds on SSD

// AFTER: Buffered reading with Files API
public byte[] readFile(File f) throws IOException {
    // Files.readAllBytes internally uses optimal buffering and NIO
    return Files.readAllBytes(f.toPath());
}
// 100MB file: ~0.3 seconds

For socket I/O, always wrap streams in BufferedInputStream/BufferedOutputStream or use NIO ByteBuffer with direct allocation for long-lived buffers:

// Direct ByteBuffer for socket I/O
ByteBuffer buffer = ByteBuffer.allocateDirect(16384);  // 16KB direct buffer
// Direct buffers avoid copying data through the Java heap to native memory

5. Concurrency and Lock Optimization

Contended locks serialize threads, destroying throughput. Profiling lock contention with JFR or Async Profiler reveals hot locks. Common mitigations include reducing lock scope, using lock-free data structures, or sharding locks:

// BEFORE: Single lock protecting entire statistics counter
public class StatsCounter {
    private long count = 0;
    private long sum = 0;
    
    public synchronized void record(long value) {  // Contended lock
        count++;
        sum += value;
    }
}

// AFTER: Lock-free with LongAdder (Java 8+)
import java.util.concurrent.atomic.LongAdder;

public class StatsCounter {
    private final LongAdder count = new LongAdder();
    private final LongAdder sum = new LongAdder();
    
    public void record(long value) {  // No contention: internal striping
        count.increment();
        sum.add(value);
    }
    
    public long getCount() { return count.sum(); }
    public long getSum() { return sum.sum(); }
}
// LongAdder maintains an array of cells striped across threads,
// reducing CAS collisions under high contention

For read-heavy workloads, use ReadWriteLock or StampedLock:

// StampedLock example for read-mostly cache
import java.util.concurrent.locks.StampedLock;

public class OptimisticCache<K, V> {
    private final Map<K, V> data = new HashMap<>();
    private final StampedLock lock = new StampedLock();
    
    public V get(K key) {
        // Try optimistic read first — no lock acquisition
        long stamp = lock.tryOptimisticRead();
        V value = data.get(key);
        if (!lock.validate(stamp)) {
            // Optimistic read failed, fall back to full read lock
            stamp = lock.readLock();
            try {
                value = data.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return value;
    }
    
    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            data.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

6. Garbage Collection Tuning

GC tuning is not a substitute for reducing allocation pressure, but selecting the right collector and sizing the heap appropriately complements code-level optimizations. The JVM offers several collectors:

Basic GC logging configuration:

# Unified JVM logging for GC (Java 9+)
java -Xlog:gc*:file=gc.log:time,level,tags:filecount=10,filesize=10M \
     -Xms4g -Xmx4g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=100 \
     -jar myapp.jar

Analyze GC logs with tools like GCeasy, GCEasy, or gceasy.io to identify patterns: frequent young collections may indicate high allocation rates; long Remark phases in G1 suggest large reference processing overhead; serial Full GC events indicate the old generation is too small relative to the live set.

Best Practices for Java Performance Optimization

1. Measure before optimizing. Never optimize based on intuition. Use a profiler to identify the actual hot paths. A method that "looks slow" might consume 0.1% of CPU while an innocuous logging call consumes 30%. Let data guide your efforts.

2. Establish a performance baseline. Before making changes, record throughput, latency percentiles (p50, p99, p999), and resource utilization under a realistic load. Use a benchmarking harness like JMH (Java Microbenchmark Harness) for micro-benchmarks:

import org.openjdk.jmh.annotations.*;

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 2)
@Fork(1)
@State(Scope.Thread)
public class StringConcatBenchmark {
    
    String[] words = {"alpha", "beta", "gamma", "delta", "epsilon"};
    
    @Benchmark
    public String stringPlus() {
        String result = "";
        for (String w : words) {
            result += w;
        }
        return result;
    }
    
    @Benchmark
    public String stringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String w : words) {
            sb.append(w);
        }
        return sb.toString();
    }
}

3. Optimize at the right level. Start with algorithmic improvements (O(n²) to O(n log n)), then move to data structure selection, then to allocation reduction, and only lastly to micro-optimizations like manual loop unrolling. The higher the abstraction level, the greater the potential gain.

4. Beware of premature optimization. Knuth's dictum holds: "Premature optimization is the root of all evil." Optimize only after profiling confirms a bottleneck. Code that is never hot does not need to be fast—it needs to be correct and maintainable.

5. Regression-test performance. Treat performance as a first-class quality attribute. Integrate performance assertions into CI pipelines: run benchmarks on dedicated hardware (not virtualized CI runners) and fail the build if throughput drops below a threshold or if allocation rate spikes.

6. Understand the JVM warm-up curve. The JIT compiler optimizes methods after they exceed invocation thresholds. Performance measurements on a cold JVM are misleading. Always include a warm-up phase (at least 10,000 iterations for micro-benchmarks) before recording metrics.

7. Use appropriate data structures from the start. While premature optimization is discouraged, outright poor choices—like using Vector (legacy, synchronized) instead of ArrayList, or LinkedList for random access—should be avoided as a matter of basic competence. Know the characteristics of the collections you use.

8. Monitor in production continuously. Profiling is not a one-time activity. Deploy with JFR enabled at low overhead, export metrics to monitoring systems, and set alerts on GC pause duration, allocation rate, and thread pool saturation. Performance regressions often appear gradually over weeks as data volumes grow.

Conclusion

Java application performance profiling and optimization form a continuous loop: profile to discover bottlenecks, apply targeted optimizations, measure the improvement, and repeat. The JVM ecosystem provides an exceptionally rich toolset—from built-in facilities like Java Flight Recorder and jcmd to external profilers like Async Profiler and Eclipse Memory Analyzer—that make it possible to pinpoint performance issues with surgical precision. The key is discipline: never optimize without data, always establish baselines, and treat performance as an ongoing engineering responsibility rather than a one-time tuning exercise. By combining profiling literacy with sound optimization techniques—efficient string handling, proper collection sizing, reduced allocation pressure, non-blocking concurrency patterns, and appropriate GC configuration—developers can build Java applications that are not only correct and maintainable but also fast, resource-efficient, and production-stable.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles