Java Concurrency Issues and Thread Synchronization
Java 14 min read
Thread interference and memory consistency are two different bugs with two different fixes. Why volatile solves one and not the other, what synchronized actually locks, and the lock objects that are shared with code you have never seen.
Almost every concurrency bug in application code is one of two things, and they are routinely treated as one. Thread interference is a lost update: two threads perform a read-modify-write and one overwrites the other. Memory consistency is a visibility problem: a thread writes a value and another thread never sees it, possibly forever.
They have different fixes. synchronized happens to solve both, which is why the distinction gets
lost, but volatile solves only the second, and an Atomic class solves only the first unless you
are careful about what else it guards. Picking the cheap tool for the wrong failure produces code
that passes every test and breaks in production under load.
This assumes you know what a thread is; if not, start with the concurrency basics. Written against Java 17.
Interference: what count++ really is
class Counter {
private int count = 0;
void increment() { count++; }
int get() { return count; }
}
count++ compiles to three bytecodes, getfield, iadd, putfield, a thread can be descheduled
between any two of them:
| Thread A | Thread B | count | |
|---|---|---|---|
| 1 | read 7 | 7 | |
| 2 | read 7 | 7 | |
| 3 | write 8 | 8 | |
| 4 | write 8 | 8 |
Two increments, one result. The demonstration is reliable enough to run:
Counter c = new Counter();
Thread a = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
Thread b = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
a.start(); b.start();
a.join(); b.join();
System.out.println(c.get()); // some number below 200000
The number varies per run and gets closer to correct on a slower machine, which is exactly what makes this class of bug expensive: it is load-dependent and it does not reproduce on a laptop.
Any read-modify-write has this shape — list.add(compute(list.size())), if (map.get(k) == null) map.put(k, v), balance -= amount. The tell is that the new value depends on the old one.
Memory consistency: the write nobody sees
Different failure, no interleaving required:
class Worker implements Runnable {
private boolean running = true;
public void run() {
while (running) {
// work
}
}
void stop() { running = false; }
}
Another thread calls stop(). The loop may never exit. Nothing here is a race in the
read-modify-write sense — there is one writer and one reader, and the write definitely happens.
The reason is that the Java Memory Model does not promise a thread ever re-reads a field from main
memory. running may live in a register or a core-local cache, and because the loop body never
touches it, the JIT is entitled to hoist the read out entirely:
if (running) { while (true) { /* work */ } } // a legal transformation
That is not a bug in the JIT. The program never established a happens-before relationship between the write and the read, so the compiler was free to assume no other thread was involved.
Happens-before is the whole model in one idea: unless some rule orders a write before a read, the read is not guaranteed to see it. The rules you will actually use are these.
- Releasing a monitor happens-before any later acquisition of the same monitor.
- A write to a
volatilefield happens-before every later read of that field. Thread.start()happens-before anything the started thread does.- Everything a thread does happens-before another thread’s
join()on it returns. - Everything before a
CountDownLatch.countDown()happens-beforeawait()returns.
volatile: visibility, and nothing else
private volatile boolean running = true;
That fixes the loop. Every read goes to memory, every write is published, and the JIT cannot hoist it. It is cheap — no lock, no contention, roughly the cost of a memory barrier.
It does not fix the counter:
private volatile int count = 0;
void increment() { count++; } // still loses updates
volatile makes each read and each write visible. It does not make the read and the write one
operation. The interleaving table above is unchanged.
The rule that follows: volatile is correct when a thread only ever assigns a value that does
not depend on the current one — a stop flag, a configuration reference swapped wholesale, a
“initialisation finished” marker. The moment the new value is a function of the old one, it is the
wrong tool.
One thing volatile does that is easy to miss: everything written before a volatile write is
visible to a thread that reads that volatile and sees the new value. That is what makes the
“publish a fully built object by assigning a volatile reference last” idiom work.
synchronized: what is actually being locked
synchronized gives mutual exclusion and the happens-before edge, so it covers both failures.
class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Both methods must be synchronised. A synchronised writer and an unsynchronised reader gives you mutual exclusion between writers and no visibility guarantee for the reader — a half-fix that looks finished.
Every object has one intrinsic lock, and the shorthand forms hide which one you took:
synchronized void instanceMethod() { }
// identical to:
void instanceMethod() { synchronized (this) { } }
static synchronized void staticMethod() { }
// identical to:
static void staticMethod() { synchronized (Counter.class) { } }
Those are two different locks. A class with a synchronised instance method and a synchronised static method touching the same static field is not protected at all, and the code reads as though it is.
Being explicit is usually better than the modifier, because a private lock object cannot be acquired by anyone else:
class Counter {
private final Object lock = new Object();
private int count = 0;
void increment() {
synchronized (lock) { count++; }
}
}
With synchronized on the method, the lock is this, and this is public. Any caller holding a
reference can synchronized (counter) { ... } and stall your internals for as long as it likes.
Intrinsic locks are reentrant — a thread already holding a lock can acquire it again, which is what lets one synchronised method call another without deadlocking itself.
Lock objects that are shared with strangers
Two lock choices are actively dangerous because the object is not yours:
synchronized ("lockName") { } // string literals are interned — globally shared
synchronized (Integer.valueOf(1)) { } // boxed -128..127 are cached — globally shared
Any other class in the JVM that locks on the same literal, including library code, is now contending
with you and can deadlock against you. Use a private final Object.
The same reasoning applies to locking on a mutable field. If the reference can be reassigned, two
threads can end up synchronising on different objects and both proceed. Make the lock field final.
Granularity
Synchronising a whole method is the easy choice and often the wrong one:
synchronized void process(Request r) {
Response res = callRemoteService(r); // 200ms, touches no shared state
results.add(res); // the only line that needs the lock
}
Every thread now queues behind a network call. Narrow it:
void process(Request r) {
Response res = callRemoteService(r);
synchronized (lock) { results.add(res); }
}
The opposite mistake is narrowing until a compound operation is split across two blocks. If the invariant spans both, the lock must too.
Atomics and CAS
For a single variable, java.util.concurrent.atomic is faster than a lock:
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
There is no lock. incrementAndGet compiles down to a compare-and-swap instruction — read the
value, compute, swap it in only if it has not changed, retry otherwise. Under low contention the
retry almost never happens.
Under high contention the retries become the cost, and LongAdder wins: it spreads the count
across per-thread cells and sums them on sum(). Use it for hot metrics counters, not where you
need an exact instantaneous read.
The limit is the same as volatile’s, one level up. An atomic protects one variable. Two atomics
that must agree with each other are two independent atomics, and there is no interleaving-free
window across both.
Compound operations on concurrent collections
ConcurrentHashMap makes every individual method atomic, and people then write:
if (!map.containsKey(k)) { // check
map.put(k, expensive()); // act — two threads can both get here
}
The map is thread-safe; the sequence is not. The collection supplies atomic versions of exactly these patterns:
map.putIfAbsent(k, v);
map.computeIfAbsent(k, key -> expensive()); // the supplier runs at most once per key
map.merge(k, 1L, Long::sum); // atomic accumulate
Prefer ConcurrentHashMap over Collections.synchronizedMap(new HashMap<>()). The wrapper locks the
whole map on every call and gives you no atomic compound operations at all — the worst of both.
Iterating a synchronised wrapper also requires manual synchronisation on the wrapper, or you get
ConcurrentModificationException.
A related trap worth naming: a shared object that is not obviously mutable can still be the shared
state. SimpleDateFormat is the classic case — see
why it is not thread-safe.
ReentrantLock, when synchronized is not enough
private final ReentrantLock lock = new ReentrantLock();
void process() {
if (!lock.tryLock(2, TimeUnit.SECONDS)) {
return; // give up instead of blocking forever
}
try {
// critical section
} finally {
lock.unlock(); // must be in a finally — the block does not do it for you
}
}
Reach for it when you need a timed or interruptible acquisition, a fairness policy, more than one
condition variable, or a lock that is released in a different scope than it was acquired. Otherwise
synchronized is simpler and cannot leak an unreleased lock.
ReadWriteLock is worth it only when reads massively outnumber writes and the critical sections are
long; the bookkeeping is not free.
The best fix is no shared state
Every technique above is a way of managing mutable state that two threads reach. Removing the sharing removes the problem class:
- Immutable objects. A
finalfield set in the constructor is safely published to every thread with no synchronisation at all — that is a happens-before rule of its own.recordtypes get this for free. - Confinement. A local variable is thread-confined by construction.
ThreadLocalconfines a field, though it needs cleanup on pooled threads. - Hand-offs instead of sharing. A
BlockingQueuebetween a producer and a consumer moves ownership rather than sharing it, and the queue’s own synchronisation supplies the happens-before edge. This is what an ExecutorService is doing underneath.
Then measure. jcmd <pid> Thread.print dumps every stack with lock ownership and is the fastest way
to find a deadlock in a running JVM. For interference and visibility, the tool is a stress test that
runs the operation from several threads and asserts the invariant afterwards — the single-threaded
unit test will pass regardless.
More on this topic in the Java guides.
Frequently asked questions
What is the difference between thread interference and a memory consistency error?
Interference is two threads interleaving a read-modify-write so one update is lost. A memory consistency error is a thread not seeing another thread’s write at all. Interference needs mutual exclusion; visibility needs a happens-before edge.
Does volatile make an operation atomic?
No. It makes each individual read and write visible
across threads. count++ is a read, an add and a write, so it can still lose updates on a volatile
field.
When is volatile enough on its own?
When a thread only assigns values that do not depend on the current one — a stop flag, a reference swapped wholesale, a completion marker.
Is synchronized slow?
An uncontended lock is cheap; the JIT biases and elides locks that never
see a second thread. The cost is contention — threads blocking on each other — so the size of the
critical section matters far more than the number of synchronized keywords.
Do a synchronized instance method and a static synchronized method exclude each other?
No. The
instance method locks this, the static one locks the Class object. Two different locks, no
mutual exclusion.
Why should I not synchronize on this?
Because this is reachable by every caller, so anyone can
acquire your lock and hold it. A private final Object lock cannot be touched from outside.
Why is locking on a String literal dangerous?
Literals are interned, so the same string anywhere in the JVM is the same object. Unrelated code — including libraries — can be contending on your lock, and deadlock against you.
Is ConcurrentHashMap enough to make my code thread-safe?
Only per operation. containsKey
followed by put is two operations and can interleave. Use putIfAbsent, computeIfAbsent or
merge, which are atomic.
AtomicInteger or synchronized for a counter?
AtomicInteger — no lock, and it is a
compare-and-swap under the hood. Under very heavy contention use LongAdder, which trades an exact
instantaneous read for far less retrying.
Do I need to synchronize reads if only one thread writes?
Yes, for visibility. One writer and
many readers still needs volatile, an atomic, or a lock on both sides, or the readers may never
observe the write.
Does making a field final make it thread-safe?
It makes the reference safely published — other
threads see the fully constructed object without synchronisation. It says nothing about the object
the reference points at, so final List<T> list is still a shared mutable list.
Where do I start looking when a concurrent program hangs?
jcmd <pid> Thread.print. It shows
every thread’s stack, what it is blocked on and which thread owns that monitor, which is usually the
whole answer for a deadlock.