双重检查锁定而不使用 volatile(但使用 VarHandle release/acquire)

double check locking without volatile (but with VarHandle release/acquire)

从某种意义上说,这个问题相当简单。假设我有这个 class:

static class Singleton {

}

并且我想为它提供一个单例工厂。我可以做(可能)显而易见的事情。我不会提及枚举可能性或任何其他可能性,因为我对它们不感兴趣。

static final class SingletonFactory {

    private static volatile Singleton singleton;

    public static Singleton getSingleton() {
        if (singleton == null) { // volatile read
            synchronized (SingletonFactory.class) {
                if (singleton == null) { // volatile read
                    singleton = new Singleton(); // volatile write
                }
            }
        }
        return singleton; // volatile read
    }
}

我可以摆脱一个 volatile read 以更高的代码复杂性为代价:

public static Singleton improvedGetSingleton() {
    Singleton local = singleton; // volatile read
    if (local == null) {
        synchronized (SingletonFactory.class) {
           local = singleton; // volatile read
           if (local == null) {
               local = new Singleton();
               singleton = local; // volatile write
           }
        }
    }

    return local; // NON volatile read
}

这几乎就是我们的代码近十年来一直使用的内容。

问题是我可以通过 VarHandle:

java-9 中添加 release/acquire 语义来使它更快吗
static final class SingletonFactory {

    private static final SingletonFactory FACTORY = new SingletonFactory();

    private Singleton singleton;

    private static final VarHandle VAR_HANDLE;

    static {
        try {
            VAR_HANDLE = MethodHandles.lookup().findVarHandle(SingletonFactory.class, "singleton", Singleton.class);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static Singleton getInnerSingleton() {

        Singleton localSingleton = (Singleton) VAR_HANDLE.getAcquire(FACTORY); // acquire

        if (localSingleton == null) {
            synchronized (SingletonFactory.class) {
                localSingleton = (Singleton) VAR_HANDLE.getAcquire(FACTORY); // acquire
                if (localSingleton == null) {
                    localSingleton = new Singleton();
                    VAR_HANDLE.setRelease(FACTORY, localSingleton); // release
                }
            }
        }

        return localSingleton;
    }
    
}

这是有效且正确的实施吗?

我将尝试自己回答这个问题... TL;DR:这是一个正确的实现,但可能比使用 volatile 的实现更昂贵?.

虽然这看起来更好,但在某些情况下可能表现不佳。我要挑战著名的 IRIW example : 独立写入的独立读取:

                        volatile x, y
     -----------------------------------------------------
     x = 1  |  y = 1   |     int r1 = x   |    int r3 = y
            |          |     int r2 = y   |    int r4 = x

这读作:

  • 有两个线程(ThreadAThreadB)写入xyx = 1y = 1
  • 还有两个线程(ThreadCThreadD)读取 xy,但顺序相反。

因为 xyvolatile 不可能有如下结果:

 r1 = 1 (x)      r3 = 1 (y)
 r2 = 0 (y)      r4 = 0 (x)

这是 volatile 中的 sequential consistency 的保证。如果 ThreadC 观察到对 x 的写入(它看到 x = 1),则意味着 ThreadD 必须观察相同的 x = 1。这是因为在顺序一致的执行中,写入就像在全局顺序中一样发生,或者它在任何地方都像原子一样发生。所以每个线程都必须看到相同的值。所以这个执行是不可能的,根据 to the JLS too:

If a program has no data races, then all executions of the program will appear to be sequentially consistent.

现在,如果我们将相同的示例移动到 release/acquirex = 1y = 1 是发布,而其他读取是获取):

                       non-volatile x, y
     -----------------------------------------------------
     x = 1  |  y = 1   |     int r1 = x   |    int r3 = y
            |          |     int r2 = y   |    int r4 = x

结果如下:

r1 = 1 (x)      r3 = 1 (y)
r2 = 0 (y)      r4 = 0 (x)

是可能的,也是允许的。这打破了 sequential consistency 并且这是正常的,因为 release/acquire 是“较弱的”。因为 x86 release/acquire 施加 StoreLoad 障碍,所以 acquire 被允许超过(重新排序) release(不像 volatile 禁止这样做)。简而言之,volatile 本身不允许重新排序,而像这样的链:

 release ... // (STORE)
 acquire ... // this acquire (LOAD) can float ABOVE the release

允许“倒置”(重新排序),因为 StoreLoad 不是强制性的。

虽然这在某种程度上是错误的和无关紧要的,因为JLS并没有解释有障碍的事情。不幸的是,这些也没有记录在 JLS 中......


如果我将此推断为 SingletonFactory 的示例,则意味着发布后:

 VAR_HANDLE.setRelease(FACTORY, localSingleton);

任何 other 执行 acquire:

的线程
Singleton localSingleton = (Singleton) VAR_HANDLE.getAcquire(FACTORY);

不保证从版本中读取值(非空 Singleton)。

想一想:在 volatile 的情况下,如果一个线程看到了易失性写入,那么其他所有线程肯定也会看到它。 release/acquire.

没有这样的保证

因此,release/acquire 每个线程可能都需要进入同步块。这可能发生在许多线程上,因为确实不知道 release 中发生的存储何时会被负载 acquire.

可见

即使 synchronized 本身确实提供先发生顺序,这段代码,至少在一段时间内(直到发布被观察到)会表现更差吗? (我假设是这样):每个线程都在竞争进入同步块。

那么到底——这大概是什么东西比较贵? volatile store 最终 看到 release。这个我没有答案。

是的,这是正确的,它存在 on Wikipedia。 (该字段是可变的并不重要,因为它只能从 VarHandle 访问。)

如果第一次读取看到一个陈旧的值,它会进入同步块。由于同步块涉及 happen-before 关系,第二次读取将始终看到写入的值。即使在维基百科上也说顺序一致性丢失了,但它指的是字段;同步块是顺序一致的,即使它们使用释放-获取语义。

所以第二次空检查永远不会成功,对象永远不会被实例化两次。

保证第二次读取将看到写入的值,因为它是使用与计算值并将其存储在变量中时持有的相同锁执行的。

在 x86 上,所有加载都具有获取语义,因此唯一的开销是空值检查。 Release-acquire 允许 eventually 看到值(这就是为什么相关方法在 Java 9 之前被称为 lazySet,并且它的 Javadoc 使用了那个完全相同的词)。在这种情况下,同步块可以防止这种情况发生。

指令不能被重新排序并进入同步块。