A Spring Boot service that answered in 80 ms last quarter now takes two seconds at the evening peak, and the only change was a new report endpoint.

Most Java performance problems we are called in to fix in 2026 come from a short list. Below are the seven we meet most often, each with a severity rating, the symptoms and the fix.

Why Java performance problems cost more than they look

A slow service costs twice. Users wait, and the team adds servers to hide the wait. In Azul's 2026 State of Java survey of more than 2,000 Java users, 74% of organizations reported more than 20% unused compute in their public cloud, which Azul, a vendor of Java runtimes, reads as overprovisioning to cover slow startup and unpredictable runtime behavior.

Fixing the cause usually pays for itself. When we moved a UK hotel metasearch engine's availability search into memory, response times fell by 60% and infrastructure cost fell by 80% (case study).

The seven most common Java performance problems in 2026

Severity is our assessment, on a scale of 1 to 10: how badly the problem hurts in production, from a slower page to an outage, and how easily it hides until it does.

#ProblemTypical symptomSeverity
1Chatty database accessSlow requests while the database is busy9/10
2Connection pool exhaustion and calls without timeoutsRequests hang, then fail together9/10
3Memory leaks and container OOM killsRestarts, growing heap, OOMKilled pods8/10
4Garbage collection pausesRegular latency spikes6/10
5Lock contention and thread starvationIdle CPUs, slow requests7/10
6Cold starts and slow warmupNew instances slow for minutes5/10
7Running on an old JDKHigher memory and CPU than needed6/10
Java performance triage: from the symptom to the likely problem. Slow requests with a busy database point to chatty database access. Requests that hang and then time out together point to connection pool exhaustion or calls without timeouts. A pod restarted as OOMKilled points to memory outside the heap, and a heap that grows after every full collection to a memory leak. Regular latency spikes with long pauses in the GC log point to garbage collection. Idle CPUs with many blocked threads point to lock contention. New instances that stay slow for minutes point to cold starts and JIT warmup.WHAT YOU SEEWHERE TO LOOK FIRSTSlow requests, busy database1. Chatty database accessRequests hang, then time out together2. Pool exhaustion, no timeoutsPod restarts as OOMKilled3. Memory outside the heapHeap grows after every full GC3. Memory leakLatency spikes, long GC pauses in logs4. GC pausesIdle CPUs, many blocked threads5. Lock contentionNew instances slow for minutes6. Cold starts and warmup
A first triage, from symptom to the problem number in this article. Confirm with a profile before changing code.

1. Chatty database access: N+1 queries and missing indexes

Severity: 9/10. The most common cause of slow Java business applications, and invisible in a CPU profile.

What you see

Response time grows with the amount of data on the page, not with traffic. The database shows thousands of small, fast queries per request, or a handful of slow ones that scan whole tables. The application servers, meanwhile, look relaxed: their threads are waiting, not working, so a CPU profile shows nothing.

Why it happens

Object-relational mappers such as Hibernate load associations lazily: a related object is fetched the first time the code touches it. That is efficient for one order and ruinous for a list. A report of 500 orders that shows each customer's name and the number of order lines issues one query for the orders, then one for each customer and one for each order's lines. That is the N+1 query, here 1,001 round trips.

Database round trips for one report of 500 orders, assuming 1 millisecond per round trip. With lazy loading the code runs 1 query for the orders and 2 more per order, 1,001 round trips that add about a second of waiting. A projection query returns the same data in a single round trip.ROUND TRIPS FOR ONE REPORT OF 500 ORDERS0 ms250 ms500 ms750 ms1000 msLazy loading1 + 2 x 500 queries1,001 round trips, about 1 sProjection1 round tripEach bar stands for ten queries. Timings assume 1 ms per round trip.
Illustrative timings for the report described above. The fix changes the number of round trips, not the speed of any single query.

Missing indexes are the other half. A filter or a sort the index does not cover forces the database to read the whole table, which is fast in development with a thousand rows and slow in production with fifty million. OFFSET pagination has the same flaw: page 2,000 makes the database read and discard every row before it.

How to fix it

  • Count queries per request in integration tests (hibernate.generate_statistics=true) and fail the build when an endpoint exceeds its budget.
  • Load what a page shows with a projection or a join fetch; batch-fetch collections instead of joining several, which would multiply the rows.
  • Take the slowest queries by total time from your database's statistics, run EXPLAIN ANALYZE, and add the index that matches the filter and the sort order.
  • Replace OFFSET with keyset pagination on large tables: ask for rows after the last key you saw.

