Mahi
My notes, blogs and thoughts on tech

Threads queueing due to synchronised methods in Java

I recently got to troubleshoot a problem in a Java web application which happened to occur under very high load. We got to the root of the problem, which was basically due to synchronisation of methods - the threads were waiting to acquire lock which was held by another thread. As the Java method was synchronized, it was protected from multiple threads accessing it at the same time.

The application was performing well under normal load but showed performance issues (high response times, threads maxing out, etc.) under high load. This was an application at work, I won't go into too much of details but thought I would write how such scenario can impact application's performance.

What are synchronised methods?

In Java, a synchronized method is one that can only be executed by one thread at a time. When a thread enters a synchronized method, it acquires the intrinsic lock (also called a monitor lock) on the object. Any other thread that tries to call a synchronized method on the same object is blocked and must wait until the first thread releases the lock.

This is useful when multiple threads share mutable state. Without synchronisation, two threads could read and write the same variable simultaneously, leading to unpredictable results — a problem known as a race condition.

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

In this example, increment() and getCount() are both synchronized. If two threads call increment() at the same time, one will wait. This guarantees that count++ — which is actually three operations (read, add, write) under the hood — is never interrupted mid-execution by another thread.

A simple example

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount()); // Always 2000
    }
}

Without synchronized, the final count would often be less than 2000 because of lost updates. With it, the result is always correct.

What happens under high load?

Synchronisation works well when contention is low — that is, when threads rarely try to acquire the lock at the same time. But in a high-traffic application, things can go wrong fast.

Imagine a web application where every incoming HTTP request spawns a thread, and each of those threads needs to call a synchronized method. If 500 threads all want the same lock simultaneously, only one can proceed at a time. The other 499 are blocked, waiting in a queue.

This causes:

Thread starvation — Some threads may wait an unexpectedly long time if others keep cutting ahead (depending on scheduling).

Increased response times — Requests that should complete in milliseconds start taking seconds because they spend most of their time waiting for a lock, not doing actual work.

Thread pool exhaustion — Most servers use a fixed-size thread pool. If threads are stuck waiting for a lock, the pool fills up. New requests can't get a thread and are rejected or time out.

CPU underutilisation — The CPU is mostly idle because threads are blocked, not running. The bottleneck is the lock, not compute capacity.

In practice, this kind of contention often shows up as high latency spikes under load, even when CPU and memory usage look fine. It can be hard to diagnose because the system isn't crashing — it's just slow.

How to avoid it

Reduce the scope of synchronisation. If it is feasible, then instead of synchronising an entire method, synchronise only the block of code that actually needs protection. The less time a thread holds a lock, the less chance of contention.

public void process(Data data) {
    // Do expensive work outside the lock
    Result result = compute(data);

    synchronized (this) {
        // Only the write needs the lock
        store(result);
    }
}

Use java.util.concurrent utilities. The java.util.concurrent package provides higher-level abstractions that are often more efficient than raw synchronized. For example:

  • AtomicInteger for a simple counter — uses hardware-level compare-and-swap instead of locking.
  • ConcurrentHashMap instead of a synchronized HashMap — allows multiple threads to read and write different segments concurrently.
  • ReentrantLock when you need more control, such as try-locking with a timeout or interruptible locking.
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

Partition shared state. If possible, give each thread its own data to work on. Thread-local storage (ThreadLocal) or partitioned data structures eliminate contention entirely because threads never share the same object.

Design for immutability. Immutable objects don't need synchronisation at all — if nothing can change, there's nothing to protect. Prefer immutable data where possible and only synchronise state that genuinely needs to be shared and mutated.

Profile before optimising. Not all synchronised methods are a problem. Only lock contention at scale causes real issues. Use a profiler or an APM tool to confirm where threads are actually blocking before restructuring code.

Synchronised methods are a simple and effective way to prevent data corruption. The trouble only starts when they become a bottleneck. Keeping critical sections short, preferring concurrent utilities, and minimising shared mutable state goes a long way toward keeping a Java application responsive under load.