How an empty synchronized
block can ‘achieve correct threading’ I explain in sections one and two. How it can be a ‘better solution’ I explain in section three. How it can nevertheless be ‘subtle and hard to use correctly’ I show by example in the fourth and final section.
1. Correct threading
In what situations might an empty synchronized
block enable correct threading?
Consider an example.
import static java.lang.System.exit;
class Example { // Incorrect, might never exit
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
toExit = true; }})
.start();
new Thread( () -> { // B
for( ;; ) {
if( toExit ) exit( 0 ); }})
.start(); }
boolean toExit; }
The code above is incorrect. The runtime might isolate thread A’s change to the boolean variable toExit
, effectively hiding it from B, which would then loop forever.
It can be corrected by introducing empty synchronized
blocks, as follows.
import static java.lang.System.exit;
class Example { // Corrected
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
toExit = true;
synchronized( o ) {} }}) // Force exposure of the change
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {} // Seek exposed changes
if( toExit ) exit( 0 ); }})
.start(); }
static final Object o = new Object();
boolean toExit; }
2. Basis for correctness
How do the empty synchronized
blocks make the code correct?
The Java memory model guarantees that an ‘unlock action on monitor m synchronizes-with all subsequent lock actions on m’ and thereby happens-before those actions (§17.4.4). So the unlock of monitor o
at the tail of A’s synchronized
block happens-before its eventual lock at the head of B’s synchronized
block. And because A’s write to the variable precedes its unlock and B’s lock precedes its read, the guarantee extends to the write and read operations: write happens-before read.
Now, ‘[if] one action happens-before another, then the first is visible to and ordered before the second’ (§17.4.5). It is this visibility guarantee that makes the code correct in terms of the memory model.
3. Comparative utility
How might an empty synchronized
block be a better solution than the alternatives?
Versus a non-empty `synchronized` block
One alternative is a non-empty synchronized
block. A non-empty synchronized
block does two things: a) it provides the ordering and visibility guarantee described in the previous section, effectively forcing the exposure of memory changes across all threads that synchronize on the same monitor; and b) it makes the code within the block effectively atomic among those threads; the execution of that code will not be interleaved with the execution of other block-synchronized code.
An empty synchronized
block does only (a) above. In situations where (a) alone is required and (b) could have significant costs, the empty synchronized
block might be a better solution.
Versus a `volatile` modifier
Another alternative is a volatile
modifier attached to the declaration of a particular variable, thereby forcing exposure of its changes. An empty synchronized
block differs in applying not to any particular variable, but to all of them. In situations where a wide range of variables have changes that need exposing, the empty synchronized
block might be a better solution.
Moreover a volatile
modifier forces exposure of each separate write to the variable, exposing each across all threads. An empty synchronized
block differs both in the timing of the exposure (only when the block executes) and in its extent (only to threads that synchronize on the same monitor). In situations where a narrower focus of timing and extent could have significant cost benefits, the empty synchronized
block might be a better solution for that reason.
4. Correctness revisited
Concurrent programming is difficult. So it should come as no surprise that empty synchronized
blocks can be ‘subtle and hard to use correctly’. One way to misuse them (mentioned by Holger) is shown in the example below.
import static java.lang.System.exit;
class Example { // Incorrect, might exit with a value of 0 instead of 1
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
exitValue = 1;
toExit = true;
synchronized( o ) {} }})
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
if( toExit ) exit( exitValue ); }})
.start(); }
static final Object o = new Object();
int exitValue;
boolean toExit; }
Thread B’s statement “if( toExit ) exit( exitValue )
” assumes a synchrony between the two variables that the code does not warrant. Suppose B happens to read toExit
and exitValue
after they’re written by A, yet before the subsequent execution of both synchronized
statements (A’s then B’s). Then what B sees may be the updated value of the first variable (true
) together with the un-updated value of the second (zero), causing it to exit with the wrong value.
One way to correct the code is through the mediation of final fields.
import static java.lang.System.exit;
class Example { // Corrected
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) if( !state.toExit() ) {
state = new State( /*toExit*/true, /*exitValue*/1 );
synchronized( o ) {} }})
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
State state = this.state; /* Local cache. It might seem
unnecessary when `state` is known to change once only,
but see § Subtleties in the text. */
if( state.toExit ) exit( state.exitValue ); }})
.start(); }
static final Object o = new Object();
static record State( boolean toExit, int exitValue ) {}
State state = new State( /*toExit*/false, /*exitValue*/0 ); }
The revised code is correct because the Java memory model guarantees that, when B reads the new value of state
written by A, it will see the fully initialized values of the final fields toExit
and exitValue
, both being implicitly final in the declaration of State
. ‘A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object's final fields.’ (§17.5)
Crucial to the general utility of this technique (though irrelevant in the present example), the specification goes on to say: ‘It will also see versions of any object or array referenced by those final fields that are at least as up-to-date as the final fields are.’ So the guarantee of synchrony extends deeply into data structures.
Subtleties
Local caching of the state
variable by thread B (example above) might seem unnecessary when state
is known to change once only. While it has its original value, the statement “if( state.toExit ) exit( state.exitValue )
” will short circuit and read it once only; otherwise it will have its final value and be guaranteed not to change between the two reads. But as Holger points out, there is no such guarantee.
Consider what could happen if we leave the caching out.
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
// State state = this.state;
//// not required when it’s known to change once only
if( state.toExit ) exit( state.exitValue ); }})
.start(); }
‘An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model. This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization.’ (§17.4)
Seeing therefore that “if( state.toExit ) exit( state.exitValue )
” lies outside of the synchronized block, and that state
is a non-volatile variable, the following transformation would be valid.
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
// State state = this.state;
//// not required when it’s known to change once only
State s = state;
if( state.toExit ) exit( s.exitValue ); }})
.start(); }
This might actually be how the code executes. Then the first read of state
(into s
) might yield its original value, while the next read yields its final value, causing the program to exit unexpectedly with a value of 0 instead of 1.