Our guide to improving Java performance walks through an N+1 fix before and after, and our guide to database management systems lists what to try before changing the database itself.

2. Connection pool exhaustion and calls without timeouts

Severity: 9/10. Turns one slow dependency into a full outage.

What you see

Everything works until a downstream system slows down. Then requests pile up waiting for a database connection or an HTTP response, threads run out, health checks fail and the service restarts, often taking its callers with it. The logs fill with timeouts from the pool rather than from the database, which sends the first hour of the investigation in the wrong direction.

Why it happens

A connection pool is a queue in front of the database. While the database answers quickly, the queue stays short. When each query takes ten times longer, each connection serves a tenth of the requests, and the rest wait. If nothing limits that wait, every thread in the service ends up blocked on the pool.

Remote calls without a timeout make it worse, because a call to a dead service can wait for minutes while it holds a thread and often a database connection too. Retries without a limit multiply the load on the system that is already struggling. Moving to virtual threads removes the thread pool that used to cap concurrent queries, so the queue moves to the connection pool unless something else limits it.

How to fix it

Give every remote call a connect and a read timeout, and size the pool for what the database can serve rather than for the number of threads. The HikariCP wiki describes an Oracle demonstration in which cutting a pool from 2,048 connections to 96 took response times from about 100 ms to about 2 ms.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20          # sized for the database, then load-tested
      connection-timeout: 2000       # ms to wait for a free connection before failing fast
  http:
    clients:
      connect-timeout: 1s
      read-timeout: 3s

Then decide what happens when a call fails: retry with a limit and a delay, return a cached or partial answer, or stop calling a failing dependency for a while with a circuit breaker. A failure in two seconds is better than a hang in two minutes, because the caller can still do something useful. Our virtual threads guide explains why the bottleneck moves to the pool.

3. Memory leaks and container OOM kills

Severity: 8/10. Crashes in production, often at night, often with no heap dump to explain them.

What you see

Two different problems look alike. A memory leak shows as a heap that is higher after every full collection, until the JVM spends most of its time collecting and finally throws OutOfMemoryError. A container OOM kill shows as a pod restarted with the status OOMKilled (Kubernetes documentation): the kernel stopped the process because its total memory passed the container's limit, so the JVM never had a chance to write a heap dump or even a log line.

Why it happens

Java leaks are references that live longer than intended: an unbounded cache, a listener registered on every request and never removed, a static map keyed by session or request data, a ThreadLocal on a pooled thread. The garbage collector cannot free an object that is still reachable, however useless it is.

OOM kills usually have a different cause: memory outside the heap that nobody budgeted for. Metaspace, thread stacks, the JIT's code cache, direct buffers used by network libraries and the garbage collector's own structures all count against the container limit. Teams that raise the heap to fill the container leave no room for them.

Memory in a container with a 4 GB limit. With the default setting the heap is 25%, 1 GB, and with about 0.5 GB of native memory the JVM uses 1.5 GB, leaving most of the container unused. With the heap at 75%, 3 GB plus 0.5 GB of native memory fits under the limit. With the heap at 90%, 3.6 GB plus 0.5 GB passes the limit, and the kernel kills the container, which Kubernetes reports as OOMKilled.JVM MEMORY IN A CONTAINER WITH A 4 GB LIMIT0 GB1 GB2 GB3 GB4 GB5 GBlimitDefault heap (25%)1.5 GB usedHeap at 75%fitsHeap at 90%OOMKilledJava heapnative memory: metaspace, thread stacks, code cache, buffers, GC
Illustrative sizes: native memory varies by application and is measured with Native Memory Tracking. The 25% default is from the java command reference.

How to fix it

For a leak, take a heap dump (jcmd <pid> GC.heap_dump heap.hprof) and open it in a heap analyzer; the class whose instance count keeps growing, and the path that keeps it reachable, name the bug. For OOM kills, measure the native side and leave room for it:

-XX:MaxRAMPercentage=75               # default is 25% of the container limit
-XX:+HeapDumpOnOutOfMemoryError
-XX:NativeMemoryTracking=summary      # then: jcmd <pid> VM.native_memory summary

The default heap is only 25% of the container's memory, so a pod that is killed is rarely short of heap; it is usually the native side that nobody accounted for.

4. Garbage collection pauses

Severity: 6/10. Rarely an outage, but it sets the tail latency users feel.

