双重检查锁定而不使用 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
这读作:
- 有两个线程(
ThreadA
和ThreadB
)写入x
和y
(x = 1
和y = 1
)
- 还有两个线程(
ThreadC
和 ThreadD
)读取 x
和 y
,但顺序相反。
因为 x
和 y
是 volatile
不可能有如下结果:
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/acquire
(x = 1
和 y = 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 使用了那个完全相同的词)。在这种情况下,同步块可以防止这种情况发生。
指令不能被重新排序并进入同步块。
从某种意义上说,这个问题相当简单。假设我有这个 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
这读作:
- 有两个线程(
ThreadA
和ThreadB
)写入x
和y
(x = 1
和y = 1
) - 还有两个线程(
ThreadC
和ThreadD
)读取x
和y
,但顺序相反。
因为 x
和 y
是 volatile
不可能有如下结果:
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/acquire
(x = 1
和 y = 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 使用了那个完全相同的词)。在这种情况下,同步块可以防止这种情况发生。
指令不能被重新排序并进入同步块。