javamultithreadingconcurrencylazy-initialization

Racy Single-Check Idiom with Intermediate Result


The racy single-check idiom is a technique for lazy initialization without synchronization or volatile. It can be used when initialization is allowed to be performed concurrently by multiple threads, but you don't care as long as the result is consistent. The JDK itself does this e.g. in ConcurrentHashMap.keySet (JDK 17):

public KeySetView<K,V> keySet() {
    KeySetView<K,V> ks;
    if ((ks = keySet) != null) return ks;
    return keySet = new KeySetView<K,V>(this, null);
}

This technique only works with primitive values or immutable classes, where every field has to be final to ensure safe publication. Here is an article in German that explains it in depth.

Now we had a discussion whether it is safe to use this technique when you have an intermediate result of the same type during initialization. For example:

class SumUpToTen {
    private Integer result;

    int getResult() {
        Integer res = result;
        if (res != null)
            return res;
        int sum = 0;
        for (int i = 1; i <= 10; i++)
            sum += i;
        result = sum;
        return sum;
    }
}

Here, we are using the racy single-check idiom to initialize the value for the sum of all integers from 1 to 10. This is of course a silly thing to do, but it's an example where in the initialization case, we have several intermediate results of the same type as the final result.

Now we know that in the absence of synchronization and volatile, the compiler can reorder instructions as long as the single-threaded behavior stays the same.

The question is: Can it assign intermediate results to the instance field, i.e. replacing the local variable sum with accesses to the field result?

[edit] More precisely, can other threads read intermediate initialization results? [edit end]

On the one hand, it's true that if it would do that, the single-threaded behavior would stay the same.

On the other hand, why should it? Assigning a value to a field is slower than to a local variable, so there is no benefit in assigning intermediate results. Second, this wouldn't merely be a reordering of instructions, but a replacement of accesses to local variables to accesses to instance fields. Also, if the safety of the pattern would depend on such minor details, then I think the pattern would be considered way too fragile and no one would use it.

Therefore, I don't think the compiler can assign intermediate results, and I do think the pattern is safe in this case, but I wanted to double check.

Also, I would normally extract the computation of the value in its own private method, like computeResult, but I didn't do it in the example to better show the "potential unsafety". I am pretty confident that whether you do extract that or you don't doesn't affect the safety of this pattern either, but can you confirm that as well?


Solution

  • [edit] More precisely, can other threads read intermediate initialization results? [edit end]

    No, the read is only allowed to return either 0, or 55, and no intermediate initialization results.

    According to the JMM:

    1. The actions of each thread in isolation must behave as governed by the semantics of that thread, with the exception that the values seen by each read are determined by the memory model. When we refer to this, we say that the program obeys intra-thread semantics. Intra-thread semantics are the semantics for single-threaded programs, and allow the complete prediction of the behavior of a thread based on the values seen by read actions within the thread. To determine if the actions of thread t in an execution are legal, we simply evaluate the implementation of thread t as it would be performed in a single-threaded context, as defined in the rest of this specification.

      In other words, the JMM says that inside each thread everything happens in a normal single-threaded manner (i.e. actions happen sequentially, one after another), the only difference is that reads may return values written to the same variable in another threads.

    2. a read action cannot return some random value, it can only return a value written to the same variable by some existing write action.

      1. Each read sees a write to the same variable in the execution.

        ... For all reads r in A, we have W(r) in A and W(r).v = r.v ...

    As a result,

    So the read action can only return either 0, or 55.