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.
| # | Problem | Typical symptom | Severity |
|---|---|---|---|
| 1 | Chatty database access | Slow requests while the database is busy | 9/10 |
| 2 | Connection pool exhaustion and calls without timeouts | Requests hang, then fail together | 9/10 |
| 3 | Memory leaks and container OOM kills | Restarts, growing heap, OOMKilled pods | 8/10 |
| 4 | Garbage collection pauses | Regular latency spikes | 6/10 |
| 5 | Lock contention and thread starvation | Idle CPUs, slow requests | 7/10 |
| 6 | Cold starts and slow warmup | New instances slow for minutes | 5/10 |
| 7 | Running on an old JDK | Higher memory and CPU than needed | 6/10 |
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.
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.
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-siteon JDK 21 and later). - Move I/O out of synchronized sections, replace hot counters with
LongAdderand synchronized maps withConcurrentHashMap. - 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.