Why can a Java request hang when CPU and network look normal?

When CPU and network are normal, the request is usually waiting for a resource or blocked by another operation, rather than actively executing. Here are some common causes:

  1. Slow or blocked database operations
  2. Thread deadlock or lock contention
  3. Web server thread pool exhaustion
  4. Downstream service calls without timeouts
  5. CompletableFuture or asynchronous-task starvation
  6. Transaction or distributed lock is never released
  7. Garbage collection pause
  8. Infinite retry or incorrectly terminated loop
  9. Response-generation or response-writing problem

1. Slow or blocked database operations

The request may be waiting for a database query to complete. If the database is slow or the query is blocked, the request will hang. The Java thread may be waiting for

  • A slow SQL query
  • A database lock
  • An uncommitted transaction
  • A database connection from an exhausted connection pool

For example:

1
2
3
// JDBC example
connection = dataSource.getConnection(); // May wait here
resultSet = statement.executeQuery(sql); // Or here

What you need to do is check the database logs, monitor the query performance, and ensure that the connection pool is properly sized. Like:

  • Active and blocked SQL queries
  • Long-running transactions
  • HikariCP or DBCP connection pool metrics
  • Logs containing messages such as “Connection pool exhausted” or “Timeout waiting for connection” or “Connection is not available”

A connection leak is particularly common: code obtains a connection but does not close it.

1
2
3
Connection conn = dataSource.getConnection();
// Use the connection
// conn.close(); // forget to close the connection

An easy way to avoid connection leaks is to use try-with-resources, which automatically closes the connection:

1
2
3
4
5
try (Connection conn = dataSource.getConnection()) {
// Use the connection
} catch (SQLException e) {
// Handle exception
} // Connection is automatically closed here

2. Thread deadlock or lock contention

The second common cause is that the request is waiting for a lock held by another thread. If two or more threads are waiting for each other to release locks, a deadlock occurs. If many threads are trying to acquire the same lock, it can lead to lock contention.

1
2
3
4
5
synchronized (lockA) {
synchronized (lockB) {
// Do something
}
}

Tipically, you can use thread dumps to analyze the state of threads and identify deadlocks or lock contention. Look for threads that are in a BLOCKED or WAITING state.
1
2
BLOCKED (on object monitor)
waiting to lock <0x00000000> (a java.lang.Object)

Or a deadlock message:
1
2
Found one Java-level deadlock:
=============================

Generate a thread dump using jstack or by sending a kill -3 signal to the Java process. Analyze the thread dump to find threads that are waiting for locks and identify the deadlock cycle.

1
2
3
jstack -l <java_pid> > thread_dump.txt
# or using jcmd
jcmd <java_pid> Thread.print > thread_dump.txt

Take three dumps approximately 10 seconds apart. If the same request thread remains at the same stack location, that location is probably where it is stuck.

3. Web server thread pool exhaustion

When the web server’s thread pool is exhausted(all Tomcat, Jetty, or application threads are occupied), incoming requests must wait for an available thread. This can lead to a backlog of requests and increased latency. Monitoring the thread pool metrics can help identify this issue.

For Tomcat, relevant settings include:

1
2
server.tomcat.threads.max=200
server.tomcat.accept-count=100

Possible causes of thread pool exhaustion include:

  • Too many long-running requests
  • Blocking operations inside request threads
  • Threads waiting for database connections or external services
  • Threads calling external APIs without timeouts

In a thread dump, you may see almost all HTTP threads waiting in the same method:

1
2
3
http-nio-8080-exec-1
http-nio-8080-exec-2
http-nio-8080-exec-3

Monitor relevant metrics such as:

  • Active HTTP threads
  • Maximum HTTP threads
  • Request queue size
  • Executor queue size
  • Rejected task count

4. Downstream service calls without timeouts

When your application makes calls to external services (e.g., REST APIs, databases, Redis, Kafka, RabbitMQ, DNS resolution, A file system or mounted network drive), it’s crucial to set appropriate timeouts. Without timeouts, a request can hang indefinitely if the downstream service is slow or unresponsive, leading to resource exhaustion and degraded performance.

Without properly configured timeouts, this can wait for a long time.

1
restTemplate.getForObject("http://example.com/api", String.class); // No timeout set

A thread waiting for an external response may appear as:

1
2
RUNNABLE
at sun.nio.ch.SocketDispatcher.read0(...)

Runnable does not always mean it it consuming CPU. It can be waiting inside native socket I/O code, which is not visible in the Java stack trace. This is a common cause of hanging requests.

to avoid this, you should configure timeouts for your HTTP client or REST template. For example, using Spring’s RestTemplate:

1
2
3
4
5
6
7
8
9
10
11
12
// Example of setting a timeout for an HTTP client call
CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(5000) // 5 seconds
.setSocketTimeout(10000) // 10 seconds
.build())
.build();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://example.com/api"))
.timeout(Duration.ofSeconds(10)) // 10 seconds
.build();

Monitor the following metrics:

  • Number of pending requests to downstream services
  • Average response time of downstream calls
  • Timeout occurrences

Use circuit breakers (e.g., Hystrix) to prevent cascading failures when downstream services are unavailable.

5. CompletableFuture or asynchronous-task starvation

This can happend when one task submits another task to the same limited thread pool and waits for it. When using CompletableFuture or other asynchronous programming constructs, it’s possible for tasks to become starved if the thread pool is exhausted or if there are circular dependencies. This can lead to tasks waiting indefinitely for resources that never become available.

1
2
3
CompletableFuture<String> future = CompletableFuture.supplyAsync(this::loadData, executor);

