Performance Principles
"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
- Write correct, clean code first
- Measure performance with realistic data and realistic load
- Identify bottlenecks using a profiler, not intuition
- Optimize the critical path specifically
- 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
}
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
}
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);
}
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
- 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 โ useStringBuilderorCollectors.joining() - Don't mix
synchronizedblocks 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
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.
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.