Performance Optimization

Measure before you optimize, Virtual Threads for I/O-bound throughput, why HikariCP's default pool size is usually already too big, and when object pooling helps versus when it fights the garbage collector

← Back to Index

Performance Principles

Premature Optimization

"Premature optimization is the root of all evil." โ€” Donald Knuth

Always measure first. Optimize only after a profiler identifies an actual bottleneck โ€” not the line of code that merely looks slow.

Optimization Process

  1. Write correct, clean code first
  2. Measure performance with realistic data and realistic load
  3. Identify bottlenecks using a profiler, not intuition
  4. Optimize the critical path specifically
  5. Measure again to confirm the change actually helped

String Operations

StringBuilder for Concatenation

// BAD โ€” string concatenation in a loop is O(nยฒ): each += allocates
// a brand-new String and copies everything built so far into it
String result = "";
for (String item : items) {
    result += item + ", ";
}

// GOOD โ€” StringBuilder mutates a single buffer โ€” O(n)
StringBuilder sb = new StringBuilder();
for (String item : items) {
    sb.append(item).append(", ");
}
String result = sb.toString();

// BEST for the common case โ€” String.join() or Collectors.joining()
String result = String.join(", ", items);

// A single concatenation is fine โ€” javac compiles it to an efficient
// invokedynamic call (StringConcatFactory) since Java 9, not naive + chaining
String msg = "Hello, " + name + "!";   // OK, not a loop

String Interning

String s1 = "hello";
String s2 = "hello";
s1 == s2;   // true โ€” both point to the same interned literal

String s3 = new String("hello");
s1 == s3;   // false โ€” a genuinely new heap object

Collection Performance

Choosing the Right Collection

// ArrayList: fast random access O(1), slower insert/delete in the middle O(n)
List<Order> orders = new ArrayList<>();

// LinkedList: O(n) random access, O(1) insert/delete at a known node โ€”
// in practice ArrayList usually wins anyway due to CPU cache locality

// HashMap: O(1) average get/put, no ordering guarantee
Map<String, Customer> customersByEmail = new HashMap<>();

// TreeMap: O(log n), but keeps keys sorted โ€” only pay for this if you need the order
Map<String, Customer> sortedCustomers = new TreeMap<>();

Initial Capacity

// BAD โ€” default capacity (10) forces several resize-and-copy operations
// as the list grows to 10,000 elements
List<Order> orders = new ArrayList<>();

// GOOD โ€” pre-size when the final size is known or estimable
List<Order> orders = new ArrayList<>(10_000);

// HashMap: account for load factor (default 0.75) to avoid an early resize
int expectedSize = 1000;
Map<String, Customer> map = new HashMap<>((int) (expectedSize / 0.75) + 1);

Avoid Boxed Primitives in Hot Loops

// BAD โ€” unboxing on every iteration
List<Integer> quantities = new ArrayList<>();
int total = 0;
for (Integer q : quantities) {
    total += q;   // autoboxing/unboxing overhead per element
}

// GOOD โ€” primitive array, no boxing at all
int[] quantities = new int[1000];
int total = 0;
for (int q : quantities) {
    total += q;
}

Stream Performance

// Simple iteration โ€” a plain loop has less overhead than a stream pipeline
for (Customer customer : customers) {
    customer.setActive(true);
}

// Complex transformations โ€” this is where streams genuinely earn their keep
Map<Long, List<Order>> ordersByCustomer = orders.stream()
    .filter(Order::isCompleted)
    .collect(Collectors.groupingBy(Order::getCustomerId));

// Parallel streams โ€” only for genuinely CPU-intensive work on large
// collections; the fork-join overhead isn't worth it otherwise
long total = orders.parallelStream()
    .filter(Order::requiresManualReview)
    .count();

// DON'T parallelize: small collections (< ~10,000 elements), I/O-bound
// operations, or anything with side effects on shared state

// BAD โ€” re-traversing the source stream twice
customers.stream().filter(Customer::isActive).count();
customers.stream().filter(Customer::isActive).map(Customer::getEmail).toList();   // second full traversal

// GOOD โ€” collect once, reuse the materialized list
List<Customer> activeCustomers = customers.stream()
    .filter(Customer::isActive)
    .toList();
long count = activeCustomers.size();
List<String> emails = activeCustomers.stream().map(Customer::getEmail).toList();

