Java Concurrency Beyond `synchronized`: 10 JDK Tools Every Developer Should Know

Explore Java's concurrency toolbox beyond `synchronized`. Learn when to use `ConcurrentHashMap`, `LongAdder`, `BlockingQueue`, and more to write faster, safer multithreaded code.

Java Concurrency Beyond `synchronized`: 10 JDK Tools Every Developer Should Know

I still remember the first time a senior engineer looked at my code and said, “You wrapped that whole map in synchronized.” He wasn’t angry. He was just tired. Every thread in our service had to line up single file to read a value that almost never changed. One slow reader, and everyone else waited. That day I started learning that the JDK ships a toolbox most of us ignore because synchronized is the first thing we ever learn.

Here is the map I wish someone had handed me back then. Ten tools, what job each one does, and what each one costs you.

I’ll start with the workhorse. ConcurrentHashMap is the collection I reach for when many threads read and write the same map and I don’t want a global lock. Internally it splits the work so two threads touching different buckets rarely block each other. The old Collections.synchronizedMap gives you one lock for the entire map. That is fine for ten threads. It is painful for ten thousand.

The part people miss is the compute family. Instead of get, modify, put, which races between threads, you hand the map a function and let it run the whole thing under the bucket lock.

ConcurrentHashMap<String, Long> hits = new ConcurrentHashMap<>();

// Wrong: two threads can read the same value and lose an increment.
Long seen = hits.get("home");
hits.put("home", (seen == null ? 0L : seen) + 1);

// Right: the whole read-modify-write runs atomically per key.
hits.merge("home", 1L, Long::sum);

// Or with compute when you need more logic.
hits.compute("home", (key, current) -> current == null ? 1L : current + 1);

The guarantee is simple. For a given key, only one thread runs your function at a time. Other keys move on without waiting. The cost is that your function must be fast and must not call back into the same map, or you can deadlock yourself.

When I need a counter that thousands of threads bump, AtomicLong starts to hurt. Every increment is a compare-and-set loop on one memory location. Under heavy contention, most threads fail the CAS and retry. LongAdder fixes this by giving each thread its own cell and summing lazily when you call sum().

LongAdder requests = new LongAdder();
requests.increment();          // cheap under contention
requests.add(5);
long total = requests.sum();   // reads all cells and adds them

The trade is that sum() isn’t a snapshot. If writes keep coming while you sum, your total may be slightly stale. For metrics, that is fine. For money, use AtomicLong and accept the contention. LongAccumulator is the same idea with a custom function instead of just addition.

LongAccumulator maxLatency = new LongAccumulator(Long::max, 0L);
maxLatency.accumulate(37);
maxLatency.accumulate(12);   // stays 37, because max

Next, CopyOnWriteArrayList. The name says everything. Every write copies the whole array. Reads never lock. I use it for listener registries where I add a listener once a week and iterate the list a million times a second.

CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();

// Iteration is safe even if another thread adds a listener mid-loop.
for (Listener l : listeners) {
    l.onEvent(event);
}

// This allocates a new array. Don't call it in a hot path.
listeners.add(newListener);

The guarantee is that iterators never throw ConcurrentModificationException and always see a consistent snapshot. The cost is memory churn. If you write often, this list will destroy your garbage collector.

For producer-consumer pipelines I reach for a BlockingQueue. ArrayBlockingQueue has a fixed size and one lock. LinkedBlockingQueue grows on demand with two locks, one for each end. SynchronousQueue has no storage at all and hands the item directly to a waiting consumer. DelayQueue releases items only after their delay expires, which is perfect for retry schedules.

BlockingQueue<Job> queue = new ArrayBlockingQueue<>(1024);

// Producer side, blocks when full.
queue.put(job);

// Consumer side, blocks when empty.
Job next = queue.take();

The guarantee is backpressure. Your producer can’t outrun your consumer. The cost is that a blocked thread is parked on a monitor, which shows up clearly in thread dumps as WAITING on a queue.

If I want no blocking at all, I use ConcurrentLinkedQueue. It is wait-free for the common case. Threads use CAS to link new nodes and never park.

ConcurrentLinkedQueue<Task> work = new ConcurrentLinkedQueue<>();
work.offer(task);       // never blocks
Task t = work.poll();   // returns null if empty, never blocks

The guarantee is progress even under contention. The cost is that size() walks the list in linear time and you get no backpressure. If nothing limits the producers, memory will grow until you notice.

Now the synchronization side. ReentrantLock gives you what synchronized gives you plus a few honest extras. You can try to acquire without waiting, you can time out, and you can have more than one condition.

ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();

lock.lock();
try {
    while (items.isEmpty()) {
        notEmpty.await();
    }
    return items.removeFirst();
} finally {
    lock.unlock();
}

The rule I follow: always lock() outside the try, always unlock() in a finally. If you lock inside the try and the lock call throws, you unlock something you never held.

StampedLock is the one people get wrong most often. It offers a read lock that doesn’t block writers, which is great for read-mostly data. But it is not reentrant, and you must not call other blocking code while holding a stamp.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
double x = this.x, y = this.y;
if (!sl.validate(stamp)) {
    stamp = sl.readLock();
    try {
        x = this.x;
        y = this.y;
    } finally {
        sl.unlockRead(stamp);
    }
}
return Math.hypot(x, y);

The optimistic path usually costs nothing. When a writer has touched the values, the validate fails and you fall back to a real read lock. The cost is complexity. I only reach for StampedLock when profiling shows read locks dominating.

