Writing thread-safe code is, at its core, about managing access to state, and in particular to shared, mutable state.
By shared, we mean that a variable could be accessed by multiple threads; by mutable, we mean that its value could change during its lifetime. We may talk about thread safety as if it were about code, but what we are really trying to do is protect data from uncontrolled concurrent access.
Whenever more than one thread accesses a given state variable, and one of them might write to it, they all must coordinate their access to it using synchronization.
If multiple threads access the same mutable state variable without appropriate synchronization, your program is broken. There are three ways to fix it:
- Don’t share the state variable across threads;
- Make the state variable immutable; or
- Use synchronization whenever accessing the state variable.
It is far easier to design a class to be thread-safe than to retrofit it for thread safety later.
When designing thread-safe classes, good object-oriented techniques—encapsulation, immutability, and clear specification of invariants—are your best friends.
A class is thread-safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of the execution of those threads by the runtime environment, and with no additional synchronization or other coordination on the part of the calling code.
No set of operations performed sequentially or concurrently on instances of a thread-safe class can cause an instance to be in an invalid state.
Thread-safe classes encapsulate any needed synchronization so that clients need not provide their own.
Stateless objects are always thread-safe.
The possibility of incorrect results in the presence of unlucky timing is so important in concurrent programming that it has a name: a race condition.
race condition or data race?
The term _race condition_ is often confused with the related term _data race_, which arises when synchronization is not used to coordinate all access to a shared non-final field. You risk a data race whenever a thread writes a variable that might next be read by another thread or reads a variable that might have last been written by another thread if both threads do not use synchronization; code with data races has no useful defined semantics under the Java Memory Model. Not all race conditions are data races, and not all data races are race conditions, but they both can cause concurrent programs to fail in unpredictable ways.The most common type of race condition is check-then-act, where a potentially stale observation is used to make a decision on what to do next.
Using a potentially stale observation to make a decision or perform a computation is what characterizes most race conditions. This type of race condition is called check-then-act: you observe something to be true (file X doesn't exist) and then take action based on that observation (create X); but in fact the observation could have become invalid between the time you observed it and the time you acted on it (someone else created X in the meantime), causing a problem (unexpected exception, overwritten data, file corruption).
A common idiom that uses check-then-act is lazy initialization.
The following snippet has race conditions (don't do this!) that can undermine its correctness. Say that threads
A and B execute getInstance
at the same time. A sees that instance is null
, and instantiates a new
ExpensiveObject
. B also checks if instance is null
. Whether instance is null
at this point depends
unpredictably on timing, including the vagaries of scheduling and how long A takes to instantiate the
ExpensiveObject
and set the instance field. If instance is null
when B examines it, the two callers to
getInstance
may receive two different results, even though getInstance
is always supposed to return
the same instance.
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
}
Operations A and B are atomic with respect to each other if, from the perspective of a thread executing A, when another thread executes B, either all of B has executed or none of it has. An atomic operation is one that is atomic with respect to all operations, including itself, that operate on the same state.
We refer collectively to check-then-act and read-modify-write sequences as compound actions: sequences of operations that must be executed atomically in order to remain thread-safe.
When a single element of state is added to a stateless class, the resulting class will be thread-safe if the state is entirely managed by a thread-safe object.
Where practical, use existing thread-safe objects, like AtomicLong
, to manage your class’s state. It is simpler
to reason about the possible states and state transitions for existing thread-safe objects than it is for arbitrary
state variables, and this makes it easier to maintain and verify thread safety.
When multiple variables participate in an invariant, they are not independent: the value of one constrains the allowed value(s) of the others. Thus, when updating one, you must update the others in the same atomic operation.
To preserve state consistency, update related state variables in a single atomic operation.
Java provides a built-in locking mechanism for enforcing atomicity: the synchronized block. Every Java object can implicitly act as a lock for purposes of synchronization; these built-in locks are called intrinsic locks or monitor locks.
Intrinsic locks in Java act as mutexes (or mutual exclusion locks), which means that at most one thread may own the lock.
Reentrancy means that locks are acquired on a per-thread rather than per-invocation basis.
Intrinsic locks are reentrant, if a thread tries to acquire a lock that it already holds, the request succeeds.
Holding a lock for the entire duration of a compound action can make that compound action atomic.
If synchronization is used to coordinate access to a variable, it is needed everywhere that variable is accessed.
When using locks to coordinate access to a variable, the same lock must be used wherever that variable is accessed.
For each mutable state variable that may be accessed by more than one thread, all accesses to that variable must be performed with the same lock held. In this case, we say that the variable is guarded by that lock.
Every shared, mutable variable should be guarded by exactly one lock. Make it clear to maintainers which lock that is.
For every invariant that involves more than one variable, all the variables involved in that invariant must be guarded by the same lock.
Acquiring and releasing a lock has some overhead, so it is undesirable to break down synchronized blocks too far.
There is frequently a tension between simplicity and performance. When implementing a synchronization policy, resist the temptation to prematurely sacrifice simplicity (potentially compromising safety) for the sake of performance.
Avoid holding locks during lengthy computations or operations at risk of not completing quickly such as network or console I/O.