Object Creation

// Reuse cached immutable instances where the JDK already provides them
Boolean flag = Boolean.TRUE;              // not `new Boolean(true)` โ€” deprecated and pointless
Integer num = Integer.valueOf(42);      // caches -128..127 automatically

// Create genuinely expensive-to-construct objects once, not per call
private static final DateTimeFormatter ORDER_DATE_FORMAT =
    DateTimeFormatter.ofPattern("yyyy-MM-dd");

public String formatOrderDate(LocalDate date) {
    return date.format(ORDER_DATE_FORMAT);   // reused, never rebuilt
}
Object pooling is for genuinely expensive resources โ€” not a default optimization for ordinary objects

Pooling makes sense for things that are actually costly to create: database connections (HikariCP), thread pools, or a heavyweight parser/formatter. Applying the same instinct to ordinary short-lived POJOs โ€” an OrderLineItem, a small DTO โ€” usually makes things worse on a modern JVM. Generational garbage collectors are specifically optimized for allocating and immediately discarding short-lived objects via thread-local allocation buffers (TLABs) โ€” that path is extremely cheap. A hand-rolled object pool for cheap objects adds synchronization overhead, complicates object lifecycle (a pooled object holding stale state from its previous use is a real, recurring bug class), and fights a garbage collector that was already good at exactly this job. Profile before pooling anything that isn't a connection, a thread, or a comparably expensive resource.

// Lazy initialization with double-checked locking โ€” still correct with
// `volatile`, but in a Spring application this is rarely something you
// should hand-write: a @Bean or @Service is already a container-managed
// singleton, with lifecycle handled for you
private volatile ExpensiveParser instance;

public ExpensiveParser getInstance() {
    if (instance == null) {
        synchronized (this) {
            if (instance == null) {
                instance = new ExpensiveParser();
            }
        }
    }
    return instance;
}

Virtual Threads (Java 21+) โ€” the Biggest Throughput Change in Years for I/O-Bound Apps

Most Java web services spend the overwhelming majority of a request's time waiting โ€” on a database query, a downstream HTTP call, a payment gateway. Traditional platform threads make that waiting expensive: each platform thread reserves roughly 1MB of stack and maps directly to an OS thread, so a bounded thread pool (Tomcat's default of ~200) becomes the hard ceiling on how many concurrent requests a service can actually be waiting on at once, regardless of how idle the CPU actually is.

// BEFORE โ€” a platform-thread-per-request model: 200 threads waiting on
// a slow payment gateway means request #201 queues, even though every
// CPU core is sitting idle
@GetMapping("/orders/{id}/payment-status")
public PaymentStatus checkStatus(@PathVariable Long id) {
    return paymentGatewayClient.checkStatus(id);   // blocks the platform thread for the full round-trip
}

Virtual threads are JVM-managed, not OS-managed โ€” millions can exist simultaneously because they're cheap (kilobytes, not megabytes) and only occupy an actual OS ("carrier") thread while genuinely running CPU instructions. The moment a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread entirely, freeing that carrier to run a different virtual thread โ€” the blocking code you already wrote (no reactive rewrite needed) now scales to a completely different order of concurrent, waiting requests.

# application.properties โ€” Spring Boot 3.2+, one line switches Tomcat's
# request-handling threads to virtual threads
spring.threads.virtual.enabled=true
// Using virtual threads directly, outside a web container
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<PaymentStatus>> results = orderIds.stream()
        .map(id -> executor.submit(() -> paymentGatewayClient.checkStatus(id)))
        .toList();
    // Each submitted task gets its own virtual thread โ€” thousands of
    // concurrent, blocking gateway calls with no reactive code at all
}
Pinning: the one real gotcha that defeats the whole benefit

A virtual thread executing inside a synchronized block or method cannot be unmounted from its carrier thread while blocked โ€” it stays pinned, blocking the carrier exactly like a platform thread would, which silently reintroduces the original scalability limit for any code path that combines synchronized with blocking I/O. The fix is replacing synchronized with java.util.concurrent.locks.ReentrantLock in hot paths that hold a lock across a blocking call โ€” the lock itself doesn't pin the carrier thread. This is entirely invisible until you're specifically profiling for it, which is exactly why it's worth knowing about before it surfaces as "virtual threads didn't actually help" in production.