What you see

Latency spikes at regular intervals, matching pauses in the GC log, while the average response time looks fine. The slowest 1% of requests are many times slower than the median, and they cluster around collections.

Why it happens

Pauses grow with the work the collector must do while the application waits, and that work grows with the allocation rate and the size of live data. Often the collector was never chosen at all: on JDK 26 and earlier, the JVM silently picks Serial, a single-threaded stop-the-world collector, on a machine with one CPU or less than 1,792 MB of memory (JEP 523), which describes many small containers. Old flags copied from Java 8 tuning guides add their own surprises.

How to fix it

Turn on GC logging, check which collector is running, size the heap from measurements and then pick the collector for the goal: G1 for most services, Parallel for batch throughput, generational ZGC for short pauses. Netflix moved its default from G1 to generational ZGC and reported better average and p99 latency at equal or lower CPU (Netflix Technology Blog). Before tuning flags, cut the allocation rate: a profiler's allocation view names the code that creates the most garbage, often logging, JSON mapping or boxing in a loop.

5. Lock contention and thread starvation

Severity: 7/10. Throughput stops growing with load while the CPUs stay idle.

What you see

Requests slow down under load, yet CPU use is low. A thread dump shows many threads BLOCKED on the same monitor, or waiting in the same pool. Adding instances helps a little; adding threads makes it worse.

Why it happens

Only one thread at a time can hold a lock, so anything slow inside a synchronized block, such as a database call or a log write to a slow disk, makes every other thread wait. Shared counters and maps that every request updates become hot spots as traffic grows. Pools are the second trap: parallelStream() and CompletableFuture without an executor share one JVM-wide pool, so a blocking call inside them stalls unrelated work everywhere in the process.

How to fix it

  • Find the lock with a profiler's contention view (jfr view contention-by-site on JDK 21 and later).
  • Move I/O out of synchronized sections, replace hot counters with LongAdder and synchronized maps with ConcurrentHashMap.
  • Give blocking work its own executor, and keep the common pool for CPU-bound tasks.
  • On virtual threads, run JDK 24 or later, where synchronized no longer pins a virtual thread to its carrier (JEP 491).

6. Cold starts and slow warmup

Severity: 5/10. Costly in autoscaling and frequent deployments, harmless in a service that runs for weeks.

What you see

A new instance passes its readiness check and then serves its first minutes slowly. When traffic spikes, the autoscaler adds instances that cannot help yet, so teams keep extra ones running all the time, and every deployment shows as a small latency spike on the dashboard.

Why it happens

A JVM starts by loading and linking thousands of classes, then interprets the code while the JIT compiler gathers profiles and compiles the hot methods, first quickly and then with full optimization. A Spring application adds its own startup work on top. Until that is done, the same request costs several times more CPU, and a container with a tight CPU limit gets throttled exactly when it needs the most.

How to fix it

Use Project Leyden's AOT cache. In JEP 483, Spring PetClinic started 42% faster on JDK 24 with a cache built by a training run, and Java 25 adds method profiles so hot code is compiled right after startup (JEP 515). Send warmup traffic before the readiness probe passes, and give the JVM enough CPU to run the JIT and GC threads during startup.

7. Running on an old JDK

Severity: 6/10. No single incident, just years of performance and memory left unused.

What you see

Nothing breaks. The service simply uses more memory and CPU than the same code would on a current JDK, and the team tunes around problems that newer releases fixed, such as GC pauses, slow startup or pinned virtual threads.

Why it happens

Upgrades compete with features for the same people, and a service that works is easy to postpone. Every postponed release adds to the gap, until the upgrade looks like a project of its own.

How to fix it

Upgrade to Java 25, the current long-term support release, and test the gains you get without code changes: compact object headers (JEP 519) cut heap use by 22% on SPECjbb2015 in the JEP's measurements, and JDK 26 made G1 faster for applications that update many object references (JEP 522). Support dates for every version are in our Java end-of-life calendar, and our guide to Java profilers lists the diagnostics each release added.

How to find which problem you have

Guessing is the expensive part. Measure first: record a profile from the running system with JDK Flight Recorder or async-profiler, check the database's statistics, and read the GC log. Our guide to Java profilers compares the tools, and the low-latency Java guide covers measuring tail latency correctly.

If a service is slow or costly and the cause is not obvious, our performance engineering team finds the bottleneck under production-like load and verifies the fix before it ships.