避免线程安全记忆供应商中的易失性读取



我想创建一个给定Supplier的记忆版本,以便多个线程可以同时使用它,同时保证原始供应商的get()最多被调用一次,并且所有线程都看到相同的结果。双重检查锁定似乎很合适。

class CachingSupplier<T> implements Supplier<T> {
private T result = null;
private boolean initialized = false;
private volatile Supplier<? extends T> delegate;
CachingSupplier(Supplier<? extends T> delegate) {
this.delegate = Objects.requireNonNull(delegate);
}
@Override
public T get() {
if (!this.initialized && this.delegate != null) {
synchronized (this) {
Supplier<? extends T> supplier = this.delegate;
if (supplier != null) {
this.result = supplier.get();
this.initialized = true;
this.delegate = null;
}
}
}
return this.result;
}
}

我的理解是,在这种情况下,delegate需要volatile,否则synchronized块中的代码可能会重新排序:写入delegate可能发生在写入result之前,可能会在完全初始化之前将result暴露给其他线程。这是对的吗?

因此,通常这需要在每次调用时在synchronized块之外对delegate进行易失性读取,在result未初始化时,每个竞争线程最多只进入一次synchronized块,然后再也不会。

但是,一旦初始化了result,是否也可以通过首先检查非易失性标志initialized和短路来避免后续调用时delegate的不同步易失性读取的成本,无论多么微不足道?还是这绝对比正常的双重检查锁定给我买不到什么?还是它以某种方式损害性能大于帮助?还是真的坏了?

不要实现双重检查锁定,请使用为您完成工作的现有工具:

class CachingSupplier<T> implements Supplier<T> {
private final Supplier<? extends T> delegate;
private final ConcurrentHashMap<Supplier<? extends T>,T> map=new ConcurrentHashMap<>();
CachingSupplier(Supplier<? extends T> delegate) {
this.delegate = Objects.requireNonNull(delegate);;
}
@Override
public T get() {
return map.computeIfAbsent(delegate, Supplier::get);
}
}

请注意,通常情况下,简单地进行一次急切的首次评估,并在将供应商发布到其他线程之前将其替换为不断返回的供应商,这甚至更简单和足够。或者只是使用volatile变量并接受如果多个线程遇到尚未评估的供应商,可能会有一些并发评估。


下面的实现仅用于信息(学术)目的,强烈建议使用上述更简单的实现。

您可以改用不可变对象的发布保证:

class CachingSupplier<T> implements Supplier<T> {
private Supplier<? extends T> delegate;
private boolean initialized;
CachingSupplier(Supplier<? extends T> delegate) {
Objects.requireNonNull(delegate);
this.delegate = () -> {
synchronized(this) {
if(!initialized) {
T value = delegate.get();
this.delegate = () -> value;
initialized = true;
return value;
}
return this.delegate.get();
}
};
}
@Override
public T get() {
return this.delegate.get();
}
}

在这里,initialized是在synchronized(this)保护下写入和读取的,但在第一次计算时,delegate被一个新的Supplier所取代,该总是返回评估值,而无需进行任何检查。

由于新供应商是不可变的,因此即使由从未执行过synchronized块的线程读取,它也是安全的。


正如 igaz 正确指出的那样,如果CachingSupplier实例本身没有安全发布,则上面的类不能免受数据争用的影响。一个完全不受数据争用影响的实现,即使发布不当,但在普通访问情况下仍然可以在没有内存障碍的情况下工作,甚至涉及更多:

class CachingSupplier<T> implements Supplier<T> {
private final List<Supplier<? extends T>> delegate;
private boolean initialized;
CachingSupplier(Supplier<? extends T> delegate) {
Objects.requireNonNull(delegate);
this.delegate = Arrays.asList(() -> {
synchronized(this) {
if(!initialized) {
T value = delegate.get();
setSupplier(() -> value);
initialized = true;
return value;
}
return getSupplier().get();
}
});
}
private void setSupplier(Supplier<? extends T> s) {
delegate.set(0, s);
}
private Supplier<? extends T> getSupplier() {
return delegate.get(0);
}
@Override
public T get() {
return getSupplier().get();
}
}

我认为这更加强调了第一个解决方案的美感......

它被破坏了,即它不是多线程安全的。 根据 JMM 的说法,简单地"看到"共享内存值(在您的示例中,读取器线程可能会看到 #initialized 为真)不是发生之前的关系,因此读取器线程可以:

load initialized //evaluates true
load result //evaluates null

以上是允许的执行。

没有办法避免同步操作的"成本"(例如,易失性写入的易失性读取),同时避免数据争用(以及随之而来的损坏代码)。句点。

概念上的困难在于打破常识推理,即要使线程看到初始化为 true,>必须先写 true 才能初始化;尽管很难接受,但该推断是不正确的

正如Ben Manes指出的那样,易失性读取只是x-86上的普通负载

最新更新