Virtual threads help I/O-bound throughput specifically โ€” they do nothing for CPU-bound work, which is still limited by actual core count. See Thread Pools & ExecutorService for how platform thread pools are sized and managed, and Application Servers for how this changes container thread configuration.

Database Performance

Connection Pooling โ€” sized correctly, not maximized

@Bean
public HikariDataSource dataSource() {
    HikariConfig config = new HikariConfig();
    config.setMaximumPoolSize(10);
    config.setMinimumIdle(10);      // match maximum โ€” a fixed-size pool avoids pool churn
    config.setConnectionTimeout(30000);
    return new HikariDataSource(config);
}
Bigger is not faster โ€” a database has a fixed number of CPU cores too

The intuitive assumption โ€” more connections in the pool means more throughput โ€” is usually wrong past a fairly small number. Every query still has to execute on the database server's own finite CPU cores; beyond the point where the database is already saturated, additional connections just mean more queries queuing inside the database instead of inside your application, adding context-switching overhead with no throughput gain. HikariCP's own guidance suggests a formula in the neighborhood of connections = ((core_count * 2) + effective_spindle_count) as a starting point, not a fixed default โ€” for most modern SSD-backed databases this lands surprisingly low, often in the 10-20 range even for a moderately busy service, and should be verified by actually measuring throughput at different pool sizes rather than assumed.

Batch Operations

// BAD โ€” N individual round-trips
for (Order order : orders) {
    orderRepository.save(order);
}

// GOOD โ€” a single batched call
orderRepository.saveAll(orders);

Fetch Only What You Need

// BAD โ€” loads every column of every Order just to read the total
List<BigDecimal> totals = orderRepository.findAll().stream()
    .map(Order::getTotal)
    .toList();

// GOOD โ€” a projection fetches only the column actually needed
@Query("SELECT o.total FROM Order o")
List<BigDecimal> findAllTotals();

// Pagination for anything that could grow unbounded
Page<Order> page = orderRepository.findAll(PageRequest.of(0, 20));

Caching

@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @CacheEvict(value = "products", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }
}

// In-memory memoization for values not backed by a full cache provider
private final Map<String, BigDecimal> taxRateCache = new ConcurrentHashMap<>();

public BigDecimal getTaxRate(String region) {
    return taxRateCache.computeIfAbsent(region, this::lookupTaxRate);
}

Profiling Tools

Essential Profiling Tools
  • JProfiler / YourKit โ€” commercial, deep profilers
  • VisualVM โ€” free, bundled with the JDK
  • async-profiler โ€” low-overhead sampling profiler, safe for production use
  • JMH โ€” microbenchmarking harness for isolated method-level comparisons
  • JFR (Java Flight Recorder) โ€” always-on, low-overhead production profiling built into the JDK

JMH Benchmark Example

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class StringConcatBenchmark {

    private List<String> items;

    @Setup
    public void setup() {
        items = IntStream.range(0, 100).mapToObj(String::valueOf).toList();
    }

    @Benchmark
    public String stringConcat() {
        String result = "";
        for (String item : items) { result += item; }
        return result;
    }

    @Benchmark
    public String stringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String item : items) { sb.append(item); }
        return sb.toString();
    }
}

JMH's default warm-up iterations exist because the JIT compiler doesn't optimize code immediately โ€” early calls run interpreted or under a cheap tier-1 compilation, and only "hot" code gets the fully optimized tier-4 (C2) compilation. Benchmarking without a warm-up phase measures the JIT compiler's warm-up cost, not the code's actual steady-state performance.

Best Practices and Common Pitfalls

โœ… Do

  • Measure with a profiler before optimizing anything โ€” intuition about "the slow part" is wrong surprisingly often
  • Enable virtual threads (spring.threads.virtual.enabled=true) for I/O-bound services on Java 21+ โ€” it's close to a free throughput win for typical blocking web/database code
  • Size a connection pool by measuring actual throughput at different sizes, not by maximizing it
  • Reserve object pooling for genuinely expensive resources (connections, threads) โ€” not ordinary short-lived objects a modern GC already handles cheaply
  • Use projections and pagination for any query that could return an unbounded or very large result set

