Concurrent programming in Java often necessitates careful management of shared data to ensure correctness and visibility across threads. The volatile keyword is a common tool for this, guaranteeing that writes to a variable are immediately visible to other threads. However, not all shared variables in concurrent utilities are marked volatile, leading to situations where developers might wonder how visibility is maintained. A prime example is the outcome field within Java's FutureTask.
The FutureTask class is a fundamental component of the java.util.concurrent package, representing an asynchronous computation. It allows a task to be submitted to an executor, and its result to be retrieved later using the get() method. Despite outcome holding the computation's result—written by one thread and read by another—it is not declared volatile. This article explores how FutureTask achieves correct synchronization and visibility for outcome through a technique often referred to as "implicit synchronization," by leveraging the Java Memory Model's (JMM) happens-before guarantees.
Understanding Happens-Before
The Java Memory Model defines the happens-before relationship, a crucial concept for understanding when one action's effects are guaranteed to be visible to another action. It forms the backbone of concurrent programming correctness in Java. The formal definition of happens-before is provided by JSR 133, "Java Memory Model and Thread Specification."
The key happens-before rules are:
- Program Order Rule: Within a single thread, each action happens-before every subsequent action in that thread's program order. This considers control flow, including branches and loops, not just lexical order.
- Monitor Lock Rule: An unlock operation on a monitor (e.g., exiting a
synchronizedblock) happens-before every subsequent lock operation on the same monitor. - Volatile Variable Rule: A write to a
volatilevariable happens-before every subsequent read of that samevolatilevariable. - Thread Start Rule: A call to
Thread.start()happens-before any action in the started thread. - Thread Termination Rule: All actions in a thread happen-before another thread detects that the first thread has terminated (e.g., by
Thread.join()orThread.isAlive()returningfalse). - Thread Interruption Rule: A call to
Thread.interrupt()happens-before the interrupted thread detects the interruption (e.g., by catchingInterruptedExceptionor callingThread.isInterrupted()). - Finalizer Rule: The construction of an object happens-before the start of its
finalize()method. - Transitivity Rule: If action A happens-before action B, and action B happens-before action C, then action A happens-before action C.
In the context of happens-before, an "action" refers to fundamental operations like reading or writing to variables, as well as synchronization operations such as locking, unlocking, and volatile reads/writes. These rules dictate memory visibility and ordering guarantees across threads.
FutureTask's Internal Implementation
Let's examine relevant snipets from the FutureTask source code (typical of JDK 8 and later) to understand how outcome's visibility is ensured without the volatile keyword.
The FutureTask class includes two critical fields for our discussion:
class FutureTask<V> implements RunnableFuture<V> {
/**
* The run state of this task, initially NEW. The run state is volatile,
* but not necessarily declared volatile in Field itself.
* Transitions from:
* NEW -> COMPLETING -> NORMAL
* NEW -> COMPLETING -> EXCEPTIONAL
* NEW -> CANCELLED
* NEW -> INTERRUPTING -> INTERRUPTED
*/
private volatile int state;
/** The result of the computation. Set via set/setException */
private V outcome; // NOT volatile
// ... other fields and methods
}
Notice that state is declared volatile, while outcome is not.
The set method is responsible for setting the computation's result:
protected void set(V v) {
// Only proceed if the task is in NEW state
if (state != NEW) return;
// Atomically transition state from NEW to COMPLETING
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = v; // Write the result to the non-volatile outcome field
// Use a store/release barrier to make all preceding writes visible.
// This effectively transitions state from COMPLETING to NORMAL.
UNSAFE.putOrderedInt(this, stateOffset, NORMAL);
finishCompletion(); // Handle waiters, etc.
}
}
And the get method (or its helper report method) is responsible for retrieving the result:
public V get() throws InterruptedException, ExecutionException {
int s = state; // Volatile read of the state
if (s <= COMPLETING) {
// If not yet completed, await completion
s = awaitDone(false, 0L);
}
return report(s); // Delegate to report to retrieve outcome
}
private V report(int s) throws ExecutionException {
if (s == NORMAL) {
return outcome; // Read the result from the non-volatile outcome field
}
// ... handle exceptions or cancellations
throw new ExecutionException(result); // result holds an exception
}
The Mechanism of Implicit Synchronization
The magic lies in how the state field, being volatile (or effectively volatile due to UNSAFE operations), coordinates visibility for the non-volatile outcome field. Let's trace the happens-before chain:
- Worker Thread (
setmethod):- The worker thread first successfully executes
UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING). This operation acts as a full fence, implying a volatile write that ensures all preceding writes in this thread (if any) are visible and all subsequent reads/writes do not get reordered before it. - Next, the result is assigned:
outcome = v;. By the Program Order Rule, this write tooutcomehappens-before the subsequent actions in the same thread. - Finally, the worker thread executes
UNSAFE.putOrderedInt(this, stateOffset, NORMAL). This is a "release" write. It guarantees that all memory writes that occurred *before* this operation within the same thread (specifically, the write tooutcome) are made globally visible. It acts as a store barrier, preventing writes from being reordered after it. - Combining the Program Order Rule and the semantics of
putOrderedInt, we can establish that the write tooutcomehappens-before thestatetransitions toNORMAL.
- The worker thread first successfully executes
- Consumer Thread (
getmethod):- The consumer thread calls
get(), which performs a read:int s = state;. This is avolatileread, which acts as an "acquire" operation. - When this read observes the
stateasNORMAL, a happens-before relationship is established between the "release" write (UNSAFE.putOrderedInt(..., NORMAL)in the worker thread) and this "acquire" read. This means all writes made visible by the release write (includingoutcome = v;) are now guaranteed to be visible to the consumer thread. - Subsequently, if the state is
NORMAL, the consumer thread callsreport(s), which executesreturn outcome;. By the Program Order Rule, the volatile read ofstatehappens-before the read ofoutcome.
- The consumer thread calls
- **Transitivity:**Because the write to
outcomehappens-before thestatebecomesNORMAL(in the worker thread), and thestatebeingNORMALhappens-before the read ofoutcome(in the consumer thread), the Transitivity Rule ensures that the write tooutcomein the worker thread happens-before the read ofoutcomein the consumer thread. This guarantees that the consumer thread will always see the fully written and correct value ofoutcome.
The Critical Role of the COMPLETING State
The intermediate COMPLETING state, despite seeming transient, is essential for this implicit synchronization. Let's consider a hypothetical simplified set method that bypasses the COMPLETING state:
// Hypothetical set method without COMPLETING
protected void setSimplified(V v) {
// Attempt to directly transition state from NEW to NORMAL
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, NORMAL)) {
outcome = v; // Write the result *after* the state transition
// No explicit putOrderedInt for final state transition
finishCompletion();
}
}
In this simplified scenario, the write to outcome happens *after* the state has been set to NORMAL via a CAS operation. While a CAS operation does include memory barriers, it's typically a read-modify-write operation. If outcome = v; were to happen *after* the compareAndSetInt(..., NEW, NORMAL), the guarantee that outcome = v; happens-before the state becomes NORMAL would be compromised or become more complex to reason about.
The actual FutureTask implementation uses COMPLETING to effectively "sandwich" the outcome assignment between two synchronization operations:
UNSAFE.compareAndSwapInt(..., NEW, COMPLETING): An atomic volatile write.outcome = v;: The non-volatile write.UNSAFE.putOrderedInt(..., NORMAL): A volatile-like release write.
This sequence ensures that by the time state finally transitions to NORMAL (via the release write), the outcome has definitively been set and its write is made visible. The COMPLETING state clearly signals that the result is in the process of being stored, preventing threads from prematurely attempting to read an uninitialized or partially written outcome evenif they somehow observed an intermediate state before NORMAL was fully published.
This intricate design demonstrates a sophisticated application of the Java Memory Model, allowing FutureTask to provide strong memory consistency guarantees for its results without the performance overhead of making outcome itself volatile. It's a testament to the meticulous engineering behind Java's concurrent utilities.