CountDownLatch is a one-shot gate. One thread waits, many threads count down. I use it for “wait until three services have warmed up.”

CountDownLatch ready = new CountDownLatch(3);
for (Service s : services) {
    new Thread(() -> {
        s.warmUp();
        ready.countDown();
    }).start();
}
ready.await();
System.out.println("All warm.");

Phaser is the flexible version. It supports dynamic registration, so tasks can join and leave between phases. I use it for iterative work where every round must finish before the next starts.

Phaser phaser = new Phaser(1); // register the main thread

for (int i = 0; i < workers; i++) {
    phaser.register();
    new Thread(() -> {
        for (int round = 0; round < 5; round++) {
            doWork(round);
            phaser.arriveAndAwaitAdvance();
        }
        phaser.arriveAndDeregister();
    }).start();
}
phaser.arriveAndDeregister();

A Semaphore counts permits. I use it to cap how many threads can hit a slow downstream at once.

Semaphore slots = new Semaphore(10);

slots.acquire();
try {
    callSlowService();
} finally {
    slots.release();
}

One pattern I use for fairness is a tryAcquire with a timeout so a stuck thread doesn’t hang forever.

if (slots.tryAcquire(2, TimeUnit.SECONDS)) {
    try {
        callSlowService();
    } finally {
        slots.release();
    }
} else {
    metrics.increment("throttled");
}

Exchanger is the odd one. Two threads meet, swap objects, and go their separate ways. I use it for double buffering, where one thread fills a buffer while the other drains the previous one.

Exchanger<byte[]> swap = new Exchanger<>();

// Producer
byte[] empty = new byte[4096];
byte[] full = swap.exchange(empty);
// now full contains data produced by the consumer side

// Consumer
byte[] ready = swap.exchange(someBuffer);
// now ready contains data produced by the producer side

The guarantee is a rendezvous. Both threads block until the other arrives. The cost is that if one side dies, the other waits forever. I always wrap it with a timeout.

Now the part that bites people. AtomicReference with a compare-and-set loop is not automatically faster than a lock. Under low contention it wins big, because there is no parking. Under high contention, threads spin and burn CPU while the winner does the work.

AtomicReference<Config> ref = new AtomicReference<>(initial);

void update(UnaryOperator<Config> change) {
    while (true) {
        Config current = ref.get();
        Config next = change.apply(current);
        if (ref.compareAndSet(current, next)) {
            return;
        }
        // another thread changed it, loop and try again
    }
}

If that change.apply is expensive, you are doing that work on every failed retry. A short synchronized block would have been cheaper. I benchmark both before I decide.

False sharing is another trap. Two counters that live next to each other in memory share a cache line. When thread A writes one, thread B’s cache line is invalidated, even though B only touches the other. The fix is padding.

@jdk.internal.vm.annotation.Contended
static final class PaddedCounter {
    volatile long value;
}

On JDK 8 and later with -XX:-RestrictContended, the annotation gives you the padding. Without it, two hot counters in the same object can cost you a lot of throughput for no obvious reason.

I also want to say something honest about synchronized. For short critical sections, it is often the fastest option. The JVM can bias and lock-eliminate it. It gives you safe publication and happens-before for free. If your critical section is a few field assignments, don’t reach for ReentrantLock. The extra flexibility is not worth the extra footgun.

For observability, I lean on jcmd. A thread dump tells you who is parked, who is blocked, and where. Run it three times thirty seconds apart if you want to see whether a thread is stuck or just slow.

jcmd <pid> Thread.print
jcmd <pid> Thread.print -l   # include lock owners

The -l flag shows which lock each blocked thread is waiting on and who owns it. That has saved me hours more than once.

JFR is the other half. The jdk.JavaMonitorEnter and jdk.ThreadPark events show how often threads block and for how long. If I suspect a lock is hot, this is where I look first.

jcmd <pid> JFR.start name=locktest duration=60s filename=lock.jfr

When I moved to virtual threads, most of this stayed the same. A LongAdder still counts. A ConcurrentLinkedQueue still hands off work. But two things changed. synchronized blocks now pin a carrier thread, so I replace them with ReentrantLock in code that runs on virtual threads. And blocking calls like queue.take() no longer park an OS thread, which means a producer-consumer pipeline with ten thousand virtual consumers is now realistic.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            try {
                Job j = queue.take();
                process(j);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

Behind a CompletableFuture pipeline, the same tools hold up. A ConcurrentHashMap<String, CompletableFuture<Result>> is a clean way to make sure a slow lookup runs once no matter how many callers ask for it.

ConcurrentHashMap<String, CompletableFuture<Result>> cache = new ConcurrentHashMap<>();

CompletableFuture<Result> get(String key) {
    return cache.computeIfAbsent(key,
        k -> CompletableFuture.supplyAsync(() -> slowLookup(k)));
}

The compute runs once per key. Everyone else shares the same future. When it completes, they all complete.

What I want you to take away is that each tool has a job. ConcurrentHashMap for shared maps. LongAdder for hot counters. CopyOnWriteArrayList for read-heavy registries. BlockingQueue for backpressure. ConcurrentLinkedQueue for wait-free handoff. ReentrantLock and StampedLock when you need more than synchronized gives you. CountDownLatch and Phaser for milestones. Semaphore for bounded access. Exchanger for pairwise swaps. And AtomicReference with CAS only when the work inside the loop is small.

I still write synchronized blocks. I just don’t write them everywhere anymore.


// Keep Reading

Similar Articles