return future.get(); // This can block if the executor is exhausted, may wait indefinitely

A particularly dangerous pattern is:

1
2
3
4
executor.submit(()->{
Future<Result> child = executor.submit(this::doWork);
return child.get(); // This can block if the executor is exhausted, may wait indefinitely
})

If every executor thread is waiting for another task in the same executor, queued tasks can never run.

to avoid this, you should always apply timeouts when waiting for asynchronous tasks to complete. For example:

1
Result result = future.get(5, TimeUnit.SECONDS); // Wait for 5 seconds

6. Transaction or distributed lock is never released

The request may be waiting for a transaction or distributed lock that is never released. This can happen if a transaction is not committed or rolled back, or if a distributed lock is not released due to an application bug or crash.

  • A Redis distributed lock
  • A database advisory lock
  • A ReentrantLock or Semaphore in Java
  • A CountDownLatch or CyclicBarrier that is never counted down

Example:

1
2
3
4
5
6
lock.lock();
try {
process();
} finally {
lock.unlock();
}

Or
1
boolean acquired = lock.tryLock(10, TimeUnit.SECONDS);

Avoid unlimited waits for locks. Always ensure that locks are released in a finally block, and consider using timeouts when acquiring locks.

1
2
3
4
5
6
7
8
9
10
boolean acquired = lock.tryLock(10, TimeUnit.SECONDS);
if (acquired) {
try {
process();
} finally {
lock.unlock();
}
} else {
// Handle lock acquisition failure
}

7. Garbage collection pause

A long garbage collection (GC) pause can cause the application to become unresponsive. During a GC pause, all application threads are stopped, which can lead to requests hanging. Monitoring GC logs and metrics can help identify if this is the cause of the hang.

At this point, you should check the GC logs for long pauses. For example, in a GC log, you might see:

1
2
3
-Xlog:gc*:file=gc.log:time,uptime,level,tags

[GC pause (G1 Evacuation Pause) (young) 1000ms]

Also inspect:

1
jstat -gcutil <java_pid> 1000

The warning signs of GC issues include:

  • Frequent Full GC events
  • Long GC pause times (e.g., > 1 second)
  • Very high old-generation usage
  • Memory allocation faster than collection

8. Infinite retry or incorrectly terminated loop

A request may hang due to an infinite retry mechanism or a loop that never terminates. This can happen if the code is designed to retry on failure without a proper exit condition, or if there is a logical error in the loop that prevents it from completing.

1
2
3
4
5
6
7
8
while (true) {
try {
process();
break; // Exit on success
} catch (Exception e) {
// Log and retry indefinitely
}
}

CPU and network may look normal because the thread is actively running, but it is stuck in a loop or retrying indefinitely.

The other common case is the loop contains sleep, blocking I/O, or backoff.

1
2
3
4
while (!success) {
Thread.sleep(5000);
success = callService();
}

In this case, we can search logs for repeated messages from the same request or trace ID to identify if the request is stuck in a retry loop.

9. Response-generation or response-writing problem

The business logic may finish, but the response is never completed because of:

  • Streaming response not being closed
  • Reactive stream never emitting completion
  • DeferredResult never receiving a result
  • An exception being swallowed
  • Servlet async context not being completed
1
2
3
DeferredResult<ResponseEntity<?>> result = new DeferredResult<>();
// If neither setResult nor setErrorResult is called,
// the client may continue waiting.

For servlet asynchronous processing:

1
2
3
AsyncContext asyncContext = request.startAsync();
// Ensure the async context is completed
asyncContext.complete();

Step 1: Add request tracing

Log the beginning and end of each request with the same request ID:

1
2
3
4
5
6
7
log.info("Request started: requestId={}", requestId);

try {
return service.process();
} finally {
log.info("Request finished: requestId={}", requestId);
}

Add logs before and after major operations:

1
2
3
4
5
6
7
log.info("Before database query");
repository.findData();
log.info("After database query");

log.info("Before downstream API");
client.callApi();
log.info("After downstream API");

This quickly identifies the operation where execution stops.

Step 2: Capture thread dumps

While the request is hanging:

1
2
3
4
5
jcmd <pid> Thread.print > dump1.txt
sleep 10
jcmd <pid> Thread.print > dump2.txt
sleep 10
jcmd <pid> Thread.print > dump3.txt

Look for the request thread and check whether it is:

  • BLOCKED: waiting for a Java lock
  • WAITING: waiting indefinitely
  • TIMED_WAITING: sleeping or waiting with a timeout
  • In Socket.read: waiting for an external service
  • In JDBC code: waiting for the database
  • In HikariPool.getConnection: waiting for a database connection
  • In Future.get: waiting for an asynchronous task

Step 3: Check resource pools

Inspect:

  • Tomcat active/max threads
  • Database active/max connections
  • Executor active threads and queue size
  • Redis connection pool
  • HTTP client connection pool

Step 4: Verify every external call has a timeout

There should be timeouts for:

  • Database queries
  • Database connection acquisition
  • HTTP connection and response
  • Redis commands
  • Futures
  • Locks
  • Message broker operations

A healthy network does not prevent an external system from accepting a connection and then never returning a response.

Final thoughts

In production Java applications, I would investigate these first:

  • Slow or blocked database query
  • Database connection-pool exhaustion
  • HTTP/Tomcat thread-pool exhaustion
  • Downstream service call without a timeout
  • Lock contention or deadlock
  • Future.get() or asynchronous-thread-pool starvation

The most useful first action is to capture several thread dumps while the request is hanging. They normally show exactly what the request thread is waiting for.