The problem: a counter that loses updates
A service counts processed requests in a plain field and exposes the value on a status endpoint. In a load test, one million requests are handled by many threads, yet the counter shows fewer than one million. Nothing crashes, no exception is logged, and the number differs on every run. This is a race condition, the most typical concurrency bug: correctness depends on how the threads happen to interleave.
class RequestCounter { private long count; void increment() { count++; } // read, add, write: three steps long get() { return count; }}count++ looks like one operation but is a read, an addition and a write. Two threads can read the same value, both add one and both write the same result, so one update is lost. A second, separate problem is that one thread might never see another thread's write at all. Both issues need a clear mental model.
Threads versus ExecutorService
You can create a thread directly with new Thread(task).start(). It works for a demo, but in services it leads to unbounded thread creation, no reuse, no central place for naming, error handling or shutdown.
ExecutorService separates what to run (a Runnable or Callable) from how it runs (the pool policy).
ExecutorService pool = Executors.newFixedThreadPool(8);try { Future<Report> f = pool.submit(() -> reportService.build(customerId)); Report report = f.get(5, TimeUnit.SECONDS);} catch (ExecutionException e) { throw new ReportFailedException(e.getCause()); // task's own exception} finally { pool.shutdown();}Points to remember:
- An exception thrown inside a submitted
Callabledoes not propagate to the submitter until you callget(), where it arrives wrapped in anExecutionException. Withexecute(Runnable), an uncaught exception goes to the thread's uncaught-exception handler, which by default just prints it. - Size pools deliberately. CPU-bound work wants a number of threads close to the number of cores; blocking I/O wants more, but not unlimited. A pool is also a limit on concurrency, which can protect a downstream system.
- Always shut pools down, and give threads meaningful names through a
ThreadFactoryso thread dumps are readable. - In Spring, prefer a configured
TaskExecutoror@Asyncwith a named executor rather than rawExecutorscalls scattered through the code. CompletableFuturecomposes asynchronous steps. Pass your own executor tosupplyAsync, otherwise tasks use the common pool.
Visibility and happens-before
Modern CPUs and the JVM reorder and cache operations for speed. Without synchronisation, the Java Memory Model (JLS chapter 17) does not say that a write made by thread A will ever be seen by thread B, or that the order of writes will look the same to both. The model defines a happens-before relationship: if action X happens-before action Y, then the effects of X are visible to Y and are ordered before it.
The important happens-before edges for application code:
- Unlocking a monitor happens-before every later locking of the same monitor (
synchronized). - A write to a
volatilefield happens-before every later read of that same field. Thread.start()happens-before any action in the started thread, and all actions in a thread happen-before another thread successfully returns fromjoin()on it.- Actions before submitting a task to an executor happen-before the task runs, and the task's actions happen-before the result returned through
Future.get(). - Within a single thread, program order applies.
Without such an edge between a write and a read, you have a data race, and the program's behaviour is not something you can reason about. The classic example is a flag:
class Worker implements Runnable { private boolean stopped; // BUG: not volatile public void run() { while (!stopped) { doWork(); } // may loop forever } void stop() { stopped = true; }}The JIT compiler is allowed to read stopped once and keep it in a register, because nothing in the loop says another thread might change it. Declaring the field volatile creates the needed edge.
volatile versus synchronized
volatile gives visibility and ordering for a single variable. It does not give atomicity for compound actions. volatile long count; count++; is still a lost-update race.
synchronized gives both mutual exclusion (only one thread at a time holds the monitor) and visibility (the happens-before edge above). Use it, or ReentrantLock, to protect an invariant that involves more than one variable or a check-then-act sequence.
class Account { private long balance; synchronized void withdraw(long amount) { if (balance < amount) throw new IllegalStateException("insufficient"); balance -= amount; // check and update as one unit } synchronized long balance() { return balance; } // read must also lock}Rules of thumb: use volatile for simple state flags and safely published references; use synchronized or locks when several steps must happen as one unit; keep critical sections short; never call unknown code (callbacks, overridable methods) while holding a lock, because that is how deadlocks happen.
Atomic classes
For single-variable counters and accumulators, java.util.concurrent.atomic provides lock-free atomic operations built on compare-and-set, with the same memory visibility as volatile.
class RequestCounter { private final AtomicLong count = new AtomicLong(); void increment() { count.incrementAndGet(); } long get() { return count.get(); }}That is the fix for the opening example. Under heavy contention LongAdder often scales better for pure counters, at the price of a more expensive sum(). Atomics help with one variable; two atomics updated one after the other are not an atomic pair, so a multi-field invariant still needs a lock.
ConcurrentHashMap
A plain HashMap shared between threads is unsafe: lost updates and corrupted internal state are possible. ConcurrentHashMap is designed for concurrent access, and individual operations are thread-safe. But a sequence of calls is not atomic:
// Race: two threads can both see "absent" and both putif (!cache.containsKey(key)) { cache.put(key, load(key));}// Atomic per keycache.computeIfAbsent(key, k -> load(k));Use computeIfAbsent, merge and compute for check-then-act on a single key, and keep the lambda short and free of blocking work or modifications to the same map. ConcurrentHashMap rejects null keys and values, which removes the ambiguity of get returning null. See choosing collections for the broader decision.
Common mistakes
- Assuming a
volatileor "thread-safe" field makes a compound operation safe. - Synchronising on a mutable field, on a string literal or on a boxed value; use a private final lock object.
- Holding locks while doing I/O, which stalls every thread waiting for the lock.
- Acquiring several locks in different orders in different places, which produces a deadlock.
- Using a plain
SimpleDateFormat-style shared mutable helper across threads; prefer immutable types such asjava.timeclasses. - Calling
Thread.sleepto "wait for" another thread instead of using a proper signal such asCountDownLatch, aFutureor aBlockingQueue. - Ignoring interruption. Respect
InterruptedExceptionfor cancellation. - Relying on parallel streams for blocking work; see stream pitfalls.
Trade-offs
The safest concurrent design is the one that shares nothing: confine data to one thread, or pass immutable objects. When you must share, prefer high-level constructs (concurrent collections, queues, executors) over hand-rolled locking. Locks are simple to reason about but can block and deadlock; atomics and lock-free structures avoid blocking but are limited to simple state and can be subtle. Finer-grained locking increases throughput under contention and also the chance of mistakes.
Since Java 21, virtual threads change the cost of blocking threads, but they do not change the memory model or the need for safe sharing; see virtual threads.
Checklist
- Shared mutable state identified, and each piece has a named protection (lock, atomic, confinement, immutability).
- Executors used instead of raw threads, with named threads and a shutdown path.
- Compound actions use
synchronized, locks orcompute-style methods. - Flags read in loops are
volatile. - Locks have a documented order and are never held during I/O.
- A concurrency test exists that stresses the code with many threads, knowing that passing does not prove correctness.
Where to go next
Read virtual threads to see how Java 21 changes the thread-per-request model, and memory and garbage collection for what happens on the heap. The middle Java developer roadmap shows where concurrency sits among the surrounding skills.