โŒ Don't

  • Don't concatenate strings with += inside a loop โ€” use StringBuilder or Collectors.joining()
  • Don't mix synchronized blocks with blocking I/O inside virtual-thread code โ€” it pins the carrier thread and silently defeats the scalability benefit
  • Don't reach for parallel streams on small collections or I/O-bound work โ€” the fork-join overhead outweighs any gain
  • Don't hand-roll a singleton with double-checked locking inside a Spring-managed class โ€” the container already provides this
  • Don't benchmark without a warm-up phase โ€” you'll measure JIT compilation cost, not real performance

Interview Questions

๐ŸŽ“ Junior level

Q: Why is string concatenation with += inside a loop O(nยฒ)?
String is immutable, so each += creates an entirely new String object and copies every character built so far into it. Doing this once per iteration across n iterations means roughly 1 + 2 + 3 + ... + n character copies in total โ€” quadratic in n. StringBuilder mutates a single growable buffer instead, making the whole operation linear.

Q: Why should you pre-size an ArrayList when you know roughly how many elements it will hold?
Without a specified capacity, the list starts small and has to repeatedly allocate a larger backing array and copy every existing element into it as it grows. Specifying the expected capacity upfront avoids those repeated resize-and-copy operations entirely.

Q: What's the main benefit of Virtual Threads for a typical Spring Boot web service?
Most web requests spend most of their time waiting on I/O โ€” a database query, an external API call. Traditional platform threads are expensive and capped in number, so that waiting limits how many requests can be in flight simultaneously. Virtual threads are cheap enough to create millions of, and the JVM automatically frees the underlying OS thread while a virtual thread is blocked on I/O, letting far more requests wait concurrently using the same blocking code you'd already written.

๐Ÿ”ฅ Senior level

Q: Your team enables Virtual Threads expecting a major throughput improvement, but production metrics show almost no change under load. What's the most likely cause, and how would you confirm it?
The most likely cause is pinning: a hot code path that combines a synchronized block or method with a blocking I/O call inside it. When a virtual thread executing inside synchronized blocks on I/O, the JVM cannot unmount it from its carrier thread โ€” it stays pinned, blocking that carrier exactly as a platform thread would, which silently reintroduces the original concurrency ceiling for any request that touches that code path. To confirm it, JFR (Java Flight Recorder) records pinning events specifically โ€” enabling JFR and filtering for virtual thread pinning events during a load test will show exactly which synchronized block is responsible. The fix is replacing that synchronized block with java.util.concurrent.locks.ReentrantLock, which provides equivalent mutual exclusion without pinning the carrier thread while blocked.

Q: A team doubles their HikariCP maximumPoolSize from 10 to 20 hoping to fix slow response times under load, and throughput doesn't improve โ€” it gets slightly worse. Explain why.
A connection pool's ceiling is only useful up to the point where the database server itself can actually execute that many queries concurrently โ€” and the database has a fixed number of CPU cores, disk I/O capacity, and lock contention characteristics of its own. Once the database is already saturated at the original pool size, adding more connections doesn't create additional database capacity โ€” it just means more queries queuing and context-switching inside the database's own execution engine instead of queuing more visibly inside the application's connection pool. The additional connections add real overhead (each one holds server-side resources, and increased concurrent query execution increases lock contention and cache thrashing on the database) without adding real throughput, which is exactly why the change made things slightly worse rather than better. The correct diagnostic is measuring actual throughput at several different pool sizes under realistic load, rather than assuming a larger number is strictly safer.

Q: Why does pooling ordinary short-lived domain objects (like a small DTO created per request) tend to hurt performance on a modern JVM, even though it "obviously" reduces allocation?
Modern generational garbage collectors are specifically optimized for exactly this allocation pattern: short-lived objects are allocated in a thread-local allocation buffer (TLAB) with no synchronization needed between threads, and a young-generation collection that finds most such objects already unreachable can reclaim that entire buffer extremely cheaply โ€” often cheaper than the bookkeeping a hand-rolled pool requires to safely hand out and reclaim instances across threads. A manual object pool reintroduces exactly the kind of shared, synchronized state the GC's allocation path is designed to avoid, and adds a real correctness risk on top: a pooled object that isn't fully reset between uses can leak stale state from a previous caller into a new one, a bug class that doesn't exist at all if the object is simply allocated fresh and discarded. Pooling is worth its complexity specifically when construction cost is high relative to the GC's handling of it โ€” a database connection or a thread โ€” not for objects a modern JVM was already built to allocate and collect cheaply.