Concurrency
Writing correct concurrent code is one of the hardest things a programmer can be asked to do. Single-threaded code is easy to reason about because what happens and when it happens are locked together - a debugger can tell you the entire state of a program just by showing you the call stack. Concurrency deliberately breaks that link, decoupling what gets done from when it gets done, and that decoupling is exactly why concurrent bugs are so much harder to find than ordinary ones.
Why bother with concurrency at all
The appeal isn't abstract. Decoupling what from when can make a system's structure clearer - instead of one long procedural main loop, the system looks like many small, independent collaborators, each responsible for one job. It can also solve problems that a single thread genuinely cannot: a job that has to poll dozens of slow web services within a fixed time budget, a server that has to stay responsive as its user count climbs into the hundreds, or a batch job that can split its data across multiple machines and run the pieces in parallel. None of those get faster by writing better single-threaded code - they need real concurrency.
Myths that make concurrency more dangerous
A few widely held beliefs about concurrency are simply false, and believing them is what gets teams into trouble:
- "Concurrency always improves performance." It only helps when there's real wait time to overlap - idle time waiting on I/O, or genuinely independent work that can run on separate cores. Absent that, threads just add coordination overhead for no benefit.
- "The design doesn't change when you add threads." It does, often drastically. A concurrent version of an algorithm can look nothing like its single-threaded counterpart, because decoupling what from when reshapes the whole structure.
- "A container (web server, EJB container, etc.) handles concurrency for me, so I don't need to think about it." Containers manage some of it, but any state your own code shares across requests is still your problem, and getting it wrong still causes real bugs.
It's worth adding a few blunter truths alongside the myths: concurrency always costs something in overhead and code complexity, correct concurrent code is hard even for simple-looking problems, and concurrency bugs are rarely repeatable - which is precisely why teams are tempted to dismiss them as flukes instead of fixing them.
Why it's so hard: a trivial example
Consider a class that hands out sequential ids by incrementing a field and returning it. Run that on a single thread and it's flawless. Share one instance between two threads, and "increment then return" stops being one operation - it's a read, an add, and a write, and the two threads' reads/adds/writes can interleave in ways that lose an update entirely, so both threads can walk away with the same id. Nothing in the source code looks wrong; the bug only exists in the gap between what the code says and what the machine actually executes underneath it, one instruction at a time. Even this single-line increment compiles down to enough byte-code steps that two threads have thousands of distinct ways to interleave through it - and only a handful of those interleavings produce the wrong answer, which is exactly why the bug hides so well.
Concurrency defense principles
A handful of design habits keep concurrent code from turning into a minefield:
- Apply the Single Responsibility Principle to threading. Concurrency-related code has its own lifecycle of tuning and bugs, distinct from ordinary business logic, and deserves to live in its own small, focused classes rather than being tangled into everything else.
- Limit the scope of shared data. Every additional place that touches shared state is another place someone can forget to guard it, and another place a bug can hide. Fewer critical sections, more encapsulation.
- Prefer copies of data over sharing it. If a thread can work on its own copy and only merge results at the end, there's nothing left to synchronize over during the actual work. The extra allocation is usually a much better trade than a lock.
- Keep threads as independent as possible. A thread that only ever touches its own local variables behaves as if it's the only thread in the world - it has no synchronization problems to have, because there's no shared state to step on.
Know your execution models
Most real concurrency problems boil down to variations on three classic patterns, and it's worth being able to recognize each one on sight:
- Producer-consumer. One or more producer threads generate work and place it on a shared queue; one or more consumer threads pull work off that queue and process it. The queue is a bounded resource, so producers wait when it's full and consumers wait when it's empty, with each side signaling the other as the queue's state changes.
- Readers-writers. A shared resource is read constantly and updated occasionally. Favor readers too heavily and writers starve, leaving stale data around forever; favor writers too heavily and reader throughput collapses while everyone waits for updates to finish. The whole problem is finding a balance between the two.
- Dining philosophers. A group of threads compete for a shared set of resources acquired in some order (the classic image: philosophers around a table, each needing the fork to their left and right to eat). Get the acquisition order wrong and the whole system can deadlock - or livelock, where every thread stays busy stepping aside for the others but none of them ever actually makes progress.
Dependent method calls are a code smell
Calling more than one synchronized method on the same shared object, in sequence, is a common source of subtle bugs - another thread can slip in between the two calls and change the object's state right when your code assumed nothing else was happening. There are three ways to make this safe when it's unavoidable:
- Client-based locking - the calling code acquires the lock before the first call and holds it until after the last one.
- Server-based locking - the shared object exposes one new method that performs the whole sequence internally, under a single lock, so the client only ever calls one method.
- Adapted server - when the shared object can't be modified, wrap it in an adapter that performs the locking on the client's behalf.
Needing any of these three is itself a signal worth noticing: a design where objects don't depend on the internal state of other shared objects across multiple calls is a design with far fewer places for this class of bug to hide.
Increasing throughput and avoiding deadlock
Locks are not free - every synchronized section adds contention and delay, so the goal is the fewest, smallest critical sections that still keep shared data safe. A section that's larger than the update it protects doesn't make the code safer, it just makes every thread wait longer for no benefit.
Deadlock is the classic failure mode of poorly designed locking: two or more threads stuck forever, each holding a resource the other one needs. It requires four conditions to happen at once, and breaking any single one of them is enough to make deadlock impossible:
- Mutual exclusion - a resource can only be held by one thread at a time.
- Lock and wait - a thread can hold one resource while waiting to acquire another.
- No preemption - a resource can't be forcibly taken away from the thread holding it.
- Circular wait - a cycle of threads exists where each one is waiting on the next.
In practice, the easiest condition to break is usually circular wait: if every thread acquires its locks in the same fixed global order, a cycle can never form, because a thread that already holds a "later" lock will never be the one asking for an "earlier" one.
Shutting a concurrent system down cleanly is its own hard problem, and deserves the same respect as the rest of the design. A parent thread waiting for children that are themselves deadlocked, or a shutdown signal that arrives while a consumer is blocked waiting on a producer that already exited, are both ways a "simple" shutdown sequence quietly turns into another deadlock. It's worth designing and testing shutdown early rather than bolting it on at the end.
Testing threaded code
Concurrent bugs are rare, sporadic, and platform-dependent almost by nature, which makes them easy to dismiss and hard to actually catch. A few habits make the search more productive:
- Treat every spurious, hard-to-reproduce failure as a real bug, not a fluke. "It only failed once" is exactly the profile of a genuine concurrency bug, not evidence against one.
- Get the non-threaded logic correct first. Pull as much of the real logic as possible into plain, thread-ignorant classes that can be tested in complete isolation, so a threading bug and an ordinary logic bug never have to be chased down at the same time.
- Make threaded code pluggable and tunable - able to run with one thread, many threads, or a configurable number, and able to swap in fast or slow test doubles for whatever it depends on, so the same code can be exercised under many different conditions.
- Run with more threads than processors. Forcing extra task-switching increases the chance that a missing critical section or a deadlock actually surfaces during a test run instead of hiding.
- Run on every platform you'll deploy to. Different operating systems schedule threads differently, so code that never fails on one platform can fail constantly on another.
Concurrency is a place where the payoff for care is unusually large: the bugs are rare enough that most teams never see them until production, and expensive enough once they do that the extra discipline up front - small critical sections, independent threads, deliberate testing - is almost always the cheaper path.